최근 운영체제를 공부하면서 세마포어, 모니터에 대해서 접하게 되었고
Java에서는 이를 어떻게 구현하고 있을까 알아보며 해당 내용을 정리하게 되었다.
세마포어는 락을 획득하는 P 연산, 락을 해제하는 V 연산, 그리고 자원 개수를 나타내는 변수로 구성된 추상 자료형(ADT, Abstract Data Type)이다.
"추상 자료형"이라는 말은, 그 개념은 명확히 정의되어 있지만 구현은 프로그래머가 직접 정의해야 한다는 뜻이다.
즉, 프로그래머가 자원 보호를 위해 직접 P/V 연산을 조합해 동기화를 구현해야 하므로 실수하기 쉽고 번거롭다.
이러한 불편함을 해결하기 위해 프로그래밍 언어 차원에서 세마포어를 추상화한 것이 바로 "모니터(monitor)"이다.
그렇다면 Java에서는 이를 어떻게 구현하고 있는지 좀 더 구체적으로 살펴보자
Java에서는 모니터 개념을 synchronized 키워드를 통해 제공한다.
또한, Java는 스핀락(spin lock)으로 인한 CPU 낭비를 줄이기 위해 block & wait 구조를 사용한다.
이 구조는 모니터 락을 획득하지 못한 스레드가 바쁘게 CPU를 돌리는 대신, wait() / notify() 메커니즘을 통해 잠들었다가 깨어나는 방식으로 동작한다.
이때 사용하는 wait(), notify(), notifyAll()은 모든 객체가 상속받는 Object 클래스에 정의되어 있기 때문에,
Java의 모든 객체는 모니터 기능을 내장하고 있다. 물론, 이 메서드들은 반드시 synchronized 블록 안에서 호출되어야 정상적으로 작동한다.
synchronized는 락을 통한 동기화를 제공하는 대표적인 키워드이며, Java는 CPU 낭비를 줄이기 위해 단순한 spin lock이 아닌 block & wait 구조를 채택하고 있다. 그렇다고 해서 spin lock을 전혀 사용하지 않는 것은 아니다.
synchronized는 락 경쟁이 없는 상황에서는 경량 락(Lightweight Lock)을 사용한다.
이때는 객체의 Mark Word를 CAS로 갱신하며 락을 획득하려 시도하고, 짧은 시간 동안 spin하여 락을 기다린다.
가벼운 경우 spin lock을 사용하는 이유는 기다리는 시간이 짧을 것이라는 보장이 되어져 있기 때문이다.
경쟁이 있는 상황에서는 중경 락을 사용하는데 이는 상태 전이가 발생한다. 즉 경량 락은 상태 전이 없이 락을 획득할 수 있으므로, context switching 비용을 줄이는 데 유리하다.
하지만 다음과 같은 경우에는 즉시 중량 락(Heavyweight Lock)으로 전환된다.
Object.wait(), notify(), notifyAll()이 호출되는 경우중량 락으로 전이되면 JVM은 ObjectMonitor 객체를 생성하고, 스레드를 OS 수준에서 BLOCKED 상태로 전환시켜 대기시킨다.
아래 테스트 코드 결과를 JFR를 통해서 나타내었다. 결과를 보면 Blocked 상태가 발생했다는 것을 확인할 수 있다.
package com.example.demo;
public class HeavyLockTest {
static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lock) {
System.out.println("T1 acquired lock");
try {
Thread.sleep(30_000);
} catch (InterruptedException e) {}
}
}, "Thread-T1");
Thread t2 = new Thread(() -> {
synchronized (lock) {
System.out.println("T2 acquired lock");
}
}, "Thread-T2");
t1.start();
Thread.sleep(100); // ensure T1 acquires lock
t2.start();
t1.join();
t2.join();
}
}

해당 동작은 HotSpot JVM의 objectMonitor.cpp, synchronizer.cpp 파일에 구현되어 있으며, 주요 로직은 다음과 같다.
Java 5부터는 java.util.concurrent.locks 패키지에 Lock 인터페이스가 도입되었고, 이는 synchronized보다 더 유연하고 정교한 동기화를 제공한다.
Lock lock = new ReentrantLock();
if(lock.tryLock()) {
try{
//lock 얻고 할 일
}finally{
lock.unlock();
}
}else{
//락을 얻지 못할 때 할 일
}
Lock 인터페이스 외에도 ReadWriteLock이라는 인터페이스가 존재한다.
락을 두개 관리한다. 하나는 읽기 전용, 하나는 쓰기 전용이다.
public class SharedObjectWithLock {
//...
ReentrantLock lock = new ReentrantLock();
int counter = 0;
public void perform() {
lock.lock();
try {
// Critical section here
count++;
} finally {
lock.unlock(); //반드시 락을 해제할 것 안그러면 deadlock이 발생한다.
}
}
//...
}
public void performTryLock(){
//...
boolean isLockAcquired = lock.tryLock(1, TimeUnit.SECONDS);
if(isLockAcquired) {
try {
//Critical section here
} finally {
lock.unlock();
}
}
//...
}
ReentrantLock은 JVM 모니터가 아닌 사용자 공간의 AQS(AbstractQueuedSynchronizer) 기반으로 동작한다. 락 경합이 발생할 경우, 스레드는 먼저 내부 대기 큐에 등록되고, 자신의 차례가 아닐 경우 LockSupport.park()를 호출해 WAITING 상태로 진입한다.
AQS는 스레드 대기열을 내부적으로 관리하고, park()/unpark()로 효율적인 락 대기를 구현해주는 추상 클래스입니다. ReentrantLock을 비롯한 다양한 락 구현체들이 이를 기반으로 만들어져 있습니다.
실제로 아래 코드를 JFR(Java Flight Recorder) 툴로 결과를 확인해보면 Thread-T2는 BLOCKED가 아니라 WAITING 상태로 분류되는 것을 볼 수 있다. 이는 내부적으로 park() 호출을 통해 스레드가 직접 대기 상태에 들어가기 때문이다.
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
lock.lock();
try {
Thread.sleep(30000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
});
Thread t2 = new Thread(() -> {
lock.lock();
try {
System.out.println("T2 acquired lock");
} finally {
lock.unlock();
}
});

여기서 하나의 의문점이 발생한다.
Reentrant Lock과 synchronized는 성능 차이가 존재하는가?
실제로 테스트를 진행해봤는데 유의미한 차이가 발생하지 않았다.
동등한 테스트를 위핸 synchronized가 경량락에서 끝나지 않도록 경합이 많이 발생하는 환경으로 테스트 코드를 작성하였다.
public static void syncBenchmarkContention() throws InterruptedException {
final Object lock = new Object();
int threadCount = 50;
int iteration = 1000;
long totalTime = 0;
for (int i = 0; i < iteration; i++) {
List<Thread> threads = new ArrayList<>();
List<Long> times = new ArrayList<>(Collections.nCopies(threadCount, 0L));
CountDownLatch ready = new CountDownLatch(threadCount);
CountDownLatch start = new CountDownLatch(1);
for (int j = 0; j < threadCount; j++) {
final int index = j;
threads.add(new Thread(() -> {
try {
ready.countDown(); // 스레드가 준비됐음을 알림
start.await(); // 모든 스레드가 동시에 시작
long begin = System.nanoTime();
synchronized (lock) {
// 경합을 유도할 만큼 락 보유 시간 늘리기
try {
Thread.sleep(1); // or busy work
} catch (InterruptedException e) {}
long end = System.nanoTime();
times.set(index, end - begin);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}));
}
for (Thread t : threads) t.start();
// 모든 스레드 준비 후 동시에 시작
ready.await();
start.countDown();
for (Thread t : threads) t.join();
for (long time : times) totalTime += time;
}
System.out.println("[synchronized] 평균 대기 시간(ns): " + totalTime / (iteration * threadCount));
}
public static void reentrantBenchmarkContention() throws InterruptedException {
final ReentrantLock lock = new ReentrantLock();
int threadCount = 50;
int iteration = 1000;
long totalTime = 0;
for (int i = 0; i < iteration; i++) {
List<Thread> threads = new ArrayList<>();
List<Long> times = new ArrayList<>(Collections.nCopies(threadCount, 0L));
CountDownLatch ready = new CountDownLatch(threadCount);
CountDownLatch start = new CountDownLatch(1);
for (int j = 0; j < threadCount; j++) {
final int index = j;
threads.add(new Thread(() -> {
try {
ready.countDown(); // 스레드 준비 완료
start.await(); // 모두 동시에 시작
long begin = System.nanoTime();
lock.lock();
try {
// 락 오래 잡기 → 경합 유도
Thread.sleep(1);
long end = System.nanoTime();
times.set(index, end - begin);
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}));
}
for (Thread t : threads) t.start();
ready.await(); // 모든 스레드가 준비될 때까지 대기
start.countDown(); // 모든 스레드 동시에 시작
for (Thread t : threads) t.join();
for (long time : times) totalTime += time;
}
System.out.println("[ReentrantLock] 평균 대기 시간(ns): " + totalTime / (iteration * threadCount));
}

[synchronized] 결과 요약
즉, synchronized에서는 실제로 monitor block이 발생했고, 스레드가 OS-level에서 대기하고 있었다는 것.
[ReentrantLock] 결과 요약
즉, ReentrantLock은 사용자 수준에서 구현된 락(AQS 기반)이고, 내부적으로 Thread park/unpark 메커니즘을 쓰기 때문에, Monitor Block 대신 Thread Park로 이벤트가 나타남
실험 결과를 보면 둘의 성능 차이는 별로 없다는 것을 생각해 볼 수 있다. 단지 두 방식이 각기 다른 방식으로 동기화를 처리한다는 사실을 보여주는 데 의미가 있다.
Java 8에서 도입한 락 클래스로 읽기 락과 쓰기 락 모두 지원한다.
그런데 이 락은 lock()을 모두 호출하면 스탬프라는 Long 값을 반환한다. 이 값을 사용하면 나중에 락을 해제하거나 유효성 검사를 할 수 있다.
public class StampedLockDemo {
Map<String,String> map = new HashMap<>();
private StampedLock lock = new StampedLock();
public void put(String key, String value){
long stamp = lock.writeLock();
try {
map.put(key, value);
} finally {
lock.unlockWrite(stamp);
}
}
public String get(String key) throws InterruptedException {
long stamp = lock.readLock();
try {
return map.get(key);
} finally {
lock.unlockRead(stamp);
}
}
}
왜 long 값을 반환하는가?
StampedLock은 내부적으로 락의 상태를 숫자(stamp)로 표현합니다. 이 값은 락을 획득할 때마다 고유한 값으로 증가하며, 이 스탬프를 통해 락을 해제할 때 정확히 어떤 락을 해제해야 하는지 식별할 수 있다.
long stamp = lock.writeLock(); 이 시점에서 stamp는 어떤 쓰기 락이 획득되었는지 나타내는 고유 식별자이다. 이후 해제할 때도 lock.unlockWrite(stamp); 이 값을 정확히 전달해야 락이 제대로 해제된다. 왜 그냥 boolean이나 void가 아닌가?
다른 락 (예: ReentrantLock)은 락 자체의 상태만 관리하면 되지만, StampedLock은 다음과 같은 고급 기능을 지원하기 때문이다. 바로 낙관적 락 (tryOptimisticRead())이다. 실제로 락을 걸지 않고 일단 읽고 나서, 이 스탬프가 여전히 유효한지 확인한다. 스탬프가 변하지 않았으면 데이터가 변경되지 않았다고 간주한다.
Stamped Lock은 낙관적 락이라는 기능도 제공한다.
락을 굳이 걸 필요 없이 읽고 난 후에 "쓰기 없었는지"만 확인하면 대부분의 경우 정합성을 지키면서도 성능까지 챙길 수 있다.
public String readWithOptimisticLock(String key) {
long stamp = lock.tryOptimisticRead(); // 100
String value = map.get(key); // alick
if(!lock.validate(stamp)) { // 101로 바뀜
stamp = lock.readLock(); // lock 드디어 획득
try {
return map.get(key); // bob
} finally {
lock.unlock(stamp);
}
}
return value; // bob
}
Condition 클래스는 스레드가 락을 잡은 상태에서 어떤 조건이 만족될 때까지 기다릴 수 있는 기능을 제공한다. 즉 synchronized와 Object의 wait(),notify()와 비슷하지만 ReentrantLock에서 좀 더 유연하게 조건을 나눠서 기다릴 수 있는 도구이다.
public class ProducerConsumer {
private static final int CAPACITY = 5;
private final Queue<Integer> buffer = new LinkedList<>();
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // 생산자용 조건 변수
private final Condition notEmpty = lock.newCondition(); // 소비자용 조건 변수
// 생산자
public void produce(int value) throws InterruptedException {
lock.lock();
try {
while (buffer.size() == CAPACITY) {
System.out.println("Buffer full, producer waiting...");
notFull.await(); // 버퍼가 꽉 차면 생산자는 기다림
}
buffer.offer(value);
System.out.println("Produced: " + value);
notEmpty.signal(); // 소비자 하나 깨움
} finally {
lock.unlock();
}
}
// 소비자
public int consume() throws InterruptedException {
lock.lock();
try {
while (buffer.isEmpty()) {
System.out.println("Buffer empty, consumer waiting...");
notEmpty.await(); // 버퍼가 비면 소비자는 기다림
}
int value = buffer.poll();
System.out.println("Consumed: " + value);
notFull.signal(); // 생산자 하나 깨움
return value;
} finally {
lock.unlock();
}
}
}
Java는 운영체제의 세마포어/모니터 개념을 언어 수준에서 synchronized로 구현하고 있으며, 더 확장된 유연한 동기화를 위해 Lock, StampedLock, Condition 등의 API도 제공하고 있다. 이를 통해 Java는 다양한 상황에 맞는 동기화 전략을 갖춘 강력한 멀티스레딩 환경을 지원한다.
https://www.baeldung.com/java-concurrent-locks
Intellij VM Option에 아래 명령어를 추가해주면 된다.
-XX:+UnlockDiagnosticVMOptions
-XX:+DebugNonSafepoints
-XX:StartFlightRecording=filename=recording.jfr,settings=profile,dumponexit=true