Aplikacje w językach Java i Kotlin zarządzają pamięcią za pomocą sterty z odśmiecaniem. Gdy obiekty nie są już dostępne, moduł odśmiecania pamięci (GC) w końcu odzyskuje zajmowane przez nie miejsce. Wycieki pamięci występują, gdy obiekty, które nie są już potrzebne, są nadal przechowywane przez „korzenie GC”, co uniemożliwia ich odzyskanie.
Podstawowe pojęcia
Elementy główne GC
Korzeń GC to specjalny typ obiektu, który moduł odśmiecania pamięci traktuje jako zawsze dostępny. Przykłady:
- Aktywne wątki (i obiekty, do których odwołują się ich aktualnie wykonywane ramki stosu Java).
- Klasy z aktywnymi metodami.
- Odwołania JNI (globalne lub lokalne odwołania przechowywane przez kod natywny).
Ścieżka do katalogu głównego GC
Dopóki istnieje łańcuch odwołań od elementu GC Root do obiektu, ten obiekt jest „osiągalny” i nie można go usunąć. Ten łańcuch jest nazywany ścieżką do głównego elementu GC. Aby naprawić wyciek pamięci, musisz zidentyfikować i przerwać ten ciąg.

Drzewa dominatorów
Ścieżka do głównego węzła GC informuje o tym, dlaczego obiekt jest aktywny, ale nie mówi, ile pamięci można by odzyskać, gdyby to odwołanie zostało przerwane. W tym celu używamy drzew dominatorów.
Mówi się, że obiekt A dominuje nad obiektem B, jeśli każda ścieżka od dowolnego głównego elementu GC do obiektu B musi przechodzić przez obiekt A. Jeśli A dominuje nad B, odzyskanie A gwarantuje też odzyskanie B, ponieważ nie ma innych ścieżek od żadnego węzła głównego do B.
Poniższy diagram przedstawia wykres obiektów i odpowiadające mu drzewo dominatorów. Zwróć uwagę, że do obiektu D prowadzą w grafie ścieżki z obiektów A i B, więc ani A, ani B nie dominują nad D. Najbliższym dominatorem jest element główny GC.

Uzyskiwanie zrzutów sterty Java
Zrzut sterty to zrzut wszystkich obiektów na stercie Javy w określonym momencie.
Korzystanie z narzędzia ADB
Aby przechwycić zrzut stosu z działającego procesu, możesz przekazać nazwę pakietu bezpośrednio do am dumpheap. Aby uruchomić to polecenie, musisz skompilować aplikację za pomocą <profileable android:shell="true"/> lub <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 .
Korzystanie z Perfetto
Perfetto może też rejestrować zrzuty sterty Javy w ramach śledzenia całego systemu, jeśli w konfiguracji Perfetto włączysz źródło danych android.java_hprof. Jest to przydatne do powiązania stanu sterty z innymi zdarzeniami systemowymi.
Aby zarejestrować zrzut sterty w aplikacji MemoryLab za pomocą Perfetto, możesz użyć tego polecenia:
# 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
Zobacz: Zrzuty sterty Java w Perfetto dokumentacja.
Analiza za pomocą AHAT
AHAT (Android Heap Analysis Tool) to zalecane narzędzie do wyświetlania plików .hprof
w przeglądarce.
Rozpoczęcie testu AHAT
Jeśli masz zainstalowany program ahat, uruchom go za pomocą tego polecenia:
ahat heap.hprof
Możesz też uruchomić samodzielny plik JAR:
java -jar ahat.jar heap.hprof
Następnie otwórz przeglądarkę i wpisz http://localhost:7100.
Szczegółowe informacje o uzyskiwaniu lub tworzeniu AHAT znajdziesz w repozytorium źródłowym AHAT.
Kluczowe przepływy pracy związane z analizą
Wykrywanie wycieków
W widoku Alokacje wyszukaj klasę aktywności (MainActivity).

Kliknij zajęcia, aby znaleźć wszystkie instancje.
Kliknij instancję,MainActivity aby ją sprawdzić.

W widoku instancji możesz znaleźć ścieżkę próbki od głównego elementu GC, która pokazuje łańcuch odwołań uniemożliwiających usunięcie obiektu przez mechanizm odśmiecania pamięci, oraz rozmiar obiektu, który pokazuje, ile pamięci jest przechowywane przez tę konkretną instancję.

Analizowanie bitmap
AHAT ma specjalną obsługę wyświetlania android.graphics.Bitmap obiektów, które często zużywają dużo pamięci. Kliknij instancję mapy bitowej, aby zobaczyć renderowaną podgląd jej zawartości.

Strona wycieków aktywności
AHAT ma specjalny widok do identyfikowania wycieków pamięci związanych z aktywnościami, które są jednym z najczęstszych i najbardziej szkodliwych wycieków pamięci w Androidzie.
- Działanie: w MemoryLab kliknij Leak an Activity (Wyciek aktywności). Spowoduje to uruchomienie
LeakedActivity, które celowo ujawnia swoje dane. - Zrzut: zrób zrzut sterty.
- Analizuj: na pasku bocznym AHAT kliknij Wycieki aktywności.
- Weryfikacja: narzędzie AHAT wyświetli
com.android.memorylab.LeakedActivityjako wyciek, ponieważ jego polemDestroyedma wartość „prawda” (co oznacza, że cykl życia działania dobiegł końca), ale nadal jest dostępne z poziomu głównego elementu GC.

Porównywanie zrzutów stosu
Porównanie 2 zrzutów sterty to jeden z najskuteczniejszych sposobów identyfikowania problemów z pamięcią. Porównując „czysty” zrzut podstawowy ze zrzutem wykonanym po wykonaniu pewnych działań, możesz od razu zobaczyć, które obiekty zostały zgromadzone.
Ćwiczenie: wykrywanie wycieków informacji za pomocą porównywania
Wartość bazowa: uruchom MemoryLab i zrób zrzut sterty wartości bazowej:
adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof adb pull /data/local/tmp/base.hprof .Działanie: w aplikacji kilka razy kliknij Allocate Java Memory(10MB) (Przydziel pamięć Javy (10 MB)).
Końcowy: wykonaj drugi zrzypamięci:
adb shell am dumpheap com.android.memorylab /data/local/tmp/leaked.hprof adb pull /data/local/tmp/leaked.hprof .Porównaj: uruchom AHAT, używając drugiego zrzutu jako głównego, a pierwszego jako punktu odniesienia:
java -jar out/host/linux-x86/framework/ahat.jar leaked.hprof --baseline base.hprofAnaliza – przegląd: strona Przegląd zawiera teraz kolumnę Δ (Delta). W przypadku sterty
appzobaczysz dużą dodatnią wartość delta, co oznacza znaczny wzrost pamięci.

- Szczegółowe informacje: w menu kliknij zrootowane. Na tej stronie wyświetlane są obiekty
dostępne z poziomu głównych elementów odzyskiwania pamięci, posortowane według rozmiaru przechowywanych danych. U góry zobaczysz
MainActivityz dużą dodatnią różnicą.

Rejestrowanie zrzutów stosu alokacji
Przykładowa ścieżka od głównego elementu GC informuje, dlaczego obiekt jest nadal aktywny, ale nie mówi, jak został utworzony. Ślady stosu alokacji zawierają dokładny wiersz kodu, w którym został przydzielony obiekt.
Koncepcja i kompromisy: rejestrowanie zrzutu stosu każdej alokacji jest kosztowne obliczeniowo i zużywa dużo pamięci. W przypadku dużej aplikacji produkcyjnej może to sprawić, że będzie ona niemal bezużyteczna. MemoryLab to jednak niewielka aplikacja, w której możemy bezpiecznie włączyć to śledzenie, aby określić źródło alokacji.
Ćwiczenie: identyfikowanie źródła tablic bajtów
Zacznij od śledzenia: wymuś zatrzymanie MemoryLab i uruchom go ponownie z flagą
--track-allocation. Zwiększ domyślną głębokość stosu, aby uzyskać więcej kontekstu.# 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/.MainActivityDziałanie: kilka razy kliknij Allocate Java Memory(10MB) (Przydziel pamięć Javy (10 MB)).
Dump (Zrzut): zrób zrzut sterty i pobierz go.
Analizuj: otwórz zrzut w narzędziu AHAT. Otwórz dużą instancję
byte[]. (np. sprawdźMainActivity→mJavaAllocations(ArrayList) →elementData(Object[]) → element tablicy[0]).Sprawdź: w widoku instancji sprawdź sekcję Miejsce przydziału. Wyświetli pełny zrzut stosu prowadzący do
MainActivity.allocateJava.

Wyszukiwanie zduplikowanych ciągów znaków i nadmiaru danych związanych z nawodnieniem
Nawet jeśli aplikacja nie ma klasycznych wycieków GC-root, jej aktywny stos Java może być przepełniony tysiącami zduplikowanych java.lang.String instancji utworzonych podczas deserializacji JSON, Protobuf, Cursor lub bazy danych Room. Powtarzające się klucze, ciągi stanu, etykiety kategorii lub adresy URL są często przydzielane na nowo przy każdej odpowiedzi sieciowej lub zapytaniu do bazy danych. W przypadku dużych aplikacji do obsługi kanałów, komunikatorów i aplikacji z treściami zduplikowane ciągi znaków stanowią zwykle od 30% do 60% pamięci String.
Aby sprawdzić zduplikowane ciągi znaków w AHAT:
- Otwórz stronę Przydziały i przefiltruj ją według
java.lang.String. - Podczas porównywania dwóch zrzutów sterty za pomocą
--baselinesprawdź, czyjava.lang.Stringpo wypełnieniu pliku danych lub wczytaniu lokalnej pamięci podręcznej liczba instancji i łączna liczba bajtów rosną nieproporcjonalnie. - Przejrzyj tabelę instancji
java.lang.String(posortowaną według rozmiaru lub wartości), aby znaleźć identyczne wartości ciągów znaków zachowane w wielu obiektach modelu w pamięci.
- Rozwiązanie: unikaj wywoływania funkcji
String.intern()bez rozróżniania danych wejściowych użytkownika lub sieci, ponieważ tabela wewnętrzna środowiska wykonawczego jest globalna i może powodować spory o blokadę lub przechowywać ciągi znaków dłużej niż jest to konieczne. Zamiast tego usuwaj duplikaty ciągów znaków domeny o wysokiej częstotliwości podczas deserializacji za pomocą ograniczonego, zakresowego bufora deduplikacji (np.LruCache<String, String>w parserze lub adapterze) albo przedstawiaj stałe zbiory wartości jako wyliczenia lub stałe całkowite.
Analizowanie dynamiki pamięci Javy (profil łączony)
Aby uzyskać pełny obraz zachowania aplikacji w zakresie pamięci, możesz połączyć liczniki pamięci, aktywność wątków i profilowanie alokacji oparte na stosie wywołań w jednym śladzie Perfetto. Umożliwia to korelowanie danych dotyczących pamięci w całym systemie (takich jak RSS i rozmiar sterty) z konkretnymi miejscami wykonywania kodu i przydzielania pamięci.
Użyjemy połączonej konfiguracji, która umożliwia:
- Liczniki pamięci (
linux.process_stats): odczytuje RSS i inne dane dotyczące pamięci. - ATrace (kategorie
dalvik,memory,sched): rejestruje stany wątków i zdarzenia GC. - Heapprofd (
android.heapprofd): obejmuje stertycom.android.art(Java) ilibc.malloc(natywne) z ciągłymi zrzutami co 5 sekund.
Ćwiczenie: analiza pamięci łączonej
W tym ćwiczeniu uruchomimy aplikację MemoryLab i wykonamy sekwencję operacji związanych z pamięcią, aby zaobserwować różne wzorce w śladzie:
- Stan podstawowy: stan bezczynności.
- Java Churn: tymczasowe przydziały, które są natychmiast usuwane przez odśmiecanie pamięci.
- Trwałe przydzielanie pamięci w języku Java: przydzielanie obiektów Java, które pozostają w pamięci.
- Alokacja bitmapy: alokowanie dużych zasobów graficznych (które znajdują się w pamięci natywnej lub pamięci graficznej).
- Odzyskiwanie: zwalnianie wszystkich przydzielonych zasobów.
1. Uruchamianie i przygotowywanie
Wymuś zatrzymanie aplikacji i uruchom ją ponownie, aby zapewnić czysty stan:
adb shell am force-stop com.android.memorylab adb shell am start -W -n com.android.memorylab/.MainActivity
2. Rozpocznij śledzenie i wywołaj sekwencję
Rozpoczniemy 40-sekundowe śledzenie i wywołamy zdarzenia związane z pamięcią za pomocą poleceń am
broadcast.
Rozpocznij śledzenie:
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 EOFUruchom sekwencję (wykonaj te polecenia w terminalu hosta podczas działania śledzenia, zachowując sugerowane odstępy czasu):
# 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_ALLAlternatywa (narzędzie CLI): profilowanie możesz też rozpocząć bezpośrednio za pomocą skryptu
heap_profile, kierując go na sterty Java i natywne za pomocą ciągłych zrzutów:external/perfetto/tools/heap_profile -n com.android.memorylab \ --heaps com.android.art,libc.malloc \ -c 5000 \ -d 40000 \ -o java_memory_profile
3. Analizowanie połączonego logu czasu
Otwórz zebrane java_memory.perfetto-trace w Perfetto UI.
Kluczowe ścieżki w Perfetto
Zanim przeanalizujesz oś czasu, znajdź te kluczowe ścieżki dla procesu com.android.memorylab:
mem.rss.anon(Anonimowa pamięć RSS): znajduje się w sekcji Pamięć procesu. Ta ścieżka pomiaru mierzy pamięć fizyczną (RAM) przydzieloną do procesu przez system operacyjny. Reprezentuje rzeczywiste wykorzystanie pamięci.Heap size (KB): również w sekcji Pamięć. Jest to licznik specyficzny dla Dalvik/ART reprezentujący przestrzeń adresową wirtualną zarezerwowaną dla sterty Javy. Odzwierciedla wewnętrzny limit sterty maszyny wirtualnej, który zmienia się w miarę przydzielania obiektów i uruchamiania odśmiecania pamięci.HeapTaskDaemon: znajduje się na liście wątków w procesie. Jest to wątek w tle, w którym moduł odśmiecania pamięci ART wykonuje większość swojej pracy. Aktywność w tym miejscu oznacza aktywne karty GC.- Zrzuty ciągłej alokacji (heapprofd): wyświetlane jako kolorowe wycinki wzdłuż górnej osi czasu. Każdy wycinek reprezentuje okres. Kliknięcie pojedynczego wycinka lub wybranie zakresu czasu umożliwia sprawdzenie wykresu płomieniowego (w dolnym panelu) dla com.android.art (alokacje w języku Java) lub libc.malloc (alokacje natywne), aby zobaczyć, co zostało zaalokowane w tym okresie.
Analiza fazy chronologicznej
Przeanalizujmy ślad chronologicznie, aby zobaczyć, jak te ścieżki wchodzą ze sobą w interakcje na każdym etapie ćwiczenia.
Faza 1. Wartość bazowa (0–5 s)
- Co się dzieje: aplikacja jest w stanie bezczynności i oczekuje na polecenia.
- Stan śledzenia:
mem.rss.anon: linia prosta na poziomie podstawowym (zwykle około 60–80 MB w zależności od urządzenia).Heap size (KB): linia pozioma odpowiadająca początkowej alokacji sterty Javy.HeapTaskDaemon: bezczynny (nie wyświetla się żaden wycinek).- Zrzuty alokacji: pokazują minimalne alokacje podstawowe.

Etap 2. Zmiana alokacji pamięci w Javie (5–15 s)
- Co się dzieje: uruchamiany jest proces
AllocationChurnThread, który wielokrotnie przydziela 1-megabajtowe tablice i je odrzuca. - Stan śledzenia:
Heap size (KB): Wykazuje szybki wzór piłokształtny. Rozmiar sterty rośnie wraz z przydzielaniem pamięci i gwałtownie spada, gdy uruchamia się odśmiecanie pamięci.HeapTaskDaemon: pokazuje niemal stałą aktywność, a fragmenty wykonania idealnie pokrywają się ze spadkami na wykresieHeap size.mem.rss.anon: śledzi aktywność sterty Javy.- Zrzuty przydziału sterty Java: wybieranie wycinków na tym śladzie pokazuje przydziały sterty com.android.art.
Próbki alokacji wskazują, że głównym alokatorem jest AllocationChurnThread. Wszystkie alokacje mają ten sam stos wywołań, który wskazuje na funkcję lambda w MainActivity.java.

Faza 3. Trwałe przydzielanie pamięci w Javie (15–20 s)
- Co się dzieje: przydzielamy 10 MB obiektów Java i przechowujemy do nich odwołanie w
mJavaAllocations. - Stan śledzenia:
Heap size (KB): Linia bazowa wykresu zębatego wzrasta o około 10 MB.mem.rss.anon: Wzrost o około 10 MB, ponieważ system operacyjny musi obsługiwać tę trwałą alokację za pomocą nowych stron fizycznych.- Zrzuty przydziału sterty Java: wybieranie wycinków na tym śladzie pokazuje przydziały sterty com.android.art.
- Zrzuty alokacji (wykres płomieniowy): sprawdzenie sterty com.android.art w zrzucie wykonanym w tym oknie pokazuje nową ścieżkę alokacji z
MainActivity.allocateJava, która wpływa na rozmiar zachowanych danych.
Wybierz próbkę alokacji, która obejmuje okres pokrywający się ze wzrostem o 10 MB w przypadku alokacji stałej. Powinny się pojawić 2 różne ścieżki wywołań alokacji. Jedna będzie odpowiadać za krótkotrwałą alokację, którą obserwowaliśmy wcześniej, a druga za nową, długotrwałą alokację.

Faza 4. Przydzielanie mapy bitowej (20–30 s)
- Co się dzieje: przydzielamy też 20 MB map bitowych.
- Stan śledzenia:
Heap size (KB): jak poprzednio.mem.rss.anon: pokazuje znaczny wzrost o około 20 MB, co odpowiada natywnym przydziałom danych pikseli mapy bitowej.- Zrzuty alokacji (wykres płomieniowy): tym razem skup się na wycinkach sterty libc.malloc (natywnej).
Ścieżki wywołań alokacji natywnej ujawniają alokację bitmapy pochodzącą z natywnych bibliotek graficznych. Jest to dobry przypadek użycia natywnego śledzenia alokacji, ponieważ alokacje bitmapy nie będą widoczne w stercie Javy.

Faza 5. Odzyskiwanie (30–40 s)
- Co się dzieje: wywołujemy
FREE_ALL, usuwając odniesienia do wszystkich trwałych alokacji i map bitowych w języku Java, a następnie wywołujemySystem.gc(). - Stan śledzenia:
Heap size (KB): spada z powrotem do poziomu podstawowego.mem.rss.anon: Spada, pokazując, że system operacyjny odzyskuje strony fizyczne.HeapTaskDaemon: pokazuje końcowy wzrost aktywności podczas przetwarzania odzyskiwania pamięci.

Monitorowanie historycznych błędów braku pamięci (ApplicationExitInfo)
Rejestrowanie zdarzeń LMK w momencie ich wystąpienia jest przydatne podczas aktywnego debugowania, ale w przypadku telemetrii w terenie możesz używać interfejsu ApplicationExitInfo. Dzięki temu aplikacja może dowiedzieć się, dlaczego została zamknięta w poprzedniej sesji.
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
}
}
Sprawdzone metody
- Najpierw linia bazowa: zawsze wykonuj zrzut sterty „linii bazowej” po zainicjowaniu aplikacji, ale przed wykonaniem testowanego działania.
- Użyj strony wycieków aktywności w AHAT: AHAT zawiera specjalną stronę Wycieki aktywności, która automatycznie identyfikuje instancje aktywności, które zostały zniszczone, ale nadal są przechowywane w pamięci. Jest to często najszybszy sposób wykrywania typowych wycieków.
- Sprawdź ścieżkę do elementów GC Roots: w przypadku każdego obiektu, który wyciekł, użyj widoku Ścieżka od elementu głównego w AHAT, aby dokładnie określić, które odwołanie utrzymuje go przy życiu (np. pole statyczne, długotrwały wątek lub zarejestrowany odbiornik).
← Narzędzia | ↑ W górę | Mapy bitowe →