티스토리 뷰

Java & Kotlin

[JVM] 힙 객체 헤더의 비효율과 이를 줄이기 위한 새로운 자바 객체 헤더(New Java Object Header: Compact Object Headers)

망나니개발자 2025. 10. 14. 10:00
반응형



 

1. 힙 객체 헤더의 비효율과 이를 줄이기 위한 새로운 자바 객체 헤더
(New Java Object Header: Compact Object Headers)


[ 힙 객체 헤더의 비효율성 ]

앞서 설명하였듯 자바에서 객체의 크기를 구하려면 기본적으로 객체의 헤더 크기에 변수의 크기(원시 타입인 경우) 또는 참조의 크기(참조 타입인 경우)를 더해주어야 한다. 그리고 만약 더해진 값이 8의 배수가 아니라면, 별도의 패딩 바이트를 더하여 객체의 크기가 항상 8의 배수가 되도록 보정하고 있다. Klass 포인터 압축 유무 혹은 최대 힙의 크기 등에 따라 실제 사용되는 바이트의 수가 달라질 수 있는데, 64비트 JVM을 기준으로 이를 정리하면 다음과 같다.

상태 마크 워드의 크기 클래스 워드의 크기
클래스 포인터 비압축(non-compressed) 8 바이트 8 바이트
클래스 포인터 압축(compressed) 8 바이트 4 바이트

 

 

만약 클래스 포인터 압축 기능을 사용중이라면, 별도의 4바이트 정렬 패딩이 요구될 것인데, 일반적으로 최대 힙의 크기가 32GB 이하라면 압축 옵션이 자동으로 활성화된다. 따라서 대부분의 애플리케이션은 헤더로 기본 12 바이트에, 4바이트의 정렬 바이트가 사용될 수 있다.

이렇듯 모든 객체는 객체의 실제 데이터인 페이로드(payload) 이외에도 헤더나 패딩을 위한 추가적인 메모리가 요구된다. 최악의 경우 4바이트 데이터로도 충분한 Integer 객체는 16 바이트가 소요될 수 있고, 8바이트 데이터로도 충분한 Long 객체는 24바이트가 소요될 수 있다. 추가적인 헤더와 정렬 패딩으로 인해 순수 데이터에 비해 3.4배의 메모리 오버헤드가 발생할 수 있는 것이다. 이러한 부분은 가급적이면 Integer, Long과 같은 박싱 타입이 아닌, int나 long과 같은 원시 타입을 권장하는 이유이기도 하다.

 

 

 

 

[ 객체 헤더의 크기를 줄이기 위한 자바의 노력 ]

Compact Object Header의 목표

Compact Object Header는 JEP-450을 통해 JDK 24에, 기존의 객체 헤더 레이아웃을 대체하기 위한 목적으로 도입되었다. 이는 다음의 2가지 목표를 가지고 “Project Lilliput”이라는 제목으로 진행되었다.

  • 객체 헤더의 크기를 64 비트까지 줄이고, 가능하다면 최대 32비트까지 줄이기
  • 헤더 레이아웃을 보다 유연하게 구성하기

 

 

앞서 설명하였듯 64비트 JVM을 기준으로 객체 헤더는 최소 16 바이트가 요구되기 때문에, 이를 8 바이트로 줄이고 가능하다면 4 바이트까지 줄이는 것이다. 또한 현재 객체 헤더는 고정된 레이아웃을 가지고 있어 변경이나 최적화가 어려운데, 빌드 시점 또는 런타임 시점에 헤더 비트를 사용하는 방식을 구성할 수 있는 유연한 레이아웃을 만드는 것이다. 예를 들어, 가상 스레드를 위해 synchronized 키워드의 사용은 지양되고 있는데, 이를 위해 락 상태를 위한 헤더 비트를 다른 용도로 재활용하게 구성할 수 있는 것이다. 마찬가지로 해시코드도 최초 호출 시에 마크 워드에 할당되다 보니, 해시 코드를 호출할 일이 없어 마크 워드의 비트를 활용할 일이 없다면 다른 용도로 활용할 수 있게 하자는 것이다.

결국 Compact Object Header는 자바 객체의 헤더를 더 작고 유연하게 만들어 메모리 사용량을 줄이고, 더 많은 객체를 효율적으로 다룰 수 있도록 JVM 내부 구조를 개선하는 작업이라고 볼 수 있다.

 

 

Compact Object Headers의 세부 구현

Compact Object Header에서는 마크 워드와 클래스 워드의 구분을 제거하고, 압축된 형태의 클래스 워드를 마크 워드에 포함시킨다. 즉, 먼저 64비트 JVM 기준으로 64비트를 사용하던 미압축 상태의 클래스 포인터를 32비트로 압축시킨다. 그리고 새로운 인코딩 방식을 도입하여 이를 22비트까지 줄인다.

Before Compressed:
 ┌───────────────────────────────────────────────────────────────────┐
 │                            Klass Pointer                          │
 │                               (64b)                               │
 └───────────────────────────────────────────────────────────────────┘
 
After Compressed:
 ┌─────────────────────────────────┐
 │         Klass Pointer           │
 │             (32b)               │
 └─────────────────────────────────┘
 
 
After Compressed and Encoding:
 ┌─────────────────────────────────┐
 │      Encoded Klass Pointer      │
 │             (22b)               │
 └─────────────────────────────────┘

 

 

그리고 마크 워드와 클래스 워드의 경계를 없애고, 압축된 클래스 워드를 마크 워드에 포함시키는 것이다. 이렇게 하여 최종적으로 완성된 Compact Object Header 레이아웃을 살펴보면 다음과 같다.

  • Compressed Klass Pointer: 압축된 클래스 포인터
  • Hash Code: 해시 코드(기존과 동일함)
  • Valhalla-reserved bits: Project Valhalla를 위해 예약된 4비트
  • GC Age: GC 구현을 위한 객체의 나이(기존과 동일함)
  • Self Forwarded Tag: GC 포워딩 태그
  • Tag: 객체 상태 태그

 

 

 ┌────────────────────────┬─────────────────────────┬───────┬───────┬─┬────┐
 │ Encoded Klass Pointer  │        Hash Code        │  VVVV │  Age  │S│Tag │
 │           (22b)        │          (31b)          │  (4b) │ (4b)  │1│(2b)│
 └────────────────────────┴─────────────────────────┴───────┴───────┴─┴────┘

 

 

객체 헤더 레이아웃이 변경됨에 따라 세부 동작 역시 함께 변경된 부분이 있다. 먼저 이전에는 잠금 연산이 수행되면 마크 워드의 정보를 락 레코드에 저장하고, 마크 워드에는 락 레코드의 포인터를 넣으면서 값을 덮어 씌우는 작업이 수행되었다. 이로 인해 JVM 내부에서 클래스 정보를 즉시 읽을 수 없는 상태가 되었는데, 이제는 마크 워드를 해당 포인터(tagged pointer)로 덮어쓰지 않으므로, 런타임에 클래스 정보를 빠르게 접근하는 등의 장점을 얻을 수 있게 되었다.

새롭게 추가된 Self Forwarded Tag는 메모리 단편화와 효율적인 공간 확보를 위해 객체를 이동(relocate)시킬 때, 이전 위치(old copy)에서 새 위치(new copy)로의 매핑을 기록하는 Fowarding 과정을 위해 추가되었다. 이를 통해 다른 객체들이 이전 위치를 참조하고 있어도, 새 위치를 올바르게 참조하도록 업데이트할 수 있다. ZGC는 별도의 forwarding table에 포워딩 정보를 기록하지만 나머지 GC들은 포워딩 정보를 기존 객체 헤더에 덮어쓰기(overwrite)게 된다. 즉, old object header에 새 객체 주소를 저장하고, new copy는 원래 header를 그대로 보존한다. 자세한 내용은 중요하지 않으니 넘어가도록 하자.

또한 GC는 내부적으로 힙 영역을 탐색(GC Walking)하며 객체를 확인한다. 힙 메모리를 선형적(linear)으로 쭉 훑게 되는데, 각 객체의 크기(object size)를 알아야 다음 객체 위치로 이동 가능하다. 객체의 크기를 계산하려면 객체의 클래스 포인터(klass pointer)가 필요한데, 자바에서 객체의 크기는 클래스 메타데이터에 정의되어 있기 때문이다. 하지만 Compact Object Header에서는 클래스 포인터가 Mark Word 안에 압축 저장되어 있어, GC가 객체를 읽을 때 이를 디코딩하고 압축 해제하는 작업이 필요해진다. 하지만 해당 비용은 매우 작고, 힙 스캔 시 메모리 접근 비용(memory access cost)이 훨씬 크기 때문에 무시 가능한 수준이다. 즉, 성능 상 큰 부담이 되지 않는 수준인 것이다.

 

 

[ Compact Object Headers의 효과와 기대 ]

해당 기능은 자바 25에서 실험 기능이 아닌 정식 기능으로 포함되었으며, 벤치마크 결과 메타데이터의 사용량을 줄여 다음과 같은 메모리 오버헤드를 줄이고 성능을 개선하는 효과가 있었다고 한다.

  • 특정 환경에서 SPECjbb2015 벤치마크 실행 시, 힙 사용량이 22% 감소하고 CPU 사용량이 8% 감소함
  • 다른 환경에서는 SPECjbb2015의 가비지 컬렉션 횟수가 G1과 Parallel 수집기 모두에서 15% 감소함
  • 고도로 병렬화된 JSON 파서 벤치마크에서는 실행 시간이 10% 단축되었음

 

 

해당 기능을 활용하려면 다음의 JVM 옵션을 활성화해주면 된다.

-XX:+UseCompactObjectHeaders

 

 

자바 내부에서는 Compact Object Header를 기본적으로 활성화하기 위한 시도도 진행되고 있다. 해당 기능이 정식으로 처리되면, 이제 우리는 훨씬 효율적인 JVM 헤더를 기본적으로 활용하게 될 것이다.

 



 

관련 포스팅

 

참고 자료

 

 

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2026/06   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함