사용자 모드와 커널 모드
JVM에서 스레드 동기화는 초기에는 운영체제의 커널 레벨 리소스를 사용하는 시스템 콜을 필요로 했습니다. 이 방식은 무거운 잠금(heavyweight lock)으로 알려져 있으며, 컨텍스트 스위칭 비용이 크기 때문에 성능에 부담을 줍니다. 현대 JVM은 이러한 문제를 해결하기 위해 다양한 사용자 모드 내 최적화 기법을 도입했습니다.
CAS (Compare-and-Swap)
CAS는 원자성을 보장하는 하드웨어 명령어를 기반으로 하는 비차단 알고리즘의 핵심입니다. 주어진 위치의 현재 값이 예상값과 일치할 경우에만 새 값을 쓰며, 그렇지 않으면 실패합니다. 이는 반복적으로 시도하는 스핀 대기 패턴과 결합되어 경량 잠금이나 무잠금 알고리즘을 구현하는 데 사용됩니다.
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (weakCompareAndSetVolatile(current, next))
return next;
}
}
CAS의 한계 중 하나는 ABA 문제입니다. 즉, 값이 A → B → A로 변경되었지만 CAS는 변화가 없었다고 판단할 수 있습니다. 이를 해결하기 위해 AtomicStampedReference처럼 버전 번호 또는 타임스탬프를 함께 관리하는 방법이 사용됩니다.
Unsafe 클래스를 통한 저수준 접근
sun.misc.Unsafe는 자바 코드가 하드웨어 수준의 원자 연산을 수행할 수 있게 해주는 강력한 도구입니다. 필드 오프셋 기반의 직접 메모리 접근과 CAS 연산을 지원합니다.
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class LowLevelAccessExample {
private volatile int value = 0;
private static final long VALUE_OFFSET;
private static final Unsafe UNSAFE;
static {
try {
Field f = Unsafe.class.getDeclaredFields()[0];
f.setAccessible(true);
UNSAFE = (Unsafe) f.get(null);
VALUE_OFFSET = UNSAFE.objectFieldOffset(
LowLevelAccessExample.class.getDeclaredField("value"));
} catch (Exception e) {
throw new Error(e);
}
}
public void increment() {
int current;
do {
current = UNSAFE.getIntVolatile(this, VALUE_OFFSET);
} while (!UNSAFE.compareAndSwapInt(this, VALUE_OFFSET, current, current + 1));
}
}
실제 CAS는 네이티브 코드에서 cmpxchg 어셈블리 명령어를 통해 구현되며, 멀티코어 환경에서는 lock 접두사를 붙여 버스 신호를 잠급니다.
Mark Word와 객체 헤더 구조
자바 객체의 헤더는 Mark Word라고 불리는 중요한 필드를 포함하며, 이는 객체의 락 상태, 해시코드, GC 정보 등을 저장합니다. OpenJDK JOL(Java Object Layout) 도구를 사용하면 이 구조를 확인할 수 있습니다.
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
64비트 JVM에서 Mark Word는 다음과 같은 형식을 가집니다:
- 일반 객체: [unused:25][hash:31][age:4][biased_lock:1][lock:2]
- 편향 잠금 객체: [thread_ptr:54][epoch:2][age:4][biased_lock:1][lock:2]
하위 3비트는 잠금 상태를 나타내며, 001(무잠금), 000(편향 잠금), 010(경량 잠금), 110(중량 잠금) 등의 상태를 표현합니다.
synchronized의 진화 과정
- 무잠금 상태: 객체 생성 직후. Mark Word는 001 패턴을 가짐.
- 편향 잠금: 특정 스레드가 우선적으로 접근하도록 허용. 첫 진입 시 스레드 ID를 Mark Word에 기록. 이후 동일 스레드는 동기화 오버헤드 없이 접근 가능.
- 경량 잠금: 경쟁 발생 시 편향 잠금 해제 후, 각 스레드 스택에
Lock Record를 생성하고 CAS로 Mark Word를 자신의 레코드 포인터로 교체함. - 중량 잠금: 과도한 스핀 경쟁 시 OS 뮤텍스를 사용하는 전통적인 잠금으로 승격. 스레드는 대기 큐에 들어가며 CPU 사이클을 소모하지 않음.
편향 잠금은 기본적으로 활성화되어 있지만, 초기 지연(-XX:BiasedLockingStartupDelay=4000)이 있어 JVM 시작 직후 생성된 객체들은 무잠금 상태로 시작합니다. 또한, hashCode() 호출 시 무조건 무잠금 상태로 전환되며 더 이상 편향될 수 없습니다.
저수준 구현 분석
synchronized 블록은 바이트코드에서 monitorenter와 monitorexit 명령어로 변환됩니다. HotSpot JVM은 이를 해석기 런타임에서 처리하며, 다음 순서로 진행됩니다:
InterpreterRuntime::monitorenter호출ObjectSynchronizer::fast_enter→ 편향 잠금 처리ObjectSynchronizer::slow_enter→ 경량/중량 잠금 처리- 지속적인 경쟁 시
ObjectSynchronizer::inflate를 통해ObjectMonitor객체를 할당받아 OS 뮤텍스와 연결
최종적으로 생성된 네이티브 코드는 lock cmpxchg와 같은 인스트럭션을 포함하게 됩니다.
자동 최적화 기법
잠금 제거 (Lock Elimination)
JIT 컴파일러는 지역 변수로 생성된 StringBuffer와 같이 공유되지 않는 객체의 동기화 블록을 제거할 수 있습니다.
public void concat(String a, String b) {
StringBuffer sb = new StringBuffer(); // 로컬 변수
sb.append(a).append(b); // 동기화 메서드, 하지만 JIT에 의해 제거됨
}
잠금 확장 (Lock Coarsening)
반복적인 잠금 획득을 하나의 범위로 병합하여 오버헤드를 줄입니다.
public String build(int count, String sep) {
StringBuilder sb = new StringBuilder(); // StringBuilder는 비동기
for (int i = 0; i < count; i++) {
sb.append(i).append(sep); // 만약 여기서 StringBuffer를 사용했다면, 100번 잠금 대신 한번만 잠금
}
return sb.toString();
}
volatile의 역할
volatile 키워드는 두 가지 주요 목적을 가집니다:
- 가시성 보장: 한 스레드의 쓰기가 다른 모든 스레드에 즉시 보입니다.
- 재정렬 방지: volatile 읽기/쓰기 주변의 명령어 재배치를 금지합니다.
DCL(Double-Checked Locking) 싱글턴 패턴에서 volatile은 필수입니다. 인스턴스 필드가 초기화되기 전에 참조가 다른 스레드에 노출되는 것을 막습니다.
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
JVM은 volatile 접근 시 lock addl $0, (%rsp)와 같은 메모리 배리어를 삽입하여 순서성을 보장합니다.
synchronized vs ReentrantLock
| 특징 | synchronized | ReentrantLock |
|---|---|---|
| 경쟁 수준 | 높은 경쟁에서 우수 | 낮은 경쟁에서 우수 |
| CPU 사용 | 중량 잠금: 대기 중 비활성 | CAS 기반: 스핀 중 CPU 사용 |
| 기능 | 기본 제공 | 타임아웃, 인터럽트, 공정성 등 고급 기능 |
실제 선택은 워크로드 특성에 따라 달라지며, 항상 벤치마킹을 통해 결정해야 합니다.
고급 주제
배치 편향 재설정 및 해제
JVM은 클래스 단위로 편향 해제 횟수를 추적합니다. 특정 임계치(기본 20회)에 도달하면 해당 클래스의 모든 객체에 대해 배치 편향 재설정을 수행하여 새로운 스레드가 쉽게 편향될 수 있도록 합니다. 해제 횟수가 추가로 증가하면(기본 40회) 해당 클래스는 더 이상 편향되지 않도록 마킹됩니다.
하드웨어 지원 메커니즘
- MESI 프로토콜: 캐시 일관성을 유지하기 위한 상태 머신.
- 버스 잠금 vs 캐시 라인 잠금:
lock접두사는 전체 버스를 잠그지 않고 관련 캐시 라인만 잠급니다. - 메모리 배리어:
mfence,lfence,sfence는 로드/저장 순서를 제어합니다.