Jak zoptymalizować wydajność aplikacji Java: praktyczne techniki, narzędzia i przykłady

0
148
3.5/5 - (2 votes)

Nawigacja:

Czym jest wydajność w aplikacjach Java i jak ją mierzyć

Trzy główne wymiary wydajności: czas, zasoby, skalowalność

Wydajność aplikacji Java nie sprowadza się do jednego wskaźnika „działa szybko / działa wolno”. Kluczowe są trzy wymiary: czas (jak szybko obsługiwane jest konkretne żądanie), zużycie zasobów (CPU, pamięć, I/O, sieć) oraz skalowalność (jak system zachowuje się pod rosnącym obciążeniem). Optymalizacja polega na znalezieniu balansu między tymi aspektami, a nie na śrubowaniu jednego z nich w izolacji.

Czas można rozpatrywać jako opóźnienie (latency) pojedynczej operacji – ile trwa od momentu wysłania żądania do otrzymania odpowiedzi – oraz jako przepustowość (throughput), czyli ile takich operacji system jest w stanie wykonać w jednostce czasu. Te dwa parametry często są w konflikcie. Zwiększenie przepustowości (np. przez przetwarzanie wsadowe lub mocniejsze buforowanie) może pogorszyć opóźnienie pojedynczego żądania.

Zużycie zasobów to z kolei koszt obsługi danego ruchu: ile procent CPU zajmują wątki aplikacji, jak szybko rośnie i kurczy się heap, ile operacji I/O generują poszczególne moduły i jak intensywnie wykorzystywana jest sieć. Dwie aplikacje o podobnym czasie odpowiedzi mogą dramatycznie różnić się kosztem zasobów – jedna będzie stabilnie działać na małej maszynie, druga wymusi skalowanie poziome i pionowe.

Skalowalność w praktyce oznacza odpowiedź na pytanie: co się stanie, jeśli ruch wzrośnie x razy?. Dobrze zaprojektowana aplikacja Java powinna wykazywać możliwie liniową zależność między obciążeniem a wymaganymi zasobami. Jeśli po zwiększeniu liczby równoległych żądań z 50 do 200 czas odpowiedzi skacze dziesięciokrotnie, mamy problem albo z blokadami, albo z wąskimi gardłami (baza, I/O, zewnętrzne serwisy).

Metryki techniczne a metryki biznesowe

Metryki techniczne (CPU, GC, czas odpowiedzi, liczba błędów) to tylko jedna strona medalu. Dla biznesu liczy się czas realizacji konkretnej operacji – np. złożenia zamówienia, wygenerowania raportu, pobrania listy produktów. Dlatego w systemowym podejściu do wydajności konieczne jest wiązanie metryk technicznych z metrykami biznesowymi.

Przykład: endpoint REST do tworzenia zamówienia ma średnie opóźnienie 100 ms, ale w godzinach szczytu 95 percentyl (p95) rośnie do 2 sekund. Z technicznej perspektywy p95 = 2 s może wyglądać „jeszcze znośnie”, ale jeżeli proces sprzedaży zakłada natychmiastową informację zwrotną dla klienta, 2 sekundy opóźnienia na jednym z kroków może wywołać lawinowy efekt – klient rezygnuje, konsultant zaczyna dopytywać, pojawiają się ponowne próby wysłania formularza.

Dlatego warto definiować SLA (Service Level Agreement) i SLO (Service Level Objective) nie tylko w języku „średnie opóźnienie API”, ale także: czas realizacji zamówienia, maksymalna liczba błędnych transakcji na godzinę, procent operacji kończących się sukcesem w czasie krótszym niż X ms. Z takich wymagań dopiero wynikają cele techniczne – np. p95 dla endpointu /orders nie większy niż 300 ms.

Latency, throughput, CPU, RAM i I/O – jak je czytać

Podstawowy zestaw metryk, bez których trudno mówić o sensownej optymalizacji aplikacji Java, obejmuje:

  • Latency – czas odpowiedzi endpointów (średnia, p90, p95, p99);
  • Throughput – liczba żądań na sekundę (RPS) lub transakcji na minutę;
  • CPU – procent użycia CPU przez proces JVM oraz ogólnie na maszynie;
  • RAM/Heap – zajętość heap, metaspaces, liczba i długość pauz GC;
  • I/O – opóźnienia i liczba operacji dyskowych, opóźnienia i pasmo sieciowe;
  • Threading – liczba aktywnych wątków, wątki w stanie BLOCKED/WAITING.

Zestawiając te dane ze sobą, można szybko ocenić charakter problemu. Jeśli CPU jest niskie, a czas odpowiedzi wysoki – zwykle wąskim gardłem nie jest procesor, tylko I/O (baza, sieć, dysk) albo blokady w kodzie. Jeśli CPU jest bliskie 100%, a GC raportuje częste pełne zbiórki (full GC), problem może leżeć w nadmiernej alokacji obiektów i błędnej konfiguracji Garbage Collectora.

Ważne jest też oddzielenie wydajności z perspektywy użytkownika od wydajności z perspektywy serwera. Użytkownik końcowy widzi tylko czas renderowania strony lub odpowiedzi API przez sieć, więc może cierpieć z powodu opóźnień, które nie mają nic wspólnego z JVM (np. wolny front-end, przeglądarka, łącze). Z drugiej strony, serwer może być obciążony do granic możliwości, a wydaje się „szybki”, bo ruch jest jeszcze mały.

Dlaczego przed optymalizacją trzeba mieć liczby

Optymalizowanie aplikacji Java „na oko” kończy się zwykle przepaleniem czasu. Programista spędza godziny na mikrooptymalizacjach pętli, wymianie jednego algorytmu sortowania na inny, podczas gdy realny problem leży w jednym przeciążonym połączeniu do bazy danych lub nieprzemyślanym kliencie HTTP, który nie korzysta z połączeń keep-alive.

Bez twardych danych:

  • nie wiadomo, które fragmenty kodu tworzą „gorące ścieżki” (hot paths);
  • nie da się zmierzyć efektu wprowadzonej zmiany (czasem nowa „optymalizacja” jest wolniejsza);
  • zespół traci zaufanie do procesu optymalizacji, bo kolejne „ulepszenia” nic nie zmieniają.

Dlatego punktem wyjścia jest zawsze pomiar: profilowanie aplikacji Java, zebranie metryk z produkcji, przeprowadzenie testów obciążeniowych. Dopiero na tej podstawie powstają hipotezy, co wymaga zmiany, oraz da się realnie ocenić, czy dana technika, narzędzie lub wzorzec przynosi oczekiwany zysk wydajnościowy.

Zbliżenie kolorowego kodu Java na monitorze komputerowym
Źródło: Pexels | Autor: Markus Spiske

Podejście do optymalizacji: proces zamiast „magii” w kodzie

Cykl: pomiar → analiza → hipoteza → zmiana → weryfikacja

Systemowe podejście do optymalizacji wydajności w Javie można sprowadzić do jednego powtarzalnego cyklu:

  • Pomiar – zbieranie metryk, profilowanie, testy obciążeniowe;
  • Analiza – identyfikacja wąskich gardeł, gorących ścieżek, nadmiernych alokacji;
  • Hipoteza – konkretny pomysł na zmianę: „zamieniamy X na Y, oczekiwany efekt Z”;
  • Zmiana – implementacja, konfiguracja, refaktoryzacja lub modyfikacja ustawień JVM;
  • Weryfikacja – ponowny pomiar w tych samych warunkach, porównanie wyników.

Jeśli taki cykl jest realizowany konsekwentnie, po kilku iteracjach pojawia się bardzo wartościowy artefakt: doświadczenie specyficzne dla danego systemu. Zespół zaczyna rozumieć, które obszary aplikacji są najbardziej wrażliwe na zmiany konfiguracyjne, które biblioteki generują dużo alokacji, jakie strategie GC lepiej sprawdzają się w ich konkretnym profilu ruchu.

Ten cykl warto dokumentować: jakie były dane wejściowe, jakie hipotezy, co zostało zmienione, jakie były wyniki. Z czasem tworzy się wewnętrzna „checklista wydajnościowa” – powtarzalny zestaw działań, które zwykle przynoszą efekt dla danej architektury (np. mikroserwisy komunikujące się przez HTTP/JSON, Spring Boot, Hibernate, PostgreSQL).

Anti-pattern „przedwczesnej optymalizacji”

Przedwczesna optymalizacja (ang. premature optimization) to klasyczna pułapka: projektowanie i pisanie zbyt skomplikowanego, „ultra wydajnego” kodu w obszarach, które nigdy nie staną się wąskim gardłem. W kontekście aplikacji Java przejawia się to często:

  • nadmiernym stosowaniem niskopoziomowych API (np. NIO) bez realnej potrzeby;
  • rezygnacją z czytelnych bibliotek/wzorców na rzecz „ręcznego” kodu, bo „będzie szybciej”;
  • skomplikowanymi strukturami danych, trudnymi do utrzymania i debugowania;
  • tuningiem GC, zanim w ogóle wiadomo, jaki jest profil pamięci aplikacji.

Efekt uboczny jest prosty: kod staje się trudny w utrzymaniu, a zysk wydajnościowy często jest marginalny albo zerowy. Dopiero, gdy pojawią się pierwsze realne testy obciążeniowe, okazuje się, że wąskim gardłem jest wcale nie ten „shardowany” cache w pamięci, ale pojedyncza tabela w bazie danych lub blokada w globalnym locku.

Rozsądne podejście polega na pisaniu prostego, czytelnego, poprawnego kodu, wspartego podstawowymi dobrymi praktykami (np. unikanie niepotrzebnych obiektów w hotspotach), a dopiero później – w odpowiedzi na wyniki pomiarów – na selektywnym wprowadzaniu bardziej agresywnych optymalizacji.

Definiowanie celów wydajnościowych i serwisowych

Zanim zacznie się cokolwiek „przyspieszać”, trzeba wiedzieć, dokąd się zmierza. Konkretny cel wydajnościowy może wyglądać tak:

  • p95 czasu odpowiedzi endpointu /payments < 250 ms przy 500 RPS;
  • średnia liczba pauz GC < 5 na minutę, każda < 50 ms;
  • użycie CPU < 70% przy docelowym obciążeniu;
  • co najmniej 99,5% transakcji zamówień kończy się sukcesem w czasie < 2 s.

Cele te można powiązać z SLA wobec klientów (zewnętrznych lub wewnętrznych) oraz z SLO dla zespołów technicznych. Dobrą praktyką jest zdefiniowanie budżetu opóźnień – ile milisekund „konsumuje” każdy z elementów ścieżki: front-end, gateway, serwis A, serwis B, baza danych, zewnętrzne API. Jeśli endpoint biznesowy ma limit 400 ms, a baza danych regularnie zużywa 300 ms, pole manewru po stronie kodu Java jest znikome.

Po więcej kontekstu i dodatkowych materiałów możesz zerknąć na Programista Java.

Wąskie gardła i gorące ścieżki w praktyce

Typowy scenariusz antywzorca: zespół spędza tygodnie na optymalizacji algorytmu przetwarzania danych (zmiana struktur danych, ręczna implementacja sortowania, “inteligentne” cache’e), a finalny zysk jest mniejszy niż milisekunda na żądanie. Dopiero po podłączeniu profilera okazuje się, że 80% czasu żądania konsumuje pojedyncze zapytanie SQL wykonywane w pętli albo blokujący wywołanie HTTP do zewnętrznego serwisu.

Dlatego identyfikacja hot paths w kodzie (np. poprzez sampling profiler) jest o wiele ważniejsza niż mikrooptymalizacje w przypadkowych fragmentach. Przydatnym narzędziem jest tu profilowanie na poziomie requestów – możliwość zobaczenia, jaka część czasu żądania spędzana jest w warstwie web, jaka w serwisach domenowych, a jaka w integracjach (baza, messaging, inne mikroserwisy).

Narzędzia do profilowania i pomiarów w Javie

Profilery: sampling vs instrumentacja

Profilowanie aplikacji Java można realizować głównie na dwa sposoby: przez sampling oraz przez instrumentację. W podejściu samplingowym profiler co pewien interwał (np. co 10 ms) odczytuje stos wywołań (stack trace) z wątków JVM i na tej podstawie szacuje, w których metodach spędzany jest czas CPU. Z kolei instrumentacja polega na wstrzyknięciu dodatkowego kodu (bytecode instrumentation), który mierzy czas wejścia/wyjścia z metod.

Sampling jest mniej dokładny, ale za to znacznie lżejszy i bezpieczniejszy do użycia także na środowiskach zbliżonych do produkcyjnych. Instrumentacja daje bardzo precyzyjne dane, ale potrafi znacząco spowolnić aplikację i zniekształcić wyniki (tzw. efekt obserwatora). Do tuningowania produkcyjnych systemów zwykle wystarcza profiler samplingowy (np. async-profiler, Java Flight Recorder).

Wbudowane narzędzia JDK i zewnętrzne rozwiązania

Ekosystem Javy oferuje kilka klas narzędzi do profilowania i diagnostyki:

  • Java Flight Recorder (JFR) i Java Mission Control (JMC) – wbudowane w JDK narzędzia do zbierania i analizy zdarzeń z JVM (alokacje, GC, wątki, I/O, itp.);
  • VisualVM – uniwersalne narzędzie do profilingu CPU, pamięci, monitorowania wątków, heap dumpów;
  • async-profiler – lekkie narzędzie do samplingowego profilowania CPU i alokacji, generujące flame graphy;
  • profilery komercyjne: YourKit, JProfiler, IntelliJ Profiler – bardzo wygodne, z dobrą integracją IDE.

Profilowanie CPU w praktyce

Suche informacje o profilerach są mało użyteczne, dopóki nie zostaną przełożone na konkretny workflow. Typowy scenariusz wygląda tak: aplikacja ma wysoki czas odpowiedzi przy średnim użyciu CPU. Intuicja podpowiada „to na pewno baza”, ale profiler potrafi szybko zweryfikować tę tezę.

Przykładowa procedura, korzystając z async-profiler:

  1. Uruchom aplikację ze standardowymi ustawieniami JVM, bez dodatkowego tuningu.
  2. Wykonaj test obciążeniowy na problematycznym endpointcie (np. przy użyciu JMeter, k6, Gatling).
  3. W trakcie testu uruchom profilowanie CPU na kilkadziesiąt sekund:
    ./profiler.sh -d 60 -e cpu -f cpu.svg <PID>
  4. Otwórz wygenerowany flame graph i zidentyfikuj szczyt dominującego stosu wywołań.

Jeżeli na flame graphie królują metody z pakietów sterownika bazy danych lub klienta HTTP, wiadomo, że główny koszt leży poza samym „czystym” kodem Javy. Jeśli na szczycie pojawia się jakaś pętla w serwisie domenowym, można już celować w konkretną klasę i metodę.

Profilowanie alokacji pamięci

Czas CPU to tylko połowa obrazu. Druga to intensywność alokacji (allocation rate), która wpływa zarówno na GC, jak i na lokalne cache CPU (podmiana obiektów w pamięci). Aplikacja może mieć „znośne” czasy odpowiedzi, ale przy wyższym ruchu zaczyna się dławić przez wzmożoną pracę garbage collectora.

Do analizy alokacji przydają się:

  • Java Flight Recorder – zdarzenia typu ObjectAllocationInNewTLAB, ObjectAllocationOutsideTLAB;
  • async-profiler z trybem alloc:
    ./profiler.sh -d 60 -e alloc -f alloc.svg <PID>
  • profilery komercyjne z widokami „allocation hotspots”.

Po zebraniu danych szukaj metod, które generują największy wolumen alokacji. Często okazuje się, że:

  • niepotrzebnie tworzony jest nowy obiekt buildera lub wrappera przy każdym wywołaniu;
  • korzystasz z API, które z założenia jest „pachnące alokacjami” (np. intensywne użycie Streams z map/flatMap na gorącej ścieżce);
  • serializacja/deskserializacja JSON produkuje więcej obiektów, niż można by sądzić.

Uwaga: celem nie jest „zero alokacji”, tylko ograniczenie nadmiernego tworzenia krótkotrwałych obiektów w krytycznych fragmentach kodu. Eliminowanie każdej alokacji na siłę zwykle kończy się kodem nie do utrzymania.

Analiza wątków i blokad

W aplikacjach wielowątkowych klasyczne CPU/alloc profiling to za mało. Pojawiają się problemy z blokadami (lock contention), deadlockami lub po prostu zbyt dużą liczbą wątków systemowych.

Do diagnozy przydają się:

  • jstack – szybki zrzut stosów wszystkich wątków;
  • Java Mission Control – widok wątków, czas w Runnable vs Blocked;
  • profilery z widokami „threads & locks” (np. YourKit, JProfiler).

Dobrym nawykiem jest okresowe generowanie thread dump podczas testu obciążeniowego i sprawdzanie:

  • czy nie ma wątków zablokowanych na współdzielonych strukturach (np. cache, globalne mapy);
  • czy pula wątków (thread pool) nie jest wyczerpana – wszystkie wątki w stanie RUNNABLE lub WAITING na zasób zewnętrzny;
  • czy liczba wątków nie rośnie niekontrolowanie (błędy konfiguracji executorów, brak zwalniania zasobów).
Laptop z edytorem kodu Java obok kubka kawy na biurku
Źródło: Pexels | Autor: Daniil Komov

Monitorowanie aplikacji Java w środowisku produkcyjnym

Metryki techniczne i biznesowe

Profilowanie pod obciążeniem jest niezbędne, ale prawdziwy obraz wydajności dostarczają dopiero metryki z produkcji. Ważne są zarówno wskaźniki techniczne, jak i biznesowe.

Do podstawowego zestawu można zaliczyć:

  • czas odpowiedzi (latencja) – p50, p90, p95, p99, rozdzielone per endpoint/operację;
  • przepustowość (throughput) – żądania na sekundę, podzielone na typy żądań;
  • zużycie CPU, pamięci heap i non-heap, rozmiar stosów wątków;
  • statystyki GC – liczba pauz, czas trwania, procent czasu spędzany w GC;
  • liczba błędów i timeoutów w integracjach (baza, kolejki, zewnętrzne API).

Równolegle trzeba patrzeć na metryki domenowe, np. ile zamówień na minutę obsługuje system, ile z nich kończy się błędem, jak długo trwa cały proces biznesowy. Dopiero złożenie obu perspektyw pokazuje, czy np. wzrost latencji realnie uderza w SLA użytkownika.

Prometheus, Micrometer i popularne stacki monitoringu

Najwygodniejszy sposób zbierania metryk z aplikacji Java to ekspozycja endpointu HTTP w formacie zrozumiałym dla systemów typu Prometheus. W świecie Spring Boot standardem jest Micrometer, który potrafi automatycznie eksponować:

  • metryki JVM (GC, pamięć, wątki);
  • czasy odpowiedzi kontrolerów HTTP;
  • liczniki błędów i wyjątków;
  • własne metryki biznesowe.

Przykład prostego licznika z Micrometer:

@Component
public class PaymentMetrics {

  private final Counter failedPayments;

  public PaymentMetrics(MeterRegistry registry) {
    this.failedPayments = Counter.builder("payments_failed_total")
        .description("Liczba nieudanych płatności")
        .tag("provider", "bankX")
        .register(registry);
  }

  public void incrementFailed() {
    failedPayments.increment();
  }
}

Zebrane metryki trafiają do Prometheusa, a następnie można je wizualizować w Grafanie. Tip: buduj dashboardy nie tylko „infrastrukturą” (CPU, RAM), ale też „ścieżkami użytkownika” – np. pełny flow złożenia zamówienia, rozbity na poszczególne mikroserwisy.

Logi, trace’y i korelacja z metrykami

Same metryki mówią „że coś jest źle”, ale nie pokazują dokładnie „co”. Dlatego aplikacja powinna generować:

  • logi techniczne (z sensownym poziomem szczegółowości, najlepiej w formacie JSON);
  • trace’y rozproszone (distributed tracing) – np. OpenTelemetry, Zipkin, Jaeger;
  • logi audytowe kluczowych operacji biznesowych.

Dzięki trace’om można prześledzić pojedyncze żądanie przez wszystkie mikroserwisy i komponenty. Gdy latencja endpointu rośnie, łatwo wtedy zobaczyć, w którym miejscu łańcucha pojawia się dodatkowe opóźnienie: baza, zewnętrzny serwis, cache czy konkretna operacja domenowa.

Praktyczne minimum:

  • korelacja logów i trace’ów po wspólnym identyfikatorze (np. traceId w MDC);
  • logowanie czasu wykonania najważniejszych operacji (np. „checkout completed in 350ms”);
  • automatyczne tagowanie metryk i logów kontekstem (nazwa serwisu, instancja, wersja builda).

Alerting oparty na SLO

Monitoring bez alertów jest tylko kolorowym grafem w Grafanie. Jednocześnie alerty „na wszystko” powodują szum i ignorowanie powiadomień. Rozsądne podejście to powiązanie alertów z SLO.

Przykłady:

  • jeśli p95 /payments > 300 ms przez 5 minut – ostrzeżenie dla zespołu;
  • jeśli p99 /checkout > 1 s przez 2 minuty – poważny alert na kanał on-call;
  • jeśli błądność (error rate) > określony próg – blokada rolloutów, automatyczne skalowanie.

Dobrze jest też mieć osobne alerty „operacyjne” na GC (np. duża liczba długich pauz) oraz na nietypowe użycie pamięci (szybki wzrost heapu mimo braku zwiększonego ruchu).

Laptop z kodem Java w ciemnym pokoju obok kubka kawy
Źródło: Pexels | Autor: Daniil Komov

JVM i garbage collector – konfiguracja, która naprawdę ma znaczenie

Dobór GC do profilu aplikacji

Nowoczesne JVM oferują kilka algorytmów garbage collection, m.in.:

  • G1GC – domyślny w nowszych wersjach Javy, dobry uniwersalny wybór dla większości systemów;
  • ZGC – niskie czasy pauz, dobrze radzi sobie z dużymi heapami (dziesiątki GB);
  • Shenandoah – podobny cel jak ZGC: bardzo krótkie pauzy kosztem nieco większego narzutu.

Dla typowych aplikacji webowych/mikroserwisów G1GC jest w pełni wystarczający, o ile nie ma ekstremalnych wymagań co do jittera (zmienności opóźnień). ZGC i Shenandoah mają sens przy bardzo dużych heapach lub systemach twardo wrażliwych na pauzy (np. trading).

Podstawowe flagi JVM związane z pamięcią

Zestaw minimalnych, a jednocześnie rozsądnych ustawień pamięci to:

  • -Xms i -Xmx – minimalny i maksymalny rozmiar heapu; w środowisku produkcyjnym w wielu przypadkach opłaca się je ustawić na tę samą wartość, by uniknąć kosztownej realokacji;
  • -XX:+UseG1GC (jeśli nie jest domyślne) – włączenie G1;
  • -XX:MaxGCPauseMillis=<target> – docelowy czas pauzy GC (np. 100–200 ms);
  • -XX:+UseContainerSupport – poprawna interpretacja limitów zasobów w środowiskach kontenerowych.

W praktyce strojenie zaczyna się od obserwacji: jak szybko heap się zapełnia, jak często uruchamia się young/old GC, ile trwają pauzy. Na tej podstawie można modyfikować docelowy czas pauz, rozmiary regionów (dla G1) lub zdecydować, że potrzebny jest inny rodzaj kolektora.

Analiza logów GC

Bez logów GC tuning JVM jest zgadywanką. Włączenie szczegółowego logowania nie jest kosztowne, a dostarcza masę informacji. Przykładowa konfiguracja (Java 11+):

-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=10M

Logi można analizować ręcznie lub narzędziami takimi jak:

  • GCViewer;
  • GCeasy;
  • Java Mission Control (import logów GC).

W logach interesują m.in.:

  • jakie są czasy poszczególnych typów GC (young/old, mixed);
  • czy nie pojawiają się Full GC z bardzo długimi pauzami;
  • jaki procent czasu procesu zajmuje GC.

Jeżeli GC zużywa istotną część CPU i często zatrzymuje świat (stop-the-world), trzeba wrócić do profilu alokacji w kodzie i sprawdzić, czy nie jest generowana nadmierna liczba krótkotrwałych obiektów w newralgicznych miejscach.

JVM w świecie kontenerów

Uruchamianie Javy w Dockerze czy Kubernetesa wnosi parę dodatkowych zmiennych:

  • limit pamięci kontenera (resource limits) – JVM musi widzieć realny limit, w przeciwnym razie może próbować użyć więcej pamięci niż wolno;
  • limit CPU – liczba vCPU wpływa na dobór domyślnej liczby wątków GC i innych pul wątków;
  • overcommit – zbyt wiele JVM na jednym węźle fizycznym potrafi zamienić się w „GC hell”.

Tip: konfigurując -Xmx, zostaw margines na:

  • metaspace (-XX:MaxMetaspaceSize, jeśli jest ustawione);
  • stacki wątków (-Xss razy liczba wątków);
  • off-heap (np. buffery NIO, cache off-heap z bibliotek takich jak Caffeine w niektórych trybach).

Zarządzanie pamięcią w kodzie: unikanie nadmiernej alokacji i wycieków

Świadome korzystanie z obiektów i kolekcji

Java zachęca do tworzenia obiektów, ale w miejscach o wysokiej częstotliwości wywołań każdy dodatkowy obiekt ma znaczenie. Typowe źródła nadmiernej alokacji:

  • tworzenie nowych obiektów String zamiast użycia istniejących (np. niepotrzebne substring / concat w pętli);
  • użycie „ciężkich” kolekcji (HashMap, ArrayList z dużą początkową pojemnością) tam, gdzie wystarczyłaby mała tablica lub List.of();
  • budowanie małych, tymczasowych list/map dla pojedynczych wywołań.

Przykładowo, zamiast:

List<String> list = new ArrayList<>();
list.add(value);
return list;

często wystarczy:

Unikanie zbędnych obiektów tymczasowych

Każdy obiekt utworzony na gorącej ścieżce (np. w pętli obsługującej żądania) trafi do młodej generacji i prędzej czy później zostanie przeskanowany przez GC. Jeśli tych obiektów są tysiące na sekundę, GC będzie miał pełne ręce roboty, nawet jeśli „wszystko jest poprawne”.

Typowe przykłady:

  • budowanie łańcuchów znaków konkatenacją w pętli zamiast użycia StringBuilder;
  • tworzenie obiektów „helperów” przy każdym wywołaniu, mimo że są bezstanowe;
  • opakowywanie prymitywów w klasy typu Integer, Long tylko po to, by przejść przez interfejs, który mógłby być generiksem na prymitywach (np. IntStream zamiast Stream<Integer>).

Zamiast:

public String join(List<String> parts) {
  String result = "";
  for (String p : parts) {
    result += p;
  }
  return result;
}

lepiej:

W tym miejscu przyda się jeszcze jeden praktyczny punkt odniesienia: Tworzenie własnych skryptów instalacyjnych dla aplikacji Java.

public String join(List<String> parts) {
  StringBuilder sb = new StringBuilder();
  for (String p : parts) {
    sb.append(p);
  }
  return sb.toString();
}

Podobnie z obiektami stateless – zamiast:

public class PriceFormatter {

  public String format(BigDecimal value) {
    DecimalFormat df = new DecimalFormat("#0.00");
    return df.format(value);
  }
}

lepiej utworzyć formater raz:

public class PriceFormatter {

  private final DecimalFormat df = new DecimalFormat("#0.00");

  public String format(BigDecimal value) {
    return df.format(value);
  }
}

Uwaga: w takim przypadku trzeba wziąć pod uwagę bezpieczeństwo wątkowe (np. DecimalFormat nie jest thread-safe). Rozwiązanie: ThreadLocal, osobne instancje na wątek lub użycie klasy, która jest bezpieczna współbieżnie.

Przemyślane rozmiary kolekcji i unikanie nad-alokacji

Kolekcje Javy mają wewnętrzne tablice, które „puchną”, gdy rośnie liczba elementów. Jeśli często inicjujesz listę z domyślną pojemnością, a potem dodajesz do niej np. 1000 elementów, JVM musi kilka razy realokować tablicę i kopiować dane.

W miejscach o stabilnym rozkładzie rozmiaru danych warto ustawić pojemność z góry:

List<Order> orders = new ArrayList<>(estimatedSize);

Jeżeli obiekt przyjmie maksymalnie kilka elementów, a tworzysz go często, rozważ prostą tablicę:

OrderLine[] lines = new OrderLine[3];

Przy małych kolekcjach sporo narzutu dokłada też „opakowanie” typu:

return Collections.singletonList(value);

lub od Javy 9:

return List.of(value);

Te stałe implementacje list dla 1–10 elementów są mocno zoptymalizowane, co często wygrywa z „manualnym” budowaniem ArrayList.

Bufory i recykling obiektów

W niektórych przypadkach opłaca się recykling obiektów (object pooling). Dotyczy to głównie:

  • obiektów ciężkich w tworzeniu (np. parserów, enkoderów);
  • własnych struktur, które zawierają spore bufory tablicowe;
  • przetwarzania strumieni binarnych (np. protokoły sieciowe, pliki).

Zanim jednak wprowadzisz pooling, zmierz, czy faktycznie alokacje są problemem – pulowanie może skomplikować kod i wprowadzić problemy współbieżności.

Przykładowo, zamiast przy każdym odczycie tworzyć nowy bufor:

byte[] readData(InputStream in) throws IOException {
  byte[] buffer = new byte[8192];
  int read = in.read(buffer);
  return Arrays.copyOf(buffer, read);
}

można:

class ReusableBuffer {

  private final byte[] buffer = new byte[8192];

  public byte[] readInto(InputStream in) throws IOException {
    int read = in.read(buffer);
    return Arrays.copyOf(buffer, read);
  }
}

lub przy dużym ruchu – stosować pulę buforów (np. ArrayBlockingQueue<byte[]>). W aplikacjach o wysokiej przepustowości (serwery HTTP, brokerzy) takie zmniejszenie alokacji bardzo uspokaja GC.

Wyciek pamięci w Javie – skąd się bierze, skoro jest GC?

„Wyciek” w Javie to zwykle nie brak zwolnienia pamięci, lecz utrzymywanie referencji do obiektów, które nie są już potrzebne. GC nie może ich usunąć, bo z jego perspektywy nadal są osiągalne.

Typowe źródła:

Do kompletu polecam jeszcze: Wolny WordPress w firmie: diagnostyka przyczyn — znajdziesz tam dodatkowe wskazówki.

  • globalne mapy cache, które rosną w nieskończoność;
  • kolekcje statyczne lub singletony przechowujące dane „na wszelki wypadek”;
  • rejestracje listenerów/event handlerów bez późniejszego wyrejestrowania;
  • wątki i pule wątków z własnymi referencjami do dużych obiektów;
  • niewyczyszczone ThreadLocal.

Uproszczony przykład:

public class UserSessionStore {

  private static final Map<String, UserSession> SESSIONS = new HashMap<>();

  public void registerSession(String id, UserSession session) {
    SESSIONS.put(id, session);
  }
}

Bez mechanizmu usuwania starych sesji mapa będzie rosnąć, mimo że użytkownicy się wylogowali lub sesje wygasły. Po kilku dniach ruchu heap będzie pełny obiektów, które „logicznie” są martwe.

Detekcja wycieków pamięci

Kiedy podejrzewasz wyciek, warto zestawić kilka sygnałów:

  • heap rośnie stopniowo w czasie, a GC nie jest w stanie go „zbić” do poprzedniego poziomu;
  • częstotliwość GC rośnie, ale zużycie pamięci nie spada znacząco;
  • po restarcie proces od razu działa „lekko”, a po kilku godzinach lub dniach – znów zaczyna mielić GC.

Typowa procedura:

  1. Włącz pełne logi GC i obserwuj trend.
  2. W momencie, gdy aplikacja jest „spuchnięta”, zrób heap dump (zrzut pamięci): np. jmap -dump:live,format=b,file=heap.hprof <pid> albo przez JMX/VisualVM.
  3. Załaduj dump do narzędzia typu Eclipse MAT, VisualVM, YourKit, JProfiler.
  4. Sprawdź największe obiekty i dominatory (obiekty, które zatrzymują najwięcej pamięci) – często jest to jedna mapa, lista albo ThreadLocal.

Eclipse MAT (Memory Analyzer Tool) ma wbudowane raporty „Suspected Leak”, które potrafią automatycznie wskazać podejrzane ścieżki referencji.

Bezpieczne korzystanie z ThreadLocal

ThreadLocal jest przydatne do przechowywania kontekstu żądania (np. identyfikatory trace’ów, lokalne formatery dat), ale w środowisku z pulą wątków łatwo o wyciek. Wątek z puli żyje długo, więc wszystko, co trzyma w ThreadLocal, również.

Problemy pojawiają się zwłaszcza w aplikacjach serwerowych:

  • zmiana classloadera (np. redeploy na serwerze aplikacyjnym) przy żywych wątkach;
  • brak czyszczenia ThreadLocal po zakończeniu żądania;
  • przechowywanie w ThreadLocal dużych struktur danych (np. cache obiektów domenowych).

Zabezpieczenie jest proste: po obsłużeniu żądania warto czyścić kontekst:

try {
  RequestContext.set(context);
  // logika biznesowa
} finally {
  RequestContext.clear();
}

Przy własnych filtrach/handlerach HTTP da się to zrealizować na poziomie cross-cutting (np. filtr Servlet, filter w Spring WebFlux, interceptor w Spring MVC).

Cache w pamięci – jak nie zabić GC

Cache w pamięci (in-memory cache) jest szybki, ale jeśli nie ma polityki wygasania, zamieni się w magazyn wszystkiego, co kiedykolwiek widziała aplikacja. To jeden z najczęstszych powodów stopniowego narastania heapu.

Przy projektowaniu cache’a warto zadbać o:

  • limit rozmiaru (liczba elementów, łączny rozmiar szacowany przez wagę);
  • politykę usuwania (LRU, LFU, FIFO itp.);
  • czas życia wpisu (TTL – time to live, TTI – time to idle);
  • monitorowanie hit/miss oraz rozmiaru cache’a.

Biblioteka Caffeine dobrze sprawdza się w aplikacjach Javowych – ma rozbudowane opcje konfiguracji, statystyki oraz przemyślaną implementację współbieżną. Przykład prostego cache’a:

Cache<String, Product> productCache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(10))
    .recordStats()
    .build();

Dzięki limitowi rozmiaru i TTL heap nie będzie rósł bez kontroli. Statystyki (hit rate, evictions) pozwalają ustalić, czy cache w ogóle ma sens, czy tylko zużywa pamięć.

Finalizery, cleaners i zasoby zewnętrzne

Finalizery (finalize()) są przestarzałe i od dawna odradzane – powodują nieprzewidywalne opóźnienia w zwalnianiu zasobów i obciążają GC. Nowocześniejszym mechanizmem są cleaners (java.lang.ref.Cleaner), ale i tu podstawową zasadą jest: zasoby zewnętrzne (połączenia, uchwyty do plików, gniazda sieciowe) należy zwalniać jawnie.

Zamiast liczyć na GC, stosuj:

  • try-with-resources dla AutoCloseable;
  • jasne cykle życia obiektów „zarządzających” (connection pool, klient HTTP);
  • hooki zamykające zasoby przy zamykaniu aplikacji (np. DisposableBean w Spring).

Gdy obiekt trzyma uchwyt do dużego off-heapu (np. ByteBuffer.allocateDirect), poleganie na tym, że GC go „kiedyś” posprząta, kończy się łatwo wyczerpaniem pamięci natywnej przy względnie małym heapie.

Struktura obiektów a lokalność danych

Wydajność to nie tylko liczba obiektów, ale też to, jak są ułożone w pamięci. Przesadna „obiektowość” (obiekt w obiekcie w obiekcie) prowadzi do wielu skoków po pamięci (cache misses w CPU). Dotyczy to zwłaszcza:

  • struktur typu drzewka/kompozyty o bardzo drobnych węzłach;
  • tablic obiektów, gdzie każdy element jest osobną instancją na heapie;
  • zastosowania Optional jako pola w milionach małych obiektów.

Przykładowo, zamiast:

class Point {
  double x;
  double y;
}

List<Point> points = new ArrayList<>();

czasem lepiej wykorzystać dwie tablice prymitywów:

double[] xs = new double[n];
double[] ys = new double[n];

Nie jest to „ładne obiektowo”, ale przy intensywnych obliczeniach numerycznych czy analityce różnica w wydajności potrafi być istotna.

Alokacje w strumieniach i programowaniu funkcyjnym

Strumienie Javy (API Stream) i lambdy są wygodne, lecz potrafią generować dodatkowe obiekty: adaptery, iteratory, klasy anonimowe. W większości przypadków koszt jest akceptowalny, jednak w gorących pętlach może się kumulować.

Kilka zasad praktycznych:

  • na ścieżkach ultra-krytycznych preferuj proste pętle for nad zagnieżdżonymi stream().map().filter().collect();
  • unikaj zbędnego tworzenia kolekcji tymczasowych (np. collect(toList()) tylko po to, by zaraz znów przejść pętlą);
  • rozważ IntStream, LongStream itp. zamiast boxed typów, by zredukować autoboxing.

Porównanie:

long total = invoices.stream()
    .filter(Invoice::isPaid)
    .map(Invoice::getAmount)
    .mapToLong(BigDecimal::longValue)
    .sum();

vs prostsza wersja:

long total = 0L;
for (Invoice i : invoices) {
  if (i.isPaid()) {
    total += i.getAmount().longValue();
  }
}

Druga forma zużyje mniej obiektów pośrednich i będzie przyjaźniejsza dla GC. Różnica jest widoczna przy dużych kolekcjach i częstym wykonywaniu.

Podejście „measure first” w optymalizacjach pamięci

Optymalizacje pamięci zawsze warto opierać na pomiarach. Kolejność działań, która dobrze sprawdza się w praktyce:

  1. Uruchom profilowanie alokacji (np. Flight Recorder, YourKit, JProfiler) na realnym lub zbliżonym do realnego obciążeniu.