synchronized, volatile, ReentrantLock은 이미 써봤을 거예요. 이번 딥다이브는 그 아래 한 층을 팝니다. 메모리 배리어가 어디에 삽입되는지, AQS의 CLH 큐가 어떻게 스레드를 줄 세우는지, 객체 헤더 mark word가 락을 어떻게 기록하는지 — 락 경합 튜닝과 LongAdder·@Contended의 이유가 전부 여기서 나와요.
🎯 이 장을 끝내면
happens-before의 형식적 의미와 volatile이 삽입하는 메모리 배리어(Load/Store)를 설명할 수 있어요.
final 필드 안전 발행(생성자 완료 시 freeze) 메커니즘을 이해해요.
AQS의 state+CLH 대기 큐 구조와, 그 위에 올라간 ReentrantLock·Semaphore·CountDownLatch를 구분해요.
객체 헤더 mark word와 경량 락→중량 락 승격, 편향 락 JDK 15 제거, JIT의 lock elision·coarsening을 알아요.
멀티스레드에서 "한 스레드가 쓴 값을 다른 스레드가 볼 수 있는가"는 컴파일러·CPU의 명령 재배열과 캐시 때문에 자명하지 않아요. JMM(Java Memory Model)은 이걸 happens-before라는 부분 순서(partial order) 관계로 규정합니다.
📐 happens-before의 형식적 정의
동작 A가 B보다 happens-before이면(A hb B), A의 메모리 효과가 B에게 가시적(visible)이고 A가 B보다 먼저 일어난 것으로 보장돼요. 핵심 규칙:
① 프로그램 순서: 한 스레드 안에서 앞 문장 hb 뒤 문장. ② 모니터 락: 언락 hb 이후의 같은 모니터 락. ③ volatile: volatile 쓰기 hb 이후의 같은 변수 읽기. ④ 스레드 시작: Thread.start() hb 새 스레드의 첫 동작. ⑤ 종료: 스레드의 모든 동작 hb join() 반환. ⑥ 이행성(transitivity): A hb B, B hb C면 A hb C.
⚠️
happens-before는 "시간 순서"가 아니라 "가시성·순서 보장"이에요. 두 동작 사이에 hb 관계가 없으면 JVM은 자유롭게 재배열할 수 있고, 한 스레드의 쓰기가 다른 스레드에 영원히 안 보일 수도 있어요(무한 루프하는 non-volatile 플래그가 대표 사례). "실제로는 잘 보이더라"는 보장이 아니라 우연입니다.
💡
data race의 정의. hb로 순서 지어지지 않은 두 접근이 같은 변수에 접근하고 최소 하나가 쓰기이면 data race예요. race가 없는 프로그램(correctly synchronized)은 순차 일관성(sequential consistency)처럼 동작한다고 JMM이 보장합니다 — 이게 "DRF(Data-Race-Free) 보장"이에요.
🚧
메모리 배리어
volatile이 삽입하는 4가지 배리어
추상 JMM이 하드웨어로 내려가는 접점 — final freeze까지
happens-before는 추상 규칙이고, JVM은 이를 메모리 배리어(fence) 명령으로 구현해요. 배리어는 양옆의 특정 메모리 연산 재배열을 금지합니다. JSR-133 쿡북은 네 종류로 나눠요.
배리어
의미(앞 → 뒤 재배열 금지)
LoadLoad
Load1 → Load2 순서 보장. 뒤 읽기가 앞 읽기를 앞지르지 못함
StoreStore
Store1 → Store2 순서 보장. 뒤 쓰기 전에 앞 쓰기가 다른 코어에 보임
LoadStore
Load1 → Store2 순서 보장. 앞 읽기가 뒤 쓰기를 앞지르지 못함
StoreLoad
가장 비쌈. Store1 → Load2 순서 보장. 대개 CPU 전체 fence(x86 mfence/lock 접두)
🔒 volatile은 배리어를 어디에 넣나volatile 쓰기(store): 앞에 StoreStore 배리어(이전 일반 쓰기가 volatile 쓰기보다 먼저 보이게), 뒤에 StoreLoad 배리어(이후 읽기가 위로 못 올라오게). volatile 읽기(load): 뒤에 LoadLoad와 LoadStore 배리어(이후의 읽기·쓰기가 volatile 읽기를 앞지르지 못하게).
그래서 volatile 쓰기 이전의 모든 쓰기는 volatile 읽기 이후에 반드시 보여요 — 이게 hb 규칙 ③의 하드웨어 구현입니다. 값 하나의 원자성만이 아니라 주변 메모리의 가시성·순서가 volatile의 진짜 힘이에요.
⚠️
StoreLoad가 비싼 이유. 네 배리어 중 StoreLoad만 스토어 버퍼를 비우고(flush) 이후 로드가 최신 값을 보게 강제해요. x86은 TSO(Total Store Order)라 다른 세 배리어는 사실상 공짜지만 StoreLoad는 실제 fence 명령이 필요합니다. volatile 쓰기가 volatile 읽기보다 비싼 근본 원인이에요.
🧊 final 필드 안전 발행 — 생성자 완료 시 freeze
생성자 안에서 final 필드에 값을 쓰면, 생성자 끝에 freeze 액션(StoreStore 성격의 배리어)이 삽입돼요. 그 결과 객체 참조가 다른 스레드에 발행되기 전에 final 필드 쓰기가 완료된 것으로 보장됩니다.
덕분에 final 필드만 가진 불변 객체는 동기화 없이도 안전하게 발행(safe publication)돼요 — String이 대표적이죠. 단, 생성자 안에서 this를 미리 유출하면 freeze 전에 참조가 새어 이 보장이 깨집니다("this escape").
🖼️ 그림으로 보기 — volatile 쓰기/읽기 배리어 배치
⚓
AQS
state 하나 + CLH 대기 큐
java.util.concurrent 동기화 도구의 공통 뼈대
AQS(AbstractQueuedSynchronizer)는 ReentrantLock·Semaphore·CountDownLatch·ReentrantReadWriteLock이 공통으로 상속하는 골격이에요. 핵심은 딱 두 가지 — volatile int state 하나와, 획득 실패한 스레드를 줄 세우는 CLH 기반 대기 큐입니다.
🧩 state 하나로 모든 동기화를 표현
각 도구는 원자적 state에 다른 의미를 부여해요:
ReentrantLock: state = 재진입 홀드 카운트(0이면 free). Semaphore: state = 남은 퍼밋 수. CountDownLatch: state = 남은 카운트(0이면 open). ReentrantReadWriteLock: 32비트를 반으로 갈라 상위 16비트=읽기 홀드, 하위 16비트=쓰기 홀드. 서브클래스는 tryAcquire/tryRelease만 구현하고, 큐잉·블로킹·웨이크업은 AQS가 처리합니다.
💡
CLH 큐란. AQS의 대기 큐는 CLH 락을 변형한 이중 연결 리스트예요. 각 노드는 스레드 하나를 담고, 자신의 앞 노드(predecessor) 상태를 보고 대기합니다. 큐 맨 앞 노드의 후계자만 획득을 시도하고, 실패하면 LockSupport.park()로 블록돼요. 해제 시 앞 노드가 unpark()로 후계자를 깨웁니다 — 스핀 대신 블로킹이라 CPU를 안 태워요.
⚖️ 공정(fair) vs 비공정(non-fair)비공정(기본): 락이 풀리는 순간 큐를 건너뛰고(barging) 새로 온 스레드가 먼저 채갈 수 있어요. 컨텍스트 스위치가 줄어 처리량이 높지만 기아(starvation) 가능. 공정: hasQueuedPredecessors()로 앞에 대기자가 있으면 양보 → FIFO 순서 보장. 대신 스위칭 비용으로 처리량↓.
new ReentrantLock(true)가 공정 모드예요. 대부분은 비공정이 유리하고, 기아가 실제 문제일 때만 공정을 선택합니다.
🖼️ 그림으로 보기 — AQS: state + CLH 대기 큐
🪝
락 내부
mark word와 락 승격
synchronized가 실제로 객체 헤더에 남기는 흔적
모든 자바 객체는 힙에 객체 헤더를 갖고, 그 안의 mark word(64비트 JVM에서 보통 8바이트)에 해시코드·GC age·락 상태가 인코딩돼요. synchronized는 이 mark word를 단계적으로 바꿔가며 락을 표현합니다.
🟢
언락(무경합)
mark word에 해시·age만
→
🪶
경량 락(lightweight)
스택의 Lock Record를 CAS로 가리킴
→
🏋️
중량 락(heavyweight)
모니터(OS mutex)로 승격
경합이 없으면 경량 락으로 처리해요 — 스레드 스택에 Lock Record를 만들고 mark word를 CAS로 그걸 가리키게 합니다(OS 개입 없음, 빠름). 그런데 다른 스레드가 동시에 같은 락을 노려 CAS가 실패하면, 중량 락(heavyweight)으로 승격(inflation)돼요. 이때 mark word는 모니터(ObjectMonitor)를 가리키고, 대기 스레드는 OS 뮤텍스로 블록됩니다.
⚠️
편향 락(biased locking)은 이제 없어요. 예전엔 "한 스레드만 반복해 락을 잡는" 흔한 경우를 위해 편향 락(락을 특정 스레드에 편향시켜 CAS조차 생략)이 기본이었어요. 하지만 유지 비용과 복잡성 때문에 JDK 15(JEP 374)에서 기본 비활성화·deprecated됐고 이후 사실상 제거됐습니다. 현대 JVM은 경량 락 → 중량 락 2단계로 봅니다.
✂️ JIT의 락 최적화 — elision과 coarseningLock Elision(락 제거):이스케이프 분석으로 어떤 객체가 한 스레드 밖으로 절대 안 샌다고 증명되면, 그 객체에 대한 synchronized는 통째로 삭제돼요(지역 StringBuffer가 고전 예시). Lock Coarsening(락 병합): 인접한 여러 락/언락이 같은 객체에 반복되면, 개별 구간을 하나의 큰 락 구간으로 합쳐 반복 획득·해제 비용을 줄여요.
그래서 "락을 잘게 쪼개는 게 항상 빠르다"는 순진한 가정이 JIT 앞에서 무너질 수 있어요. 마이크로 최적화보다 실측(JMH)이 먼저입니다.
🍴
ForkJoinPool
워커별 deque와 work-stealing
parallelStream·CompletableFuture의 공통 엔진
ForkJoinPool은 분할 정복(divide & conquer) 작업에 최적화된 스레드 풀이에요. 일반 ThreadPoolExecutor가 하나의 공유 큐를 두는 것과 달리, ForkJoinPool은 워커 스레드마다 자기 전용 deque(양방향 큐)를 가집니다.
🥷 work-stealing 규칙
각 워커는 자기 deque의 맨 위(top/LIFO)에서 작업을 넣고 뺍니다 — 캐시 지역성이 좋고 경합이 없어요. 자기 deque가 비면, 다른 워커의 deque 반대쪽 끝(bottom/FIFO)에서 작업을 훔쳐(steal) 옵니다.
양 끝을 나눠 쓰기에 소유자와 도둑이 대개 서로 다른 끝을 건드려 경합이 적어요. 외부에서 submit()한 작업은 별도 submission queue로 들어가고, 워커들이 이를 집어 실행합니다. 부하가 자연스럽게 균형(load balancing)을 이루는 게 핵심 이점이에요.
💡
common pool 주의.parallelStream()과 인자 없는 CompletableFuture.supplyAsync()는 모두 공용 ForkJoinPool.commonPool()을 써요. 여기서 블로킹 I/O를 하면 워커가 묶여 전체 병렬 처리가 굶습니다. 블로킹 작업은 전용 Executor를 넘기거나 ManagedBlocker를 쓰세요.
📏
false sharing
캐시라인이 만드는 보이지 않는 경합
@Contended 패딩과 LongAdder의 cell striping
CPU는 메모리를 캐시라인(cache line, 보통 64바이트) 단위로 캐시에 올려요. 서로 다른 스레드가 논리적으로는 다른 변수를 수정하는데 그 변수들이 우연히 같은 캐시라인에 있으면, 한 스레드의 쓰기가 다른 코어의 캐시라인을 무효화(invalidate)시켜요. 이게 false sharing(거짓 공유)입니다.
⚠️
왜 "거짓" 공유인가. 스레드들이 실제로 같은 데이터를 공유하지 않는데도, 하드웨어가 캐시라인 단위로 일관성을 유지하느라 공유한 것처럼 캐시 핑퐁(cache line ping-pong)이 일어나기 때문이에요. 코드상 경합이 없어 보이는데 성능이 급락하면 이걸 의심하세요.
🧱 해결책 ① — 패딩과 @Contended
변수 사이에 더미 바이트(패딩)를 채워 각 변수를 독립 캐시라인에 두면 서로 간섭하지 않아요. JDK는 jdk.internal.vm.annotation.@Contended 애너테이션으로 이를 자동화해, JVM이 필드 주위에 패딩을 삽입합니다(-XX:-RestrictContended 필요). Thread의 난수 시드 등 핫한 필드에 실제로 쓰여요.
패딩은 메모리를 더 쓰는 대신 캐시 무효화를 없애는 전형적 트레이드오프예요.
🎯 해결책 ② — LongAdder의 cell stripingAtomicLong은 단일 변수에 모든 스레드가 CAS를 시도해, 고경합에선 CAS 실패·재시도가 폭증해요(핫스팟 1곳). LongAdder는 값을 여러 Cell 배열로 쪼개(striping) 스레드마다 다른 Cell에 더하게 분산합니다. 각 Cell은 @Contended로 서로 다른 캐시라인에 놓여요. 최종 값이 필요할 때 sum()으로 base와 모든 Cell을 합칩니다.
그래서 고경합 카운터는 LongAdder가 AtomicLong보다 훨씬 빨라요(경합을 공간으로 분산). 대신 sum()은 순간 정확한 스냅샷이 아닐 수 있고 메모리를 더 씁니다 — 저경합이나 정확한 즉시값이 필요하면 AtomicLong이 낫습니다.
🖼️ 그림으로 보기 — AtomicLong(단일 핫스팟) vs LongAdder(cell striping)
🧠 이 장 핵심 요약
happens-before는 시간이 아니라 가시성·순서 보장. 프로그램 순서·모니터·volatile·start·join·이행성이 핵심 규칙이고, hb 없는 접근+쓰기 = data race.
메모리 배리어 4종(LoadLoad·StoreStore·LoadStore·StoreLoad). volatile 쓰기 앞뒤에 StoreStore·StoreLoad, 읽기 뒤에 LoadLoad·LoadStore. StoreLoad가 가장 비쌈. final은 생성자 완료 시 freeze로 안전 발행.
AQS = volatile int state + CLH 대기 큐. ReentrantLock·Semaphore·CountDownLatch·RRWLock이 그 위에 구현. 기본 비공정.
락: 객체 헤더 mark word → 경량 락 → 중량 락(모니터) 승격. 편향 락은 JDK 15에서 제거. JIT의 lock elision·coarsening.