Jak zarządzać pamięcią w Javie oraz jak zachować elastyczność wewnątrz kontenerów, a jednocześnie zaoszczędzić pamięć? Poniżej znajdziecie listę problemów, na które należy zwrócić uwagę, istotne aktualizacje zawarte w nadchodzących komponentach JDK oraz istniejące sposoby na obejście szczególnie problematycznych kwestii. Stworzyliśmy listę pięciu najbardziej interesujących i przydatnych wskazówek na zwiększenie efektywności wykorzystania zasobów w aplikacjach działających na bazie Java.

Limit pamięci sterty w Dockerze

Niewłaściwe ustalanie limitów pamięci w trakcie obsługi aplikacji Java w kontenerach Dockera to problem, na który napotyka ostatnio coraz większa liczba użytkowników. Problemem jest to, że o ile opcja Xmx nie jest wyraźnie zdefiniowana, to, zgodnie z domyślnym ergonomicznym algorytmem wewnętrznego odśmiecania pamięci (GC), JVM zużywa jedną czwartą całej pamięci dostępnej dla systemu operacyjnego. Może to doprowadzić do zakończenia procesu Java przez jądro systemu operacyjnego w wypadku, gdy zużycie pamięci JVM przekroczy limit cgroups określony dla danego kontenera Dockera.

W celu rozwiązania tego problemu, OpenJDK 9 otrzymał niedawno jedno ulepszenie.

„Dodaliśmy pierwszą eksperymentalną zmianę do OpenJDK 9 tak, by JVM mógł rozpoznać, że działa wewnątrz kontenera i odpowiednio dostosować limity pamięci.” z artykułu: Java 9 Will Adjust Memory Limits if Running with Docker.

Nowa funkcja JVM (-XX:+UseCGroupMemoryLimitForHeap) automatycznie ustawia Xmx dla procesu Javy zgodnie z limitem pamięci określonym w cgroup.

Jednym ze sposobów na obejście tej kwestii przed oficjalnym wydaniem Java 9 jest wyraźne ustalenie limitu Xmx w opcjach rozruchowych JVM. Istnieje nawet otwarty pull request dla „skryptu ustawiającego lepsze domyślne wartości Xmx zgodnie z limitami pamięci Dockera”, który można znaleźć w official OpenJDK repo.

Udało nam się jednak ominąć nieprawidłowe ustalanie limitów pamięci poprzez wykorzystanie rozszerzonej warstwy wirtualizacji kontenerów systemowych w połączeniu z obrazami Dockera. Więcej informacji na ten temat można znaleźć w anglojęzycznym artykule naszego Partnera Biznesowego – firmy Jelastic: Java and Memory Limits in Containers: LXC, Docker and OpenVZ.

Śledzenie zużycia pamięci natywnej

W trakcie działania aplikacji Java w chmurze należy zwrócić uwagę na zużycie pamięci natywnej, tak zwanej off-heap memory, przez dany proces Java. Może ona zostać przeznaczona do rożnych celów:

  • Zbieracze śmieci oraz optymalizacja JIT śledzą i przechowują dane grafów obiektów w pamięci natywnej. Ponadto, od czasu JDK8, nazwy i pola klas, kod bajtowy metod, pula stałych itd. są zlokalizowane w Metaspace, która również jest przechowywana poza stertą JVM.
  • Wreszcie, by zwiększyć wydajność, wiele aplikacji Javy alokuje pamięć w strefie natywnej. Wykorzystując java.nio.ByteBuffer lub zewnętrzne biblioteki JNI, aplikacje te przechowują duże, długoterminowe bufory, które są zarządzane razem z natywnymi operacjami wejścia-wyjścia systemu.

Domyślnie alokacja Metaspace jest ograniczona jedynie przez ilość dostępnej pamięci systemu operacyjnego. W połączeniu z niewłaściwym ustaleniem limitów pamięci w kontenerach Dockera zwiększa to ryzyko niestabilnego działania aplikacji. Ograniczenie rozmiaru metadanych jest istotne, zwłaszcza jeśli masz problemy z brakiem pamięci (OOM). Można to zrobić za pomocą funkcji – XX:MaxMetaspaceSize.

Biorąc pod uwagę wszelkie obiekty przechowywane poza standardową odśmieconą pamięcią sterty, ich wpływ na wykorzystanie pamięci danej aplikacji Java nie jest taki oczywisty. Szczegółowe wyjaśnienie tego mechanizmu oraz porady dotyczące sposobu analizowania wykorzystania pamięci natywnej znajdują się w niżej cytowanym artykule:

„Kilka tygodni temu napotkałem interesujący problem gdy próbowałem przeanalizować zużycie pamięci w mojej aplikacji Java (Spring Boot + Infinispan), działającej w Dockerze. Parametr Xmx został ustawiony na 256m, jednak narzędzie monitorujące Dockera wskazywało na niemal dwukrotnie większe zużycie pamięci.”Analyzing java memory usage in a Docker container.

I na koniec ciekawa konkluzja autora:

„Jaki wniosek z tego płynie? No cóż… nigdy nie należy używać słowa 'java’ i 'micro’ w jednym zdaniu. Żartuję, oczywiście. Po prostu pamiętajcie, że zajmowanie się pamięcią w kwestii Javy, Linuxa, czy Dockera jest bardziej skomplikowane niż mogło by się na początku wydawać.”

Funkcja JVM -XX:NativeMemoryTracking=summary może zostać użyta do śledzenia alokacji pamięci natywnej. Należy jednak zauważyć, że włączenie tej opcji zmniejszy wydajność o 5-10%.

Zmiana rozmiaru wykorzystania pamięci JVM podczas uruchamiania programu

Kolejnym użytecznym rozwiązaniem na zmniejszenie zużycia pamięci przez aplikację Java jest dostosowanie opcji JVM w trakcie działania procesu Java. Od czasu JDK7u60 oraz JDK8u20 opcje MinHeapFreeRation oraz MaxHeapFreeRatio są edytowalne, co oznacza, że można zmienić ich wartości w trakcie wykonywania programu, bez konieczności restartowania procesu Java.

W artykule Runtime Commited Heap Resizing autor opisuje, w jaki sposób zmniejszyć zużycie pamięci poprzez wprowadzanie zmian w tych edytowalnych opcjach:

„… Zmiana rozmiaru zadziałała po raz kolejny, a pojemność sterty zwiększyła się z 159MB do 444MB. Opisaliśmy, że przynajmniej 85% pojemności sterty powinno być wolne, a to dało sygnał JVM do zmiany rozmiaru sterty w celu zapewnienia jej wykorzystania w nie więcej niż 15%.”

Takie podejście może przynieść znaczącą optymalizację wykorzystania zasobów dla zróżnicowanego obciążenia pracą. Następnym krokiem w ulepszaniu zmiany rozmiaru pamięci JVM może być pozwolenie na zmianę Xmx w trakcie wykonywania programu, bez konieczności resetowania procesu Java.

Polepszanie kompaktowania pamięci

W wielu wypadkach klienci chcą zminimalizować ilość pamięci zużywanej przez aplikacje Java poprzez stosowanie częstszego odśmiecania. Efektywniejsze wykorzystanie zasobów w środowiskach developerskich, testowych i kompilacji oraz w produkcjach po skokach natężenia, pozwala, na przykład, zaoszczędzić znaczne kwoty pieniędzy. Jednakże, według the official enhancement ticket, obecne algorytmy odśmiecania wymagają wielokrotnych pełnych cykli pracy, by udostępnić całą nieużywaną pamięć.

W związku z tym, w celu regulowania zachowania algorytmu odśmiecania, w JDK9 została wprowadzona nowa opcja JVM (-XX:+ShrinkHeapInSteps). Aby wyłączyć wszystkie cztery pełne cykle odśmiecania, należy zmienić tę opcję na -XX:-ShrinkHeapInSteps. Pozwoli to na szybsze uwolnienie niewykorzystywanych zasobów pamięci RAM oraz na zminimalizowanie wykorzystania pamięci sterty Java w aplikacjach o zróżnicowanym obciążeniu pracą.

Zmniejszenie zużycia pamięci w celu przyspieszenia migracji na żywo

Migracja na żywo tych aplikacji Java, które zużywają dużo pamięci może zająć sporo czasu. Aby zmniejszyć całkowity czas migracji oraz overhead zasobów, narzędzie migrujące powinno zminimalizować ilość danych przesyłanych pomiędzy hostami. Można tego dokonać poprzez kompresję pamięci RAM za pomocą pełnego cyklu odśmiecania wykonanego przed procesem migracji na żywo. Dla wielu aplikacji takie podejście może być bardziej efektywne przy zwalczaniu spadków wydajności podczas cyklu odśmiecania niż migrowanie ze zrzutu pamięci RAM.

Udało nam się znaleźć świetną pracę odnoszącą się do tego tematu: GC-assisted JVM Live Migration for Java Server Applications. Autorzy zintegrowali JVM z CRIU (Checkpoint/Restore In Userspace) i wprowadzili nową logikę odśmiecania, dzięki czemu skrócili czas migracji na żywo aplikacji Java pomiędzy hostami. Prezentowana metoda pozwala na włączenie odśmiecania zorientowanego na migrację przed stworzeniem obrazu stanu procesu Java, a następnie na zachowanie działającego kontenera na dysku i późniejsze go odzyskanie.

Co więcej, społeczność Dockera wprowadza CRIU do świadomości szerokiej rzeszy odbiorców. Obecnie ta funkcja jest wciąż na etapie eksperymentalnym.

Połączenie Javy i CRIU może odkryć wciąż nieznane możliwości optymalizacji wydajności i wdrażania dla ulepszenia hostingu aplikacji Java w chmurze. Więcej szczegółów na temat działania migracji na żywo kontenerów w chmurze można znaleźć w artykule Containers Live Migration: Behind the Scenes.

Java jest świetnym językiem programowania i już teraz bardzo dobrze działa w chmurze, a zwłaszcza w kontenerach, jednak może być jeszcze lepsza. W tym artykule przedstawiliśmy kilka kwestii, które już teraz można ulepszyć, by aplikacje Java działały płynniej i wydajniej.

Z języka Java korzysta kilkaset tysięcy programistów na całym świecie, dlatego tak ważne jest właściwe zarządzanie pamięcią. Z tego właśnie powodu nieustannie wprowadzamy nowe rozwiązania dotyczące pamięci Java do naszej platformy tak, by developerzy nie musieli sami radzić sobie z tymi problemami.