자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking
synchronized키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking과 그 빈자리를 채운 lightweight locking까지를 공식 명세와 HotSpot 자료 기준으로 따라가요. 락을 "그냥 쓰는" 단계에서 "왜 이렇게 동작하는지 아는" 단계로 넘어가고 싶은 분을 위한 글이에요.
자바를 쓰다 보면 synchronized는 거의 반사적으로 손이 가는 키워드예요. 그런데 이 한 줄이 정확히 무엇을 잠그고, 경합이 없을 때와 있을 때 비용이 어떻게 달라지는지, 그리고 왜 wait()는 synchronized 블록 안에서만 부를 수 있는지를 설명하라고 하면 의외로 말문이 막히곤 해요.
사실 synchronized의 동작은 "모든 자바 객체는 잠글 수 있다"는 한 가지 사실에서 출발해요. 그 잠금이 어디에 기록되고, 누가 들고 있고, 기다리는 스레드는 어디서 잠드는지를 차근차근 따라가 볼게요.
두 가지 문법, 하나의 메커니즘
synchronized를 쓰는 방법은 두 가지예요. 블록과 메서드죠.
public class Counter {
private int value;
// 1) synchronized 블록
public void incBlock() {
synchronized (this) {
value++;
}
}
// 2) synchronized 메서드
public synchronized void incMethod() {
value++;
}
}
겉보기엔 비슷하지만 바이트코드로 내려가면 갈라져요. javap -c -p Counter 로 들여다보면 블록 쪽은 monitorenter / monitorexit 명령어가 보여요.
public void incBlock();
...
3: monitorenter
4: aload_0
...
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit // 예외 경로용 두 번째 monitorexit
21: aload_2
22: athrow
23: return
monitorexit가 두 번 나오는 게 핵심이에요. 정상 종료용 하나, 그리고 블록 안에서 예외가 터졌을 때를 위한 하나예요. 자바 명세는 synchronized 블록이 정상이든 비정상이든 반드시 모니터를 풀고 빠져나가도록 컴파일러가 보장하라고 요구해요. 그래서 try-finally 비슷한 구조가 바이트코드에 자동으로 깔려요.
반면 synchronized 메서드 쪽은 monitorenter가 아예 없어요.
public synchronized void incMethod();
descriptor: ()V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
...
0: aload_0
1: dup
...
8: return
대신 메서드에 ACC_SYNCHRONIZED라는 플래그가 붙어요. JVM 명세에 따르면 동기화 메서드는 보통 monitorenter/monitorexit로 구현되지 않고, 런타임 상수 풀의 ACC_SYNCHRONIZED 플래그로 구별돼요. 메서드 호출 명령어가 이 플래그를 보고 진입 시점에 모니터를 잡고, 정상 return이나 예외를 던지는 athrow 시점에 알아서 풀어줘요. 결국 문법은 둘이지만 잠그는 대상과 메커니즘은 같아요. 블록은 괄호 안에 적은 객체를, 인스턴스 메서드는 this를, 정적 메서드는 그 클래스의 Class 객체를 잠가요.
재진입과 구조적 잠금
모니터에는 소유 스레드와 진입 횟수(entry count) 가 있어요. 같은 스레드가 자기가 이미 들고 있는 모니터에 다시 들어오면 카운트가 1 올라가고, 나갈 때 1씩 줄어요. 카운트가 0이 되는 순간에야 모니터가 실제로 풀려요. 그래서 synchronized 메서드가 같은 객체의 다른 synchronized 메서드를 불러도 자기 자신과는 데드락이 안 나는 거예요. 이걸 재진입(reentrant) 이라고 해요.
자바 명세는 여기에 더해 구조적 잠금(structured locking) 규칙을 둬요. 한 메서드 안에서 모니터를 잡은 횟수와 푼 횟수가 맞아야 하고, 메서드를 빠져나갈 때 그 메서드가 추가로 잡은 모니터는 모두 풀려 있어야 한다는 규칙이에요. 이 규칙이 깨지면 JVM은 IllegalMonitorStateException을 던져요. synchronized 문법을 쓰면 컴파일러가 이 균형을 자동으로 맞춰 주기 때문에 우리가 신경 쓸 일이 거의 없어요.
모니터는 어디에 있을까 — 객체 헤더와 mark word
"모든 객체를 잠글 수 있다"는 말은, 모든 객체가 잠금 상태를 적어 둘 자리를 헤더에 들고 있다는 뜻이에요. HotSpot에서 그 자리가 mark word예요.
자바 객체는 메모리에 객체 헤더 + 필드로 놓여요. 헤더의 첫 워드가 mark word인데, 여기엔 잠금 상태 말고도 identity hash code, GC 나이(age) 같은 정보가 상황에 따라 번갈아 들어가요. 한 워드를 여러 용도로 우려먹는 셈이라, 하위 태그 비트로 지금 이 워드가 무슨 상태인지를 구분해요.
HotSpot 자료 기준으로 태그 비트의 의미는 이래요.
| 태그 비트 | 상태 | mark word가 가리키는 것 |
|---|---|---|
01 |
unlocked | identity hash, age 등 일반 정보 |
00 |
lightweight / stack-locked | 잠근 스레드 스택의 락 레코드 |
10 |
inflated | 네이티브 ObjectMonitor 포인터 |
11 |
marked | GC가 사용하는 표시 |
여기서 synchronized가 실제로 하는 일은 "이 태그 비트를 안전하게 뒤집는 것"이에요. 경합이 없으면 01 ↔ 00 사이를 가볍게 오가고, 경합이 생기면 10(인플레이션)으로 승격해요. 이 승격 과정을 보는 게 이 글의 절반이에요.
경합 없는 경로 — 스택 락(stack-locking)
대부분의 synchronized는 사실 경합이 없어요. 한 스레드가 잠그고 곧바로 풀고, 다른 스레드는 그 사이에 끼어들지 않아요. 이런 흔한 경우에 무거운 OS 뮤텍스를 쓰면 손해겠죠. 그래서 HotSpot은 가벼운 경로를 먼저 시도해요. 전통적으로 이 경로를 스택 락(stack-locking) 이라고 불러요.
흐름은 이래요.
- 스레드가
monitorenter를 만나면, 자기 스택 프레임에 락 레코드(lock record, HotSpot에서는BasicLock) 를 하나 만들어요. - 객체의 mark word를 그 락 레코드에 복사해 둬요. 이렇게 옮겨 둔 원본을 displaced mark word라고 해요.
- CAS(compare-and-swap)로 객체의 mark word를 "락 레코드를 가리키는 포인터 + 태그
00"으로 바꿔요.
CAS가 성공하면 그게 끝이에요. 경합 없는 잠금의 비용은 원자적 CAS 한 번이에요. 풀 때는 반대로 displaced mark word를 CAS로 되돌려 놔요.
같은 스레드가 같은 객체에 재진입하면 어떻게 될까요? 락 레코드를 하나 더 쌓되, 이번엔 displaced mark word 자리를 비워 두는 식으로 "재귀 한 번"을 표시해요. 그래서 재진입의 비용은 거의 공짜에 가까워요. mark word는 이미 내 락 레코드를 가리키고 있으니 CAS조차 필요 없거든요.
flowchart TD
A["monitorenter on object"] --> B{"mark word tag?"}
B -->|"01 unlocked"| C["push lock record, copy header (displaced mark word)"]
C --> D{"CAS mark word to point at lock record"}
D -->|"success"| E["lightweight locked (tag 00) - one CAS, done"]
D -->|"fail: another thread owns it"| F["contention -> inflate"]
B -->|"00 already points to MY stack"| G["recursive enter: push empty lock record"]
B -->|"10 inflated"| H["go through ObjectMonitor"]
F --> H
여기까지가 행복한 경로예요. 문제는 CAS가 실패할 때, 즉 다른 스레드가 이미 그 객체를 잠그고 있을 때예요.
경합이 생기면 — 인플레이션과 ObjectMonitor
스택 락은 "잠깐 잠그고 바로 푸는" 상황에만 잘 들어맞아요. 두 스레드가 같은 객체를 두고 진짜로 다투기 시작하면, 가벼운 비트 장난만으로는 "기다리는 스레드를 어디에 재워 두고, 풀릴 때 누구를 깨울지"를 표현할 수 없어요. 이때 HotSpot은 인플레이션(inflation) 을 해요. 무거운 네이티브 모니터인 ObjectMonitor를 하나 만들고, 객체의 mark word가 그걸 가리키도록 태그를 10으로 바꿔요. 원래 mark word는 ObjectMonitor 안으로 옮겨 보관돼요.
인플레이션이 일어나는 계기는 세 가지예요.
- 경합 — 스택 락 CAS가 실패하거나, 잠긴 객체를 다른 스레드가 또 잠그려 할 때.
wait()/notify()사용 — 대기 집합(wait set)이라는 자료 구조가 필요한데, 이건ObjectMonitor에만 있어요.- identity hash code 요청 — 스택 락 상태에서는 mark word 자리에 락 레코드 포인터가 들어가 있어 hash code를 둘 곳이 없어요.
System.identityHashCode()가 호출되어 해시가 확정되면 그 값을 안전하게 보관하려고 모니터로 승격돼요.
ObjectMonitor의 내부
ObjectMonitor는 JVM 안에 C++로 구현된 구조체예요. 핵심 필드만 추리면 이래요.
| 필드 | 역할 |
|---|---|
_owner |
현재 모니터를 들고 있는 스레드 |
_recursions |
같은 스레드의 재진입 횟수 |
_EntryList / _cxq |
모니터에 들어가려고 막혀 기다리는 스레드들 |
_WaitSet |
Object.wait()로 자발적으로 잠든 스레드들 |
여기서 두 줄(_EntryList, _WaitSet)을 구분하는 게 중요해요. 둘 다 "기다리는 스레드 목록"이지만 성격이 완전히 달라요.
_EntryList에 있는 스레드는 락을 잡으려다 막힌 스레드예요. 자바 스레드 상태로는BLOCKED로 보여요._WaitSet에 있는 스레드는 스스로wait()를 불러 잠든 스레드예요. 상태는WAITING혹은TIMED_WAITING이에요.
notify()는 _WaitSet에서 스레드 하나를 꺼내 다시 락 경쟁(_EntryList)으로 옮겨요. notifyAll()은 전부 옮기고요. 중요한 건 notify()를 받았다고 바로 깨어나 실행되는 게 아니라는 점이에요. 깨어난 스레드도 결국 락을 다시 잡아야 하니까, notify()를 부른 스레드가 synchronized 블록을 빠져나가 락을 놓을 때까지 _EntryList에서 또 기다려요.
막힌 스레드는 바로 잠들까
경합이 생겼다고 곧장 OS 수준에서 스레드를 재우면(park) 문맥 교환 비용이 커요. 그래서 ObjectMonitor는 적응형 스피닝(adaptive spinning) 을 해요. 락이 곧 풀릴 것 같으면 잠깐 바쁘게 돌면서(spin) 기다려 보고, 그래도 안 풀리면 그제서야 진짜로 park 해서 OS에 양보해요. 이전에 스핀으로 락을 잘 잡았으면 다음엔 좀 더 오래 스핀하고, 자꾸 실패하면 스핀을 줄이는 식으로 스스로 조절해요. "잠깐만 기다리면 풀릴 락"과 "한참 걸릴 락"을 다르게 대접하는 거예요.
flowchart TD
A["thread wants the lock, monitor is owned"] --> B["adaptive spin: busy-wait briefly"]
B -->|"owner released in time"| C["acquire lock, become _owner"]
B -->|"still owned after spin"| D["park: enqueue on _EntryList / _cxq, OS sleeps thread (BLOCKED)"]
E["owner runs monitorexit"] --> F["unpark a successor from _EntryList"]
F --> A
G["owner calls notify()/notifyAll()"] --> H["move waiter(s) from _WaitSet to entry queue"]
H --> A
wait/notify가 synchronized 안에서만 되는 이유
Object.wait() / notify() / notifyAll()을 synchronized 밖에서 부르면 IllegalMonitorStateException이 나요. 이제 이유가 분명해져요. 이 세 메서드는 그 객체의 ObjectMonitor를 조작하는 일이에요. wait()는 호출 스레드를 _WaitSet에 넣으면서 들고 있던 락을 풀고, notify()는 _WaitSet에서 누군가를 꺼내요.
그런데 이 조작을 하려면 호출 스레드가 그 모니터의 소유자여야 해요. 소유하지 않은 모니터의 대기 집합을 건드리는 건 의미가 없으니까요. 자바 명세도 wait/notify는 현재 스레드가 그 객체의 모니터를 소유한 상태에서만 호출할 수 있고, 아니면 IllegalMonitorStateException을 던지도록 규정해요. 즉 synchronized는 단순한 잠금이 아니라 wait/notify가 동작하기 위한 전제 조건인 셈이에요.
한 가지 더, wait()는 락을 풀고 잠들었다가 깨어날 때 락을 다시 잡고 돌아와요. 그래서 wait() 전후의 코드가 같은 모니터를 들고 있다고 가정해도 안전해요. 다만 깨어나는 시점이 곧바로가 아니라는 점 때문에, wait()는 항상 조건을 다시 검사하는 while 루프 안에서 불러야 해요.
synchronized (queue) {
while (queue.isEmpty()) {
queue.wait(); // 락을 풀고 _WaitSet으로
}
return queue.removeFirst();
}
synchronized vs ReentrantLock — 같은 듯 다른 둘
여기서 자주 헷갈리는 지점을 한 번 짚고 갈게요. java.util.concurrent.locks.ReentrantLock도 "재진입 가능한 락"이고 이름도 비슷한데, synchronized와 무엇이 다를까요?
가장 큰 차이는 누가 구현하느냐예요. synchronized는 JVM에 내장된(intrinsic) 잠금이에요. 지금까지 본 mark word, 스택 락, ObjectMonitor가 전부 JVM 내부 구현이에요. 반면 ReentrantLock은 순수 자바 라이브러리로, AbstractQueuedSynchronizer(AQS) 위에 올라가 있어요. 대기 큐를 자바 코드로 직접 관리해요.
기능 차이를 정리하면 이래요.
| 항목 | synchronized |
ReentrantLock |
|---|---|---|
| 구현 위치 | JVM 내장 | 자바 라이브러리(AQS 기반) |
| 해제 | 블록·메서드 종료 시 자동 | unlock()을 직접 호출(보통 finally) |
| 타임아웃 잠금 | 불가 | tryLock(timeout) 가능 |
| 인터럽트 응답 | 진입 대기 중 불가 | lockInterruptibly() 가능 |
| 공정성(fairness) | 보장 안 함 | 생성 시 fair 모드 선택 가능 |
| 대기·통지 | wait/notify |
Condition(여러 개 가능) |
핵심은 "둘 중 뭐가 더 빠르냐"가 아니에요. 현대 JVM에서 경합 없는 synchronized는 충분히 가볍고, 경합이 심해도 둘의 성능은 상황에 따라 엎치락뒤치락해요. 진짜 선택 기준은 기능이에요. 타임아웃, 인터럽트, 공정성, 여러 개의 조건 변수가 필요하면 ReentrantLock을, 단순한 상호 배제면 자동 해제가 보장되는 synchronized를 쓰는 게 자연스러워요. 이 글이 다루는 mark word·인플레이션 이야기는 전부 synchronized 쪽이고, ReentrantLock은 완전히 다른 메커니즘(AQS)이라는 점만 분명히 해 두면 돼요.
한 번의 synchronized 호출의 일생
지금까지 본 조각들을 하나의 시나리오로 이어 볼게요. 두 스레드 A, B가 같은 객체 lock을 두고 경쟁하는 상황이에요.
- A가 첫 진입 —
lock의 mark word는01(unlocked). A는 자기 스택에 락 레코드를 만들고, displaced mark word를 복사한 뒤 CAS로 mark word를 자기 락 레코드로 바꿔요. 태그는00. 비용은 CAS 한 번. - A가 재진입 — A가 같은 객체의 다른
synchronized메서드를 호출. mark word는 이미 A의 스택을 가리키니, 빈 락 레코드만 하나 더 쌓고 끝. 거의 공짜. - B가 끼어듦 — B가
monitorenter시도. CAS가 실패해요. mark word가 A의 스택을 가리키고 있으니까요. 여기서 인플레이션이 일어나요.ObjectMonitor가 생기고 mark word 태그가10으로, A는 그 모니터의_owner가 돼요. - B는 스핀하다 park — B는 잠깐 적응형 스피닝을 하다가, A가 안 놓으면
_EntryList에 들어가 잠들어요. 이때 B의 스레드 상태는BLOCKED. - A가 조건을 만나 wait — A가
lock.wait()를 호출. A는_WaitSet으로 옮겨 가고 모니터를 풀어요. 상태는WAITING. - B가 깨어남 — 모니터가 풀렸으니
_EntryList의 B가 깨어나 모니터를 잡아요. B가_owner. - B가 notify 후 종료 — B가 일을 끝내고
lock.notify()를 부르면 A가_WaitSet에서_EntryList로 옮겨가요. B가synchronized를 빠져나가며 모니터를 놓으면 그제서야 A가 다시 모니터를 잡고wait()다음 줄부터 이어가요.
이 일곱 단계가 synchronized 한 쌍 안에서 일어나는 일의 전부예요. 경합이 없으면 1~2번에서 끝나고, 경합이 생기는 순간 3번부터의 무거운 경로로 넘어가요.
있었다가 사라진 최적화 — biased locking
여기까지가 지금(JDK 21 이후)의 그림이에요. 그런데 예전 자바를 공부했다면 biased locking(편향 잠금) 이라는 단어를 봤을 거예요. 지금은 없는 기능이라 짚고 넘어갈게요.
biased locking의 아이디어는 이거였어요. "거의 모든 잠금은 한 스레드만 쓴다. 그렇다면 처음 잠근 스레드 쪽으로 객체를 편향시켜 두고, 그 스레드가 다시 잠글 땐 CAS조차 생략하자." 경합 없는 CAS도 멀티코어에서는 캐시 라인을 건드리는 비용이라, 그것마저 없애겠다는 더 공격적인 최적화였어요. 다른 스레드가 그 객체를 건드리려 하면 safepoint에서 편향을 취소(revocation) 하고 일반 스택 락으로 되돌렸어요.
문제는 시간이 지나며 이득이 거의 사라졌다는 점이에요. JEP 374(JDK 15)는 이렇게 정리해요. biased locking으로 이득을 보던 코드는 대개 Vector, Hashtable처럼 내부적으로 synchronized를 쓰는 오래된 컬렉션을 쓰는 레거시 애플리케이션인데, 요즘 코드는 비동기화 컬렉션이나 java.util.concurrent를 쓰니 편향의 효과가 잘 안 나타난다는 거예요. 반대로 편향 취소는 safepoint를 동반해 비싸고, 무엇보다 biased locking 코드가 JVM을 복잡하게 만들어 새 기능 개발 속도를 눈에 띄게 늦추고 있었어요.
그래서 정리 수순을 밟았어요.
- JDK 15 (JEP 374) — biased locking을 기본 비활성화하고,
-XX:+UseBiasedLocking을 포함한 관련 플래그를 모두 deprecated 처리. 켜고 싶으면 명시적으로 옵션을 줘야 했어요. - JDK 18 (JDK-8256425) — biased locking 코드를 HotSpot에서 제거. mark word에서 편향에 쓰던 비트가 비고, 관련 테스트와 플랫폼별 보조 코드도 함께 사라졌어요.
지금 JDK에서 -XX:+UseBiasedLocking을 줘 봐야 아무 효과가 없어요. 옛날 블로그에서 "락은 편향 → 경량 → 중량으로 승격된다"는 3단계 설명을 보면, 그건 JDK 15 이전 이야기라고 생각하면 돼요. 지금은 경량(스택/lightweight) → 중량(인플레이션) 의 2단계예요.
다시 바뀐 경량 경로 — lightweight locking과 compact object headers
biased locking이 빠진 자리에 또 다른 변화가 들어왔어요. 전통적인 스택 락에는 구조적인 약점이 하나 있었어요. 잠글 때 객체 헤더(mark word)를 통째로 스택의 락 레코드 포인터로 덮어쓴다는 점이에요. 잠긴 동안 원래 헤더 정보는 스택으로 피신해 있으니, 그 객체의 헤더만 보고는 타입 같은 정보를 바로 알 수 없어요.
이게 문제가 된 이유는 JEP 450 Compact Object Headers(JDK 24 실험) 때문이에요. compact object headers는 메모리를 아끼려고 클래스 포인터를 mark word 안으로 압축해 넣어요. 즉 헤더 한 워드에 잠금 상태와 클래스 포인터가 같이 들어가요. 그런데 스택 락은 그 워드를 통째로 덮어쓰니, 클래스 정보가 사라져 버려요. 둘은 설계상 같이 갈 수 없어요.
그래서 헤더를 덮어쓰지 않는 새 경량 잠금 방식인 lightweight locking이 들어왔어요(JDK-8291555). mark word에 스택 주소를 박는 대신 태그 비트만 뒤집고, 모니터 연관 정보는 별도 자료 구조로 관리해 헤더를 보존해요. 동작 방식은 -XX:LockingMode 플래그로 고를 수 있어요.
| 값 | 모드 | 설명 |
|---|---|---|
0 |
LM_MONITOR |
경량 경로 없이 항상 인플레이션 |
1 |
LM_LEGACY |
전통적 스택 락(헤더를 덮어씀) |
2 |
LM_LIGHTWEIGHT |
헤더를 보존하는 새 경량 잠금 |
도입 흐름은 이래요. -XX:LockingMode는 JDK 21에서 실험 옵션으로 들어왔고(JDK-8305999), JDK 22에서 정식 product 옵션이 됐고, JDK 23부터 기본값이 LM_LIGHTWEIGHT 로 바뀌었어요. 그리고 compact object headers는 헤더를 덮어쓰는 LM_LEGACY와는 함께 켤 수 없어요.
여기서 우리가 챙길 포인트는 세부 구현이 아니라 방향이에요. synchronized의 경량 경로는 "어떻게든 객체 헤더를 덜 망가뜨리는 쪽"으로 진화해 왔고, 그 동기는 메모리를 아끼는 compact object headers와 맞물려 있다는 거예요. 자바를 쓰는 입장에서 코드는 바뀐 게 없지만, 같은 synchronized 한 줄의 내부 구현은 JDK 15·18·22·23을 거치며 꽤 많이 달라졌어요.
가상 스레드와 synchronized — 핀닝 이야기
synchronized의 내부 구현이 최근에도 바뀐 더 큰 이유 하나는 가상 스레드(virtual thread) 예요.
가상 스레드는 블로킹될 때 자기를 태우고 있던 캐리어(플랫폼) 스레드에서 내려와(unmount), 그 캐리어 스레드를 다른 가상 스레드가 쓰도록 양보하는 게 핵심이에요. 그런데 JDK 21~23에서는 가상 스레드가 synchronized 블록 안에서 블로킹되면 캐리어 스레드를 놓지 못하고 핀닝(pinning) 됐어요. 모니터를 캐리어 스레드가 들고 있는 것으로 JVM이 인식했기 때문이에요. 핀닝이 길고 잦으면, 가용한 플랫폼 스레드가 전부 핀닝돼 가상 스레드를 더 못 돌리는 기아(starvation)나 데드락까지 갈 수 있었어요. 그래서 초기 가상 스레드 가이드는 "락이 잡힌 구간에서는 synchronized 대신 ReentrantLock을 고려하라"고 권하기도 했어요.
이 문제는 JEP 491 "Synchronize Virtual Threads without Pinning"(JDK 24) 으로 해소됐어요. synchronized 안에서 블로킹된 가상 스레드도 캐리어 스레드를 풀어 주도록 모니터 구현이 손질됐어요. JDK 24부터는 과거에 알려져 있던 핀닝 시나리오 대부분이 사라졌고(네이티브 메서드나 FFM API를 통한 호출 안에서 블로킹되는 일부 경우만 여전히 핀닝), 가상 스레드 코드에서 굳이 synchronized를 피해 다닐 이유가 크게 줄었어요.
이게 앞에서 본 lightweight locking 흐름과 연결돼요. mark word를 덜 망가뜨리고 모니터 정보를 깔끔하게 분리하는 방향의 작업이, compact object headers뿐 아니라 가상 스레드의 핀닝 제거에도 토대가 됐다고 볼 수 있어요. synchronized라는 오래된 키워드가 Loom 시대에 맞게 다시 다듬어진 거예요.
인플레이션된 모니터는 영원히 남을까
마지막 의문 하나. 한 번 경합이 생겨 ObjectMonitor로 인플레이션된 객체는, 경합이 사라진 뒤에도 계속 무거운 모니터를 달고 다닐까요?
그러면 오래 도는 서버에서 모니터가 끝없이 쌓일 거예요. 그래서 HotSpot은 더 이상 쓰이지 않는 ObjectMonitor를 회수하는 deflation(디플레이션) 을 해요. 소유자가 없고 대기/진입 큐가 모두 비어 있는 idle 모니터가 회수 대상이에요.
요즘 HotSpot은 이 회수를 safepoint에서 멈춰 세우지 않고 동시에(concurrent) 처리해요. ServiceThread가 백그라운드에서 idle 모니터를 떼어내 단계적으로 free 목록으로 보내요. 덕분에 잠깐의 경합 때문에 인플레이션됐던 객체도, 경합이 끝나면 모니터가 정리돼 메모리가 무한정 늘지 않고, 그 정리 작업이 애플리케이션 멈춤(pause)으로 이어지지도 않아요. "한 번 무거워지면 끝"이 아니라 "필요할 때 무거워졌다가 다시 가벼워질 수 있다"는 게 핵심이에요.
직접 확인해보기
글로만 보면 추상적이니, 손으로 확인할 수 있는 도구를 정리할게요.
synchronized가 어떤 바이트코드로 컴파일됐는지 보기:
javap -c -p Counter
# synchronized 블록 -> monitorenter / monitorexit
# synchronized 메서드 -> flags 에 ACC_SYNCHRONIZED
경합 중인 스레드가 _EntryList(BLOCKED)인지 _WaitSet(WAITING)인지 보기. 스레드 덤프를 뜨면 상태가 그대로 드러나요:
jstack <pid>
# "waiting to lock <0x...>" -> 모니터 진입 대기 (BLOCKED)
# "in Object.wait()" -> _WaitSet 에서 대기 (WAITING)
현재 JVM의 잠금 모드 확인:
java -XX:+PrintFlagsFinal -version | grep LockingMode
# JDK 23+ 기본값은 2 (LM_LIGHTWEIGHT)
객체 헤더(mark word)가 잠금에 따라 어떻게 바뀌는지 직접 보고 싶다면 OpenJDK의 JOL(Java Object Layout) 도구로 synchronized 전후의 헤더 비트를 출력해 비교해 볼 수 있어요.
import org.openjdk.jol.info.ClassLayout;
public class HeaderProbe {
public static void main(String[] args) {
Object lock = new Object();
// 1) unlocked: 헤더의 태그 비트가 01
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
// 2) locked: 같은 객체의 헤더 비트가 바뀌어 있음
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}
}
JOL 의존성을 추가하고 위 코드를 돌리면, unlocked 상태에서 보이던 태그 비트가 synchronized 안에서 다른 패턴으로 바뀌어 있는 걸 눈으로 확인할 수 있어요. -XX:LockingMode 값을 바꿔 가며 같은 코드를 돌려 보면, 레거시 스택 락과 lightweight locking이 헤더를 어떻게 다르게 다루는지도 비교해 볼 수 있어요.
정리
synchronized 한 줄을 따라 내려오면서 본 그림을 정리하면 이래요.
- 문법은 블록(
monitorenter/monitorexit)과 메서드(ACC_SYNCHRONIZED)로 둘이지만, 잠그는 메커니즘은 하나예요. 모니터에는 소유자와 진입 횟수가 있어 재진입이 되고, 명세는 구조적 잠금을 요구해요. - 모든 객체의 헤더에는 mark word가 있고, 하위 태그 비트로 잠금 상태를 표현해요. 경합이 없으면 CAS 한 번으로 끝나는 경량 경로를 타요.
- 경합이 생기거나
wait/notify·identity hash가 필요하면 인플레이션이 일어나 네이티브ObjectMonitor로 승격돼요. 진입 대기는_EntryList(BLOCKED),wait()대기는_WaitSet(WAITING)으로 갈려요.wait/notify가synchronized안에서만 되는 이유는 그것이 모니터 소유를 전제로 한 모니터 조작이기 때문이에요. - biased locking은 JDK 15에서 비활성화(JEP 374), JDK 18에서 제거됐어요. 지금은 경량 → 중량 2단계예요.
- 경량 경로는 헤더를 덮어쓰지 않는 lightweight locking으로 진화했고(JDK 21 실험 → 22 정식 → 23 기본), 그 동기는 메모리를 아끼는 compact object headers와 맞물려 있어요.
- JEP 491(JDK 24) 로
synchronized안에서 블로킹된 가상 스레드가 더 이상 캐리어 스레드를 핀닝하지 않아요. - 인플레이션된 모니터는 동시 deflation으로 회수돼서, 잠깐의 경합 때문에 무거워졌던 객체도 다시 가벼워질 수 있어요.
ReentrantLock은 AQS 기반의 별개 메커니즘이에요. 이 글의 mark word·인플레이션 이야기는 전부 JVM 내장synchronized쪽 이야기예요.
코드는 그대로인데 내부 구현은 JDK를 거치며 계속 달라져 왔어요. synchronized를 "그냥 동작하는 키워드"가 아니라 "지금 내 JDK에서 어떤 경로로 동작하는지 아는 도구"로 바라보면, 락 경합을 진단하거나 스레드 덤프를 읽을 때 보이는 게 훨씬 많아질 거예요.
참고자료
- The Java Virtual Machine Specification, SE 21 — Chapter 6 (monitorenter / monitorexit)
- The Java Virtual Machine Specification, SE 21 — §2.11.10 Synchronization, structured locking
- The Java Virtual Machine Specification, SE 21 — Chapter 4 (ACC_SYNCHRONIZED method flag)
- Java SE 21 API — Object.wait / notify / notifyAll
- Java SE 21 API — Thread.State (BLOCKED / WAITING)
- JEP 374: Deprecate and Disable Biased Locking
- JDK-8256425: Obsolete Biased Locking in JDK 18
- JEP 450: Compact Object Headers (Experimental)
- JDK-8291555: Implement alternative fast-locking scheme (lightweight locking)
- JDK-8305999: Add experimental -XX:LockingMode flag
- JEP 491: Synchronize Virtual Threads without Pinning
- OpenJDK HotSpot Wiki — Synchronization Using The ObjectMonitorTable
- OpenJDK HotSpot Wiki — Async Monitor Deflation

