본문 바로 가기

[자바] ReentrantLock : NonFairSync의 lock() 동작 원리

들어가며

최근 많은 자바 프로젝트, 특히 Spring 프레임워크 기반의 애플리케이션에서 동시성 제어를 위해 synchronized 키워드 대신 ReentrantLock을 사용하는 사례가 늘고 있습니다. ReentrantLocksynchronized에 비해 더 유연하고 강력한 기능을 제공하며, 섬세한 락 제어가 가능하기 때문입니다.

 

이번 글에서는 ReentrantLock의 핵심 동작 원리, 그중에서도 특히 비공정(Nonfair) 방식의 lock() 메서드가 어떻게 내부적으로 동작하는지 상세히 살펴보고자 합니다.

환경

- JDK 17

🐋 ReentrantLock의 전체 구조: 핵심은 AQS

ReentrantLock의 내부를 들여다보면, 그 핵심에는 AbstractQueuedSynchronizer(AQS)가 자리 잡고 있습니다.

public class ReentrantLock {

    // 내부에 AQS를 상속하는 Sync 클래스가 있음
    abstract static class Sync extends AbstractQueuedSynchronizer { ... }

    // 공정성과 관련된 두 가지 구현: NonfairSync와 FairSync
    static final class NonfairSync extends Sync { ... }
    static final class FairSync extends Sync { ... }

    // 실제로 사용되는 sync 인스턴스 (AQS의 구현체)
    private final Sync sync;

    // 기본 생성자는 NonfairSync(비공정 락) 사용
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    // fair 파라미터로 공정/비공정 선택 가능
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

    // lock(), unlock() 등의 public API는 실제 작업을 sync 객체에게 위임
    public void lock() {
        sync.lock();
    }

    public void unlock() {
        sync.release(1); 
    }

}

구조 해설:

ReentrantLockLock 인터페이스를 구현하며, 실제 락킹 메커니즘은 내부 정적 클래스인 Sync에 위임합니다.


SyncAbstractQueuedSynchronizer(AQS)를 상속받아 구현됩니다. AQS는 락을 구축하기 위한 강력한 도구로, FIFO 대기 큐, 상태 변수(state), 조건 변수(Condition) 등을 제공합니다.


Sync에는 두 가지 구체적인 구현체, NonfairSyncFairSync가 있습니다.

- NonfairSync (비공정 락): 먼저 요청한 스레드가 아닌, 현재 실행 중인 스레드가 락을 "새치기"할 수 있는 방식입니다. 일반적으로 더 높은 처리량(throughput)을 보입니다. ReentrantLock의 기본 동작 방식입니다.
- FairSync (공정 락): 락을 요청한 순서대로 획득을 보장합니다. 하지만 문맥 교환 비용으로 인해 비공정 락보다 성능이 낮을 수 있습니다.


생성자를 통해 NonfairSync 또는 FairSync 인스턴스가 sync 필드에 할당됩니다.
lock(), unlock()과 같은 주요 메서드들은 이 sync 객체의 메서드를 호출하여 실제 작업을 수행합니다. 이러한 설계는 전략 패턴의 좋은 예시로, 락의 공정성 정책을 유연하게 교체할 수 있게 합니다.

 

AQS의 핵심: state 변수

AQS 내부에는 private volatile int state;라는 정수형 필드가 있습니다. ReentrantLock에서 이 state 변수는 다음과 같은 의미를 가집니다.

  • state == 0: 락이 현재 어떤 스레드에 의해서도 점유되지 않은 상태 (락 획득 가능)
  • state > 0: 락이 특정 스레드에 의해 점유된 상태. 값은 해당 스레드가 락을 중첩하여 획득한 횟수(재진입 횟수)를 나타냅니다.

이번 글에서는 기본 동작 방식인 NonfairSynclock() 메서드를 중심으로 살펴보겠습니다.

💿 NonfairSync의 lock() 과정

NonfairSynclock() 메서드는 다음과 같이 시작합니다.

// ReentrantLock.NonfairSync.java
final void lock() {
    if (!initialTryLock()) // 1. 가벼운 첫 시도 (CAS)
        acquire(1);       // 2. 실패 시 AQS의 획득 로직 진입
}

1. initialTryLock(): 이름에서 알 수 있듯, "초기 락 획득 시도"입니다. 이 메서드는 현재 스레드가 락을 즉시 획득할 수 있는지 확인하고, 가능하다면 CAS(Compare-And-Swap) 연산을 통해 state 값을 0에서 1로 변경하여 락을 점유합니다. 이 과정은 매우 가볍고 빠릅니다. 만약 현재 스레드가 이미 락을 보유하고 있다면 (재진입), state 값을 1 증가시킵니다.

 

2. acquire(1): initialTryLock()이 실패하면 (즉, 다른 스레드가 이미 락을 점유하고 있거나 CAS 경쟁에서 밀리면), AQS의 acquire(1) 메서드가 호출됩니다. 이 메서드는 본격적으로 락 획득을 위한 과정을 시작하며, 필요하다면 스레드를 대기 큐(CLH 큐 기반)에 넣고 대기시킵니다. 1이라는 인자는 락을 한 번 획득하겠다는 의미입니다.

🍰 AQS의 acquire() 상세 분석

initialTryLock()에 실패하면 AQS의 acquire(int arg) 메서드가 호출되고, 이 메서드는 내부적으로 acquire(Node node, int arg, boolean shared, boolean interruptible, boolean timed, long time)를 호출하게 됩니다. 이 메서드가 락 획득의 핵심 로직을 담고 있습니다.

 

acquire(int arg) 호출 시 shared=false, interruptible=false, timed=false로 가정하고 흐름을 이해할 수 있습니다.

final int acquire(Node node, int arg, boolean shared,
                      boolean interruptible, boolean timed, long time) {
    Thread current = Thread.currentThread();
    byte spins = 0, postSpins = 0;   
    boolean interrupted = false, first = false;
    Node pred = null;                

    for (;;) {

        // [1] 큐 안정화 및 선두 주자 확인
        if (!first && (pred = (node == null) ? null : node.prev) != null &&
            !(first = (head == pred))) { 
            if (pred.status < 0) { 
                cleanQueue();          
                continue;
            } else if (pred.prev == null) { 
                Thread.onSpinWait();    
                continue;
            }
        }

        // [2] 락 획득 시도 (선두 주자이거나, 아직 큐에 진입 전)
        if (first || pred == null) { 
            boolean acquired;
            try {
                if (shared)
                    acquired = (tryAcquireShared(arg) >= 0);
                else
                    acquired = tryAcquire(arg); 
            } catch (Throwable ex) {
                cancelAcquire(node, interrupted, false);
                throw ex;
            }
            if (acquired) { 
                if (first) { 
                    node.prev = null; 
                    head = node;     
                    pred.next = null; 
                    node.waiter = null; 
                    if (shared)
                        signalNextIfShared(node);
                    if (interrupted)
                        current.interrupt(); 
                }
                return 1; 
            }
        }

        // [3] Node 생성 (루프 첫 진입 시)
        if (node == null) {                 
            if (shared)
                node = new SharedNode();
            else
                node = new ExclusiveNode(); 
        }

        // [4] 대기 큐에 노드 추가 (Enqueue)
        else if (pred == null) {          
            node.waiter = current;      
            Node t = tail;              
            node.setPrevRelaxed(t);     
            if (t == null)              
                tryInitializeHead();    
            else if (!casTail(t, node)) 
                node.setPrevRelaxed(null);  
            else
                t.next = node;          
        }

        // [5] 짧은 스핀 대기 (선두 주자가 되었지만 바로 락 획득 실패 시)
        else if (first && spins != 0) {
            --spins;                        
            Thread.onSpinWait();            
        }

        // [6] 파킹(Parking) 전 상태 설정
        else if (node.status == 0) {    
            node.status = WAITING;          
        }

        // [7] 스레드 파킹 (Sleep)
        else {
            long nanos;
            spins = postSpins = (byte)((postSpins << 1) | 1); 
            if (!timed) 
                LockSupport.park(this); 
            else if ((nanos = time - System.nanoTime()) > 0L) 
                LockSupport.parkNanos(this, nanos);
            else 
                break;
            node.clearStatus(); 
            if ((interrupted |= Thread.interrupted()) && interruptible) 
                break; 
        }
    }
    // 락 획득 실패 (타임아웃, 인터럽트 등) 시 정리 작업
    return cancelAcquire(node, interrupted, interruptible);
}

AQS 큐와 Node 상태값 이해하기

AQS는 내부적으로 CLH(Craig, Landin, Hagersten) 락 큐의 변형을 사용합니다. 이 큐는 스레드들이 락을 기다리는 줄이며, 각 스레드는 Node 객체로 표현됩니다. NodewaitStatus라는 중요한 상태값을 가집니다.

  • 0 (기본값): 특별한 상태가 아님.
  • CANCELLED (1): 스레드가 타임아웃 또는 인터럽트로 인해 대기를 취소한 상태. 이 노드는 큐에서 곧 제거됩니다.
  • SIGNAL (-1): 이 노드의 후속 노드(successor)가 현재 파킹(대기) 상태이거나 곧 파킹될 예정이므로, 현재 락이 해제되거나 이 노드가 취소될 때 후속 노드를 깨워야 함을 나타냅니다.
  • CONDITION (-2): 노드가 조건(Condition) 큐에 있으며, 조건이 충족되기를 기다리는 상태. (일반 락 큐에서는 사용되지 않음)
  • PROPAGATE (-3): (주로 공유 락에서 사용) releaseShared 작업이 다른 후속 노드에게도 전파되어야 함을 나타냅니다.

 

CLH 큐 구조
head : 큐의 가장 앞 부분. 현재 락을 잡고 있는 노드
pred : 내 노드의 바로 앞 노드.
tail : 큐의 가장 마지막 부분.

(현재 락 점유자: T1)
      Head                                                       Pred                           Node(나)
       |                                                          |                              |         
       V                                                          V                              V
+-------------+  <---prev---  +-------------+  <---prev---  +-------------+  <---prev---  +-------------+
|   Node_T1   |               |   Node_T2   |               |   Node_T3   |               |   Node_T4   | <-- TAIL
| thread=T1   |               | thread=T2   |               | thread=T3   |               | thread=T4   |
+-------------+  ---next--->  +-------------+  ---next--->  +-------------+  ---next--->  +-------------+
                               (첫 번째 대기자)                                               (마지막 대기자)

1. 큐 안정화 및 선두 주자 확인

if (!first && (pred = (node == null) ? null : node.prev) != null &&  !(first = (head == pred))) {

     if (pred.status < 0) {
          cleanQueue();           
          continue;
      } else if (pred.prev == null) {
          Thread.onSpinWait();    
          continue;
      }
 }

if문 내부가 복잡해보입니다. 하나씩 천천히 풀어나가보겠습니다.

 

!first
boolean first 변수로서, 내가 첫번째 대기자가 아닌경우 해당 됩니다.

 

(pred = (node == null) ? null : node.prev) != null
만약 node 가 null 이면 pred null 할당, 그 외에는 node.prev 할당 해줍니다.
null 이 아니라는뜻은 이미 node 가 생성된 이후라는 뜻입니다.

 

!(first = (head == pred))
이전 노드가 head라면, 현재 노드가 락을 시도할 첫 번째 순서가 됩니다

 

if 문에 조건 검사와 상태갱신이 같이 있어서 한번에 해석하기 힘들다. 이 if 문 내부로 들어가는 상황을 설명하면
"현재 노드가 대기 큐에 존재하긴 하지만, 아직 head 바로 뒤에 위치하지 않아서 락을 시도할 자격이 없는 상태이며, 앞선 노드의 상태나 연결이 안정됐는지 확인해야 하는 단계"

 

그래서, 내부에서 큐 상태 안정화와 관련한 코드들이 존재합니다.

if (pred.status < 0) {
    cleanQueue();           
    continue;
}

만약 이전 노드가 취소되었다면 (CANCELLED), cleanQueue()를 호출하여 큐에서 해당 노드를 정리하고 큐의 link 를 다시 잘 연결해줍니다.

else if (pred.prev == null) {
    Thread.onSpinWait();   
    continue;
}

prev가 null이라면, 큐의 연결이 아직 완전히 안정되지 않은 상태일 수 있습니다. 뒤에 있을 4번 대기 큐 추가 로직과 관련이 있습니다. Thread.onSpinWait()를 통해 잠시 스핀 대기하며 메모리 가시성을 확보하고 큐가 안정되기를 기다립니다.

2. 가능성 있는 락 획득 시도

            if (first || pred == null) {
                boolean acquired;
                try {
                    if (shared)
                        acquired = (tryAcquireShared(arg) >= 0);
                    else
                        acquired = tryAcquire(arg);
                } catch (Throwable ex) {
                    cancelAcquire(node, interrupted, false);
                    throw ex;
                }
                if (acquired) {
                    if (first) {
                        node.prev = null;
                        head = node;
                        pred.next = null;
                        node.waiter = null;
                        if (shared)
                            signalNextIfShared(node);
                        if (interrupted)
                            current.interrupt();
                    }
                    return 1;
                }
            }

락획득 가능성이 있는 상황에서 락획득을 한번 시도해보는 로직입니다.

 

if (first || pred == null)
대기자 중 첫번째이거나, 아직 큐에 진입하기 전이어서 큐의 상태를 모를 때 시도합니다.

 

tryAcquire()
CAS 연산으로 가볍게 한번 락획득 시도를 해봅니다. 성공시, if (acquire) 큐의 링크를 정리해주는 아래 블록이 실행됩니다.

3. Node 생성

acquire 메서드에 처음 진입했을 때, node 매개변수가 null로 전달됩니다. 이 경우 Node 를 생성해줍니다.

4. 대기 큐에 노드 추가

            else if (pred == null) {          
                node.waiter = current;
                Node t = tail;
                node.setPrevRelaxed(t);       
                if (t == null)
                    tryInitializeHead();
                else if (!casTail(t, node))
                    node.setPrevRelaxed(null);  
                else
                    t.next = node;
            }

node는 생성되었지만, pred == null이라는 것은 아직 큐에 연결되지 않았다는 의미입니다.

 

node.setPrevRelaxed(t)
현재 노드의 prev 포인터를 현재 tail로 설정합니다. Relaxed는 메모리 순서 제약을 완화하여 성능을 높이는 방식입니다.

 

if (t == null) tryInitializeHead()
tail이 null이면 큐가 비어있으므로 head를 초기화합니다.

 

else if (!casTail(t, node)) ...
CAS 연산을 사용하여 현재 tail을 새로운 node로 안전하게 변경하려고 시도합니다.

  • 성공하면 t.next = node를 통해 이전 tail과 새 tail(node)을 양방향으로 연결합니다.
  • 실패하면 (다른 스레드가 먼저 tail을 변경한 경우) node.setPrevRelaxed(null)로 prev 설정을 되돌리고 루프를 다시 시작하여 재시도합니다.

일단 현재 AQS의 tail 변수에 현재 node 할당을 먼저 해주고 t.next = node; 는 느긋하게 해주는 방식을 택했습니다.


그렇다면, tail 변수에 node 할당만 받은 상태에서 다른 쓰레드가 들어오면 문제가 생길 수 있지 않을까요?

그래서 1번 큐 안정화 부분이 존재합니다. 그곳에서 pred.prev == null 안정화 조건을 사용하고 있기 때문에 잘 작동하고 있습니다.

5. 짧은 스핀 대기

else if (first && spins != 0) {
     --spins;                      
     Thread.onSpinWait();
}

만약 현재 노드가 first가 되었지만, 2번 단계의 tryAcquire에서 락 획득에 실패한 경우, 바로 파킹하지 않고 spins 횟수만큼 Thread.onSpinWait()를 하며 짧게 스핀 대기합니다. 이는 컨텍스트 스위칭 비용을 줄이기 위한 최적화입니다.

6. 파킹 전 상태 설정

else if (node.status == 0) {
   node.status = WAITING;         
}

스레드를 파킹하기 전에, node.status를 WAITING으로 설정하여, 이전 노드가 락을 해제할 때 현재 노드를 깨워야 함을 알립니다.

7. 스레드 파킹

            else {
                long nanos;
                spins = postSpins = (byte)((postSpins << 1) | 1);
                if (!timed)
                    LockSupport.park(this);
                else if ((nanos = time - System.nanoTime()) > 0L)
                    LockSupport.parkNanos(this, nanos);
                else
                    break;
                node.clearStatus();
                if ((interrupted |= Thread.interrupted()) && interruptible)
                    break;
            }

모든 락 획득 시도가 실패하고, 스핀 대기도 소진되면, LockSupport.park(this)를 호출하여 현재 스레드를 파킹(대기 상태로 전환)시킵니다. 이때 스레드는 CPU를 소모하지 않고 잠들게 됩니다.

 

이러한 과정을 통해 ReentrantLock은 효율적이고 안전하게 여러 스레드 간의 공유 자원 접근을 제어합니다.

마치며

지금까지 ReentrantLock, 특히 NonfairSync의 lock() 메서드가 내부적으로 어떻게 동작하는지 AQS의 acquire 메서드를 중심으로 살펴보았습니다. CAS 연산을 통한 빠른 락 시도, 실패 시 CLH 큐를 사용한 공정한 (혹은 비공정한) 대기열 관리, 그리고 LockSupport.park/unpark를 이용한 효율적인 스레드 대기/깨우기 메커니즘은 매우 정교하게 설계되어 있습니다.

 

ReentrantLocksynchronized보다 더 많은 기능을 제공합니다:

  • 공정성/비공정성 선택 가능
  • 인터럽트 가능한 락 획득 시도 
  • 시간 제한을 둔 락 획득 시도 
  • 다중 조건 변수 

이러한 강력한 기능과 내부의 정교한 메커니즘을 이해한다면, 더욱 견고하고 효율적인 동시성 프로그래밍을 하는 데 큰 도움이 될 것입니다. 다음번에는 unlock() 과정이나 Condition 객체에 대해서도 탐구해 보면 좋겠습니다.

Reference

https://github.com/openjdk/jdk17/blob/master/src/java.base/share/classes/java/util/concurrent/locks/ReentrantLock.java
https://github.com/openjdk/jdk17/blob/master/src/java.base/share/classes/java/util/concurrent/locks/AbstractQueuedSynchronizer.java