Skip to main content

Command Palette

Search for a command to run...

AbstractQueuedSynchronizer 내부 구조 — j.u.c가 한 줄의 int 위에 동시성을 세운 방법

Updated
12 min read

ReentrantLock, Semaphore, CountDownLatch, ReentrantReadWriteLock, FutureTask. 자바 동시성 라이브러리를 떠받치는 도구들이 모두 같은 부모 클래스 하나를 상속합니다. 이 글은 그 부모인 AbstractQueuedSynchronizer(AQS)가 어떻게 int 한 필드와 FIFO 큐 하나로 lock·semaphore·latch·rwlock·future를 동시에 표현하는지를 OpenJDK 소스 기준으로 정리합니다.

왜 AQS가 필요했는가

JDK 5에서 들어온 java.util.concurrent(JSR 166)은 synchronized 키워드와 Object.wait/notify로는 풀기 어려웠던 문제들을 한꺼번에 다뤘습니다. 시간 제한이 있는 락, 인터럽트 가능한 락, 공정성 옵션, 다중 조건 변수, 읽기·쓰기 분리, 카운트다운 래치, 세마포어 같은 도구가 한 번에 등장했습니다.

Doug Lea가 이 라이브러리를 설계하면서 직면한 문제는 단순했습니다. 이 도구들은 모두 "스레드를 어떻게 줄 세우고 깨우는가"라는 같은 메커니즘을 필요로 합니다. 모든 도구가 자기 큐를 따로 구현하면 코드 중복도 문제지만, 성능 특성도 제각각이 되어 운영이 어려워집니다.

해법은 큐와 블로킹을 한 클래스에 몰아넣고, 각 도구는 "내 동기화 상태가 어떤 의미인지"만 정의하도록 분리한 것입니다. 그 한 클래스가 AbstractQueuedSynchronizer입니다.

단 하나의 정수, 단 하나의 큐

AQS의 자료구조는 두 줄로 요약됩니다.

  • int state — 동기화 상태. 의미는 서브클래스가 정의합니다.
  • FIFO 대기 큐 — state 획득에 실패한 스레드들이 줄 서는 곳.

이 두 가지가 전부입니다. 상태가 무엇인지(락 보유 카운트인지, 남은 퍼밋 수인지, 카운트다운 횟수인지)는 AQS가 알 필요가 없습니다. AQS는 "획득을 시도해서 실패하면 큐에 넣고 park, 해제되면 다음 노드를 unpark"하는 골격만 책임집니다.

flowchart LR
  subgraph AQS["AbstractQueuedSynchronizer"]
    S["volatile int state"]
    H["head"] --> N1["Node A<br/>WAITING"] --> N2["Node B<br/>WAITING"] --> T["tail"]
  end
  Subclass["ReentrantLock / Semaphore /<br/>CountDownLatch / RWLock / FutureTask"]
  Subclass -->|"override tryAcquire,<br/>tryRelease, ..."| AQS

템플릿 메서드: 다섯 줄의 계약

AQS의 핵심 인터페이스는 다섯 개의 protected 메서드입니다. 서브클래스는 자신이 표현하려는 동기화 도구의 의미에 맞게 이들 중 필요한 것만 오버라이드합니다.

// 배타적 모드 (한 번에 한 스레드만 획득)
protected boolean tryAcquire(int arg);
protected boolean tryRelease(int arg);

// 공유 모드 (여러 스레드가 동시에 획득 가능)
protected int tryAcquireShared(int arg);
protected boolean tryReleaseShared(int arg);

// 현재 스레드가 배타 점유자인지 (Condition 변수에서 필요)
protected boolean isHeldExclusively();

기본 구현은 모두 UnsupportedOperationException을 던지므로, 서브클래스는 자기가 쓰는 모드의 두 메서드만 구현하면 됩니다. ReentrantLock은 배타 모드 두 개, SemaphoreCountDownLatch는 공유 모드 두 개, ReentrantReadWriteLock은 네 개를 모두 구현합니다.

반환 규약이 미묘합니다.

  • tryAcquire: 획득 성공 여부 boolean.
  • tryRelease: "완전히 해제되어 대기 스레드를 깨워도 되는지" boolean. 재진입 카운트가 0이 되어야 true.
  • tryAcquireShared: 음수면 실패, 0이면 성공이지만 다음 공유 획득은 실패, 양수면 성공이고 다음 공유 획득도 성공할 가능성. 이 세 갈래 반환값이 PROPAGATE 동작의 출발점입니다.
  • tryReleaseShared: 해제 후 대기 스레드를 깨워도 되는지 boolean.

CLH 큐의 자바 변형

AQS 대기 큐는 Craig·Landin·Hagersten이 제안한 CLH 락의 변형입니다. 원본 CLH는 본래 스핀락 큐였습니다. 각 노드는 자기 선행 노드의 상태를 스핀하며 관찰하다가 선행 노드가 "나 끝났다"는 신호를 주면 임계 영역에 들어갑니다. 큐는 단일 연결(prev만 있음)로 충분했습니다.

자바에서는 스레드가 길게 스핀하면 CPU가 낭비됩니다. AQS는 스핀 대신 LockSupport.park()로 스레드를 멈추고, 선행 노드의 해제 시점에 LockSupport.unpark()로 깨우는 방식을 씁니다. 그러려면 노드 A가 자기 후속 노드 B를 명시적으로 가리킬 수 있어야 합니다. 그래서 AQS는 원본 CLH에 next 링크를 추가했습니다.

OpenJDK의 현재 노드 정의(요약)는 다음과 같습니다.

abstract static class Node {
    volatile Node prev;
    volatile Node next;
    Thread waiter;          // park된 스레드 참조
    volatile int status;    // WAITING, CANCELLED, COND 비트 마스크
}

status 필드가 가질 수 있는 값은 세 가지입니다 (JDK 17+ 모더나이즈된 표현).

  • WAITING = 1 — "다음 노드가 unpark되어야 함" 신호.
  • CANCELLED = 0x80000000 (음수) — 타임아웃 또는 인터럽트로 취소된 노드.
  • COND = 2 — 이 노드는 Condition 큐에서 대기 중.
flowchart LR
  H["head<br/>(dummy)"] -->|next| A["Node A<br/>status=WAITING"]
  A -->|next| B["Node B<br/>status=WAITING"]
  B -->|next| T["Node C<br/>(tail)"]
  A -->|prev| H
  B -->|prev| A
  T -->|prev| B

head는 더미 노드입니다. 큐가 비어 있지 않을 때 head.next가 다음 차례의 노드이고, 이 노드의 스레드는 현재 락 보유자거나 곧 획득을 시도할 후보입니다.

acquire — 획득 알고리즘

배타 모드 acquire(int arg)의 코드 흐름은 다음과 같습니다.

flowchart TD
  Start["acquire(arg)"] --> Try1["tryAcquire(arg)"]
  Try1 -->|"true"| Done["획득 완료"]
  Try1 -->|"false"| Enq["addWaiter(EXCLUSIVE)<br/>큐 끝에 노드 추가"]
  Enq --> Loop{"내 prev가 head인가?"}
  Loop -->|"Yes"| RetryAcq["tryAcquire(arg)"]
  RetryAcq -->|"true"| SetHead["head = 내 노드"]
  RetryAcq -->|"false"| Wait["선행 노드 status=WAITING<br/>LockSupport.park(this)"]
  Loop -->|"No"| Wait
  Wait -->|"unpark 수신"| Loop
  SetHead --> Done

핵심은 세 가지입니다.

첫째, 큐에 들어가기 전에 tryAcquire를 한 번 더 시도합니다. 공정성 정책이 허용하면 큐를 무시하고 새치기(barging)할 수 있습니다. 비공정 락의 처리량이 더 높은 이유가 여기에 있습니다.

둘째, 큐에 들어간 뒤에도 자기 차례(prev == head)가 되면 다시 tryAcquire를 시도합니다. 큐는 "곧 깨워 줄 노드의 순서"일 뿐, 큐 안에서도 실제 획득은 CAS로 경쟁합니다.

셋째, park하기 전에 선행 노드의 status를 WAITING으로 설정합니다. 이건 "내 다음 노드(나)를 깨워 달라"는 부탁입니다. 선행 노드가 release할 때 WAITING을 보면 자신의 다음 노드를 unpark합니다.

release — 해제 알고리즘

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        signalNext(head);
        return true;
    }
    return false;
}

tryRelease가 true를 반환하면(완전히 해제되었으면) signalNext(head)head.next의 status WAITING 비트를 풀고 그 스레드를 unpark합니다. 깨어난 후속 노드는 acquire 루프로 돌아가 다시 tryAcquire를 시도합니다.

tryRelease가 false를 반환하면 (예: 재진입 락의 카운트가 아직 0이 아니면) 아무도 깨우지 않습니다. 락은 여전히 보유 중인 상태입니다.

공유 모드: acquireShared와 propagation

공유 모드는 본질적으로 같은 큐를 쓰지만, 한 번 깨어난 노드가 다음 노드까지 깨우는 전파 동작이 추가됩니다.

flowchart TD
  Start["acquireShared(arg)"] --> Try["tryAcquireShared(arg)"]
  Try -->|"음수"| Enq["큐에 노드 추가<br/>SHARED 모드 마킹"]
  Try -->|"0 또는 양수"| Done["획득 완료"]
  Enq --> Loop{"prev == head?"}
  Loop -->|"Yes"| Retry["tryAcquireShared(arg)"]
  Retry -->|"음수"| Park["park"]
  Retry -->|"0"| SetHead1["head = 내 노드<br/>(전파 안 함)"]
  Retry -->|"양수"| SetHead2["head = 내 노드<br/>+ 다음 노드 unpark"]
  Loop -->|"No"| Park
  Park -->|"unpark"| Loop

tryAcquireShared가 양수를 반환하면 "내 다음 노드도 공유 획득에 성공할 가능성이 있다"는 뜻이므로 즉시 다음 노드를 깨웁니다. 카운트다운 래치가 카운트 0에 도달했을 때 대기 중인 모든 스레드가 동시에 풀려나는 메커니즘이 이 전파입니다.

ReentrantLock — 카운트로서의 state

ReentrantLockSyncAQS를 직접 상속합니다. state는 재진입 카운트입니다. 한 번 락을 잡으면 1, 같은 스레드가 다시 잡으면 2, 해제할 때마다 1씩 감소합니다.

abstract static class Sync extends AbstractQueuedSynchronizer {
    protected final boolean tryRelease(int releases) {
        int c = getState() - releases;
        if (getExclusiveOwnerThread() != Thread.currentThread())
            throw new IllegalMonitorStateException();
        boolean free = (c == 0);
        if (free) setExclusiveOwnerThread(null);
        setState(c);
        return free;
    }
}

다른 스레드가 해제를 시도하면 IllegalMonitorStateException이 던져집니다. synchronized와 달리 락 보유자를 명시적으로 추적하는 부분이 setExclusiveOwnerThread입니다. 이건 AQS의 부모인 AbstractOwnableSynchronizer가 제공하는 한 필드짜리 추가 상태입니다.

비공정 락의 tryAcquire는 단순합니다.

final boolean tryAcquire(int acquires) {
    if (getState() == 0 && compareAndSetState(0, acquires)) {
        setExclusiveOwnerThread(Thread.currentThread());
        return true;
    }
    // 재진입 분기 생략
}

state == 0이면 큐 상태를 무시하고 CAS로 새치기를 시도합니다. 공정 락은 한 줄이 다릅니다.

final boolean tryAcquire(int acquires) {
    if (getState() == 0 && !hasQueuedPredecessors() &&
        compareAndSetState(0, acquires)) {
        setExclusiveOwnerThread(Thread.currentThread());
        return true;
    }
    // 재진입 분기 생략
}

hasQueuedPredecessors()가 true이면 (자기보다 먼저 줄 서 있는 스레드가 있으면) CAS를 시도조차 하지 않고 큐로 갑니다. 새치기가 사라지므로 처리량은 떨어지지만 starvation이 없습니다.

Semaphore — 퍼밋으로서의 state

Semaphorestate는 남은 퍼밋 수입니다. 공유 모드를 씁니다.

// NonfairSync.nonfairTryAcquireShared
final int nonfairTryAcquireShared(int acquires) {
    for (;;) {
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0 || compareAndSetState(available, remaining))
            return remaining;
    }
}

남은 퍼밋에서 요청한 만큼을 빼고 CAS로 갱신합니다. 음수면 실패(반환값이 음수), CAS 성공이면 성공입니다. 공정 버전은 hasQueuedPredecessors() 체크가 한 줄 추가됩니다.

해제는 단순한 덧셈입니다.

protected final boolean tryReleaseShared(int releases) {
    for (;;) {
        int current = getState();
        int next = current + releases;
        if (next < current) throw new Error("Maximum permit count exceeded");
        if (compareAndSetState(current, next))
            return true;
    }
}

tryReleaseShared가 true를 반환하면 AQS는 다음 노드를 깨웁니다. 깨어난 노드는 tryAcquireShared를 재시도합니다.

CountDownLatch — 카운트로서의 state

CountDownLatch는 가장 간단한 AQS 사용 예입니다.

private static final class Sync extends AbstractQueuedSynchronizer {
    Sync(int count) { setState(count); }

    protected int tryAcquireShared(int acquires) {
        return (getState() == 0) ? 1 : -1;
    }

    protected boolean tryReleaseShared(int releases) {
        for (;;) {
            int c = getState();
            if (c == 0) return false;
            int nextc = c - 1;
            if (compareAndSetState(c, nextc))
                return nextc == 0;
        }
    }
}

await()를 호출한 스레드들은 tryAcquireSharedstate==0일 때만 1을 반환하므로 그때까지 큐에서 park됩니다. countDown()을 호출한 스레드는 카운트를 1씩 줄이고, 0에 도달한 호출에서만 true를 반환합니다.

true를 받은 AQS는 head 다음 노드를 깨웁니다. 깨어난 노드의 tryAcquireShared가 양수(1)를 반환하므로 그 노드가 head가 되면서 다음 노드도 즉시 unpark합니다. 이 전파가 도미노처럼 연쇄되어 모든 대기 스레드가 풀려납니다.

ReentrantReadWriteLock — 한 long 안에 두 카운트

읽기·쓰기 락은 카운트가 두 개 필요합니다(현재 보유 중인 reader 수, 현재 보유 중인 writer의 재진입 수). AQS의 int state로는 부족하므로 OpenJDK는 AbstractQueuedLongSynchronizer를 함께 제공합니다. 동일한 설계지만 state가 long입니다.

abstract static class Sync extends AbstractQueuedLongSynchronizer {
    static final int  SHARED_SHIFT   = 32;
    static final long SHARED_UNIT    = (1L << SHARED_SHIFT);
    static final long MAX_COUNT      = Integer.MAX_VALUE;
    static final long EXCLUSIVE_MASK = (1L << SHARED_SHIFT) - 1;

    static int sharedCount(long c)    { return (int)(c >>> SHARED_SHIFT); }
    static int exclusiveCount(long c) { return (int)(c & EXCLUSIVE_MASK); }
}

상위 32비트가 reader 수, 하위 32비트가 writer 재진입 카운트입니다. reader가 한 명 늘면 state에 SHARED_UNIT(=1L << 32)를 더하고, writer가 재진입하면 state에 1을 더합니다. 두 카운트를 한 long으로 묶었기 때문에 한 번의 CAS로 양쪽 상태를 동시에 안전하게 갱신할 수 있습니다.

읽기 락 획득은 tryAcquireShared, 쓰기 락은 tryAcquire로 분기됩니다. 같은 큐 위에서 두 모드가 공존하며, AQS는 어떤 노드가 어느 모드인지를 addWaiter에서 마킹합니다.

쓰기 → 읽기 다운그레이드(writer가 쓰기 락을 잡은 채로 읽기 락을 추가 획득한 뒤 쓰기 락만 놓는 패턴)는 안전합니다. 같은 스레드의 재진입이기 때문입니다. 반대 방향 업그레이드는 데드락이 되므로 명시적으로 금지됩니다.

Condition — 두 개의 큐를 오가는 노드

synchronized 블록에서 wait/notify로 풀던 "조건이 충족될 때까지 기다린다"는 패턴을 AQS는 ConditionObject로 제공합니다. Lock.newCondition()이 반환하는 객체가 바로 그것입니다.

ConditionObject는 자기만의 큐를 별도로 가집니다. AQS의 대기 큐(sync queue)와 다른 큐입니다. 노드의 statusCOND 비트가 켜져 있으면 그 노드는 condition 큐에 있는 것입니다.

flowchart LR
  subgraph SQ["Sync Queue (락 대기)"]
    SH["head"] --> S1["Node X"] --> S2["Node Y"]
  end
  subgraph CQ["Condition Queue (조건 대기)"]
    C1["Node A<br/>status=COND"] --> C2["Node B<br/>status=COND"]
  end
  Signal["signal()"] -.->|"노드 이동"| SQ
  CQ -.->|"transferForSignal"| Signal

흐름은 이렇습니다.

  1. condition.await() 호출: 현재 스레드가 락을 보유 중이어야 합니다(isHeldExclusively가 true). AQS는 락을 완전히 해제하고, 이 스레드의 노드를 condition 큐에 넣은 뒤 park합니다.
  2. 다른 스레드가 condition.signal() 호출: condition 큐의 첫 노드를 떼어내 sync 큐의 끝에 붙이고 COND 비트를 끕니다. 이때 노드는 park 상태 그대로입니다.
  3. signal한 스레드가 결국 락을 release하면 sync 큐가 정상 동작해 그 노드를 unpark합니다.
  4. 깨어난 await 스레드는 락 재획득을 시도하고, 성공하면 await 호출 지점으로 반환합니다.

isHeldExclusively가 필요한 이유가 여기서 드러납니다. await는 락을 해제해야 하므로 현재 스레드가 락 보유자임이 보장되어야 하고, 깨어나서는 다시 락을 잡아야 하므로 배타 모드의 의미가 정의되어 있어야 합니다. 그래서 SemaphoreCountDownLatch처럼 공유 모드만 쓰는 도구는 Condition을 만들 수 없습니다.

park와 unpark — 실제로 스레드를 멈추는 곳

AQS가 호출하는 가장 낮은 수준의 함수는 java.util.concurrent.locks.LockSupport의 두 메서드입니다.

LockSupport.park(this);   // 현재 스레드를 멈춤
LockSupport.unpark(t);    // 스레드 t를 깨움

parkObject.wait와 달리 모니터를 갖고 있지 않아도 호출할 수 있고, 인터럽트도 단순히 "park가 풀린다"는 결과로 처리됩니다. unpark는 토큰 모델입니다. unpark가 먼저 호출되면 그 토큰이 저장되어 다음 park가 즉시 반환됩니다. 그래서 "park가 먼저 일어나야 unpark가 의미 있다"는 잘못된 가정을 하지 않아도 됩니다.

OpenJDK는 park/unpark를 OS 수준에서 구현합니다. 리눅스에서는 pthread_cond_wait/pthread_cond_signal이 깔립니다. 즉 큐에서 park된 스레드는 실제로 OS의 wait 큐에 들어가 CPU를 쓰지 않습니다.

Virtual Thread와 AQS — JEP 491 이후

JDK 21에서 도입된 가상 스레드는 synchronized 블록 안에서 블로킹되면 캐리어 스레드에 pin되어 가상 스레드 본연의 장점(언마운트 후 다른 가상 스레드 실행)을 잃었습니다. 그래서 21·22·23 동안 "가상 스레드에서는 synchronized 대신 ReentrantLock을 쓰라"는 권고가 표준이었습니다. AQS는 park 시점에 캐리어 스레드를 점유하지 않으므로 pin이 발생하지 않았기 때문입니다.

JDK 24의 JEP 491은 이 그림을 바꿨습니다. synchronized도 가상 스레드를 pin하지 않도록 JVM의 모니터 구현이 바뀌었습니다. 결과적으로 "가상 스레드에서는 ReentrantLock을 써야 한다"는 규칙은 더 이상 권고되지 않습니다. 두 도구의 차이는 다시 의미적인 차이(다중 Condition, fair 옵션, tryLock, lockInterruptibly 등)로만 남습니다.

다만 AQS는 여전히 j.u.c의 토대입니다. ScheduledThreadPoolExecutor, ForkJoinPool의 일부, FutureTask, Phaser 등이 AQS 위에 올라가 있습니다. JEP 491이 AQS를 대체하지는 않습니다.

진단 — 어떤 스레드가 어디서 park되어 있는가

운영 중인 JVM에서 AQS 락의 대기 상황은 스레드 덤프(jstack 또는 jcmd <pid> Thread.print)에 명확하게 찍힙니다.

"http-nio-8080-exec-7" #42 daemon prio=5 ...
   java.lang.Thread.State: WAITING (parking)
        at jdk.internal.misc.Unsafe.park(Native Method)
        - parking to wait for  <0x000000076d8c1f30> (a
          java.util.concurrent.locks.ReentrantLock$NonfairSync)
        at java.util.concurrent.locks.LockSupport.park(LockSupport.java:...)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(...)

parking to wait for <0x...> (a ReentrantLock$NonfairSync) 줄이 결정적입니다. AQS 서브클래스의 인스턴스 주소가 나오므로, 다른 스레드의 덤프에서 같은 주소를 락 보유 중으로 표시하는 스레드를 찾아 데드락이나 lock contention을 추적할 수 있습니다. jcmd <pid> Thread.print -l을 쓰면 synchronized가 아닌 j.u.c.locks 락도 보유자 정보를 함께 출력합니다.

JFR(-XX:StartFlightRecording)의 jdk.JavaMonitorWait, jdk.ThreadPark 이벤트도 AQS 대기를 캡처합니다. 가상 스레드 환경에서는 JFR이 거의 유일한 정밀 진단 도구입니다.

운영 함정 다섯 가지

unlock을 finally에 넣지 않기. synchronized와 달리 Lock.lock()은 예외가 나도 자동으로 풀리지 않습니다. try { lock.lock(); ... } finally { lock.unlock(); } 패턴을 강제하지 않으면 단 한 번의 예외로 모든 후속 스레드가 영원히 park됩니다. lock 호출은 try 안이 아니라 try 직전에 두어야 한다는 점도 함께 기억해야 합니다(획득 실패가 unlock을 호출하면 IllegalMonitorStateException).

비공정 락의 무한 starvation. 비공정 락은 새치기를 허용하므로 처리량이 높지만, 트래픽이 일정 수준 이상이면 큐 안의 특정 스레드가 무한히 밀릴 수 있습니다. 응답 지연 SLA가 중요한 경로는 new ReentrantLock(true)로 공정 모드를 켜는 편이 안전합니다. 단 공정 모드는 처리량이 떨어집니다.

Condition.await의 spurious wakeup. wait처럼 await도 가짜 깨어남(spurious wakeup)이 발생할 수 있습니다. AQS의 condition 큐가 sync 큐로 노드를 옮길 때도 마찬가지입니다. 항상 while (조건이 거짓) condition.await(); 형태의 루프로 감싸야 합니다. if로만 쓰면 잘못된 시점에 깨어나 조건이 거짓인 상태로 진행됩니다.

여러 락의 비일관된 획득 순서. 두 락을 잡는 두 코드 경로가 서로 다른 순서로 락을 잡으면 데드락이 발생합니다. AQS는 이를 자동으로 감지하지 않습니다. jstack이 데드락을 보고해 주지만, 운영 중에 잡힐 때는 이미 늦었습니다. 락 순서를 코드 레벨 규약으로 고정하거나, 가능하면 tryLock(timeout)으로 우회 가능하게 만들어야 합니다.

ReentrantReadWriteLock의 reader 폭주. RWLock은 reader가 많을 때 writer가 starvation에 빠지기 쉽습니다. 공정 모드는 일부 완화하지만 완전 해결책은 아닙니다. 읽기·쓰기 비율이 극단적이지 않다면 그냥 ReentrantLock 또는 StampedLock(낙관적 읽기)을 검토하는 편이 단순합니다.

정리

AQS는 한 줄의 int와 FIFO 큐 하나로 자바 동시성 도구 절반을 떠받칩니다. 서브클래스가 정의하는 것은 "state가 어떤 의미인가"와 "획득·해제가 그 state를 어떻게 변환하는가"뿐이고, 큐와 park·unpark·전파는 모두 AQS가 책임집니다. ReentrantLock은 state를 재진입 카운트로, Semaphore는 퍼밋 수로, CountDownLatch는 남은 카운트로, RWLock은 두 카운트의 합성으로 해석합니다. Condition은 같은 노드를 두 큐 사이에서 옮기는 작은 확장만으로 구현됩니다. JEP 491이 가상 스레드와 synchronized의 관계를 정리한 지금도 AQS는 j.u.c의 토대로 남아 있고, 운영에서 락 경합을 디버깅할 때 스레드 덤프 한 줄이 가리키는 것은 결국 이 클래스입니다.

참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ