Java ThreadLocal 내부 구조 — ThreadLocalMap, WeakReference의 절반, 그리고 메모리 누수의 진짜 원인
ThreadLocal은 "스레드마다 따로 가지는 변수"라는 한 문장으로 설명되곤 합니다. 그러나 그 한 문장 뒤에는 별도의 해시맵, WeakReference 키, open addressing, 그리고 황금비를 이용한 hash 분배가 자리 잡고 있습니다. 이 글은 OpenJDK 소스를 따라가며ThreadLocal이 실제로 어떻게 저장되고, 왜remove()를 부르지 않으면 누수가 나며, 가상 스레드 시대에 왜 ScopedValue가 등장했는지 정리합니다. Spring의RequestContextHolder, MDC, 트랜잭션 동기화 같은 코드를 매일 만지지만 그 내부가 궁금했던 분들을 대상으로 합니다.
ThreadLocal을 쓰는 모습
가장 자주 만나는 ThreadLocal 사용 패턴은 다음과 같습니다.
public class UserContext {
private static final ThreadLocal<String> USER_ID =
ThreadLocal.withInitial(() -> "anonymous");
public static void set(String userId) {
USER_ID.set(userId);
}
public static String get() {
return USER_ID.get();
}
public static void clear() {
USER_ID.remove();
}
}
서블릿 필터나 Spring 인터셉터에서 요청이 들어올 때 set을 부르고, 컨트롤러·서비스에서 get으로 꺼내며, 응답 직전에 remove로 정리합니다. 동작은 단순해 보이지만 실제 저장은 어디로 가는 걸까요. USER_ID 객체 안에는 값이 없습니다. 값은 그것을 호출한 스레드 안에 들어 있습니다.
Thread, ThreadLocal, ThreadLocalMap의 삼각관계
OpenJDK의 java.lang.ThreadLocal#get은 이렇게 시작합니다.
public T get() {
return get(Thread.currentThread());
}
호출은 곧바로 현재 스레드로 위임됩니다. 그 다음 핵심은 getMap입니다.
ThreadLocalMap getMap(Thread t) {
if (this instanceof TerminatingThreadLocal<T>) {
return t.terminatingThreadLocals();
} else {
return t.threadLocals();
}
}
즉 모든 값은 Thread 인스턴스가 들고 있는 threadLocals 필드라는 별도의 맵에 저장됩니다. ThreadLocal 자신은 단지 그 맵의 키 역할만 합니다.
flowchart LR
A[Thread - Worker-1] -->|threadLocals| M1[ThreadLocalMap-1]
B[Thread - Worker-2] -->|threadLocals| M2[ThreadLocalMap-2]
TL[ThreadLocal<String> USER_ID]
TL -.->|key| M1
TL -.->|key| M2
M1 -->|value| V1[anonymous]
M2 -->|value| V2[user-42]
이름은 ThreadLocal이지만, 의미적으로는 "Thread-keyed map"이 아니라 "Thread per ThreadLocal 인스턴스의 슬롯"에 가깝습니다. 같은 ThreadLocal 객체로 두 스레드에서 get을 부르면, 각각 다른 ThreadLocalMap을 찾아 들어가서 자기 슬롯의 값을 꺼냅니다.
ThreadLocalMap의 자료구조 — Entry는 WeakReference의 절반
ThreadLocalMap은 ThreadLocal 클래스 안에 정의된 패키지-프라이빗 정적 클래스입니다. 외부에서 직접 손댈 수 없으며, 모든 연산은 ThreadLocal의 메서드를 통해서만 이루어집니다. 핵심 자료구조는 Entry[] table 배열 하나입니다.
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// ...
}
여기에 이 글의 가장 중요한 한 줄이 있습니다.
Entry는 WeakReference<ThreadLocal<?>>을 상속합니다. 그러나 value 필드는 평범한 강한 참조입니다.
말하자면 키는 약하게, 값은 강하게 잡습니다. 이 비대칭이 바로 뒤에서 다룰 메모리 누수의 진짜 원인입니다.
flowchart LR
M[ThreadLocalMap] -->|holds| T[Entry array]
T -->|index i| E[Entry]
E -->|weak ref| K[ThreadLocal key]
E -->|strong ref| V[Object value]
해시: 0x61C88647과 황금비
ThreadLocal이 자신의 해시값을 만드는 방식은 일반적인 hashCode()와 다릅니다.
private final int threadLocalHashCode = nextHashCode();
private static final AtomicInteger nextHashCode = new AtomicInteger();
private static final int HASH_INCREMENT = 0x61c88647;
새로운 ThreadLocal 인스턴스가 만들어질 때마다 AtomicInteger에 0x61c88647을 더해 다음 해시를 발급합니다. 이 매직 넘버는 2^31에 황금비 역수((√5 − 1) / 2 ≈ 0.618)를 곱한 값에 해당합니다. Knuth의 multiplicative hashing이 권장하는 상수이고, 2의 거듭제곱 길이 테이블에 균등하게 흩뿌리는 성질을 가집니다.
ThreadLocalMap은 INITIAL_CAPACITY = 16으로 시작하며 항상 2의 거듭제곱으로 유지됩니다. 인덱스 계산은 다음과 같습니다.
int i = key.threadLocalHashCode & (len - 1);
즉 0x61C88647을 누적하며 len - 1로 마스킹하기 때문에, 같은 스레드 안에서 잇따라 만들어진 ThreadLocal들은 충돌 없이 테이블에 잘 분산됩니다. JDK가 HashMap처럼 객체의 hashCode()를 호출해 분배하지 않고 자체 카운터를 쓰는 이유가 여기에 있습니다.
set 동작 — open addressing과 청소의 결합
ThreadLocal.set()을 호출하면 결국 다음의 ThreadLocalMap#set에 도달합니다.
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.refersTo(key)) {
e.value = value;
return;
}
if (e.refersTo(null)) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
세 가지 동작이 한 메서드에 응축돼 있습니다.
첫째, open addressing (linear probing) 입니다. i 위치가 비어 있지 않으면 nextIndex(i, len)으로 한 칸씩 옮기며 빈 자리를 찾습니다. HashMap처럼 충돌 시 별도 체인을 만들지 않습니다. ThreadLocalMap은 보통 한 스레드당 엔트리 수가 적기 때문에 이쪽이 더 캐시 친화적이고 단순합니다.
둘째, stale entry 즉시 청소 입니다. probing 중에 e.refersTo(null)인 엔트리를 만나면 (즉 키가 GC된 죽은 슬롯) replaceStaleEntry로 그 자리를 재사용합니다. set은 단순히 값을 쓰는 것이 아니라 지나는 길에 시신을 치우기도 합니다.
셋째, 삽입 후 추가 청소 + 리해시 트리거 입니다. 새 엔트리를 넣은 뒤 cleanSomeSlots로 일부 슬롯을 더 훑고, 그래도 크기가 임계치를 넘으면 rehash()를 호출합니다. threshold는 len * 2 / 3이며, rehash는 먼저 모든 stale 엔트리를 청소한 다음 그래도 차 있으면 resize로 2배 확장합니다.
flowchart TD
A[set key value] --> B[i = hash and mask]
B --> C{tab i null?}
C -- yes --> D[new Entry at i]
D --> E[cleanSomeSlots]
E --> F{size >= threshold?}
F -- yes --> R[rehash]
F -- no --> END[return]
C -- no --> G{same key?}
G -- yes --> U[update value]
U --> END
G -- no --> H{key == null?}
H -- yes --> S[replaceStaleEntry]
S --> END
H -- no --> N[i = nextIndex]
N --> C
get 동작 — getEntryAfterMiss
get()의 빠른 경로는 단순합니다. 인덱스를 계산하고 tab[i]가 원하는 키면 그 자리에서 값을 반환합니다. 빠른 경로가 빗나갔을 때 호출되는 것이 getEntryAfterMiss입니다.
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
if (e.refersTo(key))
return e;
if (e.refersTo(null))
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
이 메서드는 두 가지 일을 동시에 합니다. 키를 찾아 probing을 이어가는 일과, 지나가다 발견한 stale 엔트리를 즉시 expungeStaleEntry로 청소하는 일입니다. 다시 말해 get만 호출해도 일부 죽은 슬롯은 조용히 회수됩니다. 단, "지나가는 길에 우연히 마주친 슬롯"에 한정됩니다. 키가 GC됐지만 그 누구도 probing 중에 그 슬롯을 지나가지 않으면 영원히 남습니다.
expungeStaleEntry — 청소 알고리즘의 핵심
ThreadLocalMap이 죽은 슬롯을 정리하는 가장 기본 단위는 expungeStaleEntry입니다.
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// Rehash until we encounter null
Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
두 단계로 읽으면 분명합니다.
첫 단계는 staleSlot에서 값을 끊는 일입니다. value = null을 먼저 대입한다는 점이 중요합니다. 단순히 슬롯 참조만 끊으면 value는 여전히 Entry 객체를 통해 살아 있을 수 있기 때문에, 명시적으로 끊어 GC에서 회수될 길을 열어 줍니다.
둘째 단계는 다음 null 슬롯을 만날 때까지 앞으로 훑어가며 (a) 키가 죽었으면 그 자리도 청소하고 (b) 살아 있다면 원래 hash 위치 h와 현재 위치 i가 다른지 확인합니다. 다르다면 linear probing 도중 다른 자리에 자리잡았던 엔트리이므로 가능한 한 원래 hash 위치에 가까운 빈 자리로 이동시킵니다. open addressing 테이블에서 한 슬롯을 비울 때 그 뒤를 재정렬하지 않으면 probing 체인이 끊기기 때문에 필요한 작업입니다.
cleanSomeSlots와 rehash
expungeStaleEntry가 한 슬롯 단위의 청소라면, cleanSomeSlots는 그것을 보조하는 휴리스틱 청소입니다.
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.refersTo(null)) {
n = len;
removed = true;
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}
n을 매 반복마다 절반으로 줄이므로 전체적으로는 log2(n)회 슬롯만 검사합니다. stale을 발견하면 n = len으로 리셋해 더 큰 범위를 추가로 훑습니다. 즉 평소에는 가볍게, 청소거리가 보이면 좀 더 부지런히 도는 점진적 청소입니다.
여기서도 청소되지 않은 stale은 rehash 시점에 마지막 기회를 얻습니다.
private static final int INITIAL_CAPACITY = 16;
private void setThreshold(int len) {
threshold = len * 2 / 3;
}
private void rehash() {
expungeStaleEntries();
if (size >= threshold - threshold / 4)
resize();
}
rehash는 먼저 테이블 전체를 훑어 stale을 한 번에 청소한 뒤 (expungeStaleEntries), 그 후에도 차 있으면 2배로 resize합니다. 즉 테이블 확장은 "쓸 만큼 다 청소했는데도 정말 가득 찼을 때"의 최후 수단입니다.
메모리 누수의 진짜 원인
이쯤 되면 ThreadLocal 메모리 누수의 원인이 흔히 말하는 "WeakReference라 정리가 안 된다"가 아니라는 점이 분명해집니다. 정확한 그림은 이쪽입니다.
flowchart LR
R[Thread root] --> M[ThreadLocalMap]
M --> E[Entry]
E -.->|weak| K[ThreadLocal key]
E -->|strong| V[Big value object]
GC[GC] -.->|may clear| K
style K stroke-dasharray: 5 5
키 ThreadLocal이 GC 가능해지면 Entry의 약한 참조는 자동으로 비워집니다. 그래서 Entry.get()은 null이 됩니다. 그러나 value 필드는 강한 참조이므로 그대로 살아 있습니다. 그리고 그 Entry 객체 자체는 ThreadLocalMap 배열에 의해 잡혀 있고, ThreadLocalMap은 Thread에 의해 잡혀 있습니다. 결국 Thread가 살아 있는 동안에는 value도 계속 살아 있습니다.
여기에 두 가지 사실이 더해지면 누수가 본격적인 문제가 됩니다.
첫째, 청소는 우연에 기댄다는 점입니다. expungeStaleEntry는 set/get/remove의 probing 길에 우연히 만난 슬롯에 한해서, 또는 cleanSomeSlots의 log2(n)개 검사에서 우연히 들어맞은 슬롯에 한해서 일어납니다. 해당 ThreadLocal 자체가 더 이상 쓰이지 않게 되면 그 슬롯을 다시 방문할 코드 경로도 사라집니다. 그러면 expungeStaleEntries가 호출되는 rehash 타이밍까지 누수는 그대로 남습니다.
둘째, 스레드 풀에서는 스레드가 사실상 영원히 산다는 점입니다. Tomcat의 http-nio-exec-*, Spring의 ThreadPoolTaskExecutor 작업 스레드는 애플리케이션 수명 내내 살아 있습니다. 그래서 한 요청 처리 중 ThreadLocal에 큰 객체를 넣고 remove() 없이 끝내면, 그 스레드가 새 요청을 처리해도 이전 값은 그대로 메모리에 잡혀 있습니다. 클래스 로더가 다른 웹앱 환경에서는 value가 옛 클래스 로더를 살려 두기 때문에 클래스 로더 누수까지 일으킵니다.
해법은 단 하나, remove() 호출 입니다.
try {
USER_ID.set(currentUserId);
doWork();
} finally {
USER_ID.remove();
}
remove()는 자기 자신을 키로 한 엔트리를 명시적으로 stale 처리하고, 이어서 expungeStaleEntry로 그 슬롯과 그 뒤의 죽은 슬롯들을 한꺼번에 청소합니다. 이 한 줄이 누수를 막는 가장 직접적인 방법입니다. 서블릿 필터나 Spring 인터셉터의 afterCompletion 같은 자리에 두는 게 정석입니다.
InheritableThreadLocal — 자식 스레드로의 전파
InheritableThreadLocal은 ThreadLocal을 상속하며, 두 메서드만 오버라이드합니다.
@Override
ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals();
}
@Override
void createMap(Thread t, T firstValue) {
t.setInheritableThreadLocals(new ThreadLocalMap(this, firstValue));
}
protected T childValue(T parentValue) {
return parentValue;
}
핵심은 Thread가 별도의 inheritableThreadLocals 맵을 따로 가진다는 것, 그리고 새 Thread가 만들어질 때 부모 스레드의 inheritableThreadLocals를 얕게 복사한다는 것입니다. childValue를 오버라이드하면 자식이 받을 값을 변환할 수 있습니다 (예를 들어 가변 객체를 깊은 복사 하거나, 별도 식별자를 부여하는 식).
주의 사항은 익숙한 두 가지입니다. 첫째, 스레드 풀처럼 스레드가 미리 만들어진 환경에서는 "부모 → 자식 복사"가 실제 작업 단위와 어긋납니다. 풀의 워커 스레드는 풀이 생성될 때 한 번만 부모로부터 값을 받기 때문에, 이후 작업 제출자가 누구든 그 값은 갱신되지 않습니다. 둘째, 복사된 값도 마찬가지로 remove() 책임은 자식 스레드에 있습니다. 결국 누수 위험은 더 분산될 뿐 사라지지 않습니다.
Virtual Threads와 ThreadLocal의 비용
JDK 21에서 가상 스레드가 정식 기능이 되면서 ThreadLocal의 무게가 새롭게 문제됩니다. Oracle의 가상 스레드 가이드는 다음과 같이 직설적으로 권고합니다.
Virtual threads support thread-local variables ... just like platform threads, so they can run existing code that uses thread locals. However, because virtual threads can be very numerous, use thread locals only after careful consideration.
이유는 산수에 가깝습니다. 캐시·세션·버퍼처럼 비용이 큰 객체를 ThreadLocal에 두는 패턴은 "스레드 수가 적고 오래 산다"는 전제에서 합리적이었습니다. 가상 스레드는 그 전제를 정면으로 뒤집습니다. 동시에 5만 개의 가상 스레드가 살아 있는 시스템에서 각 스레드가 사용자별 캐시를 자신의 ThreadLocal에 들고 있으면, 본래 200개 정도면 충분했을 인스턴스가 5만 개로 늘어납니다.
또 하나, 가상 스레드는 일반적으로 풀에 들어가지 않습니다. 매 작업마다 새로 만들어지고 죽기 때문에 ThreadLocal은 자연스럽게 작업 단위와 함께 사라지긴 합니다. 풀 누수 문제는 약해지는 셈입니다. 그러나 그 대신 "작업마다 무거운 초기화를 반복하게 되는" 새로운 비용이 들어옵니다. JDK 자체가 java.base에서 다수의 ThreadLocal 사용을 제거하는 작업을 가상 스레드 도입에 앞서 진행한 이유도 여기에 있습니다.
ScopedValue — JDK 25에서 정식이 된 대안
이런 배경에서 등장한 것이 ScopedValue입니다. JEP 446 (JDK 21 preview)으로 시작해 JEP 464, 481, 487의 단계적 미리보기를 거쳐 JEP 506에서 JDK 25에 final로 진입했습니다. ThreadLocal을 대체하기 위한 별도의 클래스로 도입되었고, OpenJDK는 ThreadLocal을 deprecate하지 않는다는 점을 명시했습니다.
ScopedValue의 차이는 세 가지로 압축됩니다.
첫째, 불변(immutable) 입니다. set/remove가 없으며, where(...).run(...)으로 묶인 동적 범위 안에서만 값이 보입니다. 범위를 벗어나면 자동으로 사라집니다.
private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
ScopedValue.where(USER_ID, "user-42").run(() -> {
doWork(); // USER_ID.get() == "user-42"
});
// 범위 종료. USER_ID.get()을 부르면 NoSuchElementException.
둘째, 구조적 수명 입니다. 값의 수명은 콜 스택의 구조에 묶입니다. 메서드 호출이 정상 종료하든 예외로 종료하든, 그 호출이 끝나는 순간 바인딩은 깔끔히 사라집니다. remove()를 잊어 누수가 나는 패턴 자체가 성립할 수 없습니다.
셋째, 자식 스레드와 공유 비용이 0 입니다. StructuredTaskScope로 만든 자식 스레드는 부모의 바인딩을 그대로 읽기 전용으로 공유합니다. InheritableThreadLocal처럼 값을 복사하지 않으므로, 가상 스레드 수만 큼 메모리가 늘지 않습니다.
flowchart TD
P[Parent virtual thread] -->|ScopedValue.where| W[runnable]
W --> S[StructuredTaskScope]
S --> C1[Child task 1]
S --> C2[Child task 2]
S --> C3[Child task 3]
C1 -.->|read-only share| W
C2 -.->|read-only share| W
C3 -.->|read-only share| W
ScopedValue는 ThreadLocal을 모두 대체하지는 않습니다. 예를 들어 요청 단위 캐시처럼 같은 스레드가 같은 값을 여러 번 갱신해야 하는 패턴은 여전히 ThreadLocal이 자연스럽습니다. 그러나 "한 번 바인딩하고 호출 그래프 안에서만 읽는다"는 공통 패턴 — 사용자 식별, 트랜잭션 ID, 요청 컨텍스트 — 에서는 ScopedValue가 더 안전하고 가상 스레드에 더 친화적입니다.
정리
ThreadLocal이 보여 주는 작은 풍경을 다시 한 번 정리하면 이렇습니다.
값은 ThreadLocal 객체가 아니라 Thread 안의 ThreadLocalMap에 들어 있습니다. 그 맵은 open addressing 해시 테이블이며, 키 분배에는 황금비 기반의 0x61C88647을 누적 사용합니다. Entry는 키를 약하게, 값을 강하게 잡습니다. 키가 GC되면 expungeStaleEntry / cleanSomeSlots / rehash가 우연한 길목에서 청소를 시도하지만, 그 청소는 보장이 아니라 휴리스틱입니다. 그래서 스레드 풀에서 remove()를 빼먹으면 값은 살아남고, 그 값이 큰 객체이거나 클래스 로더를 잡고 있다면 누수와 클래스 로더 누수가 됩니다.
이 비대칭은 1990년대 후반 ThreadLocal이 처음 도입될 당시의 "스레드 수 적고 수명 긴 서버" 가정을 정확히 반영합니다. 가상 스레드의 등장은 그 가정 자체를 흔들었고, 그 결과 JDK 25는 ScopedValue를 final로 내놓았습니다. 둘 중 무엇을 쓸지는 결국 "값의 수명을 코드 구조로 묶을 수 있는가"라는 질문으로 환원됩니다. 묶을 수 있으면 ScopedValue가, 묶기 어려우면 여전히 ThreadLocal이 — remove()를 잊지 않는다는 조건과 함께 — 답입니다.
참고자료
- OpenJDK 소스
java.lang.ThreadLocal: https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/lang/ThreadLocal.java - OpenJDK 소스
java.lang.InheritableThreadLocal: https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/lang/InheritableThreadLocal.java - Java SE 21
ThreadLocalAPI 문서: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/ThreadLocal.html - Java SE 21
InheritableThreadLocalAPI 문서: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/InheritableThreadLocal.html - Java SE 21
WeakReferenceAPI 문서: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/ref/WeakReference.html - Oracle Java 21 Virtual Threads 가이드 (Thread-Local Variables 절): https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html
- JEP 444: Virtual Threads: https://openjdk.org/jeps/444
- JEP 446: Scoped Values (Preview): https://openjdk.org/jeps/446
- JEP 487: Scoped Values (Fourth Preview): https://openjdk.org/jeps/487
- JEP 506: Scoped Values (Final, JDK 25): https://openjdk.org/jeps/506
- Heinz Kabutz, "Why 0x61c88647?" (JavaSpecialists Issue 164): https://www.javaspecialists.eu/archive/Issue164-Why-0x61c88647.html

