Java

Java 락 고르기: synchronized, ReentrantLock, ReentrantReadWriteLock, StampedLock

여러 스레드가 같은 데이터를 동시에 수정하면 결과가 실행 순서에 따라 달라집니다. 락은 특정 구간을 한 번에 한 스레드만 실행하도록 제한해서 이 문제를 막습니다.

Java는 언어 차원에서 지원하는 synchronized와 java.util.concurrent.locks 패키지에 속한 ReentrantLock, ReentrantReadWriteLock, StampedLock을 제공하는데, 나중에 추가된 락일수록 제공하는 기능이 많아지는 대신 사용 방법이 까다로워집니다.

이 글에서는 네 가지 락을 차례로 살펴보고, 어떤 상황에 어떤 락을 골라야 하는지 정리합니다.

먼저 알아둘 것: 재진입

락을 보유한 스레드가 같은 락을 다시 요청했을 때 그 요청을 허용하는 것을 재진입(reentrancy)이라고 합니다.

java
public class Counter {
    private int count;

    public synchronized void increment() {
        count++;
    }

    public synchronized void incrementTwice() {
        increment();   // 이미 쥐고 있는 락을 다시 요청한다
        increment();
    }
}

incrementTwice()에 진입하면서 락을 획득했는데, 그 안에서 호출하는 increment()도 같은 락을 요청합니다. 락이 “비어 있는지”만 확인한다면 이 요청은 락이 해제되기를 기다리게 되는데, 락을 해제할 주체는 지금 기다리고 있는 자기 자신입니다. 그래서 영원히 멈춥니다. 자기 자신을 기다리는 데드락입니다.

재진입 락은 “현재 소유자가 누구인지”와 “몇 번 획득했는지”를 함께 기록해서 이 문제를 해결합니다. 요청한 스레드가 소유자와 같으면 획득 횟수만 올리고 통과시키고, 다른 스레드이면 기다리게 하며, 횟수가 0으로 돌아올 때 해제합니다. 스레드 A가 incrementTwice()를 실행하는 동안 스레드 B가 increment()를 호출하는 상황을 따라가 보면 다음과 같습니다.

시점요청한 스레드소유자획득 횟수동작
A가 incrementTwice() 진입AA1비어 있으므로 획득합니다
A가 첫 번째 increment() 진입AA2요청한 스레드가 소유자와 같으므로 횟수만 올리고 통과합니다
B가 increment() 진입 시도BA2요청한 스레드가 소유자와 다르므로 기다립니다
A가 첫 번째 increment() 종료AA1횟수만 내립니다. 아직 해제되지 않았으므로 B는 계속 기다립니다
A가 incrementTwice() 종료A없음0횟수가 0이 되어 해제됩니다
B가 increment() 진입BB1비어 있으므로 획득합니다

다른 스레드를 막는 상호 배제와 같은 스레드를 통과시키는 재진입은 모두 “소유자가 누구인지”를 비교해서 결정됩니다. 자기가 락을 보유한 상태에서 같은 락을 사용하는 다른 메서드를 호출하는 일은 매우 흔하기 때문에, 재진입이 없다면 데드락을 피하기 위해 호출 구조를 일일이 추적해야 합니다.

synchronized, ReentrantLock, ReentrantReadWriteLock은 이름 그대로 재진입을 허용하고, StampedLock만 재진입을 허용하지 않습니다. StampedLock은 소유자를 기록하지 않고 stamp라는 숫자만 주고받기 때문에, 락을 요청한 스레드가 현재 소유자와 같은 스레드인지 알 길이 없습니다. 이 차이는 뒤에서 StampedLock을 다룰 때 다시 중요해집니다.

synchronized: JVM이 관리하는 기본 락

java
public class Account {
    private long balance;

    // 메서드 전체를 잠근다 (락 = this)
    public synchronized void deposit(long amount) {
        balance += amount;
    }

    // 필요한 구간만 잠근다 (락 = 지정한 객체)
    private final Object lock = new Object();
    public void withdraw(long amount) {
        synchronized (lock) {
            balance -= amount;
        }
    }
}

모든 Java 객체는 모니터(monitor)를 하나씩 가지고 있고, synchronized는 지정한 객체의 모니터를 잠급니다. 별도의 락 객체를 만들 필요가 없고, 블록을 벗어나면 예외가 발생하더라도 반드시 해제됩니다.

특징설명
해제블록을 벗어나면 자동으로 해제됩니다
재진입허용합니다
조건 대기wait() / notify() / notifyAll()을 사용하며, 대기 집합은 모니터당 하나입니다
공정성없습니다. 먼저 기다린 스레드가 먼저 락을 획득한다는 보장이 없습니다

내부 동작: 경쟁이 생기면 락이 무거워진다

synchronized의 비용은 고정되어 있지 않습니다. 기다리는 스레드가 있는지 없는지에 따라 JVM이 완전히 다른 방식으로 락을 처리하기 때문입니다. 이 동작을 이해하려면 먼저 CAS를 알아야 합니다.

CAS: 락 없이 “먼저 온 하나만 성공”을 보장하는 명령

CAS(Compare-And-Swap)는 “이 메모리 자리의 값이 내가 예상한 값과 같으면 새 값으로 바꾸고, 다르면 아무것도 하지 않는다”를 CPU 명령 하나로 수행하는 연산입니다. 비교와 교체 사이에 다른 스레드가 끼어들 수 없으므로, 여러 스레드가 동시에 같은 자리를 CAS로 바꾸려 하면 정확히 하나만 성공하고 나머지는 실패를 돌려받습니다.

java
AtomicInteger state = new AtomicInteger(0);

// "state가 0이면 1로 바꿔라" → 동시에 호출해도 한 스레드만 true를 받는다
boolean acquired = state.compareAndSet(0, 1);

락을 획득한다는 것은 결국 “비어 있는 자리에 내 이름을 먼저 적는 것”이고, CAS는 그 경쟁을 한 명만 이기게 해 주는 도구입니다. 그래서 synchronized도, 뒤에서 볼 AQS 기반의 ReentrantLock도 모두 CAS 위에서 동작합니다.

경쟁이 없을 때: 헤더에 표시만 하고 끝난다

모든 Java 객체는 헤더에 mark word라는 작은 영역을 가지고 있습니다. 경쟁이 없을 때 synchronized에 들어가는 과정은 이 영역에 “현재 스레드가 보유 중”이라고 CAS로 적는 것이 전부입니다.

스레드 A: mark word가 비어 있나? → CAS로 "A 보유 중"이라고 적는다 → 작업 → 표시를 지운다

OS에는 아무것도 요청하지 않습니다. 대기 큐도 없고, 잠든 스레드를 깨우는 일도 없습니다. CAS 한 번은 보통 수십 나노초 안에 끝나므로, 경쟁이 없는 synchronized의 비용은 매우 낮습니다.

경쟁이 생기면: 기다릴 장치가 필요해진다

스레드 B가 들어오려는데 mark word에 이미 “A 보유 중”이 적혀 있다면, B는 기다려야 합니다. 기다리는 방법은 두 가지입니다.

방법동작비용
스핀(spin)mark word가 비었는지 반복해서 확인합니다CPU 한 코어를 계속 사용합니다. A가 금방 나오면 가장 빠릅니다
파크(park)OS에 “재워 두었다가 락이 풀리면 깨워 달라”고 요청합니다CPU는 아끼지만, 커널에 진입하고 나중에 다시 깨우는 데 마이크로초 단위의 시간이 듭니다

JVM은 아주 짧게 스핀해 본 뒤에 안 되면 파크로 넘어갑니다. 그런데 “재워 두었다가 깨운다”를 하려면 기다리는 스레드 목록과 OS 수준의 대기 장치가 필요합니다. mark word에 이름 하나 적는 방식으로는 이것을 표현할 수 없습니다.

그래서 JVM은 이 시점에 ObjectMonitor라는 별도의 구조체를 만들어 객체에 연결합니다. 앞에서 말한 모니터의 실체가 바로 이 구조체이며, 그 안에 소유자, 획득 횟수, 진입 대기 집합, 조건 대기 집합, OS 세마포어가 들어 있습니다. mark word에는 이제 소유자 대신 이 구조체를 가리키는 포인터가 저장됩니다. 이 전환을 **승격(inflation)**이라고 부릅니다.

승격 전: [mark word: "A 보유 중"]
승격 후: [mark word: → ObjectMonitor { 소유자: A, 대기 중: B, C, OS 세마포어 }]

승격된 뒤에는 락을 획득하고 해제하는 경로 자체가 달라집니다.

승격 전승격 후
획득mark word에 CAS 한 번ObjectMonitor의 소유자 필드에 CAS, 실패하면 대기 큐에 등록하고 OS에 재워 달라고 요청
해제mark word의 표시를 지움소유자 필드를 비우고, 대기 큐에 스레드가 있으면 OS에 깨워 달라고 요청
비용수십 나노초경쟁이 있으면 수 마이크로초, 컨텍스트 스위치 비용 포함

가게에 비유하면, 손님이 한 명씩 텀을 두고 올 때는 문에 “사용 중” 팻말만 걸면 됩니다. 손님이 몰려서 줄이 생기면 번호표 기계와 대기 의자, 호출 기능이 필요해집니다. 번호표 시스템은 손님 한 명당 처리 비용이 팻말보다 크지만, 줄이 생긴 이상 팻말만으로는 감당할 수 없습니다. 한 번 들인 번호표 기계를 손님이 줄었다고 바로 치우지 않듯이, JVM도 승격된 모니터를 즉시 되돌리지 않고 나중에 안전한 시점에 정리(deflation)합니다.

JDK 6 이후 이 최적화가 적용되면서 ReentrantLock과의 성능 차이는 거의 사라졌고, 요즘은 성능 때문에 synchronized를 피할 이유가 없습니다. synchronized가 느리다는 인식은 대부분 경쟁이 심한 상황이거나 JDK 6 이전의 이야기입니다. 한때 널리 알려졌던 바이어스 락(biased locking)은 유지 비용에 비해 이득이 작아져서 JDK 15에서 deprecated 되었고 JDK 18에서 제거되었습니다.

제공하지 않는 기능

synchronized의 한계는 성능이 아니라 기능에 있습니다. 다음 기능들은 synchronized에 없습니다.

기능synchronized의 동작
락 획득 시도시도만 해 볼 수 없습니다. 요청하면 획득할 때까지 블로킹됩니다
타임아웃지정할 수 없습니다. 락이 풀릴 때까지 무한정 기다립니다
인터럽트 응답락을 기다리는 동안에는 인터럽트가 와도 반응하지 않습니다
여러 개의 조건 변수조건 대기 집합이 하나뿐입니다. “비어 있지 않음”과 “가득 차지 않음” 같은 조건을 구분하려면 notifyAll()로 모든 스레드를 깨워야 합니다
읽기와 쓰기 구분구분하지 않습니다. 읽기만 하는 스레드끼리도 서로를 차단합니다

이 기능 중 하나라도 필요해지면 ReentrantLock을 사용합니다.

가상 스레드와 pinning

JDK 21에서 가상 스레드가 도입되었을 때, synchronized 블록 안에서 블로킹하면 가상 스레드가 캐리어 스레드에 고정(pinning)되어 캐리어 스레드 하나를 통째로 점유하는 문제가 있었습니다. 그래서 JDK 21부터 23까지는 가상 스레드와 함께 사용할 락으로 ReentrantLock이 권장되었습니다. 이 문제는 JDK 24(JEP 491)에서 해결되었으므로, 그 이후 버전에서는 synchronized를 그대로 사용해도 됩니다.

ReentrantLock: synchronized에 기능을 더한 락

java
public class Account {
    private final ReentrantLock lock = new ReentrantLock();
    private long balance;

    public void deposit(long amount) {
        lock.lock();            // try 바깥에서 획득
        try {
            balance += amount;
        } finally {
            lock.unlock();      // 예외가 나도 반드시 해제
        }
    }
}

ReentrantLock은 synchronized와 같은 재진입 상호 배제 락이지만, 해제가 자동으로 이루어지지 않으므로 finally 블록에서 직접 해제해야 합니다. 이때 lock()은 try 블록 바깥에 둡니다. lock() 호출이 예외로 실패했을 때 lock()이 try 블록 안에 있으면, finally 블록의 unlock()이 획득하지도 않은 락을 해제하려다가 IllegalMonitorStateException을 던지기 때문입니다.

synchronized보다 추가된 기능

메서드 / 옵션할 수 있는 일
tryLock()락 획득을 즉시 시도하고, 획득하지 못하면 대기하지 않고 false를 반환합니다
tryLock(timeout, unit)정해진 시간만 대기하고 포기합니다
lockInterruptibly()대기하는 동안 인터럽트가 발생하면 InterruptedException으로 빠져나옵니다
new ReentrantLock(true)공정 모드입니다. 가장 오래 대기한 스레드가 먼저 락을 획득합니다
newCondition()조건 대기 집합을 여러 개 만들 수 있습니다
java
// 1초 안에 락을 못 얻으면 포기한다
if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        transfer(from, to, amount);
    } finally {
        lock.unlock();
    }
} else {
    throw new TransferTimeoutException();   // 영원히 기다리는 대신 실패를 알린다
}

타임아웃을 지정하면 데드락이 발생하더라도 영원히 멈추지 않고 정해진 시간이 지난 뒤에 빠져나올 수 있습니다. 조건 변수를 여러 개 사용하는 이점은 크기가 고정된 버퍼 예제에서 잘 드러납니다.

java
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull  = lock.newCondition();   // 넣는 쪽이 기다리는 조건
private final Condition notEmpty = lock.newCondition();   // 꺼내는 쪽이 기다리는 조건

public void put(T item) throws InterruptedException {
    lock.lock();
    try {
        while (count == items.length) notFull.await();
        items[putIndex] = item; ...
        notEmpty.signal();      // 꺼내는 쪽만 깨운다
    } finally {
        lock.unlock();
    }
}

synchronized를 사용했다면 notifyAll()로 넣는 스레드와 꺼내는 스레드를 모두 깨운 뒤에 각자 자기 조건을 다시 확인하게 해야 합니다. 조건 변수가 나뉘어 있으면 해당 조건과 관련된 스레드만 깨울 수 있습니다.

내부 동작: AQS

ReentrantLock을 비롯해 java.util.concurrent 패키지에 있는 락과 동기화 도구 대부분은 AbstractQueuedSynchronizer(AQS)를 기반으로 구현되어 있습니다.

lock() 호출
  ├─ state가 0이면 CAS로 1로 바꾸고 소유자를 기록 → 획득 (빠른 경로)
  ├─ 소유자가 나 자신이면 state를 +1 → 재진입
  └─ 실패하면 대기 큐에 노드를 넣고 LockSupport.park()로 잠든다
unlock() 호출
  └─ state를 -1, 0이 되면 큐의 다음 스레드를 unpark()

synchronized가 JVM 내부에서 처리하는 일을 ReentrantLock은 Java 코드로 처리합니다. 그래서 동작 방식을 java.util.concurrent.locks 소스 코드에서 전부 확인할 수 있고, 공정성이나 타임아웃 같은 정책도 Java 코드로 구현할 수 있습니다.

공정 모드는 왜 기본이 아닐까

비공정 모드에서는 락이 해제된 순간에 새로 도착한 스레드가 대기 중인 스레드보다 먼저 락을 획득할 수 있습니다(barging). 불공평해 보이지만, 대기 중인 스레드를 깨워서 실행 상태로 되돌리는 데는 시간이 걸리고, 그 사이에 이미 실행 중인 스레드가 락을 사용하고 해제하면 전체 처리량은 오히려 높아집니다. 공정 모드는 이런 새치기를 허용하지 않기 때문에 처리량이 눈에 띄게 낮아지는 대신, 어떤 스레드도 무한정 대기하지 않는다는 보장을 얻습니다.

인자가 없는 tryLock()은 공정 모드에서도 barging을 수행합니다. 대기 중인 스레드가 있더라도 락이 해제된 상태라면 즉시 획득합니다. 공정성을 지키면서 시도만 하고 싶다면 tryLock(0, TimeUnit.SECONDS)을 사용합니다.

ReentrantReadWriteLock: 읽기는 함께, 쓰기는 혼자

데이터를 읽기만 하는 스레드끼리는 서로를 차단할 이유가 없습니다. ReentrantReadWriteLock은 락을 읽기 락과 쓰기 락 한 쌍으로 나눠서, 읽기는 여러 스레드가 동시에 수행하고, 쓰기는 한 스레드만 수행하도록 합니다.

다른 스레드가 읽기 락을 보유 중일 때다른 스레드가 쓰기 락을 보유 중일 때
읽기 락 요청즉시 획득합니다대기합니다
쓰기 락 요청대기합니다대기합니다
java
public class PriceCache {
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock  = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();
    private final Map<String, Long> prices = new HashMap<>();

    public Long get(String sku) {
        readLock.lock();
        try {
            return prices.get(sku);         // 여러 스레드가 동시에 들어올 수 있다
        } finally {
            readLock.unlock();
        }
    }

    public void put(String sku, long price) {
        writeLock.lock();
        try {
            prices.put(sku, price);         // 이 순간 읽기도 쓰기도 아무도 없다
        } finally {
            writeLock.unlock();
        }
    }
}

다운그레이드는 되고 업그레이드는 안 된다

쓰기 락을 보유한 상태에서 읽기 락을 획득한 뒤 쓰기 락을 해제하는 다운그레이드는 허용됩니다. 데이터를 갱신한 직후에 다른 쓰기 작업이 끼어들지 못하게 막으면서 읽기 상태로 전환할 때 사용합니다.

java
writeLock.lock();
try {
    prices.put(sku, price);
    readLock.lock();          // 쓰기 락을 쥔 채 읽기 락 획득
} finally {
    writeLock.unlock();       // 쓰기 락만 놓는다 → 이제 읽기 락만 쥔 상태
}
try {
    use(prices);              // 그 사이 다른 쓰기는 들어올 수 없다
} finally {
    readLock.unlock();
}

반대로 읽기 락을 보유한 상태에서 쓰기 락을 요청하는 업그레이드는 데드락으로 이어집니다. 쓰기 락은 모든 읽기 락이 해제되기를 기다리는데, 그 읽기 락 중 하나를 요청한 스레드 자신이 보유하고 있으므로 영원히 기다리게 됩니다. 두 스레드가 동시에 업그레이드를 시도하면 서로를 기다리면서 멈춥니다.

java
readLock.lock();
if (prices.get(sku) == null) {
    writeLock.lock();         // 자기 자신의 읽기 락이 풀리길 기다린다 → 영원히 멈춤
    ...
}

값이 없으면 새로 저장하는 이런 패턴은, 읽기 락을 완전히 해제한 뒤에 쓰기 락을 획득하고, 쓰기 락 안에서 조건을 다시 확인하는 방식으로 작성해야 합니다.

생각만큼 빠르지 않을 수 있다

읽기 락을 획득하는 데에도 비용이 듭니다. 읽기 스레드마다 AQS의 state 변수에 CAS 연산으로 읽기 카운트를 증가시키고 감소시키므로, 읽기 스레드가 많을수록 같은 캐시 라인을 두고 경쟁하게 됩니다. 읽기 구간이 아주 짧다면 이 비용이 임계 구역 자체의 비용보다 커져서, ReentrantLock 하나로 처리하는 편이 오히려 빠를 수 있습니다.

ReentrantReadWriteLock이 이점을 발휘하는 조건은 분명합니다.

  • 읽기가 쓰기보다 압도적으로 많고
  • 읽기 구간이 충분히 길어서 읽기 작업끼리 겹치는 시간이 실제로 존재할 때

쓰기 기아(writer starvation)도 알아둘 필요가 있습니다. 읽기 요청이 끊이지 않으면 쓰기 스레드가 영원히 락을 획득하지 못할 수 있습니다. 비공정 모드에서도 대기 큐의 맨 앞에 쓰기 스레드가 있으면 새로운 읽기 요청을 일단 대기시켜서 이 문제를 완화합니다. 완전한 순서 보장이 필요하면 공정 모드를 사용합니다. 조건 변수는 쓰기 락에서만 만들 수 있고, 읽기 락의 newCondition()은 UnsupportedOperationException을 던집니다.

StampedLock: 락 없이 읽어 보고, 틀렸으면 다시

ReentrantReadWriteLock의 읽기 락은 쓰기를 차단하기 위한 비용을 매번 지불합니다. JDK 8에 추가된 StampedLock은 여기에 낙관적 읽기(optimistic read) 모드를 더해서, 읽기 작업이 쓰기 작업과 거의 겹치지 않는 상황에서는 그 비용을 아예 지불하지 않게 합니다.

모드메서드동작
쓰기writeLock()배타 락입니다. 다른 모든 모드의 접근을 차단합니다
읽기readLock()공유 락입니다. ReentrantReadWriteLock의 읽기 락과 같은 역할을 합니다
낙관적 읽기tryOptimisticRead()락을 획득하지 않습니다. 버전 번호(stamp)만 받아 둡니다

모든 획득 메서드는 long 타입의 stamp를 반환하고, 해제할 때는 이 stamp를 다시 전달합니다. 낙관적 읽기는 stamp를 받은 뒤에 값을 읽고, 읽는 동안 쓰기 작업이 끼어들지 않았는지 validate(stamp)로 확인하는 방식입니다.

java
public class Point {
    private final StampedLock sl = new StampedLock();
    private double x, y;

    public void move(double dx, double dy) {
        long stamp = sl.writeLock();
        try {
            x += dx;
            y += dy;
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    public double distanceFromOrigin() {
        long stamp = sl.tryOptimisticRead();    // 락 없이 버전만 받는다
        double curX = x, curY = y;              // 지역 변수로 복사
        if (!sl.validate(stamp)) {              // 그 사이 쓰기가 있었나?
            stamp = sl.readLock();              // 있었다면 비관적 읽기 락으로 다시
            try {
                curX = x;
                curY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.hypot(curX, curY);
    }
}

쓰기가 드물다면 distanceFromOrigin()은 대부분 validate() 호출 한 번으로 끝납니다. CAS 연산도 수행하지 않고 대기 큐에도 들어가지 않으므로, 읽기 스레드가 아무리 많아도 서로 경쟁하지 않습니다.

낙관적 읽기를 쓸 때 지켜야 할 것

낙관적 읽기 구간에서는 락을 보유하지 않은 상태라는 점을 잊으면 안 됩니다. 값을 읽는 도중에 다른 스레드가 쓰기 락을 획득해서 값을 수정하고 있을 수 있습니다.

  • 읽은 값은 지역 변수로 복사하고, validate()를 통과한 뒤에만 사용합니다.
  • 복사하는 동안 x는 새 값이고 y는 이전 값인 것처럼 서로 모순된 상태를 읽을 수 있습니다. validate()가 이런 경우를 걸러 주지만, 검증하기 전에 이 값을 배열 인덱스나 반복 조건으로 사용하면 예외나 무한 루프가 발생할 수 있습니다.
  • 그래서 낙관적 읽기 구간은 필드 몇 개를 복사하는 정도로 짧게 유지합니다.

재진입이 안 된다

StampedLock은 재진입을 허용하지 않습니다. 쓰기 락을 보유한 스레드가 같은 락의 읽기 락이나 쓰기 락을 다시 요청하면, 자기 자신이 락을 해제하기를 기다리면서 멈춥니다.

java
public void update() {
    long stamp = sl.writeLock();
    try {
        get();                      // 안에서 sl.readLock()을 호출한다면 → 영원히 멈춤
    } finally {
        sl.unlockWrite(stamp);
    }
}

락을 보유한 메서드가 같은 락을 사용하는 다른 메서드를 호출하는 구조는 흔하므로, StampedLock은 범용 락 대신 작은 내부 자료구조를 보호하는 용도로 제한해서 사용합니다. 조건 변수도 제공하지 않고, readLock()과 writeLock()은 인터럽트에 응답하지 않으므로 인터럽트 응답이 필요하면 readLockInterruptibly() 같은 변형 메서드를 사용해야 합니다. Lock 인터페이스가 필요한 곳에는 asReadLock()과 asWriteLock()으로 얻은 뷰 객체를 전달할 수 있습니다.

모드 변환

읽기 락을 보유한 상태에서 쓰기 락으로 전환하는 작업은 ReentrantReadWriteLock에서는 데드락으로 이어졌지만, StampedLock에서는 tryConvertToWriteLock()으로 전환을 시도할 수 있습니다.

java
public void moveIfAtOrigin(double newX, double newY) {
    long stamp = sl.readLock();
    try {
        while (x == 0.0 && y == 0.0) {
            long ws = sl.tryConvertToWriteLock(stamp);   // 다른 읽기가 없으면 성공
            if (ws != 0L) {
                stamp = ws;
                x = newX; y = newY;
                break;
            }
            sl.unlockRead(stamp);                        // 실패하면 놓고
            stamp = sl.writeLock();                      // 정식으로 쓰기 락을 잡는다
        }
    } finally {
        sl.unlock(stamp);   // 읽기든 쓰기든 stamp에 맞게 해제
    }
}

전환에 실패하면 0을 반환할 뿐 블로킹되지 않으므로, 실패했을 때 읽기 락을 해제하고 쓰기 락을 새로 획득하는 경로를 반드시 준비해야 합니다.

어떤 락을 고를까

상황선택
특별한 요구사항이 없다synchronized. 코드가 가장 짧고, 해제를 빠뜨릴 수 없으며, 경쟁이 없으면 비용이 매우 낮습니다
타임아웃, tryLock, 인터럽트 응답, 공정성, 여러 개의 조건 변수 중 하나라도 필요하다ReentrantLock
읽기가 쓰기보다 압도적으로 많고, 읽기 구간이 길다ReentrantReadWriteLock
읽기가 매우 짧고 매우 빈번하며, 재진입이 필요 없고, 측정으로 이득을 확인했다StampedLock 낙관적 읽기

이 표의 순서가 곧 선택 순서입니다. synchronized에서 시작해서, 구체적인 기능이 필요해질 때 다음 단계의 락으로 넘어갑니다. 특히 ReentrantReadWriteLock과 StampedLock은 이론상 빠를 것 같다는 이유가 아니라 측정해서 이득이 확인될 때 선택합니다.

락을 고르기 전에 락이 정말 필요한지를 먼저 확인하는 일도 중요합니다. 불변 객체는 애초에 락이 필요 없고, ConcurrentHashMap이나 AtomicLong, LongAdder 같은 도구는 락 없이 동시성을 처리하도록 이미 최적화되어 있습니다. 직접 락을 획득하는 코드는 이런 도구로 표현할 수 없는 복합 연산에만 남겨 두는 편이 안전합니다.

정리

synchronizedReentrantLockReentrantReadWriteLockStampedLock
도입처음부터JDK 5JDK 5JDK 8
재진입OOOX
해제자동finallyfinallyfinally
tryLock / 타임아웃XOOO
인터럽트 응답XOO*Interruptibly 메서드만
공정 모드XOOX
조건 변수wait/notify 하나여러 개쓰기 락만X
읽기 / 쓰기 분리XXOO
낙관적 읽기XXXO
  • 네 락의 차이는 성능이 아니라 기능에 있습니다. 경쟁이 없을 때 synchronized는 이미 충분히 빠릅니다.
  • ReentrantLock은 synchronized에 타임아웃, 인터럽트 응답, 공정성, 여러 개의 조건 변수를 더한 락이고, 그 대가로 finally 블록에서 직접 해제해야 합니다.
  • ReentrantReadWriteLock은 읽기 작업끼리 겹치는 시간이 실제로 존재할 때만 이득이 있고, 읽기 락에서 쓰기 락으로 업그레이드하면 데드락이 발생합니다.
  • StampedLock은 락 없이 읽은 뒤에 검증하는 방식으로 읽기 비용을 없애지만, 재진입이 불가능하므로 작은 자료구조를 보호하는 용도로만 사용합니다.