HOWTO · Java

Java에서 메모리 누수 찾기

이 기사에서는 Java에서 메모리 누수를 찾는 방법에 대해 설명합니다.

이 페이지의 내용

Java 메모리 누수는 애플리케이션이 더 이상 필요하지 않은 객체를 계속 도달 가능한 상태로 유지할 때 발생합니다. 가비지 컬렉터는 도달할 수 없는 객체를 회수하지만, 살아 있는 참조를 통해 여전히 도달 가능한 객체는 회수하지 못합니다. 이렇게 보유된 객체가 쌓이면 메모리 사용량이 늘고 결국 java.lang.OutOfMemoryError로 이어질 수 있습니다. 메모리 사용량이 높다는 사실만으로 누수를 단정할 수는 없습니다. 비슷한 작업을 반복할 때 살아 있는 객체 집합이 증가하는지 조사하세요.

Java 메모리 누수란?

Java는 객체 메모리를 자동으로 관리하므로, 메모리 누수는 대개 메모리를 직접 해제하지 못하는 문제가 아니라 프로그램이 의도보다 오래 참조를 보유하는 문제입니다. 애플리케이션 관점에서는 더 이상 쓸모없는 객체라도 정적 필드, 오래 유지되는 서비스, 컬렉션 또는 다른 객체에서 참조하면 계속 도달 가능한 상태일 수 있습니다. 가비지 컬렉터는 도달 가능한 객체를 올바르게 보존합니다. 이 차이를 알면 원치 않는 객체 보유를 일시적인 할당 급증이나 정상적인 힙 증가와 구분하는 데 도움이 됩니다.

Java 메모리 누수의 일반적인 원인

참조가 본래 목적보다 오래 남게 만드는 소유권 및 수명 주기 문제를 살펴보세요. 흔한 사례로는 크기 제한이 없는 캐시, 세션이 끝난 뒤에도 보유되는 세션 객체, 오래된 항목을 제거하지 않고 계속 추가하는 컬렉션, 사용이 끝난 뒤에도 등록 상태로 남는 리스너, 더 이상 필요 없는 노드를 보유하는 사용자 정의 자료 구조가 있습니다. 이런 사례는 조사할 단서이지, 특정 객체가 실제로 누수되고 있다는 증거는 아닙니다. 각 참조의 소유자가 누구인지, 그 소유자가 언제 참조를 해제해야 하는지 확인하세요.

Java 메모리 누수를 찾기 위한 실용적인 작업 흐름

먼저 동일한 작업 부하를 반복 가능한 주기로 재현하세요. 각 주기가 완료되고 가비지 컬렉터가 실행된 뒤 살아 있는 메모리의 기준값을 기록합니다. 할당 도중 나타난 서로 무관한 최고치가 아니라 서로 대응하는 시점을 비교하세요. 동일한 작업 부하 주기를 반복한 뒤 GC 이후 살아 있는 메모리를 비교하세요. 기준값이 높아지는 것은 조사할 신호이지 누수의 증거는 아닙니다.

동일한 작업 부하 주기, GC 이후 메모리 증가, 힙 덤프 비교, 보유 참조 추적, 수정 및 재검증을 보여 주는 다이어그램

일시적인 할당과 증가하는 보유 객체 집합을 구분하기 위한 조사 흐름입니다.

기준값이 계속 높아지면 작업 부하가 비슷한 시점에 힙 정보를 수집하세요. 객체 수와 보유 크기를 비교하고, 의심되는 객체를 계속 도달 가능한 상태로 유지하는 참조를 추적한 다음, 수명이 지나치게 긴 소유자를 찾습니다. 한 가지에 집중해 변경한 뒤 같은 작업 부하를 반복하고 동일한 측정값을 비교하세요. 표본 하나만으로는 오해할 수 있으므로 누수라고 결론 내리기 전에 작업 부하의 변동과 애플리케이션의 정상 동작도 고려하세요.

항목을 제거하면 컬렉션이 해당 항목에 대한 참조를 해제하는 방식을 보여 주는 독립 실행형 Java 예제를 실행해 보세요. 예제의 코드와 표시 레이블 및 출력은 바이트 단위로 보존되어 영어로 표시됩니다.

이 섹션의 코드와 표시되는 레이블 또는 출력은 영어로 유지됩니다.

import java.util.HashMap;
import java.util.Map;

public class CacheLifecycle {
    public static void main(String[] args) {
        Map<Integer, byte[]> cache = new HashMap<>();
        for (int i = 0; i < 3; i++) {
            cache.put(i, new byte[1024]);
        }
        System.out.println("Entries before cleanup: " + cache.size());
        cache.clear();
        System.out.println("Entries after cleanup: " + cache.size());
    }
}

이 간단한 예제는 컬렉션의 소유권과 정리를 보여 줍니다. 힙 사용량을 측정하거나 실제 운영 환경의 누수를 입증하지는 않습니다. 실제 애플리케이션에서는 수명 주기나 캐시 정책에 따라 항목이 더 이상 필요하지 않을 때만 제거하세요.

힙 덤프와 보유 객체 분석

힙 덤프는 특정 시점의 객체와 참조를 기록합니다. 비정상적으로 많은 객체 유형을 찾고, 객체의 보유 크기를 살펴보며, 정적 필드나 애플리케이션 서비스처럼 오래 유지되는 루트까지 객체를 유지하는 경로를 따라가세요. 비슷한 작업 부하 주기를 마친 뒤 수집한 덤프를 비교하면 어떤 객체 집합이 늘어나고 계속 도달 가능한 상태로 남는지 확인할 수 있습니다. 덤프 하나는 조사할 상태를 보여 줄 뿐, 그 상태가 누수라는 사실을 입증하지는 않습니다. 의심되는 객체 보유가 애플리케이션의 예상 수명 주기와 맞지 않는지 확인하세요.

프로파일러와 JVM 모니터링 도구 사용

모니터링으로 시간이 지남에 따라 힙 사용량, 할당 또는 가비지 컬렉션 동작이 변하는지 확인할 수 있습니다. 프로파일러는 객체 집합과 할당 활동을 애플리케이션 동작과 연결하는 데 도움이 될 수 있으며, 힙 덤프 분석으로 인스턴스가 계속 도달 가능한 상태로 남는 이유를 살펴볼 수 있습니다. NetBeans Profiler, VisualVM, JProfiler는 Java 프로파일링 작업 흐름에서 볼 수 있는 도구의 예이며, 현재 이용 가능 여부나 기능에 대한 권장 사항은 아닙니다. 조사 중인 JVM와 환경에 맞는 도구를 선택하고, 그래프와 경고는 그 자체로 진단 결과라고 여기지 말고 해석이 필요한 증거로 다루세요.

가비지 컬렉션 로그 살펴보기

JVM 옵션 -verbose:gc는 상세 가비지 컬렉션 로깅을 활성화하는 예입니다. 이 옵션의 적용 가능 여부와 생성되는 로그의 형식 및 해석은 JVM 버전에 따라 다릅니다. 비슷한 작업 부하 구간에서 가비지 컬렉션 동작과 수집 후 메모리 상태를 살펴보세요. 로그는 수집 후에도 메모리가 계속 살아 있는지 파악하는 데 도움이 될 수 있지만, 요약만으로는 어떤 참조가 객체를 유지하는지 또는 그 원인이 무엇인지 알 수 없습니다.

Eclipse 메모리 누수 경고와 그 한계 이해하기

Eclipse 경고는 코드의 특정 리소스 또는 참조 패턴에 주의를 기울이도록 도울 수 있습니다. 경고가 발생한 맥락을 살펴보고 관련 객체와 리소스의 수명 주기를 확인하세요. IDE 경고는 제한적인 신호입니다. 모든 경고가 메모리 누수를 뜻한다고 단정할 수 없으며, 경고가 없다는 사실도 애플리케이션에 누수가 없음을 입증하지는 않습니다. 오래된 JDK 1.5 호환성 주장을 현재의 감지 동작 전반을 설명하는 근거로 삼지 마세요.

수정 사항을 확인하고 보유 메모리 줄이기

의심되는 소유자나 정리 경로를 변경한 뒤 같은 작업 부하를 반복하고, 서로 대응하는 시점의 GC 이후 살아 있는 메모리와 객체 수, 보유 경로를 비교하세요. 예상되는 객체는 계속 사용할 수 있고 더 이상 필요하지 않은 객체는 보유되지 않는지 확인합니다. 기준값이 여전히 증가한다면 새로운 증거를 살펴보고 참조 추적을 이어가세요. 성능 향상은 비슷한 조건에서 측정한 결과가 뒷받침할 때만 보고하세요. 목표는 참조 보유가 바뀌었는지 확인하는 것이며, 메모리가 줄면 애플리케이션이 언제나 빨라진다고 가정하지 않는 것입니다.