Piotr Wójcik
Asseco Poland S.A.
Piotr WójcikSenior Java Developer @ Asseco Poland S.A.

Diagnozowanie wycieków pamięci w Javie

Sprawdź, jak można zdiagnozować wycieki pamięci w Javie z użyciem Memory Analyzera i dowiedz się więcej o ustawieniach parametrów JVM dla uruchamianej aplikacji.
20.06.20225 min
Diagnozowanie wycieków pamięci w Javie

W poniższym artykule pokazę, jak można zdiagnozować wycieki pamięci w Javie, plus dodam garść informacji o ustawieniach parametrów JVM dla uruchamianej aplikacji.


Zacznijmy od odrobiny teorii. Czym właściwie jest wyciek pamięci? Jest to niepożądany stan w którym zablokowany zostaje pewien obszar pamięci przez niepotrzebne już obiekty. Innymi słowy pamięć zostaje zajęta rzeczami, które nie są potrzebne i powinny zostać usunięte, aby aplikacja mogła działać bezawaryjnie.

Java posiada mechanizm automatycznego zbierania nieużywanych obiektów, zwany Garbage Collector. Mechanizm ten bardzo ułatwia pracę programisty, ponieważ nie musi on zajmować się decydowaniem, który obiekt w kodzie nie jest już potrzebny i kiedy oznaczyć go do usunięcia, celem zwolnienia pomięci. Zdarzają się jednakże przypadki, gdzie najczęściej z winy nieprawidłowo zaprojektowanej aplikacji, może dochodzić do nadmiernego zużycia pamięć, powodującego w konsekwencji spowolnienie lub całkowite zatrzymanie aplikacji.

W Javie pamięć podzielona jest na stertę i stos. My zajmiemy się stertą, ponieważ to tam przechowywane są obiekty i to one będą nas interesowały. Sterta w Javie podzielona jest na 3 główne części, young memory, old memory oraz meta space, które od Javy w wersji 7 zastępuje wcześniejsze permgen memory. Young generation możemy podzielić jeszcze  na eden oraz survivor space 1 i 2. Obiekty początkowo trafiają do edenu, a następnie z czasem i kolejnymi iteracjami Garbage Collectora, przesuwane są dalej w kierunku old generation lub są uznawane za niepotrzebne i całkowicie usuwane z pamięci. Out Of Memory Exception występuje, gdy dojdzie do utworzenia takiej ilości obiektów, które nie zdążą, bądź nie mogą zostać usunięte i przestaną się mieścić na stercie. Przejdźmy teraz do przykładu.

Przykładowy program

Poniżej prezentuje kod prostego, przykładowego programu, którego zadaniem jest doprowadzić do przepełnienia pamięci, przez wstawienie do ArrayList dużej ilości obiektów typu Integer.

github.com/bohun82/HeapDumpTest

Wykonanie zrzutu sterty

Jeśli w naszej aplikacji występują problemy z ilością pamięci, należy dokonać zrzutu, który  następnie może zostać poddany analizie. W pliku będącym rezultatem takiego zrzutu odnajdziemy obiekty, będące aktualnie na stercie oraz relacje między nimi.

Aby wykonać zrzut, dodajemy do parametrów wywołania maszyny wirtualnej dla Naszej aplikacji, parametr -XX:+HeapDumpOnOutOfMemoryError. Poniżej przykład, jak wygląda to w przypadku uruchomienia aplikacji w środowisku IntelliJ :

Wybieramy z menu RunEdit configurations… i w konfiguracji naszej aplikacji uzupełniamy jak powyżej. Jak widać, konfiguracja jest dodatkowo uzupełniona o parametr -Xmx128m. Oznacza on, że deklarujemy maksymalną wielkość sterty dla naszej aplikacji na 128MB. Oczywiście rozmiar możemy zmodyfikować, zmieniając występująca w nim liczbę np. 256, 512, 1024, albo inną żądaną wartość. Wówczas parametr wyglądałby następująco np. -Xmx512m.

W powyższym przykładzie ustawiłem ten parametr na wartość 128 MB, ponieważ chciałem, aby szybko doszło do przepełniania pamięci i wykonania zrzutu. Parametrów jest kilka, ich listę oraz zastosowanie możemy zobaczyć wykonując polecenie java -X w konsoli. Będąc przy temacie rozmiaru pamięci warto wspomnieć o jeszcze jednym parametrze -Xms128m. Ustawia się go podobnie jak parametr Xmx, ten jednak odpowiada nie za maksymalną wielkość, a za inicjalną wielkość sterty, czyli tą z jaką nasz aplikacja startuje.

Praca na wykonanym zrzucie

 Po przepełnieniu pamięci i automatycznym wykonaniu zrzutu, utworzony zostanie plik java_pid[numerProcesu].hprof.

Przykładowy komunikat o utworzeniu pliku :

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid7447.hprof ...
Heap dump file created [228662667 bytes in 0,842 secs]


W tym miejscu będzie nam potrzebny MAT (Memory Analyzer Tool). Jest to narzędzie oparte o popularne IDE „Eclips”. Pobrać je można ze oficjalnej strony.

Uruchamiamy MAT-a, z menu wybieramy FileOpen Heap Dump… i wybieramy nasz plik zrzutu. Po rozpakowaniu i załadowaniu pliku, możemy wybierać Leak Suspect Report, jak na obrazku poniżej.

MAT przeanalizuje wówczas plik i przedstawi nam wykres, na którym oznaczone zostaną obiekty pochłaniające największą ilość pamięci.

W wielu przypadkach wskazuje problem od ręki i tym samym skraca analizę. Niestety nie zawsze, a przynajmniej nie zawsze wprost. W przykładzie powyżej widzimy, że analiza wskazała na zbyt dużą tablicę Object[]. Jest to poniekąd prawda, ponieważ faktycznym winowajcą jest obiekt ArrayList, który zawiera tablicę obiektów i w niej przechowuje zawartość. Nam na pierwszy rzut oka niestety niewiele to powie.

W tym przypadku, jak i chyba w większości, preferuje sprawdzenie listy największych obiektów znajdujących się w zrzucie. Aby otrzymać takie informację należy wybrać opcję Open Dominator Tree for entire heap, zaznaczone strzałką na screenie powyżej. Zobaczymy wtedy informację o wszystkich obiektach znajdujących się na stercie (screen poniżej). Tutaj małe wyjaśnienie, z czym mamy do czynienia. Widzimy tu listę obiektów i wielkość tych obiektów w dwóch kolumnach Shallow Heap i Retained Heap.

Shallow heap to ilość pamięci, jaką zajmuje pojedynczy obiekt. Retained heap to ilość pamięci, jaka zostałaby uwolniona w przypadku, gdyby obiekt został zebrany przez Garbage Collector. Jest to również suma wszystkich powiązanych z sobą obiektów (suma ich shallow heap). Ta druga wartość jest tą, która nas interesuje. Aby było łatwiej znaleźć interesujący nas obiekt, klikając w nagłówek Retianed Heap możemy sortować wyświetlone obiekty po wielkości, rosnąco lub malejąco. W tym przykładzie po prostu szukamy największego obiektu. Często niestety nie jest tak łatwo, trzeba wykazać się intuicją i doświadczeniem w poszukiwaniu nieużywanego, zajmującego pamięć obiektu. Na szczęście MAT znacząco nam to ułatwia.

Na screenie powyżej widać, że to ArrayList jest winowajcą. Posiada ona listę obiektów, w której znajdują się obiekty Integer, których wartości możemy odczytać w liście w okienku inspector po lewej (fajny ten MAT ;)).

Jak widzimy z kolejnego screena powyżej, mamy możliwość przeszukiwania również po referencjach do i z obiektu. Co może być pomocne w przypadku, gdy chcemy się dowiedzieć, gdzie znajduje się wywołanie obiektu stwarzającego problemy. Działa to następująco. Incoming references, czyli referencje wchodzące, to takie, które mają referencje do obiektu, względem którego o nie odpytujemy. Czyli jeśli mamy obiekt X i sprawdzamy jego referencje wchodzące, to otrzymamy listę obiektów, które przechowują referencje do X np. X x = new X(). W przypadku Outgoing references, mamy analogicznie referencje wychodzące, które są zawarte w obiekcie X, czyli referujące jak gdyby na zewnątrz od niego.

Jak widać, temat jest bardzo rozległy, ale mam nadzieję, że choć w części udało mi się go przybliżyć.

Uwagi

Uwaga, jeśli podczas ładowania zrzutu wystąpią problemy, np. gwałtowne zamknięcie MAT-a, dobrze jest zwiększyć mu ilość pamięci w pliku MemoryAnalyzer.ini. Dokonać tego możemy przez zmianę w znanym nam już parametrze -Xmx.

Konfiguracja sprzętu użyta w artykule

Cpu: Intel i7 8th generation
RAM: 8 GB
OS: Debian 9, kernel 4.9.0-8-amd64

<p>Loading...</p>