Zarządzanie pamięcią w Javie, z wbudowanym garbage collection, jest jednym z najlepszych osiągnięć tego języka. Pozwala programistom na tworzenie nowych obiektów bez martwienia się o alokację i deallokację pamięci, ponieważ garbage collector automatycznie odzyskuje pamięć do ponownego użycia. Pozwala to na szybszy rozwój z mniejszą ilością szablonowego kodu, jednocześnie eliminując wycieki pamięci i inne problemy związane z pamięcią. Przynajmniej w teorii.
Ironicznie, Java garbage collector wydaje się działać zbyt dobrze, tworząc i usuwając zbyt wiele obiektów. Większość problemów z zarządzaniem pamięcią jest rozwiązywana, ale często kosztem tworzenia poważnych problemów z wydajnością. Dostosowanie garbage collection do wszystkich rodzajów sytuacji doprowadziło do powstania skomplikowanego i trudnego do zoptymalizowania systemu. Aby zrozumieć, jak działa zarządzanie pamięcią w maszynie wirtualnej Java (JVM), należy najpierw zrozumieć, jak działa garbage collection.
Jak naprawdę działa Java Garbage Collection
Wielu ludzi myśli, że garbage collection zbiera i wyrzuca martwe obiekty. W rzeczywistości, Java garbage collection robi coś wręcz przeciwnego! Żywe obiekty są śledzone, a wszystko inne wyznaczane jako śmieci. Jak zobaczysz, to fundamentalne nieporozumienie może prowadzić do wielu problemów z wydajnością.
Zacznijmy od sterty, która jest obszarem pamięci używanym do dynamicznej alokacji. W większości konfiguracji system operacyjny przydziela stertę z wyprzedzeniem, aby była zarządzana przez JVM podczas działania programu. Ma to kilka ważnych konsekwencji:
- Tworzenie obiektów jest szybsze, ponieważ globalna synchronizacja z systemem operacyjnym nie jest potrzebna dla każdego pojedynczego obiektu. Alokacja po prostu żąda pewnej części tablicy pamięci i przesuwa wskaźnik offsetu do przodu (patrz rysunek 2.1). Następna alokacja zaczyna się od tego offsetu i żąda kolejnej części tablicy.
- Gdy obiekt nie jest już używany, garbage collector odzyskuje pamięć bazową i ponownie wykorzystuje ją do przyszłej alokacji obiektów. Oznacza to, że nie ma jawnego usuwania obiektów i żadna pamięć nie jest zwracana do systemu operacyjnego.
Rysunek 2.1: Nowe obiekty są po prostu alokowane na końcu używanej sterty.
Wszystkie obiekty są alokowane na obszarze sterty zarządzanym przez JVM. Każdy element, którego używa programista, jest traktowany w ten sposób, włączając w to obiekty klas, zmienne statyczne, a nawet sam kod. Tak długo jak obiekt jest przywoływany, JVM uważa go za żywy. Gdy do obiektu nie ma już odniesień, a zatem nie jest on osiągalny dla kodu aplikacji, odśmiecacz usuwa go i odzyskuje niewykorzystaną pamięć. Jakkolwiek prosto to brzmi, nasuwa się pytanie: jaka jest pierwsza referencja w drzewie?
Korzenie kolekcji śmieci – źródło wszystkich drzew obiektów
Każde drzewo obiektów musi mieć jeden lub więcej obiektów-korzeni. Tak długo, jak aplikacja może dotrzeć do tych korzeni, całe drzewo jest osiągalne. Ale kiedy te obiekty korzeniowe są uważane za osiągalne? Specjalne obiekty zwane korzeniami zbierania śmieci (ang. garbage-collection roots, GC roots; patrz rysunek 2.2) są zawsze osiągalne i tak samo osiągalny jest każdy obiekt, który ma korzeń zbierania śmieci w swoim własnym korzeniu.
W Javie istnieją cztery rodzaje korzeni GC:
- Zmienne lokalne są utrzymywane przy życiu przez stos wątku. Nie jest to wirtualne odniesienie do rzeczywistego obiektu i dlatego nie jest widoczne. Dla wszystkich intencji i celów, zmienne lokalne są korzeniami GC.
- Aktywne wątki Java są zawsze uważane za żywe obiekty i dlatego są korzeniami GC. Jest to szczególnie ważne w przypadku zmiennych lokalnych wątków.
- Zmienne statyczne są przywoływane przez swoje klasy. Ten fakt sprawia, że są one de facto GC roots. Same klasy mogą być garbage-collected, co spowoduje usunięcie wszystkich zmiennych statycznych, do których się odwołują. Ma to szczególne znaczenie, gdy używamy serwerów aplikacji, kontenerów OSGi lub ogólnie ładowarek klas. Omówimy związane z tym problemy w sekcji Problem Patterns.
- Referencje JNI są obiektami Javy, które natywny kod utworzył jako część wywołania JNI. Obiekty utworzone w ten sposób są traktowane specjalnie, ponieważ JVM nie wie, czy jest do nich odwołanie przez natywny kod, czy nie. Takie obiekty reprezentują bardzo specjalną formę GC root, którą zbadamy bardziej szczegółowo w sekcji Problem Patterns poniżej.
Figura 2.2: Korzenie GC są obiektami, do których JVM odwołuje się samodzielnie i w ten sposób powstrzymuje każdy inny obiekt przed zbieraniem śmieci.
Dlatego prosta aplikacja Java ma następujące korzenie GC:
- Zmienne lokalne w metodzie main
- Główny wątek
- Zmienne statyczne klasy main
Zaznaczanie i wymiatanie śmieci
Aby określić, które obiekty nie są już używane, JVM co jakiś czas uruchamia algorytm nazywany bardzo trafnie algorytmem mark-and-sweep. Jak można się domyślić, jest to prosty, dwuetapowy proces:
- Algorytm przegląda wszystkie odwołania do obiektów, zaczynając od korzeni GC, i zaznacza każdy znaleziony obiekt jako żywy.
- Wszystko z pamięci sterty, która nie jest zajęta przez zaznaczone obiekty, jest odzyskiwane. Jest ona po prostu oznaczana jako wolna, zasadniczo wymiatana z nieużywanych obiektów.
Garbage collection ma na celu usunięcie przyczyny klasycznych wycieków pamięci: nieosiągalne, ale nieusunięte obiekty w pamięci. Działa to jednak tylko w przypadku wycieków pamięci w pierwotnym znaczeniu. Możliwe jest istnienie nieużywanych obiektów, które są nadal osiągalne przez aplikację, ponieważ programista po prostu zapomniał o ich dereferencji. Takie obiekty nie mogą być zbierane do śmieci. Co gorsza, taki logiczny wyciek pamięci nie może zostać wykryty przez żadne oprogramowanie (patrz rysunek 2.3). Nawet najlepsze oprogramowanie analityczne może jedynie zwrócić uwagę na podejrzane obiekty. Analizę wycieków pamięci przeanalizujemy w podrozdziale Analizowanie wpływu wykorzystania pamięci i zbierania śmieci na wydajność, poniżej.