Java 락 동기화에 대하여

byeol·2025년 4월 19일

최근 운영체제를 공부하면서 세마포어, 모니터에 대해서 접하게 되었고
Java에서는 이를 어떻게 구현하고 있을까 알아보며 해당 내용을 정리하게 되었다.

모니터란

세마포어는 락을 획득하는 P 연산, 락을 해제하는 V 연산, 그리고 자원 개수를 나타내는 변수로 구성된 추상 자료형(ADT, Abstract Data Type)이다.

"추상 자료형"이라는 말은, 그 개념은 명확히 정의되어 있지만 구현은 프로그래머가 직접 정의해야 한다는 뜻이다.

즉, 프로그래머가 자원 보호를 위해 직접 P/V 연산을 조합해 동기화를 구현해야 하므로 실수하기 쉽고 번거롭다.

이러한 불편함을 해결하기 위해 프로그래밍 언어 차원에서 세마포어를 추상화한 것이 바로 "모니터(monitor)"이다.

그렇다면 Java에서는 이를 어떻게 구현하고 있는지 좀 더 구체적으로 살펴보자

Java에서의 모니터

Java에서는 모니터 개념을 synchronized 키워드를 통해 제공한다.

또한, Java는 스핀락(spin lock)으로 인한 CPU 낭비를 줄이기 위해 block & wait 구조를 사용한다.

이 구조는 모니터 락을 획득하지 못한 스레드가 바쁘게 CPU를 돌리는 대신, wait() / notify() 메커니즘을 통해 잠들었다가 깨어나는 방식으로 동작한다.

이때 사용하는 wait(), notify(), notifyAll()모든 객체가 상속받는 Object 클래스에 정의되어 있기 때문에,
Java의 모든 객체는 모니터 기능을 내장하고 있다.
물론, 이 메서드들은 반드시 synchronized 블록 안에서 호출되어야 정상적으로 작동한다.

Synchronized 블록 내부 구조

synchronized는 락을 통한 동기화를 제공하는 대표적인 키워드이며, Java는 CPU 낭비를 줄이기 위해 단순한 spin lock이 아닌 block & wait 구조를 채택하고 있다. 그렇다고 해서 spin lock을 전혀 사용하지 않는 것은 아니다.

synchronized는 락 경쟁이 없는 상황에서는 경량 락(Lightweight Lock)을 사용한다.

이때는 객체의 Mark Word를 CAS로 갱신하며 락을 획득하려 시도하고, 짧은 시간 동안 spin하여 락을 기다린다.

가벼운 경우 spin lock을 사용하는 이유는 기다리는 시간이 짧을 것이라는 보장이 되어져 있기 때문이다.

경쟁이 있는 상황에서는 중경 락을 사용하는데 이는 상태 전이가 발생한다. 즉 경량 락은 상태 전이 없이 락을 획득할 수 있으므로, context switching 비용을 줄이는 데 유리하다.

하지만 다음과 같은 경우에는 즉시 중량 락(Heavyweight Lock)으로 전환된다.

  • 스레드 간의 락 경쟁이 발생하여 spin이 실패할 경우
  • 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 파일에 구현되어 있으며, 주요 로직은 다음과 같다.

  • CAS를 활용한 경량 락 획득
  • spin 실패 시 inflate
  • park/unpark 기반 BLOCKED 상태 진입
  • _WaitSet, _EntryList 대기 큐 관리

java.util.concurrent.Locks에 대하여

Java 5부터는 java.util.concurrent.locks 패키지에 Lock 인터페이스가 도입되었고, 이는 synchronized보다 더 유연하고 정교한 동기화를 제공한다.

Lock과 Synchronized의 차이

  • Synchronized block은 메서드 내부에 선언된다. Lock은 메서드와 분리된 lock(), unlock() 메서드를 가질 수 있다.
  • Synchronized block은 공정성을 가질 수 없다. 스레드는 lock이 release되어야 lock을 얻을 수 있으며 선호도를 지정할 수 없다. 하지만 Lock API는 공정성 상태값을 지정할 수 있어서 오랫동안 기다린 스레드에게 lock을 획득하게 할 수 있다.
  • 어떤 스레드가 Synchronized block에 들어가려고 하는데 이미 다른 스레드가 해당 객체의 모니터 락을 가지고 있으면 이 스레드는 block 상태로 대기한다. 자바의 Lock API는 tryLock()이라는 특별한 메서드가 존재하는데 tryLock()을 사용하면 다른 스레드가 Lock을 가지고 있지 않을 때만 Lock을 얻는다. 락이 이미 사용중이면 그냥 실패로 넘어간다. 즉 기다리지 않는다.
Lock lock = new ReentrantLock();

if(lock.tryLock()) {
   try{
     //lock 얻고 할 일
   }finally{
     lock.unlock();
   }
}else{
  //락을 얻지 못할 때 할 일
}
  • Synchronized Block에 진입하기 위해 대기중인 thread는 해당 스레드에 인터럽트가 발생해도 반응하지 않는다.(응답 없는 스레드 발생, 정리가 안되고, 문제 추적이 어렵다.) ⇒ 죽을 때까지 락을 기다린다. 반면에 Lock API는 lockInterruptly()를 제공해서 락을 얻기 위해 기다리는 와중에 인터럽트가 발생하면 예외를 던지고 빠져나올 수 있다.

Lock API method

  • void lock()
    락이 비어있으면 즉시 획득하고 다른 스레드가 이미 락을 가지고 있으면 현재 스레드는 락이 해제될 때까지 대기한다. = synchronized
  • void lockInterruptibly()
    lock()처럼 락을 얻으려고 시도하지만 락을 기다리는 와중에 interrupt()가 호출되면 락 획득을 멈추고 예외를 던지고 빠져나올 수 있다.
  • boolean tryLock()
    락을 즉시 한 번 시도한다. 락 획득을 성공하면 true 반환, 실패하면 false를 반환한다. 락이 잠겨 있으면 포기하고 다른 일을 시도한다.
  • boolean tryLock(long timeout, TimeUnit timeuit)
    락을 획득하려 정해진 시간동안 기다린다. 시간 안에 락을 얻으면 true, 얻지 못하면 false를 반환한다.
  • void unlock()
    현재 스레드가 얻은 락을 해제하며 반드시 필요한 작업이다.
    따라서 try/catch and finally 코드를 추천한다.

Lock 인터페이스 외에도 ReadWriteLock이라는 인터페이스가 존재한다.
락을 두개 관리한다. 하나는 읽기 전용, 하나는 쓰기 전용이다.

  • Read Lock : 여러 스레드가 동시 접근 가능(단, 쓰기 중일 때는 안된다.)
  • Write Lock : 단 하나의 스레드만 획득 가능, 읽기 락도 모두 막힌다.

Reentrant Lock

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();
        }
    }
    //...
}
  • 락을 1초동안 기다림
  • 그 안에 락을 얻지 못하면 false 반환 → 작업 안함, 그냥 지나침
  • 무한 대기 없이 유연하게 대응 가능
  • Reentrant Read Write Lock이 존재한다.

ReentrantLock의 대기 상태는 BLOCKED가 아니다.

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();
    }
});

Synchronized vs. ReentrantLock

여기서 하나의 의문점이 발생한다.
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] 결과 요약

    • 평균 대기 시간: 32,179,416 ns (약 32ms)
    • Java Monitor Blocked: 41,132번
      → heavyweight lock (모니터 락)까지 진화했음을 의미

    즉, synchronized에서는 실제로 monitor block이 발생했고, 스레드가 OS-level에서 대기하고 있었다는 것.

  • [ReentrantLock] 결과 요약

    • 평균 대기 시간: 32,340,021 ns (약 32ms)
    • Java Thread Park: 37,748번
      → ReentrantLock은 내부적으로 AbstractQueuedSynchronizer (AQS)를 사용
      → 락 대기 시 LockSupport.park()를 호출함 → JVM에서 이건 Thread Park 이벤트로 기록됨
    • Java Monitor Blocked = 0 → synchronized와 달리 ReentrantLock은 모니터를 사용하지 않기 때문

    즉, ReentrantLock은 사용자 수준에서 구현된 락(AQS 기반)이고, 내부적으로 Thread park/unpark 메커니즘을 쓰기 때문에, Monitor Block 대신 Thread Park로 이벤트가 나타남

실험 결과를 보면 둘의 성능 차이는 별로 없다는 것을 생각해 볼 수 있다. 단지 두 방식이 각기 다른 방식으로 동기화를 처리한다는 사실을 보여주는 데 의미가 있다.

Stamped Lock

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 variable에 대하여

  • 프로세스가 모니터 안에서 기다릴 수 있도록 하기 위해서 condition variable을 사용할 수 있다.
  • condition variable은 wait와 signal 연산에 의해서만 접근 가능하다.

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

테스트 도구 JFR

Intellij VM Option에 아래 명령어를 추가해주면 된다.

-XX:+UnlockDiagnosticVMOptions 
-XX:+DebugNonSafepoints 
-XX:StartFlightRecording=filename=recording.jfr,settings=profile,dumponexit=true
  1. -XX:+UnlockDiagnosticVMOptions
  • JVM의 진단용 옵션(Diagnostic Options)을 사용 가능하게 하는 스위치이다.
  • 기본적으로는 감춰져 있는 고급/실험적인 기능들을 활성화하기 위해 필요하다.
  • 아래 옵션인 -XX:+DebugNonSafepoints를 사용하려면 이 옵션을 먼저 켜야 한다.
  1. -XX:+DebugNonSafepoints
  • JFR이나 perf와 같은 툴이 스레드가 실행 중인 정확한 명령어 위치를 추적할 수 있게 해준다.
  • 기본적으로 JVM은 safepoint에서만 정확한 stack trace를 수집할 수 있는데, 이 옵션은 non-safepoint 지점에서도 스택 정보를 수집하게 만든다.
  • 성능 분석을 더 정확하게 하고 싶을 때 필요하지만, 소폭의 오버헤드가 발생할 수 있다.
  1. -XX:StartFlightRecording=...
  • Java Flight Recorder(JFR)를 JVM 시작 시 자동으로 활성화하는 설정이다.
  • ✅ filename=recording.jfr
    • 기록된 JFR 데이터를 recording.jfr 이라는 파일명으로 저장한다.
    • JVM 종료 시 파일이 생성된다.
  • ✅ settings=profile
    • 수집할 데이터를 어느 수준으로 할지 정하는 사전 설정값이다.
    • profile 은 성능 분석에 적합하도록 설정된 모드로, 다음과 같은 정보들을 더 많이 수집한다.
    • CPU 샘플링
    • 힙 할당 정보
    • Lock contention
    • GC 이벤트 등
    • default 보다 더 상세한 정보를 수한다.
  • ✅ dumponexit=true
    • JVM이 종료될 때 자동으로 JFR 데이터를 덤프하여 지정된 파일로 저장한다.
    • recording.jfr 파일은 JVM 종료 시 생성된다.
profile
꾸준하게 Ready, Set, Go!

0개의 댓글