Skip to content
hello tis
Go back

[26.10.23] 학습: ArrayList 확장과 GC의 객체 이동 비교하기

대량 조회 결과를 ArrayList에 모으다 OOM(메모리 할당 실패)을 겪었습니다. 배열 확장과 GC 전후의 주소와 크기를 비교해, 확장에 필요한 공간과 객체가 이동하는 조건을 확인했습니다. GC는 더 이상 접근할 수 없는 객체의 공간을 회수하는 동작입니다.

배열 교체와 객체 이동의 차이 확인을 위한 주소 비교하기

복사 대상 파악을 위한 배열 교체 과정 확인하기

ArrayList의 내부 배열인 Object[]는 원소 객체를 가리키는 참조값을 저장합니다. 용량은 이 배열의 칸 수입니다.

원소 객체는 배열과 별도로 존재하므로 배열 교체와 객체 이동을 나눠 확인해야 합니다. (Java 언어 명세)

add로 용량이 부족해지면 새 배열을 할당하고 객체 참조값을 복사한 뒤 내부 배열을 교체하고 새 원소를 저장한다

객체 위치 파악을 위한 Heap과 Region 구조 확인하기

Heap은 JVM(Java 실행 환경)이 객체와 배열을 저장하는 메모리 영역입니다. G1은 Heap을 같은 크기의 Region으로 나눠 관리합니다.

Eden과 Survivor를 합쳐 Young 세대라고 부릅니다. Young GC는 이 영역에서 유지할 객체를 Survivor나 Old로 복사해 기존 공간을 비웁니다. Full GC는 Heap 전체를 대상으로 공간을 회수하고 정리합니다.

Young에서 Old로 옮기는 것을 승격이라 하며, 이때 쓰는 기준 중 하나가 승격 임계값입니다. 큰 객체는 Humongous로 분류돼 처음부터 Old의 연속된 Region에 할당됩니다. (G1 공식 설명)

G1 Heap을 같은 크기의 Region으로 나누며, Region 하나에 여러 객체를 저장하거나 Humongous 객체 하나를 연속된 여러 Region에 배치한다

배열 교체 확인을 위한 확장 전후 주소 비교하기

Amazon Corretto 21과 G1에서 확장 전후를 비교했습니다. 객체와 참조 관계를 기록한 Heap dump에서 주소를 읽고, GC 로그의 Region 주소 범위와 대조했습니다.

비교 항목Heap주요 조건
실제 ArrayList 확장과 단계별 복사512MiB용량 100만 → 150만, Region 4MiB
Humongous 경계값256MiBRegion 1, 4, 8MiB, 객체 참조값 4B와 8B
Young GC 전후 이동256MiBRegion 4MiB, Young 32MiB, 승격 임계값 2와 0
Humongous 회수 시점256MiBRegion 4MiB, Young 32MiB, Object[] 3MiB

참조가 끊긴 배열도 기록하도록 Heap dump에 -all 옵션을 사용하고, GC 발생 여부를 로그에서 확인했습니다. (Heap dump 옵션)

  1. ArrayList<Message>(1_000_000)에 서로 다른 Message 객체 100만 개를 넣었습니다.
  2. 교체 후에도 비교할 수 있도록 기존 배열을 별도 필드에 보관했습니다.
  3. 최소 1,000,001칸을 확보하는 ensureCapacity(1_000_001)로 용량을 150만으로 늘렸습니다.

OpenJDK 21은 일반적인 확장 경로에서 용량을 약 1.5배로 늘립니다. (ArrayList 구현, 배열 복사 구현)

두 배열을 비교한 뒤 기존 배열을 보관하던 필드를 null로 바꾸고, GC 전후를 확인했습니다.

시점기존 배열 주소새 배열 주소Message(id=0) 주소
확장 전0x58a800000없음0x58bc90988
확장 후0x58a8000000x58c0000000x58bc90988
기존 배열 참조 해제, GC 전0x58a8000000x58c0000000x58bc90988
Full GC 후회수됨0x58c0000000x58bc86138

확장 중 GC는 0회였고, 두 배열의 앞 100만 칸은 같은 객체를 가리켰습니다. Message 수도 100만 개로 유지됐습니다. 원소 객체의 주소는 확장 때 유지되고 Full GC 후 바뀌었습니다.

확장에 필요한 메모리 계산을 위한 배열 크기 확인하기

복사 중 필요한 공간을 확인하려고, 별도로 만든 150만 칸의 배열에 참조값을 50만 개씩 나눠 복사했습니다.

시점새 배열에서 객체를 가리키는 칸 수Message 객체 수새 배열 자체 크기
할당 직후01,000,000약 5.72MiB
절반 복사 후500,0001,000,000약 5.72MiB
전체 복사 후1,000,0001,000,000약 5.72MiB

아직 객체를 가리키지 않는 null 칸도 공간을 차지하므로 새 배열은 할당 직후부터 전체 크기를 사용했습니다. 기존 배열 약 3.81MiB와 새 배열 약 5.72MiB가 함께 존재해 배열에만 약 9.54MiB가 필요했습니다. 원소 객체의 공간은 별도입니다.

Humongous 할당 조건 확인을 위한 크기별 배열 배치 비교하기

Humongous 기준 확인을 위한 배열 크기 비교하기

JDK 21의 G1은 배열 자체의 크기가 Region 크기의 절반을 초과하면 Humongous로 분류합니다. 이 경계 전후의 배열을 만들어 배치를 비교했습니다. (판정 구현)

Region 크기는 JVM이 정하거나 -XX:G1HeapRegionSize로 지정합니다. 비교 조건은 다음과 같습니다.

Object[] 전체 크기 = align8(16 + 배열 길이 × 4)
Humongous 조건    = 전체 크기 > 4MiB / 2

헤더는 JVM의 배열 관리 정보이고, align8은 전체 크기를 8B의 배수로 올림하는 정렬 계산입니다.

Object[] 길이헤더와 정렬을 포함한 크기할당 직후 Region
524,2822MiB − 8BEden
524,2842MiBEden
524,2852MiB + 8BHumongous
524,2882MiB + 16BHumongous

할당 공간 계산을 위한 Region 사용량 확인하기

Humongous 배열의 Region에서 남는 공간에는 다른 객체를 넣을 수 없습니다. 따라서 필요한 Region 수는 배열 크기를 Region 크기로 나눈 값을 올림해 계산했습니다.

배열 전체 크기가 Region 절반을 초과하면 Humongous로 분류하고, 연속된 빈 Region 두 개를 확보해 Old 세대에 배열 하나를 할당한다

확장 중 두 배열이 차지한 공간은 Region 3개인 12MiB로, 배열 크기의 합인 약 9.54MiB보다 컸습니다.

JVM 설정의 영향 확인을 위한 Humongous 기준 비교하기

참조값을 작게 저장하는 압축 참조를 켜면 배열 한 칸이 4B, 끄면 8B를 차지했습니다. Region 크기와 이 설정에 따라 Humongous 기준이 얼마나 달라지는지 비교했습니다. (압축 참조 설명)

Region 크기객체 참조값 크기Region 절반최초 Humongous 배열 길이
1MiB4B512KiB131,069
4MiB4B2MiB524,285
4MiB8B2MiB262,143
8MiB4B4MiB1,048,573

같은 길이의 배열도 Region 크기와 참조값 크기에 따라 Humongous 여부가 달라졌습니다.

연속 공간의 필요성 확인을 위한 빈 Region 배치 비교하기

빈 Region의 총량이 같아도 주소 순서로 붙어 있는지에 따라 할당 결과가 달라지는지 확인했습니다. Heap 256MiB, Region 1MiB에서 1MiB 배열 230개를 만들고, 하나씩 건너뛰며 115개를 해제한 경우와 연속된 115개를 해제한 경우에 각각 64MiB 배열을 요청했습니다.

요청 직전의 배치를 비교했습니다. Corretto 17과 21에서 조건별로 3회 실행했고, 할당 결과는 매번 같았습니다.

JDK해제 위치빈 Region 합계최장 연속 구간64MiB 배열 할당
17교차136MiB23MiBOOM
17연속136MiB113MiB성공
21교차137MiB25MiB추가 Full GC 후 성공
21연속137MiB111MiB성공

빈 공간이 흩어진 조건에서 JDK 17은 OOM이 발생했고, JDK 21은 추가 Full GC에서 Humongous 객체를 재배치한 뒤 할당에 성공했습니다. (G1 변경 사항)

JDK 21의 추가 Full GC가 Humongous 객체를 재배치해 빈 Region 합계 139MiB를 유지하면서 최장 연속 구간을 26MiB에서 139MiB로 늘린다

공간 낭비 확인을 위한 배열 크기 비교하기

Region 끝의 남는 공간이 추가 할당에 미치는 영향을 확인했습니다. JDK 21에서 배열 100개를 유지한 채, 배열 크기를 1MiB와 1MiB + 8B로 바꿔 각각 64MiB 배열을 요청했습니다.

배열 하나의 크기배열 크기 합계배열의 Region 점유64MiB 배열 할당
1MiB100MiB100MiB성공
1MiB + 8B100MiB + 800B200MiBOOM

배열 하나가 Region 1개 대신 2개를 차지하면서, 배열 크기의 합은 800B 늘었지만 Region 사용량은 100MiB 늘어 추가 할당에 실패했습니다.

객체 이동 확인을 위한 Young GC 전후 주소 비교하기

Young GC가 배열과 원소를 각각 이동시키는지 확인했습니다. 일반 배열과 Humongous 배열의 0번 칸에 같은 Token의 참조값을 저장하고, GC 전후 주소를 비교했습니다.

승격 임계값을 2로 고정하고, Young GC가 끝날 때마다 Heap dump를 수집했습니다.

Young GC가 Eden의 Token을 Survivor로 복사하고, 같은 주소에 있는 Humongous Object[]의 0번 참조값을 Token의 새 위치로 갱신한다

시점Token 주소Region 역할과 번호
할당 직후0x7ffbf87a0Eden 62
Young GC 1회 후0x7fd904588Survivor 54
Young GC 2회 후0x7fdc98690Survivor 55
Young GC 3회 후0x7f049ba60Old 1

일반 배열도 Young GC마다 주소가 바뀌었지만 Humongous 배열은 3회 모두 같은 주소를 유지했습니다. 두 배열은 참조값이 갱신돼 계속 같은 Token을 가리켰습니다.

Full GC에서는 일반 배열의 주소가 유지된 채 Region 역할만 Eden에서 Old로 바뀌었습니다. 세대는 주소와 Region 역할을 함께 봐야 구분할 수 있었습니다.

Old로 이동하는 조건 확인을 위한 승격 임계값 비교하기

Token이 Old로 이동하는 시점이 설정에 따라 달라지는지, 승격 임계값 2와 0을 비교했습니다.

객체의 age는 0에서 시작해 Survivor로 복사될 때마다 1씩 늘어납니다. G1은 복사 전의 age를 비교하므로, 이 조건에서는 임계값이 2일 때 두 번의 GC로 age가 2가 된 뒤 다음 GC에서 Old로 옮겨졌습니다. Survivor의 남은 공간도 이동 시점에 영향을 줍니다. (승격 구현)

기존 배열의 회수 시점 확인을 위한 GC 결과 비교하기

기존 배열의 참조를 끊은 뒤 공간을 언제 다시 쓸 수 있는지 확인했습니다. 내부 배열과 같은 Object[]를 3MiB로 만들어 4MiB Humongous Region 하나에 배치했습니다. 배열을 보관한 필드를 유지한 경우와, null로 바꿔 마지막 참조를 끊은 경우의 GC 결과를 비교했습니다.

배열 참조확인 시점Object[]의 실측 결과
유지Young GC 또는 Full GC 후배열 유지
해제GC 전배열이 Heap에 남음
해제Young GC 2회 후배열이 Heap에 남음
해제Full GC 후배열 회수, Region을 재사용할 수 있음

참조 해제와 공간 회수는 별개였습니다. 확장에 필요한 공간은 기존 배열이 회수될 때까지 두 배열을 함께 고려해야 했습니다.


Share this post:

Previous Post
[26.10.26] ClickHouse 통합 전 분석과 복구 검토하기
Next Post
[26.10.22] 사내 버셀에 정적 페이지 업로드 API 추가하기