JVM 메모리 레이아웃 글을 쓸 때 "힙이 32GB를 넘지 않으면 Compressed Oops가 켜져 포인터가 절반으로 줄어든다"는 각주를 달고 넘어갔다. 막상 "32GB"가 어디서 나온 숫자냐고 스스로 물어보니 답이 궁색했다. HotSpot 소스를 따라가 보니 이 숫자는 근사치였고, 그 사이에 4개의 서로 다른 인코딩 모드가 숨어 있었다.
Compressed Oops는 64비트 오브젝트 포인터(oop)를 32비트 정수(narrowOop)에 담는 인코딩이다. 트릭은 정렬에 있다. ObjectAlignmentInBytes(기본값 8, runtime/globals.hpp)에 의해 힙에 놓이는 모든 객체의 시작 주소는 8의 배수로 강제된다. 즉 유효한 오브젝트 포인터의 하위 3비트는 항상 0이고, 어떤 정보도 담지 않는 그 3비트는 버려도 손실이 없다.
그래서 인코딩은 "힙 베이스로부터의 거리를 구해서, 8바이트 단위로 우측 시프트한 값"이다.
// compressedOops.inline.hpp (발췌)
inline oop CompressedOops::decode_raw(narrowOop v) {
return cast_to_oop((uintptr_t)base() + ((uintptr_t)v << shift()));
}
inline narrowOop CompressedOops::encode_not_null(oop v) {
uint64_t pd = pointer_delta((void*)v, (void*)base(), 1);
return narrow_oop_cast(pd >> shift());
}
base()와 shift()는 요청마다 계산하는 값이 아니라 JVM 부팅 시 힙 크기를 보고 딱 한 번 정해지는 전역 상태다. 32비트 정수가 8바이트 단위 오프셋을 담으니, 이론적으로 표현 가능한 범위는 2^32 × 2^3 = 2^35 바이트 — 32GB가 여기서 나온다.
compressedOops.cpp의 initialize()는 힙 예약 크기를 두 개의 기준값과 비교해서 모드를 정한다.
| 모드 | 조건 | base | shift | 비고 |
|---|---|---|---|---|
| Unscaled | heap < 4GB | 0 | 0 | 시프트조차 불필요, 주소 그대로 캐스팅 |
| ZeroBased | 4GB ≤ heap < 32GB | 0 | 3 | 시프트만으로 인코딩 |
| Disjoint (HeapBased) | heap ≥ 32GB, base 비트가 오프셋 범위와 안 겹침 | 실주소 | 3 | 덧셈 대신 OR 한 번으로 저렴하게 디코딩 |
| HeapBased | heap ≥ 32GB, 그 외 | 실주소 | 3 | 매 디코딩마다 덧셈 필요 |
기준이 되는 두 상수는 globalDefinitions.hpp/arguments.cpp에 있다. UnscaledOopHeapMax = 2^32(4GB)는 32비트 정수만으로 전체 주소를 담을 수 있는 한계고, OopEncodingHeapMax = UnscaledOopHeapMax << 3 = 32GB는 힙 시작 주소를 논리적으로 0(base = nullptr)으로 간주할 수 있는 한계다.
핵심은 Unscaled와 ZeroBased엔 덧셈이 전혀 없다는 점이다. 힙이 32GB를 넘어 HeapBased로 떨어지면 포인터 역참조마다 덧셈 연산이 하나씩 더 붙는다 — 압축 자체는 유지돼도 공짜는 아니게 되는 셈이다.
ZeroBased 모드(base=0, shift=3)에서 힙 안의 객체 주소가 0x600000000(24GB 지점)이라고 하면:
encode: narrowOop = (0x600000000 - 0) >> 3 = 0xC0000000
decode: oop = 0 + (0xC0000000 << 3) = 0x600000000 // 원래 주소로 복원
0xC0000000은 32비트 안에 깔끔하게 들어간다. 힙 베이스가 0이 아닌 HeapBased 모드로 넘어가면 디코딩 때마다 base() + (v << shift())의 덧셈이 매번 실행된다는 차이가 바로 여기서 생긴다.
arguments.cpp의 max_heap_for_compressed_oops()는 이 32GB에서 한 번 더 깎는다.
size_t displacement_due_to_null_page =
align_up(os::vm_page_size(), _conservative_max_heap_alignment);
return OopEncodingHeapMax - displacement_due_to_null_page;
힙 베이스 바로 아래에 페이지 하나를 비워두는데, 이는 힙 베이스 자체에 뭔가 할당되는 상황을 막고 암묵적 null 체크가 그 빈 페이지에서 시그널을 발생시키게 하기 위해서다(주석 기준). 정확한 깎이는 크기는 페이지 크기와 GC별 힙 정렬 단위에 따라 달라져 일반화하긴 조심스럽지만, 요점은 분명하다 — "32GB"는 근사치이고 그 경계 바로 아래에 힙을 잡았다고 항상 ZeroBased가 보장되진 않는다. 이 근처에서 힙을 튜닝할 땐 -Xlog:gc+heap+coops=debug로 실제 모드를 확인하는 편이 안전하다.
덧붙이면 UseCompressedClassPointers(narrow klass 포인터)는 이 32GB 규칙을 공유하지 않는, compressedKlass.hpp가 따로 관리하는 별도의 인코더다. 오브젝트 포인터 압축이 켜져 있다고 클래스 포인터 압축의 한도가 저절로 같아지는 게 아니다.
Compressed Oops는 "8바이트 정렬 덕에 버려도 되는 하위 3비트"를 이용한 시프트 트릭이고, 힙 크기에 따라 Unscaled/ZeroBased/Disjoint/HeapBased 네 모드가 갈리며 뒤로 갈수록 디코딩 비용이 붙는다. "32GB"는 정확한 경계가 아니라 null-guard 페이지만큼 깎인 근사치라, 그 언저리에서 힙을 튜닝할 땐 로그로 실제 모드를 확인하는 게 유일하게 확실한 방법이다. 다음엔 Compact Object Headers(Lilliput)가 객체 헤더를 8바이트로 줄이는 방식을 더 파볼 생각이다.
oops/compressedOops.{hpp,cpp,inline.hpp}, oops/compressedKlass.{hpp,cpp}, runtime/arguments.cpp, runtime/globals.hpp, utilities/globalDefinitions.hpp