글을 작성하게 된 계기
엘라스틱서치(ElasticSearch)를 학습하며 Compressed Oops(Compressed Ordinary Object Pointer) 에 대해 알게 되었고, 이를 정리하기 위해 글을 작성하게 되었습니다.
1. 32bit vs 64bit 시스템과 메모리 주소
우리가 사용하는 컴퓨터 시스템은 32bit 또는 64bit 아키텍처에 따라 CPU가 처리할 수 있는 주소의 범위가 다릅니다. bit 수가 클수록 한 번에 처리할 수 있는 데이터의 크기 와 접근할 수 있는 메모리 공간 이 더 넓어집니다. 이는 운영체제가 사용할 수 있는 가상 주소 공간의 최대 크기를 의미하며, 실제 사용 가능한 메모리는 하드웨어 및 OS에 따라 더 적을 수 있습니다.
- What is the difference between 32 bit and 64 bit computers?
- Difference Between 32-bit and 64-bit Operating Systems
- Are 64 bit programs bigger and faster than 32 bit versions?
1-1. 32bit 시스템
32bit 시스템에서는 하나의 주소가 4byte(32bit) 입니다. 즉, 최대 주소 값은 2^32(4,294,967,296) 로 약 4GB 메모리 공간까지 접근할 수 있습니다.
이를 그림으로 보면 다음과 같습니다. 32bit 시스템에서는 주소 하나당 1byte를 가리키며, 이러한 주소는 0x00000000부터 0xFFFFFFFF까지 총 2³²개 존재합니다. 각 주소는 1byte 단위로 메모리에 직접 접근할 수 있으며 32bit 시스템에서는 그 주소 값을 저장하기 위해 4byte 크기의 포인터가 사용됩니다.
1
2
3
4
5
6
7
8
9
10
주소 크기: 32bit (0x00000000 ~ 0xFFFFFFFF) = 총 2^32 = 4GB
주소 하나당 가리키는 크기: 1byte (byte-addressable)
┌────────────┬────────────┬────────────┬────────────┬────────────┐
| 0x00000000 | 0x00000001 | 0x00000002 | 0x00000003 | ... |
├────────────┼────────────┼────────────┼────────────┼────────────┤
| 7F | A3 | 00 | FF | ... |
└────────────┴────────────┴────────────┴────────────┴────────────┘
↑ ↑ ↑ ↑
1 byte 1 byte 1 byte 1 byte
1-2. 64bit 시스템
반면 64bit 시스템은 주소 공간의 최댓값이 2^64(18,446,744,073,709,551,616) 로 주소 하나가 8byte, 즉 64bit입니다. 64bit에서는 프로세서의 레지스터, 주소 버스, 데이터 버스 등이 64bit 폭을 가지며, 그만큼 더 큰 수를 다룰 수 있고, 더 많은 메모리에 직접 접근할 수 있습니다.
64bit 시스템에서도 주소 하나는 여전히 1byte를 가리킵니다. 그러나 각 주소를 표현하기 위해 64bit, 즉 8byte 크기의 포인터가 사용됩니다. 64bit 주소 공간에서는 주소 값이 0x0000000000000000부터 0xFFFFFFFFFFFFFFFF까지 존재하며, 이론적으로 총 2^64개, 즉 16 EB의 메모리 공간에 접근할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
주소 크기: 64bit (0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF) = 총 2^64 = 16EB
주소 하나당 가리키는 크기: 1byte (byte-addressable)
┌────────────────────┬────────────────────┬────────────────────┬────────────────────┐
| 0x0000000000000000 | 0x0000000000000001 | 0x0000000000000002 | ... |
├────────────────────┼────────────────────┼────────────────────┼────────────────────┤
| 7F | 01 | 9C | ... |
└────────────────────┴────────────────────┴────────────────────┴────────────────────┘
↑ ↑ ↑
1 byte 1 byte 1 byte
물론 실제로 이 전체 주소 공간을 다 사용하는 시스템은 잘 없는데요, 대부분의 운영체제는 이보다 훨씬 적은 주소 범위만을 실질적으로 활용합니다. 예를 들어, 대부분의 64bit 리눅스 시스템에서는 48bit 정도까지만 실제 가상 주소 공간으로 사용되며, 나머지는 예약 상태로 남아있습니다.
이는 다른 시스템도 비슷한데요, 관심이 가면 한 번 참조해 보세요.
2. 자바에서의 객체 참조와 메모리 문제
자바는 모든 객체를 힙(Heap) 메모리에 생성하며, 객체가 저장된 주소의 참조 값을 가집니다. 예를 들어, 아래 변수 p는 객체 자체가 아닌, 힙 어딘가에 있는 Person 객체의 주소 값입니다. 일반적인 객체 변수, 배열 요소, 클래스의 필드 등은 모두 이 주소 값을 통해 객체에 접근하게 되고요. 32bit JVM에서는 4byte, 64bit JVM에서는 8byte(64bit) 참조를 사용합니다.
1
Person p = new Person("Alice");
주소 크기가 커지면 그만큼 더 많은 메모리를 다룰 수 있지만, 한편으로는 참조 값 자체가 차지하는 크기도 두 배가 되므로 메모리 사용량이 증가 하고, 캐시 효율 도 떨어질 수 있습니다. 참조 값이 커질수록, 객체 내부 구조도 커지고, 필드나 배열에서의 메모리 사용량도 늘어나기 때문입니다. 특히 많은 객체를 참조하는 자바에서는 이러한 참조 값 크기의 차이가 성능/메모리 효율에 큰 영향을 미칩니다.
- 메모리 사용량 증가
- 캐시 효율 감소
2-1. 메모리 사용량 증가
64bit 시스템을 사용하게 되면 메모리 사용량이 증가하는 문제가 발생할 수 있습니다. 예를 들어, 참조 값이 8byte가 되면, 아래 배열은 참조 값만으로 약 800MB의 메모리를 소모하게 됩니다. 객체 수가 많아질수록 이는 더욱 커지며, 전체 힙 사용량 증가, GC 처리 시간 증가 등 성능 저하로 이어질 수 있습니다.
1
final Object[] arr = new Object[100_000_000];
자바 객체에 대한 상세 정보는 OpenJDK에서 제공하는 JOL(Java Object Layout) 라이브러리를 통해 확인할 수 있는데, 자바 객체의 실제 메모리 레이아웃(Memory Layout)을 분석할 수 있게 도와줍니다. 이를 사용해 64bit가 되면 어떤 일이 발생하는지 눈으로 확인해 보겠습니다.
- 객체 헤더(마크워드, 클래스 포인터)
- 필드의 오프셋, 크기, 정렬
- 내부 padding (정렬 때문에 생기는 공백)
- 객체 전체 크기
먼저 JOL 의존성을 추가해줍니다.
1
implementation("org.openjdk.jol:jol-core:0.17")
이후 VM에 -XX:-UseCompressedOops -Xmx64g 옵션을 추가해 아래 코드를 실행합니다. 해당 옵션은 Compressed Oops를 적용하지 않는 옵션으로, 객체 참조를 32bit가 아닌 64bit로 사용하게 만듭니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Slf4j
@DisplayName("[UnitTest] Buffer Unit Test")
class CompressedOopsTest {
static class RefTest {
Object a;
Object b;
Object c;
}
@Test
void print64bit() {
final RefTest ref = new RefTest();
log.info("\n: {}", ClassLayout.parseInstance(ref).toPrintable());
}
}
결과를 살펴보면 객체 헤더에서 klass pointer 가 4byte에서 8byte로 늘어나고, 참조 값 하나하나가 모두 8byte로 저장된 것을 볼 수 있습니다. 즉, 이에 따라 힙 메모리 사용량이 늘어나는 것이죠.
1
2
3
4
5
6
7
8
9
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x01180b08
12 4 (alignment/padding gap)
16 8 java.lang.Object RefTest.a null
24 8 java.lang.Object RefTest.b null
32 8 java.lang.Object RefTest.c null
Instance size: 40 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
일반적으로 JVM은 힙의 사용량이 특정 임계치(Young 영역이 100%에 가까워질 경우)를 넘으면 Minor GC를 수행하며, Old 영역이 일정 비율을 초과하게 되면 Full GC를 유도합니다. Full GC는 애플리케이션의 모든 쓰레드를 멈추고 전체 힙을 스캔하기 때문에 정지 시간(Stop-The-World)이 길어지고, 응답 지연(Latency)이나 처리량 저하로 이어질 수 있습니다.
2-2. 캐시 효율 감소
캐시 미스 증가는 왜 나왔는지 궁금할 수 있는데요, 메모리가 커지면 객체를 저장할 수 있는 주소 공간이 넓어지지만, 동시에 참조 값 자체도 증가하며, 이로 인해 힙 메모리 내에서 더 넓은 범위에 흩어질 가능성 이 높아집니다.
1
2
3
4
5
6
7
8
9
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x01180b08
12 4 (alignment/padding gap)
16 8 java.lang.Object RefTest.a null
24 8 java.lang.Object RefTest.b null
32 8 java.lang.Object RefTest.c null
Instance size: 40 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
즉, arr 배열 자체는 연속된 메모리 공간에 있지만, 각 요소는 힙에 흩어진 객체를 가리키는 참조 값 입니다. 참조 값의 크기가 커지면 객체들이 더 넓은 주소 공간에 분산되어 저장될 가능성이 높아지고, 이로 인해 CPU 캐시 라인에 연속적으로 로드되지 않아 캐시 미스가 증가하게 됩니다.
1
final Object[] arr = new Object[100_000_000];
즉, 4byte에서 8byte가 되면서 메모리상에서 연속되지 않은 위치에 배치될 확률도 높아지는 것이죠. 이런 경우 CPU가 한 번에 캐시 라인으로 읽어올 수 있는 인접 객체들이 실제로는 메모리상 멀리 떨어져 있을 수 있어, 캐시 미스 발생 빈도가 증가합니다. 이는 특히 많은 객체를 순차적으로 순회할 때 성능 저하로 이어질 수 있죠.
1
2
3
4
5
6
7
8
9
10
11
Object[] arr (in Eden or Tenured Gen, contiguous memory)
┌────────────┬────────────┬────────────┬────────────┬────────────┐
arr[0] → │ 0x1000_0000│ 0x1000_0008│ 0x1000_0010│ 0x1000_0018│ ... │
└────────────┴────────────┴────────────┴────────────┴────────────┘
↓ ↓ ↓ ↓
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ Obj A │ │ Obj B │ │ Obj C │ │ Obj D │ ... (scattered in heap)
└────────┘ └────────┘ └────────┘ └────────┘
0x7ff1_aa00 0x7ff1_2bc0 0x7ff2_9010 0x7ff3_12e0 ← 실제 객체 위치들 (힙)
↓ ↓
필요한 객체 필요한 객체 ← 연속되지 않은 위치
캐시 라인은 CPU가 메모리에서 데이터를 읽을 때 한 번에 가져오는 연속된 메모리 블록 단위입니다. 특정 주소뿐 아니라 그 주변 데이터까지 함께 캐시에 올려 공간 지역성을 활용함으로써 성능을 높입니다.
이를 정리하면 64bit 시스템은 오히려 참조 값 자체가 8byte로 커지기 때문에 객체 간의 간격이 더 벌어지고, 그 결과 캐시 효율이 떨어지고 성능 저하가 발생할 수 있습니다.
- 배열은 순차적으로 저장되어 있어, 예를 들어 arr[0] ~ arr[7]까지는 한 64byte 캐시 라인에 들어갈 수 있습니다.
- 하지만 이 값들은 참조값일 뿐, 실제 객체 데이터는 흩어진 힙 메모리에 있습니다.
- 해당 주소를 따라가 힙에서 객체를 가져올 때, 힙의 객체가 CPU 캐시에 없으면 캐시 미스가 발생합니다.
- 많은 객체가 흩어져 있다면, CPU는 새로운 캐시 라인을 로딩해야 하고, 이로 인해 캐시 미스가 급증합니다.
참고로 아래와 같은 방법으로 현재 JVM이 몇 bit를 사용하는지 확인할 수 있습니다.
1
final String arch = System.getProperty("sun.arch.data.model");
3. Compressed Oops
이러한 문제를 해결하기 위해 JVM은 압축 객체 포인터, 즉 Compressed Oops(Compressed Ordinary Object Pointer) 를 도입했습니다. 이는 객체 참조 값을 64bit가 아닌 32bit로 압축해 표현할 수 있도록 했습니다. 이를 통해 참조 값이 절반 크기로 줄어들며, 메모리 사용량이 절감되고, 객체 밀도 또한 높아져 CPU 캐시 효율과 GC 성능이 향상됩니다.
하지만 32bit로 표현할 수 있는 값의 범위는 최대 약 43억 개로 제한되므로, 단순히 4byte로 주소를 표현할 경우 약 4GB까지만 메모리 주소를 가리킬 수 있습니다. 이 제한을 극복하기 위해 JVM은 객체를 힙 메모리에 8byte 단위로 정렬하여 배치합니다. 즉, 객체의 시작 주소는 항상 0x00000008, 0x00000010, 0x00000018과 같이 8의 배수로 정렬되는 것입니다.
1
2
3
4
5
6
7
8
9
────────────────────────────────────────────
주소 (실제) 객체
────────────────────────────────────────────
0x000000000000 객체 A
0x000000000008 객체 B
0x000000000010 객체 C
0x000000000018 객체 D
...
────────────────────────────────────────────
이 구조에서는 전체 주소를 저장할 필요가 없는데, JVM은 객체가 몇 번째 8byte 블록 에 있는지만 저장하고, 참조 값에 8을 곱해 실제 주소를 계산하기 때문입니다. 예를 들어, 참조 값이 1이면 실제 주소는 8byte(1×8), 참조 값이 2면 16byte(2×8)가 되는 것입니다.
1
2
3
4
5
6
7
8
9
────────────────────────────────────────────
압축 포인터 값 해석된 실제 주소
────────────────────────────────────────────
0 → 0x000000000000
1 → 0x000000000008
2 → 0x000000000010
3 → 0x000000000018
...
────────────────────────────────────────────
이렇게 압축된 참조 값을 32bit로 저장하면서도 최대 가리킬 수 있는 주소 범위는 약 34GB(2³² × 8)가 됩니다. 따라서 JVM 힙 크기가 32GB 이하일 경우, 4byte 크기의 압축 포인터만으로도 힙 전체를 참조할 수 있습니다. 이 범위 안에서는 Compressed Oops를 사용할 수 있으며, 메모리 절약과 성능 향상이 됩니다.
1
2
3
4
5
6
────────────────────────────
압축 미사용 시 (8byte 참조)
────────────────────────────
객체 필드 = 8byte 참조
배열 요소 = 8byte 참조
변수 = 8byte 참조
1
2
3
4
5
6
────────────────────────────
Compressed Oops 사용 시 (4byte 참조)
────────────────────────────
객체 필드 = 4byte 참조
배열 요소 = 4byte 참조
변수 = 4byte 참조
만약 JVM 힙 크기가 32GB를 초과하게 되면, 더 이상 압축 포인터로 모든 객체를 표현할 수 없게 되므로 Compressed Oops 기능을 자동 비활성화하고, 참조 값을 다시 64bit로 처리하게 됩니다.
이때는 객체당 참조 필드 크기가 8byte로 증가하므로, 메모리 사용량이 증가하고 GC 효율도 떨어지게 됩니다.
4. 정리
Compressed Oops는 64bit JVM에서 객체 참조를 32bit로 압축하여 메모리 사용량을 줄이고, GC 성능을 향상하는 기술입니다. JVM은 참조 값에 8을 곱해 실제 주소를 계산하며, 힙 크기가 32GB 이하일 경우 자동으로 활성화됩니다. 압축이 비활성화되면 객체 참조당 8byte가 필요하게 되어 메모리 사용량이 증가하고, GC 효율이 떨어지며, 캐시 미스도 증가할 수 있습니다. 메모리 구조가 이해가 잘 안돼서 민욱이 한테 도움을 받았는데요, 시간 내서 도와줘서 고맙습니다. 💪🏻
이러한 차이는 -XX:-UseCompressedOops 옵션과 JOL 또는 Unsafe를 통해 직접 확인할 수 있습니다.