← all posts
DEV 2026.05.02 · 11 min read Advanced

JVM 동기화는 어떻게 작동하는가

Object Monitor부터 Virtual Thread까지, JVM이 스레드 경쟁을 처리하는 방식과 그 비용을 단계별로 추적한다.


synchronized 키워드 하나가 JVM 내부에서 얼마나 많은 층을 거치는지 생각해본 적 있는가? Object Monitor, Mark Word, CAS, AQS, Safepoint — 이 모든 것이 스레드 안전성이라는 하나의 목표 아래 맞물려 있다. 그렇다면 이 구조 전체를 관통하는 설계 원리는 무엇인가?

락의 출발점: Object Monitor

JVM에서 모든 객체는 잠재적인 락이다. synchronized (obj)를 쓰는 순간 JVM은 해당 객체의 ObjectMonitor를 찾는다. C++ 구조체인 ObjectMonitor는 세 가지 핵심 필드를 갖는다.

ObjectMonitor:
  _owner     → 현재 락 소유 스레드
  _EntryList → synchronized 진입을 기다리는 스레드들
  _WaitSet   → wait()를 호출해 조건을 기다리는 스레드들

Entry Set과 Wait Set의 차이가 중요하다. Entry Set은 락 획득 경쟁 중이고, Wait Set은 조건 신호를 기다린다. wait()는 락을 해제하고 스레드를 Wait Set으로 옮긴다. notify()는 Wait Set의 스레드를 Entry Set으로 이동시킬 뿐, 즉시 실행하지 않는다 — notify() 호출자가 락을 놓아야 비로소 깨어난 스레드가 경쟁에 참여할 수 있다.

흔한 오해

wait()은 락을 유지한 채 대기하지 않는다. 락을 해제해야 다른 스레드가 notify()를 호출할 수 있기 때문이다. 깨어난 후에는 락을 다시 획득해야 진행된다.

Mark Word와 락 상태 전이

ObjectMonitor를 생성하는 것은 비싸다(~1000ns). 그래서 JVM은 대부분의 동기화가 경쟁 없이 끝난다는 관찰을 바탕으로 세 단계 락 전략을 도입했다.

Unlocked (001)
    ↓ 첫 synchronized
Biased Lock (101)  — Thread ID만 저장, ~5ns
    ↓ 다른 스레드 진입 시도
Thin Lock (00)     — CAS 기반 Stack Lock Record, ~50ns
    ↓ CAS 실패 (경쟁)
Fat Lock (10)      — ObjectMonitor, ~1000ns

전이는 한 방향만 가능하다. Mark Word는 객체 헤더 64비트 안에 현재 락 상태와 포인터를 함께 인코딩한다. Biased Lock은 단일 스레드가 반복 획득할 때 CAS조차 생략하지만, 다른 스레드가 진입을 시도하면 Safepoint에서 Revoke(~10,000ns)해야 한다. Java 15 이후 Biased Lock이 기본 비활성화된 이유다 — 현대 멀티스레드 워크로드에서는 Revoke 비용이 이득을 앞서는 경우가 많다.

CAS: Lock-Free 동기화의 핵심

Thin Lock과 이후 등장하는 모든 고수준 동기화 도구는 CAS(Compare-And-Swap) 위에 서 있다.

// AtomicInteger 내부 (간략화)
public final int incrementAndGet() {
    int v;
    do {
        v = getIntVolatile(this, valueOffset);
    } while (!compareAndSwapInt(this, valueOffset, v, v + 1));
    return v + 1;
}

x86에서 lock cmpxchg 명령어 하나로 비교와 교체를 원자적으로 수행한다. 경쟁이 적을 때 CAS는 synchronized보다 4배 빠르다. 그러나 경쟁이 심해지면 스핀 재시도가 CPU를 낭비한다 — 이때는 스레드를 재우는 synchronized가 오히려 낫다.

CAS의 구조적 약점은 ABA 문제다. 값이 A→B→A로 바뀌어도 CAS는 성공한다. AtomicStampedReference는 값과 스탬프를 쌍으로 비교해 이를 해결한다.

AQS: 동기화 도구의 공통 뼈대

ReentrantLock, Semaphore, CountDownLatch는 모두 AbstractQueuedSynchronizer 위에 구현된다. AQS의 핵심은 단순하다 — volatile int state 하나와 CLH Queue.

AQS state:
  ReentrantLock → 재진입 횟수
  Semaphore     → 남은 permits
  CountDownLatch → 남은 카운트

CLH Queue: head → [Node A] → [Node B] → [Node C] ← tail
  각 Node: 스레드 참조 + waitStatus

락 획득에 실패한 스레드는 큐에 노드를 추가하고 LockSupport.park()로 대기한다. 해제 시 unpark()로 다음 노드를 깨운다. synchronized의 Entry Set과 유사하지만, AQS는 Fair/Unfair 모드타임아웃, 인터럽트 응답을 세밀하게 제어할 수 있다.

트레이드오프

Fair Lock은 FIFO를 보장하지만 hasQueuedPredecessors() 체크 비용이 있다. Unfair Lock(기본값)은 큐를 건너뛰고 즉시 CAS를 시도해 2~5배 빠르지만 일부 스레드가 오래 기다릴 수 있다. synchronized는 항상 Unfair다.

Virtual Thread: 패러다임의 전환

Java 21의 Virtual Thread는 앞선 모든 최적화와 다른 층의 문제를 다룬다. Platform Thread는 OS 스레드와 1:1 매핑되어 스택에 1~2MB를 쓴다. 10,000 연결은 10,000 스레드 — 불가능하지 않지만 비싸다.

Virtual Thread는 M:N 매핑이다. 수백만 개의 Virtual Thread가 CPU 코어 수만큼의 Carrier Thread(ForkJoinPool) 위에서 실행된다. 핵심은 Blocking I/O 시 Unmount다 — socket.read()가 블록되면 Virtual Thread만 멈추고, Carrier는 다른 Virtual Thread를 실행한다.

// I/O-bound 작업: Virtual Thread가 압도적
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> socket.read(buffer));  // Blocking
    }
}
// Platform Thread 200개: 50,000ms
// Virtual Thread: 1,000ms (50배)

단, synchronized 블록 안에서 블록되면 Carrier가 함께 고정(Pinning)된다. synchronized 대신 ReentrantLock을 쓰면 Unmount가 가능하다.

정리

  • synchronized는 Object Monitor → ObjectMonitor(_EntryList/_WaitSet) → OS park/unpark 순서로 동작한다.
  • 락은 Biased → Thin → Fat 순으로 전이되며, 비용은 5ns → 50ns → 1000ns다.
  • CAS는 Lock-Free 동기화의 기반이지만, 경쟁이 심하면 스핀 비용이 커진다.
  • AQS는 state + CLH Queue로 ReentrantLock/Semaphore/CountDownLatch를 통일한다.
  • Virtual Thread는 I/O-bound 작업에서 Platform Thread 대비 수십 배 처리량을 낸다. CPU-bound에는 이점이 없다.

JVM 동기화의 모든 계층은 같은 질문에 답한다 — 경쟁이 없을 때는 최대한 빠르게, 경쟁이 있을 때는 CPU를 낭비하지 않고 기다리게. 그 균형점이 계층마다 다를 뿐이다.