[0619] ThreadLocal / ExecutorService

ㅇㅇㅈ·2025년 6월 19일

ThreadLocal

각각의 스레드가 독립적으로 데이터를 저장하고 관리할 수 있는 클래스.
각 스레드마다 개별적인 변수 공간을 할당하여, 공유 자원 없이, 안전하게 데이터를 유지할 수 있다.

  • 스레드마다 독립적인 저장공간 제공 ▷ 데이터 공유 없이 동기화 문제 해결
  • 스레드가 종료되면 자동으로 데이터 제거 (메모리 누수 방지)
  • get()을 호출하면 현재 실행 중인 스레드와 데이터 변환
  • set()을 호출하면 현재 실행 중인 스레드와 데이터 변경
    깜자
어째서 이런게 존재하는 걸까..

분명 저번에는 멋쟁이 `finally` 블록이 각 try-catch 들에서 반드시 파일을 닫아 메모리 누수를 방지한다고 했는데..
이젠 스레드마다 변수를 다르게 설정해서, 메모리 누수를 방지할 수 있다고 한다...

돌아와 인마...

암튼 마지막에 ThreadLocal.remove() 메소드를 호출해 데이터를 정리하게 된다고 한다...

 

 

ThreadLocal을 사용하는 이유

멀티 스레드 환경에서의 동시성 문제

여러 스레드가 공유 자원에 접근하다보면 데이터 충돌이 발생해,

  • Race condition (경쟁 상태)
  • 데이터 불일치
  • deadlock (교착 상태)
    등의 문제가 일어날 수 있는데,

스레드마다 독립적인 데이터 공간을 가지(=공유 자원 없이 데이터 일관성을 유지할 수 있게 되며),
동기화synchronized없이 안전하게 멀티 스레드 프로그래밍이 가능하다고 한다.

내 뇌가 지금 교착 상태다

 

 


ThreadLocal에서 메모리 누수가 발생하는 원인

thread pool과 함께 사용할 때 주의해야 함.

1) ThreadLocal은 스레드의 내부 ThreadLocalMap에 데이터를 저장

  • 각 스레드의 내부에는 ThreadLocalMap이라는 해시맵이 존재하며, ThreadLocal은 여기에 데이터를 저장한다.
  • ThreadLocalMap의 키는 ThreadLocal 객체 자체이며, 값은 저장된 데이터이다.

 

...잠깐 정리 좀 해야할 것 같다.

ThreadLocal 변수의 키Key 역할

  • 개발자가 ThreadLocal<T> 객체를 만들고, 여기에 값을 지정.
  • 해당 값은 현재 실행 중인 스레드의 ThreadLocalMap에 저장된다.
  • 이때 Map의 Key는 내가 new로 만든 ThreadLocal 객체 자신, Map의 value는 내가 넣으라고 한 값.

 

여러 스레드 간의 간섭

  • 스레드A가 threadLocal.set("A") 하면,
    → 스레드A의 map: { (threadLocal, "A"( }
  • 스레드B가 threadLocal.set("B") 하면,
    → 스레드B의 map: { (threadLocal, "B") }

동일한 ThreadLocal 객체를 여러 스레드에서 공유해도,


Thread t1 = new Thread(() -> {
    myLocal.set("A 값");
    System.out.println(myLocal.get()); // 출력: A 값
});

Thread t2 = new Thread(() -> {
    myLocal.set("B 값");
    System.out.println(myLocal.get()); // 출력: B 값
});

t1.start();
t2.start();

저장 공간은 각 스레드마다 완전히 따로 있다.

 


2) 스레드 풀 사용시 ThreadLocal 값 유지

  • 스레드 풀을 사용할 경우, 한 번 생성된 ㅅ레드가 계속 재사용되므로 ThreadLocal 데이터가 유지될 수 있다.
  • 스레드가 종료되지 않으면, 이전에 저장된 값이 메모리에 남아 새로운 작업에도 영향을 끼칠 수가 있다.

 

스레드 풀 + ThreadLocal

값이 남아돈다!!!

 
ThreadLocal스레드마다 고유한 저장공간
→ 스레드 풀은 스레드를 재활용해 작업을 처리하려 한다.

여기서 생기는 문제

  • ThreadLocal에 값을 넣음(set())
    → 작업이 끝나고, ThreadLocal의 값을 (remove() 호출 없이) 그냥 둔 상태로 스레드가 다시 풀로 전환
  • 다음에 다른 작업이 이 스레드를 사용
    → 이전 값이 아직 남아 있음
  • 결국,
    ThreadLocal 값이 지워지지 않고 다른 작업에서 값이 섞이는 문제 발생.

때문에 작업이 끝난 뒤엔 반드시 remove()로 깨끗이 청소해야 한다.

약한 참조/강한 참조

  • ThreadLocal의 내부 구조 상,
    ThreadLocal 객체(Key)는 약한 참조
    값(Value)은 강한 참조
  • ThreadLocal 객체를 더이상 코드에서 사용하지 않더라도,
    ThreadLocalMap 내부에 값이 남아있다면 GC(가비지 컬렉터)가 Value를 수거하지 못하게 된다.
  • 결국,
    ThreadLocal의 객체는 없으나, 값이 메모리에 남아있는 메모리 누수가 발생하게 된다.
    ThreadLocal<MyBigObject> leak = new ThreadLocal<>();
    leak.set(new MyBigObject());

    // leak 변수를 더이상 코드에서 안 써도,
    // 스레드의 ThreadLocalMap에는 값이 남아있다
}

해당 경우에도 반드시 remove()를 호출해 깨끗이 청소해야 한다.

 


ConcurrentHashMap

HashMap은 여러 스레드가 동시에 값을 바꿀 때 동기화, 데이터 깨짐 등의 문제가 생길 수 있다.
ConcurrentHashMap은 멀티 스레드 환경에서 안전하게 값을 넣고 꺼낼 수 있게 해주는 클래스.

원리

  • HashMap 전체를 한 번에 lock하지 않고,
    내부적으로 여러 구역(세그먼트, bucket)으로 나누어
    동시에 여러 스레드가 다른 구역을 건들 수 있도록 한다.
  • 단일 객체가 Map 인터페이스 전체를 점령하지 않도록 한다.

예시


// 여러 스레드가 동시에 접근해도 안전!
Runnable task = () -> {
    for (int i = 0; i < 1000; i++) {
        map.put("count", map.getOrDefault("count", 0) + 1);
    }
};

Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();

System.out.println(map.get("count"));
  • 해당 코드는 두 스레드가 동시에 값을 더해도 안전하게 작동.
  • 만일 HashMap으로 했다면 오류가 발생했을 것.

 


 
정리하던 도중 이상한 것을 발견했다.

ConcurrentHashMap은 참 열심히 사는 친구인 것 같았는데, 이자식은

깜자

Null값을 지원하지 않는다

map.put(null, "hi");       // ❌ NullPointerException!
map.put("hello", null);    // ❌ NullPointerException!

동시성 제어에 혼란을 야기할 수 있기에 Null을 넣지 못하게 되어있다고 한다.

아니 어째 다들 하나씩 나사가 빠진 느낌이다.

HashMapTreeMapConcurrentHashMap
정렬XO (Key 오름차순)X
속도O(1)O(log n)O(1)에 가까움
nullOKey X, value OX
동기화XXO
사용처단일 스레드정렬, 범위검색멀티스레드, 동시접근

이렇다는 건가?

클래스들이 참 다양하게도 각자의 특징이 있구나 싶다.

 


ExecutorService

자체적으로 스레드 풀을 만들어준다고 한다

Thread thread = new Thread(() -> { ... });thread.start();에 익숙해지기도 전에 스레드 풀을 자동화해주는 인터페이스를 배우게 되어버렸다

ExecutorService 주요 메소드

execute(Runnable command): Runnable 작업을 실행
submit(Runnable task): Runnable 작업을 실행하고 Future<?> 반환
submit(Callable<T> task): Callable<T> 작업을 실행하고 결과를 Future<T>로 반환

shutdown(): 새로운 작업을 거부하고, 실행중인 작업 완료 후 종료
shutdownNow(): 실행 중인 작업을 강제종료, 즉시 스레드 풀 종료

 

===

예제

  • ExecutorService executor = Executors.newFixedThreadPool(2);

executor를 선언한 뒤
newFixed를 선언(ThreadPool의 크기는 2)

  • executor.execute() -> System.out.printIn(Thread.currentThread().getName());

execute 메소드를 사용해 뒷부분에 실행할 메소드를 넣은 것

  • executor.shutdown();

종료 처리

 


ThreadPool이란

미리 일정 개수의 스레드를 생성해두고 필요할 때마다 빌려 쓰는 방식

  • 새로운 작업 요청이 들어오면 기존의 스레드를 재사용하여 불필요한 스레드 생성을 방지함
  • CPU & 메모리 낭비를 줄이고 성능을 최적화

 

ThreadPool 동작 원리

1) 일정 개수의 스레드가 미리 생성됨 FixedThreadPool(n)
2) 사용자가 작업을 요청할 시, 대기 중인 스레드가 실행됨
3) 작업이 끝난 스레드는 풀로 반환되어 재사용됨
4) 스레드의 개수가 부족할 경우 새로운 작업은 대기 큐에 저장
5) shutdown()을 호출하면 더이상 새로운 작업을 받지 않고, 실행 중인 작업이 끝날 경우 스레드가 종료

쿠팡 물류센터 구조 같다.
스레드 친구들은 일주일에 두 번 이상 호출 당해도 주휴수당을 받지는 않겠지만...

 


ThreadPool 사용하는 이유

  • 반복적인 스레드 생성/소멸 비용 절감
  • 자원 낭비 방지 (메모리, CPU 과부하 방지)
  • 스레드 개수 제한 -> 성능 최적화 (과도한 스레드 사용 방지)

 


주요 ThreadPool 종류

newFixedThreadPool(int nThreads)

  • 고정된 개수의 스레드를 유지하는 스레드 풀
  • 정해진 개수 이상의 작업이 들어오면 큐에 저장됨
  • CPU가 적은 연산 작업을 수행할 때 / 작업 개수가 일정하고 스레드 개수를 제한하고 싶을 때 주로 사용

newCachedThreadPool()

  • 필요한 만큼 스레드를 동적으로 생성하고, 사용되지 않는 스레드는 제거하는 스레드 풀
  • 유휴(idle) 스레드가 있으면 재사용, 없으면 새로 생성
  • 작업량이 일정하지 않고, 순간적인 요청이 많을 때 사용
    아 이거 약간 http와 Websocket 관계같은 거구나
  • 빠른 응답 속도가 필요한 작업을 할 때 사용
  • 스레드 개수 제한이 없기에 과부하가 걸릴 수 있음

newSingleThreadExecutor()

  • 하나의 스레드만 사용하여 작업을 순차적으로 진행
  • 순차적으로 실행해야하는 작업이 필요할 때 사용
  • 데이터의 무결성이 중요한 경우

newScheduledThreadPool(int corePoolSize)
참 길다

  • 지정된 개수의 스레드로 주기적으로 작업을 진행하는 스레드 풀
  • 일정 간격으로 반복 실행이 필요한 경우(ex 타이머, 스케줄링 작업)

 

 


ExecutorService 사용 예제

예제 1)

FixedThreadPool 사용

고정된 개수의 스레드를 유지하는 스레드풀

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3);
        // 스레드 3개짜리 풀 생성

        for (int i = 1; i <= 5; i++) {
            int taskNum = i;
            executor.submit(() -> {
                System.out.println(Thread.currentThread().getName() + " 작업 실행: " + taskNum);
            });
        }
        // 3개의 스레드가 task를 맡아 작업 번호를 출력

        executor.shutdown(); // 실행 완료 후 스레드 종료
    }
}
  • 3개의 스레드가 번갈아가며 5개의 작업을 수행
  • 실행 순서는 OS 스케줄러에 따라 달라질 수 있다.

예시 출력:

pool-1-thread-2 작업 실행: 2
pool-1-thread-3 작업 실행: 3
pool-1-thread-2 작업 실행: 4
pool-1-thread-1 작업 실행: 5

 

예제 2)

ScheduledThreadPool 사용

일정 간격으로 반복작업을 하는 스레드풀

public class ScheduledThreadPoolExample {
    public static void main(String[] args) {
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);

        // 3초 후에 한 번 실행
        scheduler.schedule(() -> System.out.println("3초 후 실행"), 3, TimeUnit.SECONDS);

        // 1초 후 실행, 이후 2초마다 반복 실행
        scheduler.scheduleAtFixedRate(
            () -> System.out.println("반복 실행"), 
            1, // initialDelay: 1초 뒤에 첫 실행
            2, // period: 이후 2초마다 반복
            TimeUnit.SECONDS
        );
    }
}

예시 출력:

홀수 초마다 "반복 실행" 출력
3초 째에는 "3초 후 실행" 출력

 

예제 3)

CatchedThreadPool 사용

작업량이 일정하지 않고, 순간적인 요청이 많을 때

    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();
        // 필요한 만큼 스레드를 즉시 생성
        // 이전에 사용하던 스레드가 살아있을 경우 재사용,
        // 오래 사용 안하면 알아서 제거
        
        // 대기 큐 없이 곧바로 새 스레드를 생성하니 과부하에 주의

        for (int i = 1; i <= 10; i++) {
            int taskNum = i;
            executor.execute(() -> {
                System.out.println(Thread.currentThread().getName() + " 작업 실행: " + taskNum);
                try {
                    Thread.sleep(2000);
                    // 10개의 스레드가 2초동안 정지
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    // 누군가 깨울 경우 에러 메시지 출력
                }
            });
        }

        executor.shutdown();
    }
}

 


shutdown()shutdownNow()

  • shutdown(): 기존 작업을 모두 마친 후 스레드 종료
  • shutdownNow(): 실행 중인 작업을 즉시 중단하고 스레드 종료
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(2);

        executor.shutdown(); // 기존 작업은 다 끝내고 종료 준비

        try {
            // 5초 안에 모든 작업이 끝나지 않으면
            if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
                executor.shutdownNow(); // 남은 작업 즉시 중단
            }
        } catch (InterruptedException e) {
            executor.shutdownNow(); // 예외 시에도 즉시 중단
        }
    }
}

나도 중단되고 싶다

0개의 댓글