안동민 개발노트

본문 시작

GC 로그와 할당률

메모리 예산 오버플로를 재현하고 MemoryMXBean·GC 로그·jcmd로 힙 압력을 진단합니다.

GC 문제를 “메모리가 많다”로만 설명하면 힙 생존 객체 집합, 할당률, 객체 수명, 일시 중지, 네이티브 메모리를 뒤섞게 됩니다.

힙 사용량이 높아도 생존 객체 집합이 안정적이라면 정상일 수 있고, 힙 사용량이 낮아 보여도 할당률이 커서 GC의 CPU 사용률이 높을 수 있습니다.

먼저 어떤 메모리 영역과 어떤 증상을 설명할지 정합니다.


int 바이트 예산 곱셈의 오버플로

원소 수와 원소당 바이트 수를 int로 곱하면 표현 범위를 벗어난 결과가 순환합니다. 원문의 3,200,000,000은 int에서 -1,094,967,296이 되므로 배열 생성은 규칙상 NegativeArraySizeException을 던집니다.

lab/MemoryBudgetOverflowFailure.java
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 이후의 추세와 할당률을 함께 봅니다.

src/MemoryPoolSnapshot.java
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, 임계값 지원 여부, 런타임 레이블을 함께 수집합니다. 원문은 이 항목들을 수집하지 않으며, 더 이상 유효하지 않은 풀의 getUsage()가 null을 반환하는 경우도 처리하지 않습니다.


할당률과 생존 객체 집합

다음 예제는 각 반복에서 수명이 짧은 byte 배열을 만들고 일부만 보존 목록에 남깁니다.

배열 데이터 길이의 생성 합계와 보관 목록의 합계를 따로 출력합니다.

작은 상한을 사용해 OOM을 의도하지 않습니다.

src/BoundedAllocationPressure.java
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());
    }
}
배열 데이터의 생성 합계와 목록에 보관한 합계를 구분한다

BoundedAllocationPressure의 반복 횟수와 배열 길이로 계산한 값입니다.

배열 데이터의 생성 합계와 목록에 보관한 합계를 구분한다
소스에서 세는 값계산의미
생성한 배열 데이터 합계100 × 200 × 1,024 = 20,480,000 B모든 생성 식의 배열 길이 합
보관 목록의 배열 데이터100 × 1,024 = 102,400 Bitem이 0인 배열 100개 · 100 KiB
보관 목록의 원소 수100회 × 1개 = 100개retained.size()
생성한 배열 데이터 합계
계산: 100 × 200 × 1,024 = 20,480,000 B
의미: 모든 생성 식의 배열 길이 합
보관 목록의 배열 데이터
계산: 100 × 1,024 = 102,400 B
의미: item이 0인 배열 100개 · 100 KiB
보관 목록의 원소 수
계산: 100회 × 1개 = 100개
의미: retained.size()

배열 헤더·정렬·목록 비용은 포함하지 않습니다. 실제 힙 할당량·GC 뒤 생존 집합·초당 할당률을 측정한 값도 아닙니다.

실행 명령은 다음과 같이 GC와 세이프포인트 로그를 파일에 남깁니다.

javac --release 25 BoundedAllocationPressure.java
java -Xms32m -Xmx32m -Xlog:gc*,safepoint:file=gc.log:time,level,tags BoundedAllocationPressure

관찰할 값은 수집기 이름, 젊은 세대 컬렉션 횟수, 일시 중지 시간, 수집 전후의 힙입니다.

실행마다 정확한 일시 중지 밀리초를 기대값으로 고정하지 않습니다.


참조 큐로 약한 참조 캐시 정리

WeakReference를 캐시에 넣는 것만으로 키와 메타데이터가 자동 제거되지는 않습니다.

ReferenceQueue를 비워 오래된 항목을 정리해야 합니다.

GC 시점은 비결정적이므로 큐 등록 여부를 기능 단언으로 고정하지 않습니다.

src/WeakReferenceQueueInventory.java
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 heap.hprof

위 원문 명령의 filename=heap.hprof는 이름 붙은 옵션이 아니라 파일 이름 위치에 전달됩니다. 파일 이름을 heap.hprof로 지정하려면 jcmd <pid> GC.heap_dump heap.hprof 형태를 사용합니다.

클래스 히스토그램은 인스턴스 개수와 바이트 크기가 큰 타입을 보여 주지만, 객체 보유 경로는 힙 덤프 분석기에서 확인해야 합니다. GC.class_histogram도 공식 문서상 영향도가 높은 명령이므로 가벼운 조회라고 가정하지 않습니다.

힙 덤프는 전체 중지와 디스크 사용량·민감 데이터 노출 비용이 있으므로 운영 승인·보관·삭제 정책을 따릅니다.


메모리 압력 가설 구분

  • 할당률 증가: GC 횟수·CPU는 오르지만 GC 후 힙은 안정적일 수 있습니다.
  • 생존 객체 집합 증가: GC 이후 점유율이 계속 상승합니다.
  • 거대 객체 할당: 큰 배열·버퍼가 수집기 영역 정책에 영향을 줍니다.
  • 네이티브 메모리 압력: 직접 버퍼, 스레드 스택, 코드 캐시는 Java 힙 덤프만으로 설명되지 않습니다.
  • 클래스 로더 누수: 로더별 클래스·메타데이터와 언로드 여부를 봅니다.
GC가 자주 발생하면 힙을 늘리면 되나요?

먼저 할당 병목 지점과 생존 객체 집합을 구분합니다.

힙 확대는 컬렉션 간격을 늘릴 수 있지만 누수를 늦추거나 일시 중지 특성을 바꿀 뿐입니다.

지연 시간 목표, 컨테이너 메모리 제한, 네이티브 영역과 함께 수집기와 힙을 조정합니다.


연습 문제

원소 수와 원소당 바이트 수를 long으로 곱하고 int 배열 상한을 넘으면 명확한 예외를 내세요.

해설 보기
exercise/SafeMemoryBudgetSolution.java
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 메시지입니다.

이 함수는 실제 배열을 할당하지 않습니다. long 곱셈 자체가 넘치면 ArithmeticException이 발생하며 원문 main의 IllegalArgumentException 처리 범위 밖입니다. int 상한을 통과해도 JVM의 배열 크기 제한이나 가용 힙에 따른 할당 성공까지 보장하지 않습니다.