본문 바로가기

Language/Java

[Java] 자바의 스레드 안전성 구현과 락 최적화

들어가며

앞선 포스트들에서 JVM의 메모리 구조와 가비지 컬렉터를 깊이 있게 살펴보았습니다. 이번 포스트에서는 자바에서 스레드 안전성(Thread Safety)을 어떻게 구현하는지, 그리고 HotSpot JVM이 synchronized의 성능을 끌어올리기 위해 적용하는 락 최적화 기법들을 정리합니다.

 


1. 스레드 안전성의 다섯 가지 수준

자바에서 스레드 안전성은 강한 순서대로 다섯 가지 수준으로 분류할 수 있습니다.

1-1. 불변 (Immutable)

가장 강력한 스레드 안전성입니다. 객체가 생성된 이후 상태가 절대 변하지 않으므로, 어떤 동기화도 필요 없습니다. String, Long, BigInteger 등이 대표적이며, final 키워드로 선언된 필드들로 구성됩니다.

 

1-2. 절대적 스레드 안전 (Absolute Thread-Safety)

객체 자체가 어떤 외부 동기화 없이도 완벽하게 스레드 안전한 경우입니다. 하지만 현실적으로 이 수준을 완벽히 달성하는 클래스는 거의 없습니다. Vector는 개별 메서드가 synchronized이지만, 복합 연산(예: 순회 중 삭제)에서는 외부 동기화가 필요하므로 절대적 스레드 안전이라고 보기 어렵습니다.

 

1-3. 조건부 스레드 안전 (Conditionally Thread-Safe)

대부분의 "스레드 안전" 클래스가 실제로 해당하는 수준입니다. 개별 연산은 안전하지만, 특정 연산 조합에서는 외부 동기화가 필요합니다. Collections.synchronizedList()로 래핑한 리스트가 대표적입니다. get()이나 add() 단독 호출은 안전하지만, iterator()로 순회할 때는 별도의 synchronized 블록이 필요합니다.

 

1-4. 스레드 호환 (Thread-Compatible)

객체 자체는 스레드 안전하지 않지만, 호출하는 쪽에서 적절한 동기화를 적용하면 멀티스레드 환경에서 사용할 수 있습니다. 자바의 대부분의 클래스가 여기에 해당합니다. ArrayList, HashMap, StringBuilder 등이 대표적입니다.

 

1-5. 스레드 적대적 (Thread-Hostile)

외부에서 동기화를 적용하더라도 멀티스레드 환경에서 안전하게 사용할 수 없는 경우입니다. 동기화 없이 정적(static) 변수를 수정하는 코드가 이에 해당하며, 설계 결함으로 간주됩니다.

 


2. 상호 배제 동기화: synchronized vs ReentrantLock

스레드 안전성을 구현하는 가장 기본적인 방법은 상호 배제 동기화(Mutual Exclusion Synchronization)입니다. 자바에서는 synchronizedReentrantLock 두 가지 수단을 제공합니다.

2-1. synchronized

synchronized는 자바 언어 차원에서 제공하는 키워드입니다. JVM이 monitorenter/monitorexit 바이트코드를 통해 락의 획득과 해제를 관리하며, 블록을 벗어나면 자동으로 락이 해제되므로 락 해제를 깜빡할 위험이 없습니다.

synchronized (lock) {
    // 임계 영역
}

 

2-2. ReentrantLock

ReentrantLockjava.util.concurrent.locks 패키지에서 제공하는 API 수준의 락입니다. lock()unlock()을 명시적으로 호출해야 하며, 반드시 try-finally 블록으로 해제를 보장해야 합니다.

ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 임계 영역
} finally {
    lock.unlock();
}

 

2-3. ReentrantLock의 세 가지 고급 기능

ReentrantLock은 코드가 다소 번거롭지만, 그 대가로 synchronized에는 없는 세 가지 핵심 기능을 제공합니다.

 

대기 중단(Interruptible Locking): lockInterruptibly()를 사용하면 락을 기다리는 스레드가 인터럽트를 받았을 때 대기를 포기하고 다른 작업을 수행할 수 있습니다. synchronized는 일단 락을 기다리기 시작하면 획득할 때까지 블로킹됩니다.

 

시간 제한 대기(Timed Locking): tryLock(timeout, unit)으로 지정한 시간 동안만 락 획득을 시도하고, 시간이 지나면 false를 반환합니다. 데드락 방지나 응답성이 중요한 상황에서 유용합니다.

 

공정성 선택(Fairness): new ReentrantLock(true)로 공정 락을 만들면 가장 오래 기다린 스레드가 먼저 락을 획득합니다. synchronized는 비공정(non-fair) 방식만 지원하므로, 특정 스레드가 계속 락을 얻지 못하는 기아(starvation) 현상이 발생할 수 있습니다. 다만 공정 락은 성능 오버헤드가 있으므로 꼭 필요한 경우에만 사용합니다.

 

2-4. 어떤 걸 선택해야 하는가?

일반적인 동기화에는 synchronized를 우선 사용하는 것이 좋습니다. 코드가 간결하고, 락 해제를 잊을 위험이 없으며, JVM이 편향 락, 경량 락, 적응형 스피닝 등의 최적화를 적용해주기 때문입니다. 위에서 언급한 세 가지 고급 기능(대기 중단, 시간 제한, 공정성)이 필요한 경우에만 ReentrantLock을 선택하면 됩니다.

 


3. 논블로킹 동기화: 하드웨어 명령어와 CAS

3-1. 왜 논블로킹인가?

상호 배제 동기화의 근본적인 문제는, 락을 획득하지 못한 스레드가 블로킹된다는 점입니다. 스레드의 중단(suspend)과 재개(resume)에는 커널 모드 전환이 필요하고, 이 비용이 적지 않습니다. 공유 데이터에 실제로 경합이 발생하지 않는 경우에도 이 비용을 지불해야 합니다.

 

논블로킹 동기화는 낙관적 동시성 제어(Optimistic Concurrency Control)에 기반합니다. 핵심 아이디어는 "일단 연산을 수행하고, 충돌이 발생했으면 재시도한다"는 것입니다.

3-2. 하드웨어 원자적 명령어

논블로킹 동기화를 가능하게 하는 것은 하드웨어가 제공하는 원자적(atomic) 명령어들입니다.

명령어 동작 주요 용도
TAS (Test-and-Set) 메모리 값을 읽고 동시에 새 값을 설정 스핀락 구현
FAA (Fetch-and-Add) 메모리 값을 읽고 동시에 지정된 값을 덧셈 원자적 카운터
Swap (Exchange) 메모리 값과 레지스터 값을 원자적으로 교환 락 교환
CAS (Compare-and-Swap) 기대 값과 같으면 새 값으로 교체, 다르면 실패 논블로킹 자료구조 전반
LL/SC (Load-Linked / Store-Conditional) LL로 읽고 SC로 쓰되, 중간에 다른 쓰기가 있으면 SC 실패 CAS 대안 (ARM, RISC-V)

이 중 자바에서 가장 핵심적인 것은 CAS입니다.

 

3-3. CAS (Compare-and-Swap)

CAS는 다음과 같은 의미론(semantics)을 원자적으로 수행합니다.

CAS(메모리 위치 V, 기대 값 A, 새 값 B)
  → V의 현재 값이 A이면 B로 교체하고 true 반환
  → V의 현재 값이 A가 아니면 교체하지 않고 false 반환

x86 아키텍처에서는 CMPXCHG 명령어가 이를 지원합니다.

다음은 CAS의 동작 흐름입니다.

 

3-4. LL/SC (Load-Linked / Store-Conditional)

ARM, RISC-V 등의 아키텍처에서는 CAS 대신 LL/SC를 사용합니다. LL로 값을 읽고 SC로 쓰되, 그 사이에 해당 메모리에 다른 쓰기가 있었으면 SC가 실패합니다. CAS와 달리 ABA 문제를 하드웨어 수준에서 감지할 수 있다는 장점이 있습니다.

 

3-5. java.util.concurrent.atomic

자바는 sun.misc.Unsafe(현재는 java.lang.invoke.VarHandle)를 통해 CAS 연산을 노출하고, 이를 기반으로 java.util.concurrent.atomic 패키지의 클래스들을 제공합니다.

AtomicIntegerincrementAndGet()을 예로 들면, 내부적으로 CAS 루프(spin)로 동작합니다.

public final int incrementAndGet() {
    int oldValue, newValue;
    do {
        oldValue = get();           // 1) 현재 값을 읽는다
        newValue = oldValue + 1;    // 2) 새 값을 계산한다
    } while (!compareAndSet(oldValue, newValue)); // 3) CAS 시도, 실패하면 재시도
    return newValue;
}

 

주요 클래스는 다음과 같습니다.

   
분류 클래스
기본 타입 AtomicInteger, AtomicLong, AtomicBoolean
참조 타입 AtomicReference
배열 AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray
필드 업데이터 AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, AtomicReferenceFieldUpdater
ABA 문제 해결 AtomicStampedReference, AtomicMarkableReference

 

3-6. CAS의 한계: ABA 문제

CAS는 "값이 같으면 변경되지 않았다"고 가정합니다. 그러나 값이 A → B → A로 변경된 경우에도 CAS는 성공합니다. 대부분의 경우 문제가 되지 않지만, 연결 리스트 같은 자료구조에서는 심각한 버그로 이어질 수 있습니다.

 

자바에서는 AtomicStampedReference가 값과 함께 버전 스탬프(stamp)를 관리하여 이 문제를 해결합니다. CAS 시도 시 값뿐만 아니라 스탬프도 함께 비교하므로, 값이 A → B → A로 돌아왔더라도 스탬프가 달라 변경을 감지할 수 있습니다.

 


4. 락 최적화 기법

지금까지 synchronized와 CAS 기반 논블로킹 동기화를 살펴보았습니다. 여기서부터는 HotSpot JVM이 synchronized의 성능을 끌어올리기 위해 적용하는 최적화 기법들을 정리합니다.

 

4-1. 스핀 락 (Spin Lock)과 적응형 스핀 (Adaptive Spinning)

스레드가 락을 획득하지 못하면 일반적으로 OS 커널에 의해 중단(suspend)됩니다. 이 과정에서 사용자 모드 ↔ 커널 모드 전환 비용이 발생합니다. 만약 락을 보유한 스레드가 아주 짧은 시간 안에 락을 해제한다면, 잠깐 바쁜 대기(busy-wait)를 하는 편이 중단했다가 재개하는 것보다 효율적입니다.

 

스핀 락은 바로 이 아이디어입니다.

while (!tryAcquireLock()) {
    // 아무것도 하지 않고 계속 시도 (스핀)
}

하지만 스핀하는 동안 CPU를 계속 점유하므로, 락 보유 시간이 길면 오히려 CPU 자원 낭비가 됩니다. 따라서 스핀 횟수에 제한을 두고, 제한을 초과하면 전통적인 방식으로 스레드를 중단시킵니다.

 

문제는 적절한 스핀 횟수를 고정값으로 정하기 어렵다는 것입니다. JDK 6부터 도입된 적응형 스핀은 JVM이 이전 스핀 시도의 성공/실패 이력을 기반으로 스핀 횟수를 동적으로 조절합니다. 같은 락 객체에서 스핀이 최근에 성공한 적이 있으면 더 오래 스핀하고, 반대로 스핀이 거의 성공한 적이 없는 락이라면 스핀을 건너뛰고 바로 중단시킵니다.

 

4-2. 락 제거 (Lock Elimination)

JIT 컴파일러가 동기화된 코드를 분석했을 때, 락이 보호하는 데이터가 스레드 탈출(Thread Escape)하지 않는다고 판단되면 해당 락을 아예 제거하는 최적화입니다. 이때 탈출 분석(Escape Analysis)이 사용됩니다.

public String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);  // synchronized 메서드
    sb.append(s2);  // synchronized 메서드
    sb.append(s3);  // synchronized 메서드
    return sb.toString();
}

위 코드에서 sb는 메서드 내부에서 생성되고 외부로 전달되지 않습니다. 다른 스레드가 접근할 방법이 없으므로, JIT 컴파일러는 탈출 분석을 통해 append() 호출의 동기화를 모두 제거합니다. 결과적으로 StringBuilder를 사용한 것과 동일한 성능이 됩니다.

 

4-3. 락 범위 확장 (Lock Coarsening)

일반적으로 동기화 블록의 범위는 최소화하는 것이 좋습니다. 그러나 같은 락에 대해 락 획득과 해제가 반복적으로 일어나면, 그 자체의 오버헤드가 무시할 수 없게 됩니다.

// 최적화 전: append()마다 lock/unlock 반복
sb.append(s1);  // lock → unlock
sb.append(s2);  // lock → unlock
sb.append(s3);  // lock → unlock

// 최적화 후: 하나의 블록으로 확장
synchronized (sb) {
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
}

탈출 분석으로 락 제거가 불가능한 상황이라면, JVM은 여러 번의 개별 동기화를 하나의 큰 동기화 블록으로 합쳐서 락 획득/해제 횟수를 줄입니다. 루프 안에서 반복적으로 동기화가 일어나는 경우에도 루프 전체를 감싸는 방식으로 확장될 수 있습니다.

 

4-4. 경량 락 (Lightweight Lock)

경량 락은 "경합이 없는 상황에서는 OS 수준의 뮤텍스를 사용하지 않겠다"는 전략입니다. 이해하려면 먼저 객체 헤더의 Mark Word를 알아야 합니다.

Mark Word

64비트 HotSpot JVM에서 객체 헤더의 Mark Word는 락 상태에 따라 저장하는 정보가 달라지며, 마지막 몇 비트의 태그로 현재 상태를 구분합니다.

락 상태 Mark Word 내용 태그 비트
잠금 해제 (Normal) 해시코드, GC age 등 01
편향 락 (Biased) 스레드 ID, epoch, GC age 01 (편향 비트 1)
경량 락 (Lightweight) Lock Record 포인터 00
중량 락 (Heavyweight) Monitor 포인터 10
GC 마킹 없음 11

다음은 64비트 HotSpot JVM에서 Mark Word의 상태별 비트 레이아웃입니다.

 

경량 락 획득 과정

다음은 경량 락의 획득과 해제, 그리고 경합 시 팽창까지의 전체 흐름입니다.

  1. 스레드가 동기화 블록에 진입하면, JVM은 현재 스레드의 스택 프레임에 Lock Record 공간을 만듭니다.
  2. 객체의 Mark Word를 Lock Record에 복사합니다 (이를 Displaced Mark Word라 합니다).
  3. CAS 연산으로 객체의 Mark Word를 Lock Record의 포인터로 교체합니다.
  4. CAS가 성공하면 경량 락 획득이 완료되고, 태그 비트가 00(경량 락)으로 바뀝니다.

 

재진입 (Reentrant)

CAS가 실패했지만 Mark Word가 현재 스레드의 스택 프레임을 가리키고 있다면, 같은 스레드의 재진입이므로 추가 Lock Record를 쌓고 진입을 허용합니다.

 

경합 발생 시 팽창

CAS가 실패하고 다른 스레드가 이미 락을 보유하고 있다면, 경량 락으로는 경합을 처리할 수 없으므로 중량 락(Heavyweight Lock)으로 팽창(inflate)합니다. Mark Word는 OS 뮤텍스 기반의 모니터(Monitor) 객체를 가리키게 되고, 락을 기다리는 스레드는 중단됩니다.

 

경량 락 해제 과정

Lock Record에 저장해둔 Displaced Mark Word를 CAS로 객체 헤더에 복원합니다. 성공하면 해제 완료이고, 실패하면 이미 중량 락으로 팽창한 것이므로 모니터를 해제하고 대기 중인 스레드를 깨웁니다.

핵심은, 경합이 없으면 CAS 한 번으로 동기화가 완료된다는 것입니다.

 

4-5. 편향 락 (Biased Locking)

경량 락은 경합이 없어도 진입과 탈출 시 CAS 연산이 필요합니다. 편향 락은 한 걸음 더 나아가, 단 하나의 스레드만 락을 사용하는 경우 CAS조차 제거하는 최적화입니다.

 

최초 획득

스레드가 처음 락을 획득하면, CAS로 Mark Word에 자신의 스레드 ID를 기록하고 태그를 편향 상태로 설정합니다. 이후 같은 스레드가 동일한 락에 진입할 때는 Mark Word의 스레드 ID만 비교하면 됩니다. CAS도 필요 없으므로 사실상 동기화 오버헤드가 제로에 가깝습니다.

 

다른 스레드의 접근 — 편향 철회 (Bias Revocation)

다른 스레드가 같은 락을 획득하려 하면, 편향을 철회(revoke)해야 합니다. JVM은 전역 안전점(Safepoint)에서 기존 편향 스레드의 상태를 확인합니다.

  • 원래 스레드가 이미 동기화 블록을 벗어난 경우 → 객체를 잠금 해제 상태로 되돌림
  • 원래 스레드가 아직 동기화 블록 안에 있는 경우 → 경량 락으로 업그레이드

 

편향 락의 폐지

편향 락은 경합이 거의 없는 상황에서 효과적이지만, 여러 스레드가 번갈아 락을 사용하는 상황에서는 편향 철회 비용이 오히려 성능을 떨어뜨립니다. 이 때문에 JDK 15에서 기본 비활성화되었고(-XX:+UseBiasedLocking으로 활성화 가능), JDK 18에서 완전히 제거되었습니다. HotSpot 팀은 편향 락의 코드 복잡성 대비 최신 하드웨어에서의 성능 이득이 크지 않다고 판단했습니다.

 


5. 락 상태 전이 흐름

HotSpot의 synchronized 락은 다음과 같이 단계적으로 팽창(escalation)합니다.

처음에는 가장 가벼운 편향 락(JDK 15 미만) 또는 경량 락으로 시작하고, 경합이 발생하면 단계적으로 무거운 락으로 전환됩니다. 이 전환은 단방향이며, 한번 팽창한 락은 다시 가벼운 상태로 돌아가지 않습니다.

 


6. 정리

구분 특징 적합한 상황
synchronized 간결, 자동 해제, JVM 최적화 적용 일반적인 동기화
ReentrantLock 대기 중단, 시간 제한, 공정성 지원 고급 동기화 기능이 필요한 경우
CAS 기반 (atomic) 락 없이 원자적 연산, 낮은 오버헤드 단순한 상태 변경, 낮은 경합
스핀 락 / 적응형 스핀 짧은 대기를 커널 전환 없이 처리 락 보유 시간이 짧은 경우 (JVM 자동 적용)
락 제거 탈출 분석으로 불필요한 락 제거 스레드 탈출 없는 동기화 (JVM 자동 적용)
락 범위 확장 반복적 락 획득/해제를 하나로 합침 연속된 동기화 호출 (JVM 자동 적용)
경량 락 CAS 한 번으로 동기화 경합이 없는 상황 (JVM 자동 적용)
편향 락 CAS조차 제거, 스레드 ID 비교만 단일 스레드 접근 (JDK 15 미만)

 

상호 배제 동기화는 경합이 빈번하거나 임계 영역이 복잡한 경우에 적합하고, CAS 기반 논블로킹 동기화는 단순한 상태 변경에서 성능 면으로 유리합니다. 실무에서는 ConcurrentHashMap, LongAdder 같은 java.util.concurrent 패키지의 고수준 클래스들이 이 두 가지를 적절히 조합하고 있으므로, 직접 저수준 동기화를 구현하기보다는 이런 클래스들을 활용하는 것이 바람직합니다.

 

그리고 synchronized를 사용할 때 성능 걱정은 크게 하지 않아도 됩니다. HotSpot JVM이 스핀 락, 락 제거, 락 범위 확장, 경량 락 등의 최적화를 자동으로 적용해주기 때문입니다. 정말로 고급 기능이 필요한 경우에만 ReentrantLock이나 atomic 클래스를 선택하면 충분합니다.

 


참고 자료

  • JVM 밑바닥까지 파헤치기 — 스레드 안전성과 락 최적화 장
  • Brian Goetz, Java Concurrency in Practice — 스레드 안전성 5단계 분류