Blog
스터디
무한루프 CS 스터디
ReentrantLock vs synchronized

🎯 ReentrantLock이 동작하는 원리

요약 📌 ReentrantLock의 전체적인 동작 과정

  1. lock()을 호출하면 CAS를 통해 state 값을 0 → 1로 변경하여 락을 획득 시도.
  2. 락을 얻지 못하면 CLH 대기 큐에 노드를 추가하고 park() 상태로 대기.
  3. 선행 스레드가 unlock()을 호출하면 다음 스레드를 unpark()하여 실행.
  4. unpark()된 스레드는 다시 tryAcquire()를 실행하여 락을 획득.
  5. 락을 얻은 후에는 CLH 큐의 head를 자신으로 변경.
  6. unlock()을 호출하면 다음 대기 스레드에게 신호를 보내서 락을 넘김.

1. ReentrantLock의 핵심 개념

  • Java에서 기본적으로 synchronized 블록을 사용하면 모니터 락을 활용할 수 있음.
  • 하지만 synchronized는 타임아웃이나 인터럽트 처리 등이 불가능하며, 대기 공간이 하나로 제한되어 있음.
  • 이를 보완하기 위해 ReentrantLock이 등장했고, 보다 유연한 락 설정이 가능함.
  • 락을 걸거나 해제하는 시점을 제어할 수 있으며, 공정(Fair) / 비공정(NonFair) 모드를 지원.

2. ReentrantLock의 주요 동작 방식

  • 락을 획득하는 과정에서 두 번의 시도가 있음:
  1. 첫 번째 시도: initialTryLock()
  • CompareAndSet(CAS) 연산을 통해 state 값을 0 → 1로 변경하여 락을 획득하려고 시도.
  • 성공하면 락을 얻고 종료.
  • 실패하면 다음 단계로 진행.
  1. 두 번째 시도: tryAcquire()
  • initialTryLock()에서 실패한 경우 한 번 더 CAS 연산을 시도.
  • 비공정 모드(NonFairSync)에서는 재빠르게 락을 잡을 수 있도록 시도.
  • 공정 모드(FairSync)에서는 대기 큐에 있는 스레드가 있다면 락을 획득하지 않고 대기.
  • 두 번의 시도에도 실패하면 acquire()를 호출하여 CLH 큐에서 대기하게 됨.

3. CLH 대기 큐 (CLH Queue)

  • ReentrantLock 내부에서는 CLH 대기 큐를 사용하여 스레드가 대기함.
  • CLH 대기 큐는 FIFO(선입선출) 방식의 대기열을 유지하는 이중 연결 리스트 구조.
  • 락을 얻지 못한 스레드는 CLH 큐에 노드를 추가하고 park()를 통해 대기 상태로 들어감.

🔹 CLH Queue에서 대기하는 방식

  1. 대기할 노드를 큐에 삽입.
  2. 이전 노드(head)가 락을 해제할 때까지 기다림.
  3. 이전 노드가 unlock()을 호출하면, 현재 노드가 unpark() 되어 락을 획득함.
  4. 락을 획득하면 head를 자신으로 변경.

🔹 FairSync vs NonFairSync 차이점

  • FairSync: CLH 큐에 FIFO 순서대로 락을 부여.
  • NonFairSync: CLH 큐의 순서를 무시하고 현재 락이 비어있다면 즉시 획득 가능. (선점 가능)

4. 락을 얻지 못하면 acquire()를 통해 대기

  • acquire() 메서드는 AQS(AbstractQueuedSynchronizer)의 핵심 메서드로, 락을 얻지 못한 스레드는 CLH Queue에 노드로 추가된 후 대기.
  • 내부적으로 park()을 호출하여 OS 레벨에서 스레드를 대기 상태로 전환.
  • 락을 얻을 차례가 되면 unpark() 호출을 통해 다음 대기 중인 스레드를 깨움.

5. 락을 해제하는 unlock() 과정

  • unlock()을 호출하면 다음과 같은 과정이 수행됨:
  1. state 값을 감소시켜 락 해제
  2. CLH 큐의 head가 변경됨
  3. 다음 대기 중인 스레드에게 unpark() 호출
  4. 다음 스레드는 acquire()를 통해 다시 락을 획득

6. 추가적인 최적화 (Spin Lock)

  • NonFairSync 모드에서는 락을 빨리 획득하려고 Spin(짧은 루프에서 재시도) 를 수행함.
  • CLH 큐에서 head에 있는 스레드는 일정 횟수 tryAcquire()를 재시도하며, 바로 락을 얻을 수 있으면 park 없이 획득.
  • Spin을 사용하는 이유
  • unpark() 후 즉시 락을 얻지 못하면 다시 park() 해야 함 → 불필요한 시스템 콜이 발생.
  • 이를 줄이기 위해 일정 횟수 동안 tryAcquire()를 재시도함.

🔒 Condition의 존재 이유와 동작 원리

요약 📌 Condition의 역할과 필요성

  • Mutex는 단순히 상호 배제만을 제공하며, 스레드 간 순서를 조정하는 기능이 부족하다.
  • Condition은 특정 상태가 만족될 때까지 스레드를 대기시키고, 필요한 스레드만 깨울 수 있다.
  • synchronized는 단일 대기열만을 제공하므로, 여러 대기 공간이 필요한 경우 ReentrantLock과 Condition을 사용해야 한다.

🧐 Condition이 필요한 이유

  • Mutex상호 배제(Mutual Exclusion)를 위한 도구이며, **동기화(Synchronization)**를 제공하지 않는다.
  • 특정 조건이 충족될 때까지 대기하거나, 작업 간의 순서를 조정하는 기능이 필요할 때 Condition 변수가 사용된다.
  • 예를 들어, 버퍼에 데이터가 추가될 때까지 기다리는 경우 Condition이 필요하다.

🤝 Condition과 Mutex는 왜 함께 동작할까?

  • Condition 변수를 변경하고 signal을 수행하는 과정은 원자적으로 유지되어야 한다.
    • Condition이 바뀌고 signal이 발생하는 동안 다른 스레드가 이를 변경할 수 있기 때문.
  • 따라서 ConditionMutex를 통해 보호되는 동작이 필요하다.
  • Java에서는 LockCondition 변수를 함께 사용하는 구조를 제공하며, 이를 **모니터(Monitor)**라고 부른다.

🚀 Java에서 Condition 사용 방법

synchronized를 이용한 동기화

class SharedResource {
    private boolean available = false;
 
    public synchronized void produce() throws InterruptedException {
        while (available) {
            wait(); // 🔄 다른 스레드가 notify()할 때까지 대기
        }
        available = true;
        System.out.println("Produced!");
        notify(); // 🚀 대기 중인 스레드 중 하나를 랜덤하게 깨움
    }
 
    public synchronized void consume() throws InterruptedException {
        while (!available) {
            wait(); // 🔄 다른 스레드가 notify()할 때까지 대기
        }
        available = false;
        System.out.println("Consumed!");
        notify(); // 🚀 대기 중인 스레드 중 하나를 랜덤하게 깨움
    }
}
 
public class SynchronizedExample {
    public static void main(String[] args) {
        SharedResource resource = new SharedResource();
 
        Thread producer = new Thread(() -> {
            try {
                for (int i = 0; i < 5; i++) {
                    resource.produce();
                    Thread.sleep(100);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
 
        Thread consumer = new Thread(() -> {
            try {
                for (int i = 0; i < 5; i++) {
                    resource.consume();
                    Thread.sleep(100);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
 
        producer.start();
        consumer.start();
    }
}

⚠️ 문제점: synchronized는 단일 대기열(entry queue)만 제공하므로, 여러 대기열이 필요한 경우 Condition을 사용해야 한다.

✅ ReentrantLock과 Condition을 활용한 동기화

아래 코드는 Producer와 Consumer의 동작 순서를 보장하지 못한다.

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
 
class SharedResource {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private boolean available = false;
 
    public void produce() throws InterruptedException {
        lock.lock();
        try {
            while (available) {
                condition.await(); // 🔄 다른 스레드가 signal()할 때까지 대기
            }
            available = true;
            System.out.println("Produced!");
            condition.signal(); // 🚀 소비자 스레드 깨우기
        } finally {
            lock.unlock();
        }
    }
 
    public void consume() throws InterruptedException {
        lock.lock();
        try {
            while (!available) {
                condition.await(); // 🔄 다른 스레드가 signal()할 때까지 대기
            }
            available = false;
            System.out.println("Consumed!");
            condition.signal(); // 🚀 생산자 스레드 깨우기
        } finally {
            lock.unlock();
        }
    }
}
 
public class ConditionExample {
    public static void main(String[] args) {
        SharedResource resource = new SharedResource();
 
        Thread producer = new Thread(() -> {
            try {
                for (int i = 0; i < 5; i++) {
                    resource.produce();
                    Thread.sleep(100);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
 
        Thread consumer = new Thread(() -> {
            try {
                for (int i = 0; i < 5; i++) {
                    resource.consume();
                    Thread.sleep(100);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
 
        producer.start();
        consumer.start();
    }
}

Condition을 사용하면 await()으로 대기 후 signal()로 특정 스레드만 깨울 수 있음.

Reference