본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
16장 : JVM과 진단

GC와 메모리 압력

Java 객체는 주로 힙에 할당되고 더 이상 도달할 수 없을 때 가비지 컬렉터의 회수 대상이 됩니다.

GC는 객체가 필요 없다는 업무 의미를 알지 못하고 루트에서 참조 경로가 남았는지만 판단합니다.

힙 크기를 크게 하면 수집 빈도는 줄 수 있지만 메모리 사용과 지연 목표를 함께 봐야 합니다.

지역 객체가 항상 실제 힙 할당으로 남는 것도 아니며 JIT 최적화가 영향을 줄 수 있습니다.

이 절에서는 추측 대신 할당률, 살아남은 객체, 정지 시간을 관찰합니다.

“참조가 없다”는 것은 변수 이름이 사라졌다는 뜻이 아니라 GC 루트에서 따라갈 경로가 없다는 뜻입니다. 객체끼리 순환 참조해도 바깥 루트와 연결이 끊기면 함께 회수 대상이 됩니다. 반대로 사용하지 않는 객체라도 static 컬렉션이 붙잡고 있으면 살아 있습니다.


GC 루트에서 도달 가능한 객체는 살아 있다

실행 스레드 스택, static 필드, JNI 참조 등에서 이어진 객체는 회수되지 않습니다.

필요 없는 캐시 참조도 살아 있는 것으로 봅니다.


세대 가설은 대부분 객체가 빨리 사라진다는 관찰을 쓴다

젊은 영역에서 자주 수집하고 오래 살아남은 객체를 이전해 전체 힙 작업을 줄입니다.

정확한 구조는 선택한 GC에 따라 다릅니다.

아래 코드는 배열을 목록에서 붙잡는 동안과 참조를 지운 뒤의 도달 가능성을 구분합니다. 메모리 사용량의 즉시 감소를 검증하는 예제가 아니라 참조 수명 예제입니다.

MemoryPressure.java
import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;

public class MemoryPressure {
    public static void main(String[] args) {
        var memory = ManagementFactory.getMemoryMXBean();
        long before = memory.getHeapMemoryUsage().getUsed();

        List<byte[]> retained = new ArrayList<>();
        for (int i = 0; i < 20; i++) retained.add(new byte[100_000]);

        long after = memory.getHeapMemoryUsage().getUsed();
        System.out.println("retained=" + retained.size());
        System.out.println("heap observed=" + (before >= 0 && after >= 0));
        retained.clear(); // 다음 GC에서 회수 가능, 즉시 회수를 보장하지 않음
    }
}

20개의 byte 배열은 retained의 원소로 강하게 참조되므로 첫 줄까지 살아 있습니다. 둘째 줄은 MXBean 조회가 가능했다는 사실만 확인하며, 두 시점의 차이나 즉시 GC 실행을 보장하지 않습니다.

retained=20
heap observed=true

retained 목록이 배열을 계속 가리키는 동안 GC 대상이 아니며 clear 뒤에도 수집 시점은 JVM이 결정합니다.

GC 루트에서 참조 경로 추적

실행 스레드 스택, static 필드, JNI 참조 등에서 이어진 객체는 회수되지 않습니다.

도달하지 않는 객체만 회수 후보

젊은 영역에서 자주 수집하고 오래 살아남은 객체를 이전해 전체 힙 작업을 줄입니다.

할당률·생존률·정지 시간을 함께 관찰

짧은 객체가 많아도 할당 속도가 지나치면 GC가 자주 일어납니다.


할당률과 생존률이 수집 압력을 만든다

짧은 객체가 많아도 할당 속도가 지나치면 GC가 자주 일어납니다.

큰 객체와 장기 캐시는 old 영역 압력을 높입니다.

다음 명령과 누수 모형은 “힙이 큼”이라는 현상을 참조 누수와 단기 할당 압력으로 나눠 조사하는 출발점입니다.

비교 1
static final List<byte[]> CACHE = new ArrayList<>();
void leak() { CACHE.add(new byte[1_000_000]); } // static 루트에서 계속 도달
비교 2
// System.gc()는 회수 요청일 뿐 즉시·완전 실행 보장이 아님
// -Xmx만 늘리기 전에 GC 로그와 heap dump에서 참조 경로를 조사한다.
비교 3
java -Xlog:gc* MemoryPressure
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram

OutOfMemoryError 원인을 영역별로 나눈다

Java heap space, Metaspace, direct buffer 등 메시지와 네이티브 메모리 사용을 함께 봅니다.

무조건 힙만 늘리지 않습니다.


누수는 도달 가능한 참조가 남는 문제다

GC 언어에서도 캐시, 리스너, ThreadLocal이 더 이상 필요 없는 객체를 계속 가리키면 회수되지 않습니다.

객체 수명과 참조 경로를 조사합니다.

배열 개수와 크기를 각각 늘려 -Xmx32m 같은 제한된 힙에서 실행해 보세요. 한 번에 크게 늘리기보다 GC 로그에서 할당 속도와 수집 빈도가 어떻게 바뀌는지 관찰합니다. retained.clear()를 호출해도 ArrayList의 내부 용량은 남지만 원소 byte 배열 참조는 제거되어 다음 GC의 회수 후보가 됩니다.

힙 덤프에서는 큰 객체 하나만 찾기보다 그 객체를 GC 루트까지 연결하는 지배 참조 경로를 봅니다. 캐시라면 최대 크기·만료·제거 시점을 계약으로 두고, 리스너라면 등록과 해제를 같은 수명 범위에 묶습니다.

GC 선택은 처리량, 평균 지연, 최악 정지 시간, 사용 가능한 메모리 사이의 선택입니다. 다른 애플리케이션의 옵션을 복사하기보다 기본 GC의 로그와 실제 지연 목표를 기준선으로 두고 한 항목씩 변경합니다.


GC 튜닝은 옵션 목록보다 어떤 참조가 객체를 살리고 얼마나 빠르게 할당되며 허용 지연이 얼마인지 측정하는 일에서 시작합니다.