GC 로그와 할당률
바이트 예산 `int` 오버플로 실패를 재현하고 MemoryMXBean, 상한이 있는 할당 확인 프로그램, 약한 참조 큐, GC 통합 로그·jcmd로 힙 압력을 진단합니다.
GC 문제를 “메모리가 많다”로만 설명하면 힙 생존 객체 집합, 할당률, 객체 수명, 일시 중지, 네이티브 메모리를 뒤섞게 됩니다. 힙 사용량이 높아도 생존 객체 집합이 안정적이라면 정상일 수 있고, 힙 사용량이 낮아 보여도 할당률이 커서 GC의 CPU 사용률이 높을 수 있습니다. 먼저 어떤 메모리 영역과 어떤 증상을 설명할지 정합니다.
int 바이트 예산 곱셈의 오버플로
원소 수와 원소 byte를 int로 곱하면 2GB를 넘는 순간 음수가 될 수 있습니다.
아래는 실제 NegativeArraySizeException을 발생시킵니다.
public final class MemoryBudgetOverflowFailure {
public static void main(String[] args) {
int elements = 100_000_000;
int bytesPerElement = 32;
int bytes = elements * bytesPerElement;
System.out.println("wrong-bytes=" + bytes);
byte[] allocation = new byte[bytes];
System.out.println(allocation.length);
}
}원칙은 예산 산술을 long으로 수행하고 JVM 배열 인덱스 한계와 사용 가능한 힙을 별도 검사하는 것입니다.
계산된 예산만으로 객체 배치를 확정하지 않고 JOL·JFR·힙 히스토그램 같은 관찰 도구를 사용합니다.
MemoryMXBean 스냅샷은 순간 상태
확보한 힙, 사용 중인 힙, 최대 힙과 비힙 영역의 값을 읽어 애플리케이션 지표로 노출할 수 있습니다. 한 번 측정한 값만으로 메모리 누수를 판단하지 말고 GC 이후의 추세와 할당률을 함께 봅니다.
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryUsage;
public final class MemoryPoolSnapshot {
private static String megabytes(long bytes) {
return bytes < 0 ? "undefined" : String.format("%.2f MiB", bytes / 1024.0 / 1024.0);
}
public static void main(String[] args) {
MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
System.out.println("heap-used=" + megabytes(heap.getUsed()));
System.out.println("heap-committed=" + megabytes(heap.getCommitted()));
System.out.println("heap-max=" + megabytes(heap.getMax()));
for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) {
MemoryUsage usage = pool.getUsage();
System.out.println(pool.getName() + " used=" + megabytes(usage.getUsed()));
}
}
}수집기마다 풀 이름이 다를 수 있으므로 name 문자열을 고정된 스키마로 가정하지 않습니다.
MemoryType, 임계값 지원 여부, 런타임 레이블을 함께 수집합니다.
할당률과 생존 객체 집합
다음 예제는 각 반복에서 수명이 짧은 byte 배열을 만들고 일부만 보존 목록에 남깁니다.
전체 할당량과 보존량을 따로 출력합니다.
작은 상한을 사용해 OOM을 의도하지 않습니다.
import java.util.ArrayList;
import java.util.List;
public final class BoundedAllocationPressure {
public static void main(String[] args) {
List<byte[]> retained = new ArrayList<>();
long allocatedBytes = 0;
for (int round = 0; round < 100; round++) {
for (int item = 0; item < 200; item++) {
byte[] value = new byte[1_024];
value[0] = (byte) item;
allocatedBytes += value.length;
if (item == 0) {
retained.add(value);
}
}
}
long retainedBytes = retained.stream().mapToLong(value -> value.length).sum();
System.out.println("allocated-bytes=" + allocatedBytes);
System.out.println("retained-bytes=" + retainedBytes);
System.out.println("retained-objects=" + retained.size());
}
}실행 명령은 다음과 같이 GC와 세이프포인트 로그를 파일에 남깁니다.
javac --release 25 BoundedAllocationPressure.java
java -Xms32m -Xmx32m -Xlog:gc*,safepoint:file=gc.log:time,level,tags BoundedAllocationPressure관찰할 값은 수집기 이름, 젊은 세대 컬렉션 횟수, 일시 중지 시간, 수집 전후의 힙입니다. 20MB 할당과 100KB 보존의 차이를 봅니다. 실행마다 정확한 일시 중지 밀리초를 기대값으로 고정하지 않습니다.
참조 큐로 약한 참조 캐시 정리 확인
WeakReference를 캐시에 넣는 것만으로 키와 메타데이터가 자동 제거되지는 않습니다.
ReferenceQueue를 비워 오래된 항목을 정리해야 합니다.
GC 시점은 비결정적이므로 큐 등록 여부를 기능 단언으로 고정하지 않습니다.
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
public final class WeakReferenceQueueInventory {
public static void main(String[] args) {
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
List<WeakReference<byte[]>> references = new ArrayList<>();
for (int index = 0; index < 100; index++) {
references.add(new WeakReference<>(new byte[1_024], queue));
}
System.gc();
int enqueued = 0;
Reference<? extends byte[]> reference;
while ((reference = queue.poll()) != null) {
enqueued++;
references.remove(reference);
}
System.out.println("enqueued-observation=" + enqueued);
System.out.println("tracked-after-drain=" + references.size());
}
}System.gc()도 반드시 컬렉션을 보장하지 않습니다.
운영 캐시에는 명시적 크기·시간 기반 축출과 지표를 우선하고 약한 참조의 의미가 도메인에 맞는지 검토합니다.
실행 중인 프로세스와 jcmd
PID를 확인한 뒤 같은 사용자 권한에서 진단 명령을 실행합니다.
jcmd -l
jcmd <pid> VM.version
jcmd <pid> VM.flags
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram
jcmd <pid> GC.heap_dump filename=heap.hprof클래스 히스토그램은 인스턴스 개수와 바이트 크기가 큰 타입을 보여 주지만, 객체 보유 경로는 힙 덤프 분석기에서 확인해야 합니다. 힙 덤프는 전체 중지와 디스크 사용량·민감 데이터 노출 비용이 있으므로 운영 승인·보관·삭제 정책을 따릅니다.
메모리 압력 가설 구분
- 할당률 증가: GC 횟수·CPU는 오르지만 GC 후 힙은 안정적일 수 있습니다.
- 생존 객체 집합 증가: GC 이후 점유율이 계속 상승합니다.
- 거대 객체 할당: 큰 배열·버퍼가 수집기 영역 정책에 영향을 줍니다.
- 네이티브 메모리 압력: 직접 버퍼, 스레드 스택, 코드 캐시는 Java 힙 덤프만으로 설명되지 않습니다.
- 클래스 로더 누수: 로더별 클래스·메타데이터와 언로드 여부를 봅니다.
GC가 자주 발생하면 힙을 늘리면 되나요?
먼저 할당 병목 지점과 생존 객체 집합을 구분합니다. 힙 확대는 컬렉션 간격을 늘릴 수 있지만 누수를 늦추거나 일시 중지 특성을 바꿀 뿐입니다. 지연 시간 목표, 컨테이너 메모리 제한, 네이티브 영역과 함께 수집기와 힙을 조정합니다.
연습 문제
원소 수와 원소당 바이트 수를 long으로 곱하고 int 배열 상한을 넘으면 명확한 예외를 내세요.
해설 보기
public final class SafeMemoryBudgetSolution {
static int checkedArrayLength(long elements, long bytesPerElement) {
if (elements < 0 || bytesPerElement < 0) {
throw new IllegalArgumentException("negative");
}
long bytes = Math.multiplyExact(elements, bytesPerElement);
if (bytes > Integer.MAX_VALUE) {
throw new IllegalArgumentException("array too large: " + bytes);
}
return Math.toIntExact(bytes);
}
public static void main(String[] args) {
System.out.println("small=" + checkedArrayLength(1_000, 32));
try {
checkedArrayLength(100_000_000, 32);
} catch (IllegalArgumentException exception) {
System.out.println(exception.getMessage());
}
}
}종료 기준은 small=32000과 array too large 메시지입니다.
실제 객체 그래프 메모리는 배열의 데이터 크기 계산과 다를 수 있습니다.