지난 글에서 Race Condition을 해결 방안으로 자주나오는 synchronized에 대해서 뜯어보다 내부에서는 moniterEnter, moniterExit을 통해 모니터 락을 걸고 해제하는걸로 동기화를 처리한다고 맛봤다.
이때 모니터 락에도 성능 최적화를 위해 두가지 모드를 오가게 되는데 Lightweight Lock, Heavyweight Lock이 여기에 해당한다.
Lightweight Lock은 object header를 자신의 stack에 CAS(Compare And Set) 연산으로 복사해서 쓴다는 등, JVM 내부 CAS 연산으로 처리한다는등 잘 이해가 안되어 이번에 차근차근 알아가보려한다.
그럼 이 모드의 전환은 누가 관리하는걸까? 그건바로 HotSpot JVM이다.
자주 실행되는 코드(hot spot)을 감지해서 더 빠르게 실행한다는 의미로 성능 최적화를하는 JVM을 말한다.
실행 흐름은 아래처럼 진행되는데 JIT(Just In Time)컴파일러에 의해 더 빠른 실행이 가능한 네이티브 코드를 생성해 가능해진다.
바이트코드 → 인터프리터 → 프로파일링 → JIT 컴파일 → 네이티브 코드
많이 호출되는 부분을 프로파일링을 통해 감지하고 임계값이 넘으면 JIT 컴파일러가 네이티브 코드를 생성하고 다음 호출부터는 이미 만들어진 네이티브 코드를 사용해 최적화를 하는 방식이다.
아마 한번쯤은 읽어본 컴파일러 예열, Baseline profile을 통한 앱 성능 향상도 이 JIT 컴파일러를 통해 필요한 부분을 사전에 컴파일된 내용을 사용하기에 성능이 향상된다는 것이다.
락을 관리하기위해서 락을 걸 개체의 Object Header을 사용한다. 이 header에는 Mark Word라는 영역이 있다. 이 부분에 내가 지금 락을 사용중이야!, 아무도 사용하고있지 않아!와 같은 정보가 있고 이 정보를 기반으로 락 관리가 진행된다.
락을 걸 객체의 Mark Word를 먼저 확인한다. 다른 스레드가 사용중이 아니라면 CAS연산을 통해 자신의 스레드 ID를 Mark Word에 저장한다. 이 과정이 성공하면 락을 획득에 성공한거다.
그건 아니다. 별도의 설정을 통해 가능하긴하다.
먼저 스핀락을 시도한다.
// 2번 스레드의 행동
for (int i = 0; i < SPIN_COUNT; i++) {
if (lightweight_lock_available()) {
// 락 획득 시도
return;
}
// 잠깐 기다리기 (CPU 사이클 소모)
}
몇번 루프를 돌면서 락 획득을 시도하고 그게 실패하면 그제서야 heavyweight 모드로 전환된다. 이렇게 lightweight모드에서 heavyweight모드로 전환되는것을 Lock Inflation이라고 한다.
lightweight Lock에서 heavyweight Lock으로 전환되는 것을 말한다.
한번 heavyweight 모드로 전환되고나면 다시 돌아갈 수 없다.
스핀 한계치까지 시도한 이후 JVM 내부에서는
ObjectMonitor 객체를 할당하고 Mark Word에는 스레드 A의 Lock Record 주소가 기록되어있는데단점으로는 성능 오버헤드가 존재한다. Monitor 생성, 메모리 사용량 증가, 컨텍스트 스위치 비용이 있지만
장점으로는 대기열 관리 가능, wait/notify 지원, 데드락 감지 가능하다는 점이 있다.
OS가 관여하는 동기화 매커니즘으로 ObjectMonitor + Mutex + 대기열 관리로 이루어져있다.
간단하게 내부 구조를 요약하면
class ObjectMonitor {
volatile markOop _header; // 원본 객체 헤더 백업
void* _object; // 연결된 Java 객체
Thread* _owner; // 현재 락 소유자
// 대기열들
ObjectWaiter* _EntryList; // 락 획득 대기 중
ObjectWaiter* _WaitSet; // wait() 중인 스레드들
// 카운터들
intptr_t _recursions; // 재진입 횟수
int _count; // 대기 중인 스레드 수
// 동기화
ParkEvent* _cxq; // 경합 큐
// ...
}
와 같이 표현할 수 있다.
우선 두가지 대기열이 있다.
EntryList는 락을 얻기위한 대기열로 FIFO을 따라서 먼저온 스레드가 먼저 락을 가져간다.
WaitSet는 조건이 만족하면 EntryList로 이동해서 락을 얻게된다.
ObjectMonitor을 락에 관한 모든 요청을 처리하며
EntryList는 락을 얻으려는 모든 객체들,
WaitSet은 조건 만족 시 EntryList로 이동하는데 이때 정책에 따라 다르지만 기본적으로는 EntryList의 맨 앞으로 이동한다고 한다.
| 구분 | Lightweight Lock | Heavyweight Lock |
|---|---|---|
| 위치 | 객체 Mark Word에 직접 저장 | 별도 ObjectMonitor 객체 |
| 구현 방식 | CAS (Compare-And-Swap) 연산 | OS Mutex + 대기열 관리 |
| 메모리 사용 | 객체 헤더만 사용 | ObjectMonitor 객체 |
| 성능 | 매우 빠름 (원자 연산만) | 상대적으로 느림 (커널 호출) |
| 최적화 대상 | 경합 없는 상황 | 경합이 빈번한 상황 |
| 단계 | Lightweight Lock | Heavyweight Lock |
|---|---|---|
| 락 획득 | Mark Word를 스레드 ID로 CAS 교체 | ObjectMonitor 소유자 필드 설정 |
| 락 보유 | Mark Word에 스레드 정보 저장 | ObjectMonitor에서 상태 관리 |
| 락 해제 | Mark Word를 원래 상태로 CAS 복원 | ObjectMonitor에서 대기자 깨우기 |
| 경합 처리 | 스핀 시도 → 실패 시 Inflation | 대기열에 추가 → 블로킹 |