멀티스레드 코드를 처음 짤 때는 synchronized 하나면 충분했다. 그런데 라이브러리 코드나 동시성 잘 짠 코드를 읽다 보면 synchronized 대신 ReentrantLock을 꺼내 쓰는 경우가 자주 보인다. "둘 다 상호배제(mutual exclusion)인데 왜 굳이?"라는 의문이 들어서, 두 락이 어디서 동작하고, 무엇으로 happens-before를 보장하며, 기능적으로 무엇이 다른지를 따라가 봤다.
기준 문서는 두 갈래다. synchronized의 의미와 메모리 모델은 Java Language Specification(JLS) §17(Threads and Locks)에 정의돼 있고, ReentrantLock의 동작은 java.util.concurrent.locks 패키지 문서와 AbstractQueuedSynchronizer(AQS) 쪽에서 확인했다. 버전마다 미묘하게 다른 JVM 락 최적화 동작은 단정하기보다 "대체로 이런 흐름"으로 적는다.
한 문장으로 줄이면 이렇게 이해했다.
synchronized는 JVM(언어/런타임)에 내장된 모니터 락이고,ReentrantLock은 라이브러리(java.util.concurrent)로 구현된 락이다. 둘 다 재진입 가능하고 상호배제를 보장하지만, 후자는 그 위에 타임아웃·인터럽트·공정성·다중 Condition 같은 제어 기능을 얹은 것이다.
Lock 인터페이스의 구현체로, 명시적으로 lock() / unlock()을 호출한다. 이름 그대로 재진입이 가능하고, 내부는 AQS라는 동기화 프레임워크 위에 올라가 있다.두 락 모두 "재진입"이라는 이름값을 하지만, 결정적 차이는 락을 거는 주체가 누구냐다. synchronized는 JVM이 락의 획득/해제를 대신 관리해 주고, ReentrantLock은 개발자가 직접 해제를 책임진다.
synchronized 블록은 컴파일하면 바이트코드 monitorenter / monitorexit 한 쌍으로 바뀐다. 동기화 메서드는 별도 바이트코드 대신 메서드 플래그 ACC_SYNCHRONIZED가 붙고, 진입/이탈 시 JVM이 같은 모니터 동작을 수행하는 것으로 알려져 있다.
public void inc() {
synchronized (this) { // monitorenter
count++;
} // monitorexit (정상/예외 경로 모두)
}
HotSpot에서는 이 모니터 락을 한 번에 무거운 OS 뮤텍스로 잡지 않고, 경합 정도에 따라 단계적으로 승격(inflation)시키는 것으로 이해했다. 객체 헤더의 mark word 를 이용한 흐름은 대략 이렇다.
| 단계 | 상황 | 동작(대략) |
|---|---|---|
| Biased Lock | 단일 스레드가 반복 진입 | mark word에 스레드 ID 기록, CAS 없이 통과 |
| Lightweight Lock | 짧은 경합 | mark word에 CAS로 락 레코드 포인터 교체(스핀) |
| Heavyweight Lock | 경합 심함 | OS 모니터(뮤텍스)로 승격, 스레드 블로킹 |
다만 biased locking은 JDK 15(JEP 374)에서 기본 비활성화·폐기 예정으로 바뀐 것으로 알려져 있다. 그래서 최신 JDK에서는 위 표의 첫 단계가 사라졌다고 보는 게 안전하다. 이런 부분은 버전에 따라 동작이 달라지므로 "옛날 글의 biased lock 설명"을 그대로 믿지 않는 편이 좋다.
ReentrantLock은 내부에 Sync extends AbstractQueuedSynchronizer를 두고, 모든 락 동작을 AQS에 위임한다. AQS의 핵심은 두 가지다.
volatile int state: 락 보유 횟수. 0이면 free, 1 이상이면 점유 중(재진입하면 +1).획득의 뼈대는 state에 대한 CAS(compare-and-set)다.
// 비공정(nonfair) 획득의 개념 흐름 (실제 구현 요약)
final boolean nonfairTryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
if (c == 0) { // 비어 있으면
if (compareAndSetState(0, acquires)) { // CAS로 선점 시도
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
setState(c + acquires); // 재진입: 카운트만 증가
return true;
}
return false; // 실패 → 큐에 들어가 park
}
CAS에 실패한 스레드는 큐에 노드로 들어가 LockSupport.park()로 블로킹되고, 앞 스레드가 unlock()(state를 0으로 release)하면서 다음 노드를 깨운다. 공정(fair) 모드는 여기서 한 줄이 다르다 — 큐에 먼저 기다리는 스레드가 있으면 새 스레드가 끼어들지(barging) 못하게 막는다. 비공정 모드(기본값)는 끼어들기를 허용해서 처리량이 더 높은 대신 굶주림(starvation) 가능성이 있다고 정리했다.
기능 차이와 별개로, 두 락 모두 메모리 가시성을 보장한다는 점은 같다. JLS §17.4의 happens-before 규칙에 따르면 모니터의 unlock은 그 모니터의 이후 lock보다 happens-before 다. ReentrantLock도 java.util.concurrent.locks 패키지 문서가 명시하듯, unlock 이전의 모든 동작은 이후의 성공한 lock 다음 동작보다 happens-before 다. 즉 한쪽이 락 안에서 쓴 값은 다른 쪽이 락을 잡은 뒤 반드시 보이는데, 그 토대는 AQS의 state가 volatile이라는 점이다.
기능 차이를 한 표로 정리하면 이렇다.
| 항목 | synchronized | ReentrantLock |
|---|---|---|
| 수준 | JVM 내장(키워드) | 라이브러리(클래스) |
| 해제 | JVM이 자동(블록 이탈/예외) | 개발자가 unlock() 직접 |
| 타임아웃 획득 | 불가 | tryLock(timeout) |
| 인터럽트 응답 | 대기 중 불가 | lockInterruptibly() |
| 공정성 선택 | 불가(비공정) | 생성자 인자로 fair 선택 |
| 조건 변수 | wait/notify 1개 | 여러 개의 Condition |
ReentrantLock이 가져다주는 실질적 이점은 "락을 무한정 기다리지 않을 수 있다"는 점이다. synchronized는 한 번 대기에 들어가면 인터럽트로도 빠져나올 수 없지만, tryLock은 포기가 가능하다.
private final ReentrantLock lock = new ReentrantLock();
void transfer() throws InterruptedException {
// 1초 안에 못 얻으면 포기 → 데드락/무한대기 회피
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// 임계 구역
} finally {
lock.unlock(); // 반드시 finally에서 해제
}
} else {
// 락 획득 실패 시 대안 경로
}
}
여기서 자주 하는 실수 하나: unlock()을 finally에 넣지 않는 것이다. synchronized는 예외가 나도 JVM이 모니터를 풀어 주지만, ReentrantLock은 그 책임이 전적으로 개발자에게 있어서 finally 누락 시 락이 영영 풀리지 않는다.
synchronized는 JVM이 관리하는 모니터 락, ReentrantLock은 AQS의 volatile state + FIFO 큐로 구현한 라이브러리 락이다. 메모리 가시성(happens-before)은 둘 다 보장하지만, 타임아웃·인터럽트·공정성·다중 Condition은 후자만 제공한다.synchronized가, 세밀한 제어가 필요하면 ReentrantLock이 맞다고 정리했다.더 파고들 만한 주제:
ReentrantLock은 배타 모드지만, Semaphore·CountDownLatch는 같은 AQS의 공유(shared) 모드를 쓴다.ReentrantReadWriteLock: 하나의 state(32비트)를 읽기/쓰기 카운트로 쪼개 쓰는 방식.java.util.concurrent.locks 패키지 문서 — Lock/ReentrantLock의 메모리 일관성 보장AbstractQueuedSynchronizer API 문서 — state와 FIFO 대기 큐