Analizowanie pamięci Javy

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.

Ścieżka do katalogu głównego GC

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.

Drzewo dominatorów

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).

Widok AHAT z instancjami

Kliknij zajęcia, aby znaleźć wszystkie instancje.

Widok AHAT pokazujący instancje MainActivity Kliknij instancję,MainActivity aby ją sprawdzić.

AHAT wyświetla szczegóły instancji

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ę.

Ścieżka próbki AHAT od głównego elementu GC i rozmiar obiektu

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.

Podgląd mapy bitowej AHAT

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.

  1. Działanie: w MemoryLab kliknij Leak an Activity (Wyciek aktywności). Spowoduje to uruchomienie LeakedActivity, które celowo ujawnia swoje dane.
  2. Zrzut: zrób zrzut sterty.
  3. Analizuj: na pasku bocznym AHAT kliknij Wycieki aktywności.
  4. Weryfikacja: narzędzie AHAT wyświetli com.android.memorylab.LeakedActivity jako wyciek, ponieważ jego pole mDestroyed ma wartość „prawda” (co oznacza, że cykl życia działania dobiegł końca), ale nadal jest dostępne z poziomu głównego elementu GC.

Strona wycieków aktywności AHAT

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

  1. 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 .
    
  2. Działanie: w aplikacji kilka razy kliknij Allocate Java Memory(10MB) (Przydziel pamięć Javy (10 MB)).

  3. 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 .
    
  4. 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.hprof
    
  5. Analiza – przegląd: strona Przegląd zawiera teraz kolumnę Δ (Delta). W przypadku sterty app zobaczysz dużą dodatnią wartość delta, co oznacza znaczny wzrost pamięci.

Omówienie AHAT z informacjami o zmianach

  1. 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 MainActivity z dużą dodatnią różnicą.

Widok AHAT Rooted ze zmianą

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

  1. 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/.MainActivity
    
  2. Działanie: kilka razy kliknij Allocate Java Memory(10MB) (Przydziel pamięć Javy (10 MB)).

  3. Dump (Zrzut): zrób zrzut sterty i pobierz go.

  4. Analizuj: otwórz zrzut w narzędziu AHAT. Otwórz dużą instancję byte[]. (np. sprawdź MainActivity → mJavaAllocations (ArrayList) →elementData (Object[]) → element tablicy [0]).

  5. Sprawdź: w widoku instancji sprawdź sekcję Miejsce przydziału. Wyświetli pełny zrzut stosu prowadzący do MainActivity.allocateJava.

Witryna przydzielania AHAT

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:

  1. Otwórz stronę Przydziały i przefiltruj ją według java.lang.String.
  2. Podczas porównywania dwóch zrzutów sterty za pomocą --baseline sprawdź, czyjava.lang.String po wypełnieniu pliku danych lub wczytaniu lokalnej pamięci podręcznej liczba instancji i łączna liczba bajtów rosną nieproporcjonalnie.
  3. 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 sterty com.android.art (Java) i libc.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:

  1. Stan podstawowy: stan bezczynności.
  2. Java Churn: tymczasowe przydziały, które są natychmiast usuwane przez odśmiecanie pamięci.
  3. Trwałe przydzielanie pamięci w języku Java: przydzielanie obiektów Java, które pozostają w pamięci.
  4. Alokacja bitmapy: alokowanie dużych zasobów graficznych (które znajdują się w pamięci natywnej lub pamięci graficznej).
  5. Odzyskiwanie: zwalnianie wszystkich przydzielonych zasobów.

1. Uruchamianie i przygotowywanie

  1. 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.

  1. 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
    EOF
    
  2. Uruchom 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_ALL
    
  3. Alternatywa (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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Interfejs Perfetto pokazujący wartości podstawowe etapu 1

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 wykresie Heap 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.

Interfejs Perfetto pokazujący rezygnację w fazie 2 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.

Interfejs Perfetto pokazujący alokacje Javy w fazie 2

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.

Interfejs Perfetto pokazujący trwałą alokację pamięci Java w fazie 3 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ę.

Interfejs Perfetto pokazujący alokacje w Javie w fazie 3

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).

Interfejs Perfetto pokazujący etap 4 przydzielania mapy bitowej Ś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.

Interfejs Perfetto pokazujący alokacje natywne w fazie 4

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łujemy System.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.

Interfejs Perfetto pokazujący odzyskiwanie w fazie 5

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

  1. Najpierw linia bazowa: zawsze wykonuj zrzut sterty „linii bazowej” po zainicjowaniu aplikacji, ale przed wykonaniem testowanego działania.
  2. 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.
  3. 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 →