Java 및 Kotlin 애플리케이션은 가비지 컬렉션 힙을 통해 메모리를 관리합니다. 객체에 더 이상 도달할 수 없으면 가비지 컬렉터 (GC)가 결국 공간을 회수합니다. 메모리 누수는 더 이상 필요하지 않은 객체가 'GC 루트'에 의해 계속 보유되어 회수되지 않을 때 발생합니다.
핵심 개념
GC 루트
GC 루트는 가비지 컬렉터가 항상 도달할 수 있는 것으로 취급하는 특수한 유형의 객체입니다. 예를 들면 다음과 같습니다.
- 활성 스레드 (및 현재 실행 중인 Java 스택 프레임에서 참조된 객체)
- 활성 상태로 실행 중인 메서드가 있는 클래스
- JNI 참조 (네이티브 코드에서 보유한 전역 또는 로컬 참조)
GC 루트 경로
GC 루트에서 객체로 이어지는 참조 체인이 있는 한 해당 객체는 '도달 가능'하며 가비지 컬렉션될 수 없습니다. 이 체인을 GC 루트 경로라고 합니다. 메모리 누수를 수정하려면 이 체인을 식별하고 끊어야 합니다.

지배자 트리
GC 루트 경로는 객체가 활성 상태인 이유를 알려주지만 참조가 중단될 경우 회수되는 메모리 양은 알려주지 않습니다. 이를 위해 지배자 트리를 사용합니다.
GC 루트에서 B로 가는 모든 경로가 A를 통과해야 하는 경우 객체 A가 객체 B를 지배한다고 합니다. A가 B를 지배하는 경우 루트에서 B로 연결되는 다른 경로가 없으므로 A를 회수하면 B도 회수할 수 있습니다.
다음 다이어그램은 객체 그래프와 해당 도미네이터 트리를 보여줍니다. 그래프에서 객체 D는 A와 B 모두에 의해 도달되므로 A도 B도 D를 지배하지 않습니다. 대신 GC 루트가 가장 가까운 지배자입니다.

Java 힙 덤프 가져오기
힙 덤프는 특정 시점의 Java 힙에 있는 모든 객체의 스냅샷입니다.
ADB 사용
실행 중인 프로세스에서 힙 덤프를 캡처하려면 패키지 이름을 am dumpheap에 직접 전달하면 됩니다. 이 명령어를 실행하려면 <profileable android:shell="true"/> 또는 <debuggable>로 앱을 빌드해야 합니다.
# 1. Trigger the dump (the command takes a moment to complete):
adb shell am dumpheap -g -b png com.android.memorylab /data/local/tmp/heap.hprof
# 2. Pull the file to your development machine:
adb pull /data/local/tmp/heap.hprof .
Perfetto 사용
Perfetto는 Perfetto 구성에서 android.java_hprof 데이터 소스를 사용 설정하여 시스템 전체 트레이스의 일부로 Java 힙 덤프를 캡처할 수도 있습니다. 이는 힙 상태를 다른 시스템 이벤트와 연결하는 데 유용합니다.
Perfetto를 사용하여 MemoryLab 앱의 힙 덤프를 캡처하려면 다음 명령어를 사용하면 됩니다.
# Create a temp file for the configuration
cat > /tmp/java_heap.pbtx <<EOF
data_sources: {
config {
name: "android.java_hprof"
java_hprof_config {
process_cmdline: "com.android.memorylab"
}
}
}
EOF
# Run trace command referencing the file
external/perfetto/tools/record_android_trace -o java_heap.perfetto-trace \
-t 10s -c /tmp/java_heap.pbtx
Perfetto 문서의 Java 힙 덤프를 참고하세요.
AHAT로 분석
AHAT (Android Heap Analysis Tool)는 웹브라우저에서 .hprof 파일을 보는 데 권장되는 도구입니다.
AHAT 시작
경로에 ahat가 설치되어 있는 경우 다음 명령어로 실행합니다.
ahat heap.hprof
또는 독립형 jar를 실행합니다.
java -jar ahat.jar heap.hprof
그런 다음 브라우저에서 http://localhost:7100을 엽니다.
AHAT 획득 또는 빌드에 관한 자세한 내용은 AHAT 소스 저장소를 참고하세요.
주요 분석 워크플로
누수 찾기
할당 뷰에서 활동 클래스 (MainActivity)를 검색합니다.

수업을 클릭하여 모든 인스턴스를 찾습니다.
MainActivity 인스턴스를 클릭하여 검사합니다.

인스턴스 뷰에서 객체의 가비지 컬렉션을 방지하는 참조 체인을 보여주는 GC 루트의 샘플 경로와 이 특정 인스턴스에서 보유하는 메모리 양을 보여주는 객체 크기를 확인할 수 있습니다.

비트맵 분석
AHAT는 메모리 소비가 많은 경우가 많은 android.graphics.Bitmap 객체 보기를 위한 특별 지원이 있습니다. 비트맵 인스턴스를 클릭하여 콘텐츠의 렌더링된 미리보기를 확인합니다.

활동 누수 페이지
AHAT에는 누수된 활동을 식별하는 특수 뷰가 있습니다. 활동은 Android에서 가장 일반적이고 영향력 있는 메모리 누수 중 하나입니다.
- 작업: MemoryLab에서 활동 누수를 탭합니다. 그러면 의도적으로 자체를 누출하는
LeakedActivity가 실행됩니다. - 덤프: 힙 덤프를 가져옵니다.
- 분석: AHAT 사이드바에서 활동 누수를 클릭합니다.
- 확인: AHAT는
mDestroyed필드가 true (활동 수명 주기가 종료되었음을 나타냄)이지만 GC 루트에서 여전히 도달할 수 있으므로com.android.memorylab.LeakedActivity를 누수로 나열합니다.

힙 덤프 차이 비교
두 힙 덤프를 비교하는 것은 메모리 문제를 식별하는 가장 강력한 방법 중 하나입니다. '클린' 기준 덤프를 일부 작업을 실행한 후 가져온 덤프와 비교하면 누적된 객체를 즉시 확인할 수 있습니다.
실습: 차이 분석을 통한 누수 식별
기준: MemoryLab을 실행하고 기준 힙 덤프를 가져옵니다.
adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof adb pull /data/local/tmp/base.hprof .작업: 앱에서 Allocate Java Memory(10MB)를 여러 번 탭합니다.
최종: 두 번째 힙 덤프를 가져옵니다.
adb shell am dumpheap com.android.memorylab /data/local/tmp/leaked.hprof adb pull /data/local/tmp/leaked.hprof .비교: 두 번째 덤프를 기본으로, 첫 번째 덤프를 기준선으로 하여 AHAT를 시작합니다.
java -jar out/host/linux-x86/framework/ahat.jar leaked.hprof --baseline base.hprof분석 개요: 이제 개요 페이지에 Δ (델타) 열이 포함됩니다.
app힙의 델타가 크게 양수이므로 메모리가 크게 증가했음을 알 수 있습니다.

- 드릴다운: 메뉴에서 루팅됨을 클릭합니다. 이 페이지에는 GC 루트에서 도달할 수 있는 객체가 Retained Size별로 정렬되어 표시됩니다. 상단에
MainActivity이 표시되고 델타가 크게 양수입니다.

할당 스택 트레이스 기록
GC 루트의 샘플 경로는 객체가 아직 활성 상태인 이유를 알려주지만 객체가 생성된 방법은 알려주지 않습니다. 할당 스택 트레이스는 객체를 할당한 정확한 코드 줄을 제공합니다.
개념 및 절충안: 모든 할당의 스택 트레이스를 기록하는 것은 컴퓨팅 비용이 많이 들고 상당한 메모리를 소비합니다. 대규모 프로덕션 앱에서는 이로 인해 앱을 거의 사용할 수 없게 될 수 있습니다. 하지만 MemoryLab은 할당 소스를 정확히 파악하기 위해 이 추적을 안전하게 사용 설정할 수 있을 만큼 작은 애플리케이션입니다.
연습: 바이트 배열의 소스 식별
추적으로 시작: MemoryLab을 강제 종료하고
--track-allocation플래그로 다시 시작합니다. 더 많은 컨텍스트를 포착하도록 기본 스택 깊이를 늘립니다.# Increase the allocation tracker's stack depth (requires a process restart) adb shell setprop dalvik.vm.allocTrackerMaxStack 16 adb shell am force-stop com.android.memorylab adb shell am start --track-allocation -n com.android.memorylab/.MainActivity작업: Java 메모리 할당(10MB)을 몇 번 탭합니다.
덤프: 힙 덤프를 가져와 풀합니다.
분석: AHAT에서 덤프를 엽니다. 대형
byte[]인스턴스로 이동합니다. (예:MainActivity→mJavaAllocations(ArrayList) →elementData(Object[]) → 배열 요소[0]검사)확인: 인스턴스 뷰에서 할당 사이트 섹션을 확인합니다.
MainActivity.allocateJava로 이어지는 전체 스택 트레이스가 표시됩니다.

중복 문자열 및 하이드레이션 팽창 찾기
앱에 기존 GC 루트 누수가 없더라도 JSON, Protobuf, Cursor 또는 Room 데이터베이스 역직렬화 중에 생성된 수천 개의 중복 java.lang.String 인스턴스로 인해 라이브 Java 힙이 부풀려질 수 있습니다. 반복되는 키, 상태 문자열, 카테고리 라벨 또는 URL은 네트워크 응답이나 데이터베이스 쿼리마다 새로 할당되는 경우가 많습니다. 대규모 피드, 메시지, 콘텐츠 앱에서 중복 문자열은 일반적으로 라이브 String 메모리의 30~60%를 차지합니다.
AHAT에서 중복 문자열을 검사하려면 다음 단계를 따르세요.
- 할당 페이지를 열고
java.lang.String로 필터링합니다. --baseline로 두 힙 덤프를 비교할 때 피드를 하이드레이션하거나 로컬 캐시를 로드한 후java.lang.String인스턴스 수와 총 바이트가 불균형적으로 증가하는지 확인합니다.java.lang.String인스턴스 표 (크기 또는 값으로 정렬됨)를 탐색하여 여러 인메모리 모델 객체에 유지된 동일한 문자열 값을 확인합니다.
- 해결 방법: 런타임 인터닝 테이블은 전역이며 잠금 경합을 도입하거나 필요 이상으로 문자열을 유지할 수 있으므로 임의의 사용자 또는 네트워크 입력에서 무차별적으로
String.intern()를 호출하지 마세요. 대신, 파서나 어댑터 내의LruCache<String, String>와 같은 범위가 지정된 중복 제거 캐시를 사용하여 역직렬화 중에 빈도가 높은 도메인 문자열을 중복 제거하거나 고정된 값 집합을 열거형 또는 정수 상수로 나타냅니다.
Java 메모리 동적 분석 (결합 프로필)
애플리케이션의 메모리 동작을 전체적으로 파악하려면 메모리 카운터, 스레드 활동, 호출 스택 기반 할당 프로파일링을 단일 Perfetto 트레이스로 결합하면 됩니다. 이를 통해 시스템 전체 메모리 측정항목(예: RSS 및 힙 크기)을 특정 코드 실행 및 할당 사이트와 연관시킬 수 있습니다.
다음 기능을 사용 설정하는 결합된 구성을 사용합니다.
- 메모리 카운터 (
linux.process_stats): RSS 및 기타 메모리 측정항목을 폴링합니다. - ATrace (
dalvik,memory,sched카테고리): 스레드 상태와 GC 이벤트를 캡처합니다. - Heapprofd (
android.heapprofd): 5초마다 연속 덤프로com.android.art(Java) 및libc.malloc(네이티브) 힙을 모두 타겟팅합니다.
연습: 결합된 메모리 분석
이 연습에서는 MemoryLab 앱을 실행하고 일련의 메모리 작업을 실행하여 트레이스에서 다양한 패턴을 관찰합니다.
- 기준: 유휴 상태입니다.
- Java Churn: 즉시 가비지 컬렉션되는 임시 할당입니다.
- 영구 Java 할당: 메모리에 남아 있는 Java 객체를 할당합니다.
- 비트맵 할당: 네이티브 힙/그래픽 메모리에 있는 대형 그래픽 애셋을 할당합니다.
- 회수: 할당된 모든 리소스를 해제합니다.
1. 시작 및 준비
앱을 강제 종료하고 다시 시작하여 깨끗한 상태를 유지합니다.
adb shell am force-stop com.android.memorylab adb shell am start -W -n com.android.memorylab/.MainActivity
2. 추적 시작 및 시퀀스 트리거
40초 트레이스를 시작하고 am
broadcast 명령어를 사용하여 메모리 이벤트를 트리거합니다.
트레이스 시작:
adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/java_memory.perfetto-trace <<EOF buffers: { size_kb: 131072 fill_policy: RING_BUFFER } data_sources: { config { name: "linux.process_stats" target_buffer: 0 process_stats_config { scan_all_processes_on_start: true proc_stats_poll_ms: 100 } } } data_sources: { config { name: "linux.ftrace" target_buffer: 0 ftrace_config { ftrace_events: "sched/sched_switch" ftrace_events: "task/task_newtask" ftrace_events: "task/task_rename" ftrace_events: "ftrace/print" atrace_categories: "dalvik" atrace_categories: "am" atrace_categories: "res" atrace_categories: "memory" atrace_categories: "sched" atrace_apps: "com.android.memorylab" } } } data_sources: { config { name: "android.heapprofd" target_buffer: 0 heapprofd_config { sampling_interval_bytes: 4096 process_cmdline: "com.android.memorylab" heaps: "libc.malloc" heaps: "com.android.art" shmem_size_bytes: 8388608 block_client: true continuous_dump_config { dump_phase_ms: 1000 dump_interval_ms: 5000 } } } } duration_ms: 40000 EOF시퀀스 트리거 (트레이스가 실행되는 동안 호스트 터미널에서 다음 명령어를 실행하고 제안된 타이밍을 따름)
# Wait ~5s for trace initialization, then start Java churn: adb shell am broadcast -a com.android.memorylab.CHURN_JAVA # Wait ~10s (at 15s mark), allocate 10MB of persistent Java memory: adb shell am broadcast -a com.android.memorylab.ALLOC_JAVA # Wait ~5s (at 20s mark), allocate 20MB of Bitmaps (native/graphics): adb shell am broadcast -a com.android.memorylab.LEAK_BITMAP # Wait ~10s (at 30s mark), free everything: adb shell am broadcast -a com.android.memorylab.FREE_ALL대안 (CLI 도구):
heap_profile스크립트를 직접 사용하여 프로필을 시작할 수도 있습니다. 이 경우 연속 덤프를 사용하여 Java 및 네이티브 힙을 모두 타겟팅합니다.external/perfetto/tools/heap_profile -n com.android.memorylab \ --heaps com.android.art,libc.malloc \ -c 5000 \ -d 40000 \ -o java_memory_profile
3. 결합된 트레이스 분석
수집된 java_memory.perfetto-trace를 Perfetto UI에서 엽니다.
Perfetto의 주요 트랙
타임라인을 분석하기 전에 com.android.memorylab 프로세스의 다음 필수 트랙을 찾습니다.
mem.rss.anon(익명 RSS): 프로세스의 메모리 섹션에 있습니다. 이 트랙은 OS에서 프로세스에 할당한 실제 메모리 (RAM)를 측정합니다. 실제 메모리 사용량을 나타냅니다.Heap size (KB): 메모리 섹션에도 있습니다. Java 힙에 예약된 가상 주소 공간을 나타내는 Dalvik/ART 전용 카운터입니다. 객체가 할당되고 GC가 실행될 때 변동되는 VM의 내부 힙 한도를 반영합니다.HeapTaskDaemon: 프로세스 아래의 스레드 목록에서 찾을 수 있습니다. ART 가비지 수집기가 대부분의 작업을 실행하는 백그라운드 스레드입니다. 여기서 활동은 활성 GC 패스를 나타냅니다.- 연속 할당 덤프 (heapprofd): 상단 타임라인을 따라 색상 슬라이스로 표시됩니다. 각 슬라이스는 시간의 길이를 나타냅니다. 단일 슬라이스를 클릭하거나 시간 범위를 선택하면 com.android.art (Java 할당) 또는 libc.malloc (네이티브 할당)의 플레임 그래프 (하단 창)를 검사하여 해당 기간에 할당된 항목을 확인할 수 있습니다.
시간순 단계 분석
연습의 각 단계에서 이러한 트랙이 어떻게 상호작용하는지 시간순으로 트레이스를 검토해 보겠습니다.
1단계: 기준 (0초~5초)
- 발생한 상황: 앱이 유휴 상태로 명령을 기다리고 있습니다.
- 추적 상태:
mem.rss.anon: 기준선에서 평평한 선 (일반적으로 기기에 따라 60~80MB)Heap size (KB): 초기 Java 힙 할당과 일치하는 평탄한 선입니다.HeapTaskDaemon: 유휴 상태 (실행을 보여주는 슬라이스가 없음)- 할당 덤프: 최소 기준 할당을 표시합니다.

2단계: Java 할당 변동 (5초~15초)
- 발생한 상황:
AllocationChurnThread가 시작되고 1MB 배열을 반복적으로 할당하고 삭제합니다. - 추적 상태:
Heap size (KB): 빠른 톱니 패턴을 보여줍니다. 할당이 누적되면 힙 크기가 증가하고 GC가 실행되면 급격히 감소합니다.HeapTaskDaemon: 실행 슬라이스가Heap size톱니의 하락과 완벽하게 정렬되어 거의 일정한 활동을 보여줍니다.mem.rss.anon: Java 힙 활동을 추적합니다.- Java 힙 할당 덤프: 이 트랙에서 슬라이스를 선택하면 com.android.art 힙 할당이 표시됩니다.
할당 샘플은 AllocationChurnThread을 기본 할당자로 보여주며 모든 할당은 MainActivity.java 내부의 람다를 가리키는 동일한 호출 스택을 공유합니다.

3단계: 지속적인 Java 할당 (15~20초)
- 발생하는 상황: 10MB의 Java 객체를 할당하고
mJavaAllocations에 대한 참조를 유지합니다. - 추적 상태:
Heap size (KB): 톱니 모양의 기준선이 약 10MB 증가합니다.mem.rss.anon: OS가 새 실제 페이지로 이 영구 할당을 지원해야 하므로 약 10MB씩 증가합니다.- Java 힙 할당 덤프: 이 트랙에서 슬라이스를 선택하면 com.android.art 힙 할당이 표시됩니다.
- 할당 덤프 (플레임 그래프): 이 창에서 가져온 덤프의 com.android.art 힙을 검사하면
MainActivity.allocateJava에서 유지된 크기에 기여하는 새로운 할당 경로가 표시됩니다.
영구 할당의 10MB 증가와 겹치는 기간을 포함하는 할당 샘플을 선택합니다. 할당 호출 스택이 두 개의 서로 다른 사이트로 분기됩니다. 하나는 이전에 본 것과 동일한 단기 할당 변동을 담당하고 다른 하나는 새로운 장기 할당을 담당합니다.

4단계: 비트맵 할당 (20~30초)
- 발생한 문제: 20MB의 비트맵도 할당됩니다.
- 추적 상태:
Heap size (KB): 이전과 동일합니다.mem.rss.anon: 비트맵 픽셀 데이터의 네이티브 할당에 해당하는 약 20MB의 상당한 증가를 보여줍니다.- 할당 덤프 (플레임 그래프): 이번에는 libc.malloc (네이티브) 힙의 슬라이스에 집중합니다.
네이티브 할당 호출 스택은 네이티브 그래픽 라이브러리에서 시작된 비트맵 할당을 보여줍니다. Java 힙에서는 이러한 Bitmap 할당이 표시되지 않으므로 네이티브 할당 추적에 적합한 사용 사례입니다.

5단계: 회수 (30초~40초)
- 발생한 상황: 모든 영구 Java 할당 및 비트맵에 대한 참조를 삭제하는
FREE_ALL가 트리거되고 명시적System.gc()가 이어집니다. - 추적 상태:
Heap size (KB): 기준 수준으로 다시 떨어집니다.mem.rss.anon: OS가 실제 페이지를 회수하는 것을 보여주며 다시 아래로 떨어집니다.HeapTaskDaemon: 가비지 컬렉션을 처리할 때 마지막 활동이 급증하는 것을 보여줍니다.

이전 OOM 모니터링 (ApplicationExitInfo)
LMK가 발생하는 것을 포착하는 것은 활성 디버깅에 유용하지만 필드 원격 분석의 경우 ApplicationExitInfo API를 사용할 수 있습니다. 이를 통해 앱은 이전 세션에서 종료된 이유를 알 수 있습니다.
ActivityManager am = getSystemService(ActivityManager.class);
List<ApplicationExitInfo> exitReasons = am.getHistoricalProcessExitReasons(null, 0, 1);
if (!exitReasons.isEmpty()) {
ApplicationExitInfo info = exitReasons.get(0);
if (info.getReason() == ApplicationExitInfo.REASON_LOW_MEMORY) {
// App was killed by the system Low Memory Killer
}
}
권장사항
- 기준 먼저: 앱이 초기화된 후 테스트 중인 작업을 실행하기 전에 항상 '기준' 힙 덤프를 가져옵니다.
- AHAT의 활동 누수 페이지 사용: AHAT에는 삭제되었지만 여전히 메모리에 보관되어 있는 활동 인스턴스를 자동으로 식별하는 전용 활동 누수 페이지가 포함되어 있습니다. 일반적으로 이 방법이 일반적인 누수를 찾는 가장 빠른 방법입니다.
- GC 루트로 가는 경로 확인: 누수된 객체의 경우 AHAT의 루트에서 오는 경로 뷰를 사용하여 객체를 계속 유지하는 참조 (예: 정적 필드, 장기 실행 스레드 또는 등록된 리스너)를 정확히 파악합니다.