ThreadLocal 키가 WeakReference인데 왜 메모리가 새는가

seonwoo_jung·어제

1. 두 설명이 동시에 도는 이유

ThreadLocal을 스레드 풀에서 쓸 때 remove()를 빠뜨리면 메모리 누수가 난다는 경고는 자주 듣는다. 그런데 바로 옆에는 "키가 WeakReference라서 GC가 알아서 치워준다"는 설명도 같이 돌아다닌다. 둘 다 어딘가 맞는 말처럼 들리는데, 실제로는 어느 절반이 참인지를 구분하지 못하면 누수를 계속 만든다.

이 글은 OpenJDK의 java.lang.ThreadLocal / ThreadLocalMap 소스(JDK 17)를 직접 따라가며, 값이 어디에 저장되고, 약참조가 정확히 무엇에만 걸려 있으며, 왜 remove()가 유일하게 확실한 안전장치인지를 정리한 것이다.

2. 값은 ThreadLocal이 아니라 Thread가 들고 있다

가장 먼저 깨야 할 오해는 저장 구조다. ThreadLocalMap<Thread, Value> 같은 걸 내부에 들고 있다고 생각하기 쉬운데, 실제 방향은 정반대다. set() 소스를 보면 값을 자기 안에 넣지 않고 현재 스레드를 찾아 그 스레드의 맵에 넣는다.

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);          // t.threadLocals 반환
    if (map != null) map.set(this, value);   // 키 = this(ThreadLocal), 값 = value
    else createMap(t, value);
}

ThreadLocalMap getMap(Thread t) { return t.threadLocals; }  // 맵의 주인은 Thread

그림으로 보면 소유 관계가 뒤집혀 있다.

Thread A ──▶ ThreadLocalMap ──▶ Entry[]  ─ [tlX → "a"] [tlY → 3] ...
Thread B ──▶ ThreadLocalMap ──▶ Entry[]  ─ [tlX → "b"] ...
                                    ▲
        같은 ThreadLocal(tlX)이라도 스레드마다 다른 슬롯 / 다른 값

Thread → Map<ThreadLocal, Value> 구조다. 여기서 "스레드가 끝나면 알아서 정리된다"는 절반의 진실이 나온다. 스레드가 죽으면 그 스레드가 소유한 맵 전체가 함께 사라지기 때문이다. 짧게 살다 죽는 스레드에서는 누수가 잘 안 보이는 이유이기도 하다. 문제는 스레드를 재사용하는 이다.

3. 자료구조는 HashMap이 아니라 오픈 어드레싱

ThreadLocalMap은 이름과 달리 HashMap과 구조가 다르다. 버킷 + 연결 리스트(체이닝)가 아니라 하나의 Entry[] 배열에 선형 탐사(open addressing) 로 충돌을 푼다.

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;                       // 값은 평범한 강참조 필드
    Entry(ThreadLocal<?> k, Object v) {
        super(k);                       // 키(ThreadLocal)만 약참조로 보관
        value = v;
    }
}
private Entry[] table;                  // 길이는 항상 2의 거듭제곱

인덱스는 key.threadLocalHashCode & (len-1)로 잡는다. 이 해시코드는 새 ThreadLocal이 생길 때마다 상수 하나를 원자적으로 더해 만든다.

private static final int HASH_INCREMENT = 0x61c88647;  // 2^32 / φ 근사(황금비)
private static AtomicInteger nextHashCode = new AtomicInteger();
private final int threadLocalHashCode = nextHashCode.getAndAdd(HASH_INCREMENT);

0x61c88647을 연속으로 더하면 2의 거듭제곱 크기 배열에서 값이 거의 균등하게 흩뿌려진다고 알려져 있다(피보나치/황금비 해싱). 그래서 선형 탐사인데도 클러스터링이 잘 생기지 않는다. 충돌하면 그냥 다음 슬롯으로 넘어간다.

이 구조가 중요한 이유는 뒤에 나온다. 체이닝이 아니라 선형 탐사이기 때문에, 중간에 죽은 Entry가 하나 끼면 그 뒤 슬롯들의 탐사 경로가 흐트러진다. 그래서 청소할 때 단순히 하나 지우고 끝나는 게 아니라 rehash가 따라붙는다.

4. 누수가 생기는 정확한 지점: 참조 강도의 비대칭

핵심은 여기다. EntryWeakReference<ThreadLocal<?>>를 상속하므로 키(ThreadLocal) 는 약하게 잡힌다. 하지만 value 필드는 그냥 강참조다. 참조 사슬을 그려 보면 강도가 비대칭이라는 게 드러난다.

Thread(살아있음) → threadLocals(ThreadLocalMap) → Entry[] → Entry
                                                        │(약참조) │(강참조)
                                                        ▼        ▼
                                                  ThreadLocal   value 객체
                                                 (외부 강참조가   (계속 강하게
                                                  사라지면 GC)    매달려 있음)

외부에서 ThreadLocal 변수 참조가 사라지면 GC가 키를 회수한다. 그 순간 Entry는 key == nullstale entry(죽은 항목) 가 된다. 그런데도 Entry 자체와 value는 살아있는 Thread에서 table[]을 통해 강하게 도달 가능하다. 스레드가 죽지 않는 한(=풀 스레드) value는 회수되지 않는다.

약참조는 키에만 걸려 있다. 값은 강참조라, 키가 GC돼도 값은 살아있는 스레드에서 도달 가능해 그대로 남는다.

바꿔 말하면, 약참조 키의 진짜 목적은 "값을 자동 회수하는 것"이 아니다. "이 Entry는 이제 주인이 없는 죽은 항목"이라는 표식을 만들어 청소 대상으로 감지하게 하는 것에 가깝다.

5. 스스로 청소하지만, 조건이 붙는다

JDK는 이 stale entry를 기회주의적으로(opportunistically) 치운다. set / get / remove가 슬롯을 훑다가 key == null인 Entry를 만나면 정리 루틴이 돈다.

  • expungeStaleEntry(i): 해당 슬롯의 value = null, Entry를 null 처리한 뒤, 다음 빈 슬롯까지의 구간을 rehash한다(선형 탐사로 밀려 있던 항목들을 제자리로 당긴다).
  • cleanSomeSlots(...): 매번 전체를 스캔하면 비싸므로, 로그 스케일(log2(n))로 일부 슬롯만 훑어 분할 상환한다.
  • replaceStaleEntry(...): set 도중 만난 stale 슬롯을 재활용한다.

문제는 이 청소가 "그 맵에 다시 접근할 때"에만 일어난다는 점이다. 그런데 키가 GC된 ThreadLocal은 두 번 다시 접근되지 않는다. 그러면 그 stale entry의 value를 확실히 없앨 방법은 "접근 중인 다른 set/get이 우연히 그 슬롯을 지나가는 것"뿐이다. 우연에 기대는 청소인 셈이다.

그래서 명시적 remove()가 유일하게 확실한 안전장치다.

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null) m.remove(this);   // 해당 Entry.value를 즉시 끊고 expunge
}

풀 환경에서 누수가 실제로 어떻게 남는지 개념적으로 재현하면 이렇다.

ExecutorService pool = Executors.newFixedThreadPool(1);
ThreadLocal<byte[]> tl = new ThreadLocal<>();
pool.submit(() -> tl.set(new byte[10_000_000]));  // 풀 스레드 맵에 10MB가 강참조로 박힌다
// 이후 tl 변수 참조를 버려도(키는 GC 대상) 풀 스레드가 살아있으면
// Entry.value(10MB)는 그 스레드가 다음에 다른 ThreadLocal을 건드리기 전까지 회수되지 않는다.
// 올바른 처리: 작업 끝에서 반드시 tl.remove();  (보통 finally 블록에서)

6. 정리

  • 값은 ThreadLocal이 아니라 ThreadThreadLocalMap 에 저장된다. 스레드가 죽으면 맵째로 사라지지만, 풀은 스레드를 죽이지 않는다.
  • Entry는 키만 약참조, 값은 강참조다. 그래서 키가 GC돼도 값은 살아남아 누수가 된다.
  • 자동 청소(expungeStaleEntry 등)는 "그 맵에 다시 접근할 때"만 돌므로 우연에 의존한다. remove()(특히 finally) 가 유일하게 결정적인 정리 방법이다.

더 파고들 만한 주제 두 가지를 남긴다. 하나는 InheritableThreadLocal이 자식 스레드로 값을 복제하는 시점(Thread 생성자의 inheritableThreadLocals 처리)과 그것이 풀에서 왜 더 위험한가. 다른 하나는 expungeStaleEntry의 rehash가 선형 탐사 클러스터를 슬롯 단위로 어떻게 재배치하는가다.

참고 자료

  • OpenJDK 소스 (JDK 17): java.lang.ThreadLocal, ThreadLocal.ThreadLocalMap, java.lang.Thread#threadLocals
  • Java Platform SE API Docs — ThreadLocal, WeakReference

0개의 댓글