본문 바로 가기

[자바] synchronized는 어떻게 Lock을 거는가?

들어가며

자바에서 synchronized 는 아주 흔하게 쓰이는 키워드입니다. 인스턴스에 락이 걸린다는 사실은 알고 있었지만 정확히 객체내 어디에 락이 걸리는지에 대한 의문이 들었습니다. 이 의문에서 시작해서 실제 JVM 내부 코드와 함께 synchronized가 어떻게 작동하는지를 추적해보았습니다.

 

추가적으로, 이 글에서는 코드의 복잡도를 줄이기 위해 여러가지 최적화, 락 재진입 등의 로직은 생략하고 설명합니다. 정확한 구현이 궁금하다면 각 링크를 참고하시는 것을 추천드립니다.

환경

JDK 17

📌 TL;DR (요약)

✅ synchronized 키워드를 쓸경우, 객체의 Mark Word에 락이 걸리게 된다.
✅ 경량 락이 실패하면 ObjectMonitor를 사용한 경쟁 상태로 진입한다.

☕️ 이론: synchronized의 Lock 상태

synchronized를 사용하면 JVM은 객체의 Mark Word를 이용해 락 정보를 기록합니다.
그리고 락의 경합 정도에 따라 3단계의 상태를 가집니다:

  • Biased Lock (Java 15 이후 deprecated)
  • ✅ Lightweight Lock
  • ✅ Heavyweight Lock

이 글에서는 Lightweight → Heavyweight 전환 과정을 중심으로 설명합니다.

 

Lightweight Lock

synchronized를 사용할 경우, 우선 경량 락(Lightweight Lock)을 시도합니다.

  1. 쓰레드는 객체의 Mark Word 값을 스레드 자신의 스택에 저장합니다.
  2. 그 다음, Mark Word를 해당 저장 위치를 가리키는 포인터(CAS 연산)로 교체하려고 합니다.

Heavyweight Lock

기존에 Lightweight Lock을 가진 쓰레드가 존재해서 위의 교체가 실패하면 Heavyweight Lock으로 전환됩니다.
이 시점에서 JVM은 ObjectMonitor 객체를 생성하여 본격적인 락 경쟁을 관리합니다.

🍤 ObjectSynchronizer::enter

이 흐름은 ObjectSynchronizer::enter() 함수 내부에 잘 나타납니다.

🔗 코드 원본 보기

void ObjectSynchronizer::enter(Handle obj, BasicLock* lock, JavaThread* current) {

  //경량 락 획득 시도
  if (mark.is_neutral()) {
    if (mark == obj()->cas_set_mark(markWord::from_pointer(lock), mark)) {
      return;
    }
  }

  //경량 락 획득 실패 -> 경쟁 상황 발생시 HeavyWeight Lock 으로 전환
  while (true) {
    ObjectMonitor* monitor = inflate(current, obj(), inflate_cause_monitor_enter);
    if (monitor->enter(current)) {
      return;
    }
  }
}

위 코드의 핵심은 "경량 락이 실패하면, Heavyweight Lock으로 전환"되는 흐름입니다.
이때 생성되는 ObjectMonitor는 진짜 경합 상태를 처리하는 구조체입니다.

ObjectMonitor 는 왜 필요할까?

모니터의 여러 기능들 구현을 위해 필요하다. wait set, entry set을 관리하고, 현재 락 소유자를 추적하고 wait(), notify() 기능을 제공하기 위해서는 이를 관리해주는 대상이 필요한데, 그걸 ObjectMonitor 가 관리해준다.

🍬 ObjectMonitor::enter

스레드들간의 경합이 일어나는 코드가 들어있다. 단순히 entry set 에 추가하는 로직이 있을 줄 알았지만, 여러가지 최적화 방식이 들어 있었다.

🔗 코드 원본 보기

bool ObjectMonitor::enter(JavaThread* current) {

  //가볍게 CAS 연산으로 락 획득 시도
  void* cur = try_set_owner_from(NULL, current);
  if (cur == NULL) {
    assert(_recursions == 0, "invariant");
    return true;
  }

  //잠깐 CPU 스핀돌면서 락 획득 시도
  if (TrySpin(current) > 0) {
    return true;
  }

  //대기스택에 들어가는걸 시도 - EnterI 를 빠져나왔다면 락을 획득한 상태다
  for (;;) {
     EnterI(current);
   }

  return true;
}

경합이 적은 일반적인 상황에서는 CAS를 통해 빠르게 처리하고, 약간의 경합 시에는 스핀으로 컨텍스트 스위칭 비용을 회피하며, 실제 경합이 발생했을 때만 비용이 높은 블로킹/큐잉 방식을 사용하는 점진적 백오프(Progressive Backoff) 전략을 통해 성능을 최적화하려는 노력이 담겨 있습니다.

🍍 ObjectMonitor::EnterI

진짜 락 경쟁이 시작되는 곳입니다.
쓰레드는 _cxq라는 대기 스택에 push되고, 이후 반복적으로 락 획득을 시도합니다. 함수를 빠져나오는건 반드시 락을 획득한 후에 가능합니다.

🔗 코드 원본 보기

void ObjectMonitor::EnterI(JavaThread* current) {

    //마지막으로 한번 스핀 돌면서 락 획득 시도
    if (TrySpin(current) > 0) {
        return;
      }

    //원자적으로 대기스택 에 push하는 과정
    for (;;) {
        node._next = nxt = _cxq;
        if (Atomic::cmpxchg(&_cxq, nxt, &node) == nxt) 
            break;
      }

      //일종의 sleep 상태에 빠지면서 무한 대기
      for (;;) {
        if (TryLock(current) > 0) break;

          current->_ParkEvent->park((jlong) recheckInterval);
          recheckInterval *= 8;
          if (recheckInterval > MAX_RECHECK_INTERVAL) {
              recheckInterval = MAX_RECHECK_INTERVAL;
         }

        if (TryLock(current) > 0) break;
     }

     //락획득 
     return;
}

_cxq 대기스택

이 코드는 _cxq 에 원자적으로 푸쉬하는 내용이다. 다만 한번에 이해하기가 힘들다. 차근차근 알아보자.

    for (;;) {
        node._next = nxt = _cxq;
        if (Atomic::cmpxchg(&_cxq, nxt, &node) == nxt) 
            break;
      }

 

코드의 목표: 여러 스레드가 동시에 '대기열'(정확히는 스택처럼 동작하는 _cxq)에 자신( node )을 thread safe하게 추가(push)하는 것. 이를 위해 락(Lock)을 사용하지 않고 원자적 연산인 cmpxchg를 사용합니다.

첫줄은 아래와 같은 뜻입니다.

nxt = _cxq;
node._next = nxt;

 

cmpxchg 는 CAS 연산자 입니다. 파라미터 형태는 아래와 같습니다.
Atomic::cmpxchg(addr, expected_value, new_value)
간단히 말하면, addr 주소값에 expected_value 가 존재한다면 그 값을 new_value 로 바꿔준다는 뜻입니다.

 

자세한 사항은 블로그에 자세히 정리해 두었습니다. cmpxchg란?

 

핵심 변수:
_cxq: 대기열(스택)의 맨 앞(Head)을 가리키는 포인터. 가장 최근에 추가된 노드를 가리킵니다.
node: 현재 이 코드를 실행 중인 스레드를 나타내는 노드. 이 노드를 _cxq에 추가하려고 합니다.
nxt: _cxq의 현재 값을 임시로 저장하는 포인터 변수.

 

이해를 돕기 위해서 시각 자료를 만들어 보았습니다.

초기 상황 : 1번 쓰레드와 2번 쓰레드가 순서대로 스택에 들어간 상황.

node._next = nxt = _cxq; 실행. 현재 쓰레드는 3번

Atomic::cmpxchg(&_cxq, nxt, &node) == nxt 실행

nxt 와 _cxq 가 동일하게 2번을 가리키고 있으므로, _cxq 를 node 로 변경한다. 정상적으로 push 성공

만약, 그사이에 4번 쓰레드가 push 되었다면?


nxt 와 _cxq 가 서로 다른 쓰레드를 가리키므로 Atomic::cmpxchg(&_cxq, nxt, &node) == nxt 를 만족시키지 않고 다시 푸쉬를 시도하게 된다.

💤 park()란?

  • 일종의 Thread.sleep()과 유사합니다.
  • 락을 계속 시도하는 것은 CPU 낭비이므로, 잠시 멈췄다가 다시 시도합니다.
  • recheckInterval8배씩 증가 → 점점 더 느긋하게 기다림

전체 흐름

다시한번 전체적인 흐름을 되짚어 보자.

결론

인스턴스에 락을 건다는 것은 Mark Word를 통해 실제 메모리 영역에 락 정보를 남기고,

경쟁 상황에 따라 경량 락 → 중량 락으로 점진적으로 전환되며

최종적으로는 ObjectMonitor를 통해 대기 큐와 락 소유를 관리하게 된다는 것입니다.

 

더 깊은 내용을 다루지 않은 이유는, 한 번에 다루기엔 구현이 너무 방대하고 복잡하기 때문입니다. 코드 중간중간 생략한 부분이 매우 많습니다. 직접 코드를 본다면 많은 도움이 될 것입니다.


다음 글에서는 cxq 이후 큐 정렬 로직, notify()wait() 호출 시 흐름 등도 정리해볼 예정입니다.

Reference

https://github.com/openjdk/jdk17u/blob/master/src/hotspot/share/runtime/synchronizer.cpp
https://github.com/openjdk/jdk17u/blob/master/src/hotspot/share/runtime/objectMonitor.cpp