Darmowy fragment publikacji:
Tytuł oryginału: Java Performance: In-Depth Advice for Tuning and Programming Java 8, 11, and Beyond,
2nd Edition
Tłumaczenie: Andrzej Watrak
ISBN: 978-83-283-7031-9
© 2021 Helion SA
Authorized Polish translation of the English edition of Java Performance 2E ISBN 9781492056119 ©2020
Scott Oaks
This translation is published and sold by permission of O’Reilly Media, Inc., which owns or controls all
rights to publish and sell the same.
All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means,
electronic or mechanical, including photocopying, recording or by any information storage retrieval system,
without permission from the Publisher.
Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu niniejszej
publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą kserograficzną,
fotograficzną, a także kopiowanie książki na nośniku filmowym, magnetycznym lub innym powoduje
naruszenie praw autorskich niniejszej publikacji.
Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi ich
właścicieli.
Autor oraz Helion SA dołożyli wszelkich starań, by zawarte w tej książce informacje były kompletne
i rzetelne. Nie biorą jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani za związane z tym
ewentualne naruszenie praw patentowych lub autorskich. Autor oraz Helion SA nie ponoszą również
żadnej odpowiedzialności za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce.
Helion SA
ul. Kościuszki 1c, 44-100 Gliwice
tel. 32 231 22 19, 32 230 98 63
e-mail: helion@helion.pl
WWW: http://helion.pl (księgarnia internetowa, katalog książek)
Pliki z przykładami omawianymi w książce można znaleźć pod adresem:
ftp://ftp.helion.pl/przyklady/wydja2.zip
Drogi Czytelniku!
Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres
http://helion.pl/user/opinie/wydja2
Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję.
Printed in Poland.
• Kup książkę
• Poleć książkę
• Oceń książkę
• Księgarnia internetowa
• Lubię to! » Nasza społeczność
Spis treści
Wstęp .......................................................................................................................................................9
Pełny obraz wydajności
Platformy Javy
Platformy sprzętowe
Ogólny zarys książki
Platformy i konwencje
1. Wprowadzenie ............................................................................................................................15
16
16
16
18
21
21
22
22
24
25
25
Pisz lepsze algorytmy
Pisz mniej kodu
Śmiało, przedwcześnie optymalizuj
Rozglądaj się wokoło: baza danych jest zawsze słabym punktem
Optymalizuj pod kątem typowego użycia
Podsumowanie
Testy rzeczywistej aplikacji
Testy przepustowości, operacji wsadowych i czasu odpowiedzi aplikacji
Mikrotesty porównawcze
Makrotesty porównawcze
Mezotesty porównawcze
2. Testowanie wydajności................................................................................................................27
27
27
31
32
34
34
35
36
38
42
45
45
52
55
Analiza zmienności wyników
Zasada wczesnego i częstego testowania
Przykłady testów porównawczych
Czas wykonania operacji wsadowej
Przepustowość
Czas odpowiedzi
Java Microbenchmark Harness
Przykładowe kody
Podsumowanie
3
Poleć książkęKup książkęNarzędzia do monitorowania Javy
Monitorowanie i analiza wydajności systemu operacyjnego
Wykorzystanie procesora
Kolejka procesora
Wykorzystanie dysku
Wykorzystanie sieci
Podstawowe informacje o maszynie wirtualnej
Informacje o wątkach
Informacje o klasach
Bieżąca analiza mechanizmu porządkowania pamięci
Przetwarzanie zrzutu sterty
3. Narzędzia wydajnościowe ........................................................................................................... 57
57
58
61
62
64
65
66
69
69
69
70
70
70
74
75
77
77
79
79
86
89
91
Profilery próbujące
Profilery instrumentalizujące
Metody blokujące i szeregi czasowe wątków
Natywne profilery
Java Mission Control
Ogólne informacje o JFR
Włączenie funkcjonalności JFR
Wybieranie zdarzeń
Profilowanie maszyny JVM
Java Flight Recorder
Podsumowanie
Kompilacja HotSpot
Ogólne informacje o kompilatorze
Kompilacja etapowa
Popularne flagi kompilatora
Strojenie pamięci podręcznej kodu
Monitorowanie procesu kompilacji
Poziomy kompilacji etapowej
Deoptymalizacja
4. Kompilator JIT ............................................................................................................................ 93
93
95
96
97
97
99
102
103
106
106
107
109
110
111
Wartości progowe
Wątki kompilatora
Rozwijanie metod
Analiza ucieczki
Kod procesora
Zaawansowane flagi kompilatora
4
Spis treści
Poleć książkęKup książkęKompromisy kompilacji etapowej
Maszyna GraalVM
Prekompilacja
Kompilacja z wyprzedzeniem
Natywna kompilacja w maszynie GraalVM
Podsumowanie
112
114
115
115
118
119
Podstawy strojenia kolektora
Ogólne informacje o porządkowaniu pamięci
Porządkowanie generacji obiektów
Algorytmy porządkowania pamięci
Wybór kolektora
5. Wprowadzenie do porządkowania pamięci.................................................................................121
121
123
125
127
135
135
138
140
142
143
143
144
147
Wielkość sterty
Dobór wielkości generacji
Dobór wielkości metaprzestrzeni
Sterowanie wielowątkowością
Włączanie dzienników kolektorów w pakiecie JDK 8
Włączanie dzienników kolektorów w pakiecie JDK 11
Narzędzia do monitorowania porządkowania pamięci
Podsumowanie
Kolektor równoległy
Adaptywne i statyczne skalowanie sterty
6. Algorytmy porządkowania pamięci ............................................................................................149
149
152
156
165
168
Strojenie kolektora G1
Kolektor CMS
Kolektor G1
Strojenie kolektora w celu uniknięcia awarii trybu współbieżnego
porządkowania pamięci
Zaawansowane strojenie kolektorów
Okres dojrzewania obiektów i obszar obiektów ocalonych
Alokowanie dużych obiektów
Flaga AggressiveHeap
Pełna kontrola wielkości sterty
Kolektory eksperymentalne
Współbieżne scalanie sterty: kolektory ZGC i Shenandoah
Bez porządkowania: kolektor Epsilon
Podsumowanie
173
176
176
180
186
187
189
189
191
192
Spis treści
5
Poleć książkęKup książkęAnaliza sterty
Zmniejszenie zajętości pamięci
Histogram sterty
Zrzut sterty
Problem przepełnienia pamięci
7. Dobre praktyki używania sterty ................................................................................................. 195
195
196
197
201
205
206
208
212
214
214
219
232
233
Zarządzanie cyklem życia obiektów
Obiekty wielokrotnego użytku
Odwołania miękkie, słabe i inne
Skompresowane wskaźniki
Zmniejszanie wielkości obiektów
Leniwe inicjowanie obiektu
Obiekty niemutowalne i kanoniczne
Podsumowanie
Obciążenie pamięci
Monitorowanie obciążenia pamięci
Minimalizacja obciążenia pamięci
Monitorowanie ilości pamięci natywnej
Natywna pamięć i współdzielone biblioteki
8. Dobre praktyki używania natywnej pamięci ............................................................................... 235
235
236
237
238
241
244
245
249
Strojenie maszyny JVM pod kątem systemu operacyjnego
Duże strony pamięci
Podsumowanie
9.
Pule wątków i klasa ThreadPoolExecutors
Dobór maksymalnej liczby wątków
Dobór minimalnej liczby wątków
Wielkość kolejki zadań
Klasa ThreadPoolExecutor
Synchronizacja wątków i wydajność aplikacji ............................................................................. 251
251
Wątki i sprzęt
252
252
256
258
258
260
265
266
268
269
272
274
Cena synchronizacji
Zapobieganie synchronizacji
Fałszywe współdzielenie danych
Wykradanie pracy
Automatyczne zrównoleglanie operacji
Synchronizacja wątków
Klasa ForkJoinPool
6
Spis treści
Poleć książkęKup książkęStrojenie wątków maszyny JVM
Strojenie wielkości stosów wątków
Preferencje blokowania
Priorytety wątków
Monitorowanie wątków i blokad
Monitorowanie wątków
Monitorowanie zablokowanych wątków
Podsumowanie
278
278
279
280
280
280
280
284
10.
Strojenie puli wątków serwera
Asynchroniczne serwery REST
Asynchroniczne zapytania wychodzące
Asynchroniczne zapytania HTTP
Serwery Java .............................................................................................................................285
285
Przegląd operacji NIO w Javie
Kontenery serwerowe
287
287
289
291
291
298
298
299
301
302
Przetwarzanie danych JSON
Przetwarzanie danych
Obiekty JSON
Parsowanie danych JSON
Podsumowanie
Przykładowa baza danych
Interfejs JDBC
Sterowniki JDBC
Pule połączeń JDBC
Preparowane zapytania i pule zapytań
Transakcje
Przetwarzanie zestawu wyników
11. Wydajność baz danych — dobre praktyki...................................................................................303
303
304
304
306
307
309
316
318
318
319
323
329
330
Optymalizacja zapisu danych w platformie JPA
Optymalizacja odczytu danych w platformie JPA
Bufor platformy JPA
Spring Data
Podsumowanie
Platforma JPA
Spis treści
7
Poleć książkęKup książkęBuforowanie operacji wejścia/wyjścia
Ładowanie klas
Współdzielenie danych klas
Liczby losowe
Interfejs JNI
Wyjątki
Dzienniki
Kolekcje
Kompaktowe ciągi znaków
Duplikowanie i internowanie ciągów
Łączenie ciągów znaków
Java SE API — porady............................................................................................................... 331
Ciągi znaków
331
331
332
338
341
343
343
346
349
351
354
356
356
357
358
360
362
362
364
364
365
367
369
371
Serializacja obiektów
Pola przejściowe
Przesłanianie domyślnej serializacji
Kompresja danych
Śledzenie duplikatów obiektów
Kolekcje synchroniczne i asynchroniczne
Wielkość kolekcji
Kolekcje i wykorzystanie pamięci
Funkcje lambda i klasy anonimowe
Wydajność strumieni i filtrów
Leniwe przetwarzanie danych
12.
Podsumowanie
Dodatek. Flagi maszyny JVM............................................................................................................. 373
8
Spis treści
Poleć książkęKup książkęROZDZIAŁ 7.
Dobre praktyki używania sterty
W rozdziałach 5. i 6. szczegółowo opisałem, jak dostroić kolektor, aby jego wpływ na działanie
aplikacji był jak najmniejszy. Strojenie kolektora to ważna operacja, ale zazwyczaj jeszcze lepszy efekt
daje stosowanie dobrych praktyk programistycznych. W tym rozdziale opisuję kilka takich prak-
tyk dotyczących wykorzystania sterty.
W tym kontekście pojawiają się dwa sprzeczne podejścia. Podstawową zasadą jest oszczędne korzy-
stanie z obiektów i usuwanie ich tak szybko, jak jest to możliwe. Im mniej pamięci wykorzystuje
aplikacja, tym efektywniej działa kolektor. Z drugiej strony, częste tworzenie i usuwanie obiektów
pogarsza ogólną wydajność aplikacji, nawet jeżeli kolektor działa sprawniej. Jeżeli natomiast obiekty
są używane wielokrotnie, aplikacja działa znacznie efektywniej. Obiekty można wielokrotnie wyko-
rzystywać na kilka sposobów, m.in. stosując lokalne zmienne wątkowe, specjalne odwołania i pule
obiektów. Tego rodzaju obiekty są długotrwałe i wpływają na działanie kolektora; umiejętnie wyko-
rzystywane, poprawiają ogólną wydajność aplikacji.
W tym rozdziale opisuję różne podejścia i ich konsekwencje. Najpierw jednak przyjrzyjmy się kilku
narzędziom, dzięki którym można się dowiedzieć, co się dzieje wewnątrz sterty.
Analiza sterty
Dziennik kolektora i narzędzia opisane w rozdziale 5. są doskonałymi źródłami informacji o wpływie
porządkowania pamięci na działanie aplikacji. Jednak aby uzyskać jeszcze dokładniejszy obraz,
trzeba zajrzeć do samej sterty. Narzędzia opisane w tym podrozdziale dostarczają informacji
o obiektach aktualnie wykorzystywanych przez aplikację.
Większość narzędzi uwzględnia tylko aktywnie wykorzystywane obiekty. Te, które zostaną usunięte
podczas najbliższego pełnego porządkowania pamięci, nie są uwzględniane w prezentowanych
wynikach. W pewnych sytuacjach narzędzia te inicjują pełne porządkowanie pamięci, co może mieć
wpływ na działanie aplikacji. Oprócz tego analizują całą stertę i zbierają informacje o wykorzy-
stywanych obiektach, ale nie usuwają tych, które nie są już potrzebne. W obu przypadkach narzędzia
wykorzystują zasoby komputera przez pewien czas, dlatego nie należy ich stosować do mierzenia
wydajności aplikacji.
195
Poleć książkęKup książkęHistogram sterty
Ograniczanie zajętości pamięci jest bardzo ważne, ale tak jak w każdej kwestii dotyczącej wydaj-
ności, należy skupić się na maksymalizacji możliwych do uzyskania korzyści. W dalszej części roz-
działu poznasz przykład leniwego inicjowania obiektu typu Calendar. Jest to operacja, dzięki której
można zaoszczędzić 640 bajtów pamięci. Jednak jeżeli ten sam obiekt będzie inicjowany wielokrotnie,
różnica w wydajności aplikacji będzie niezauważalna. Celem analizy sterty jest uzyskanie informacji
o obiektach zajmujących duże ilości pamięci. Najłatwiej można to osiągnąć, tworząc histogram sterty.
Jest to szybki sposób dający wgląd w liczbę obiektów bez konieczności zrzucania całej sterty (zrzut
zajmuje sporo miejsca na dysku, a jego analiza trwa dłuższą chwilę). Jeżeli za brak pamięci są odpo-
wiedzialne obiekty kilku określonych typów, z pomocą histogramu można je szybko zidentyfikować.
Histogram sterty można utworzyć za pomocą narzędzia jcmd. Ilustruje to poniższy przykład. Proces
analizowanej aplikacji miał w tym przypadku identyfikator 8898:
jcmd 8998 GC.class_histogram
8898:
num #instances #bytes class name
----------------------------------------------
1: 789087 31563480 java.math.BigDecimal
2: 172361 14548968 [C
3: 13224 13857704 [B
4: 184570 5906240 java.util.HashMap$Node
5: 14848 4188296 [I
6: 172720 4145280 java.lang.String
7: 34217 3127184 [Ljava.util.HashMap$Node;
8: 38555 2131640 [Ljava.lang.Object;
9: 41753 2004144 java.util.HashMap
10: 16213 1816472 java.lang.Class
Na początku histogramu zazwyczaj znajdują się informacje o tablicy znaków ([C) i obiektach typu
String, ponieważ są to najczęściej wykorzystywane obiekty. Często pojawiają się też tablice bajtów
([B) i obiektów ([Ljava.lang.Object;), ponieważ w tego typu strukturach przechowują swoje dane
obiekty ładujące klasy (ang. classloaders). Jeżeli zawartość histogramu nie jest dla Ciebie zrozumiała,
zapoznaj się z dokumentacją do interfejsu JNI.
W tym przykładzie należy wyjaśnić obecność obiektów typu BigDecimal. Wiadomo, że aplikacja
tworzy wiele krótkotrwałych obiektów tego typu, ale tak duża ich liczba jest czymś nietypowym.
Histogram zawiera tylko obiekty wykorzystywane w aplikacji, ponieważ polecenie użyte do jego
utworzenia wymusza pełne porządkowanie pamięci. Jeżeli użyje się parametru -all, porządkowanie
nie zostanie wykonane i histogram będzie uwzględniał wszystkie obiekty, również te niepotrzebne
(bez odwołań).
Podobny wynik można uzyskać za pomocą następującego polecenia:
jmap -histo identyfikator_procesu
Wynik powyższego polecenia uwzględnia obiekty przeznaczone do usunięcia. Aby wymusić pełne
porządkowanie pamięci, należy użyć następującego polecenia:
jmap -histo:live identyfikator_procesu
196
Rozdział 7. Dobre praktyki używania sterty
Poleć książkęKup książkęHistogram jest prostym raportem, więc warto go tworzyć w zautomatyzowanym systemie testowania
aplikacji. Nie należy go jednak generować podczas mierzenia wydajności ustabilizowanej aplikacji,
ponieważ zajmuje to kilka sekund, a ponadto inicjuje pełne porządkowanie pamięci.
Zrzut sterty
Histogram doskonale nadaje się do diagnozowania problemów wynikających z alokowania zbyt
wielu instancji jednej lub kilku określonych klas. Jednak do przeprowadzenia głębszej analizy
potrzebny jest zrzut sterty. Dostępnych jest wiele narzędzi do wykonywania tego rodzaju analiz.
Większość z nich pozwala podłączyć się pod działającą aplikację i tworzyć zrzut. Najłatwiej robi
się to za pomocą następujących poleceń:
jcmd identyfikator_procesu GC.heap_dump /ścieżka/zrzut.hprof
lub
jmap -dump:live,file=/ścieżka/zrzut.hprof identyfikator_procesu
Opcja live w powyższym poleceniu powoduje, że narzędzie przed zrzuceniem sterty przeprowa-
dza jej pełne porządkowanie. Jest to domyślne działanie narzędzia. Jeżeli jednak z jakiegoś powodu
zrzut powinien zawierać niewykorzystywane obiekty, należy na końcu polecenia użyć parametru
-all. Narzędzie, wykonując pełne porządkowania pamięci, wprowadza oczywiście długą pauzę
w działaniu aplikacji. Jednak nawet bez porządkowania aplikacja jest wstrzymywana na dość długi
czas, niezbędny do zapisania sterty w pliku.
Oba powyższe polecenia tworzą plik o nazwie zrzut.hprof we wskazanym katalogu. Do analizowania
zrzutu służy wiele różnych narzędzi. Poniżej opisane są najczęściej stosowane.
jvisualvm
W zakładce Monitor powyższego narzędzia można zrzucić stertę działającej aplikacji lub
otworzyć zrzut wygenerowany wcześniej. Oprócz tego można przeglądać zawartość sterty,
wyszukiwać największe obiekty i przetwarzać stertę za pomocą specjalnych zapytań.
mat
Do bezpłatnego narzędzia EclipseLink Memory Analyzer Tool (mat) można załadować jeden lub
kilka zrzutów i analizować je. Narzędzie tworzy raporty zawierające informacje o źródłach poten-
cjalnych problemów. Umożliwia też przeglądanie sterty za pomocą zapytań podobnych do SQL.
Analiza sterty obejmuje przede wszystkim zachowaną pamięć obiektów (ang. retained memory),
tj. taką, która byłaby dostępna, gdyby można było te obiekty usunąć. Na rysunku 7.1 jest to pamięć
zajmowana przez obiekt Smyczki Trio, jak również obiekty Sylwia i Dawid. Nie jest to jednak pamięć
zajmowana przez obiekt Marek, ponieważ odwołuje się do niego inny obiekt. Dlatego nie można go
usunąć podczas porządkowania pamięci wraz z obiektem Smyczki Trio.
Obiekty zajmujące duże ilości pamięci zachowanej są nazywane dominatorami sterty. Jeżeli narzę-
dzie analityczne pokazuje, że sterta jest zdominowana przez kilka obiektów, problem jest prosty.
Aby go rozwiązać, wystarczy zmniejszyć ich liczbę, używać ich przez krótszy czas, uprościć ich
strukturę lub pomniejszyć je. Wprawdzie łatwiej to powiedzieć niż zrobić, ale przynajmniej analiza
jest prosta.
Analiza sterty
197
Poleć książkęKup książkęRysunek 7.1. Struktura zachowanej pamięci
Wielkości płytkich, zachowanych i głębokich obiektów
Dwa inne często stosowane w analizie sterty terminy to obiekty płytkie (ang. shallow) i głębokie (ang.
deep). Określenie wielkość obiektu płytkiego oznacza wielkość samego obiektu. Jeżeli dany obiekt zawiera
odwołania do innych obiektów, powyższy termin uwzględnia dodatkowe 4 bajty (lub 8 bajtów) zajmo-
wane przez każde odwołanie, ale nie obejmuje wielkości obiektów docelowych.
Termin wielkość głębokiego obiektu obejmuje również wielkości obiektów, do których odwołuje się
dany obiekt. Różnica pomiędzy wielkością głębokiego obiektu a zachowaną pamięcią to suma wielkości
obiektów współdzielonych. Na rysunku 7.1 pamięć zajmowana przez obiekt Marek jest uwzględniona
w wielkości głębokiego obiektu Flety Duet, ale nie w jego pamięci zachowanej.
Zazwyczaj jednak w celu zdiagnozowania problemu trzeba przeprowadzać dochodzenie detektywi-
styczne, ponieważ często obiekty są współdzielone. Wielkość tego rodzaju obiektów, jak np. Marek
na powyższym rysunku, nie jest uwzględniana w zachowanej pamięci, ponieważ usunięcie nadrzęd-
nego obiektu nie powoduje usunięcia obiektu współdzielonego. Ponadto najwięcej zachowanej
pamięci zajmują obiekty ładujące klasy, nad którymi nie ma się praktycznie żadnej kontroli.
Rysunek 7.2 przedstawia ekstremalny przypadek serwera danych giełdowych. Na początku listy
znajdują się obiekty zajmujące najwięcej pamięci zachowanej. Serwer umieszcza obiekty z silnymi
i słabymi odwołaniami odpowiednio w pamięci podręcznej i w globalnej tablicy mieszającej. Zatem
w pamięci podręcznej znajdują się obiekty zawierające wiele odwołań.
Obiekty zajmują 1,4 GB sterty (ta wartość nie jest widoczna w narzędziu). Jednak największy zbiór
obiektów posiadających jedno odwołanie ma wielkość tylko 6 MB (nie powinno dziwić, że jest to
część platformy ładującej klasy). Przeglądanie obiektów zajmujących najwięcej pamięci zachowanej
nie ułatwia diagnostyki problemów z pamięcią.
W tym przykładzie widocznych jest wiele instancji klasy StockPriceHistoryImpl, z których każda
zajmuje sporo pamięci zachowanej. Na podstawie ilości pamięci zajmowanej przez te obiekty można
wywnioskować, że tu leży problem. Jednak zazwyczaj obiekty mogą być współdzielone, więc anali-
zując zachowaną pamięć, nie da się wyciągnąć jednoznacznych wniosków.
198
Rozdział 7. Dobre praktyki używania sterty
Poleć książkęKup książkęRysunek 7.2. Widok pamięci zachowanej w narzędziu Memory Analyzer
Kolejnym ważnym krokiem może być utworzenie histogramu obiektów, przedstawionego na
rysunku 7.3.
Rysunek 7.3. Histogram obiektów w narzędziu Memory Analyzer
Histogram zawiera podsumowanie informacji o obiektach tych samych typów. W tym przypadku
widać wyraźnie, że źródłem problemu jest 7 milionów obiektów typu TreeMap$Entry, zajmujących
przestrzeń o wielkości 1,4 GB. Za pomocą narzędzia Memory Analyzer można łatwo wyśledzić tego
rodzaju obiekty i sprawdzić ich zawartość, nawet gdy nie wiadomo, co się dzieje wewnątrz aplikacji.
Za pomocą narzędzia do analizy sterty można łatwo znaleźć główne obiekty (lub ich zbiór, jak
w tym przykładzie), od których zaczyna się porządkowanie pamięci. Jednak zazwyczaj nie jest to
pomocna informacja. Obiekty główne zawierają statyczne, globalne odwołania do poszukiwanych
obiektów, prowadzące zazwyczaj przez długi łańcuch innych obiektów. Najczęściej są to statyczne
Analiza sterty
199
Poleć książkęKup książkęzmienne w klasie znajdującej się w ścieżce systemowej lub ścieżce bootstrap. Jest to m.in. klasa
Thread wraz ze wszystkimi aktywnymi wątkami. Wątki zapisują obiekty za pośrednictwem lokalnych
zmiennych lub odwołań do docelowego obiektu Runnable (ewentualnie do klasy pochodnej od
Thread i wszystkich zawartych w niej odwołań, jak w tym przykładzie).
Czasami informacja o głównych obiektach jest pomocna. Jednak jeżeli obiekt zawiera wiele odwołań,
to jest połączony z wieloma obiektami głównymi. Odwołania tworzą strukturę odwróconego
drzewa. Załóżmy, że dwa obiekty odwołują się do obiektu TreeMap$Entry. Do każdego z nich mogą
odwoływać się dwa inne obiekty, z kolei do każdego z tych obiektów mogą odwoływać się trzy inne
itd. Liczba odwołań, gwałtownie rosnąca w miarę zbliżania się obiektów głównych, oznacza, że do
każdego obiektu odwołuje się wiele obiektów głównych.
Dlatego bardziej pomocne jest wcielenie się w detektywa i znalezienie współdzielonego obiektu
znajdującego się na najniższym poziomie struktury. Można to osiągnąć analizując obiekty i odwołania
do nich do momentu znalezienia powielonych ścieżek. W tym przykładzie do obiektów Stock
PriceHistoryImpl odwołują się dwa inne obiekty: ConcurrentHashMap, zawierający atrybuty sesji,
oraz WeakHashMap, będący globalną pamięcią podręczną.
Na rysunku 7.4 rozwinięte są tylko początki ścieżek dwóch obiektów. Aby się przekonać, że zawie-
rają one dane sesji, należy dalej rozwijać ścieżkę obiektu ConcurrentHashMap, aż stanie się to oczywiste.
Podobnie należy potraktować obiekt WeakHashMap.
Rysunek 7.4. Wsteczne ścieżki odwołań widoczne w narzędziu Memory Analyzer
W tym przykładzie, z racji zastosowanych typów obiektów, analiza była nieco prostsza niż zazwyczaj
bywa w praktyce. Jeżeli główne dane wykorzystywane w aplikacji są przechowywane w obiektach
typu String, a nie BigDecimal, lub w strukturach typu HashMap, a nie TreeMap, wtedy zadanie staje się
trudniejsze. Zrzut sterty może zawierać setki tysięcy obiektów typu String i HashMap, więc znalezienie
tych właściwych jest żmudną czynnością. Zgodnie z niepisaną zasadą, należy wtedy zacząć od obiektów
reprezentujących kolekcje, np. HashMap, a nie wejścia, jak HashMap$Entry, i poszukać największych
kolekcji.
200
Rozdział 7. Dobre praktyki używania sterty
Poleć książkęKup książkęKrótkie podsumowanie
Znalezienie obiektów zajmujących najwięcej pamięci jest pierwszym krokiem do
optymalizacji kodu.
Za pomocą histogramu można szybko i łatwo diagnozować problemy w pamięcią,
których źródłem jest duża liczba obiektów określonego typu.
Analiza zrzutu sterty jest jedną z najskuteczniejszych technik diagnozowania problemów
z zajętością pamięci. Wymaga jednak cierpliwości i wysiłku.
Problem przepełnienia pamięci
Maszyna JVM zgłasza błąd przepełnienia pamięci w następujących przypadkach:
braku wystarczającej ilości natywnej pamięci,
zapełnienia metaprzestrzeni,
zapełnienia sterty (aplikacja nie jest w stanie tworzyć nowych obiektów),
zbyt długiego czasu porządkowania pamięci.
Najczęściej występują dwa ostatnie przypadki, które są związane ze stertą. Jednak przepełnienie
pamięci nie oznacza od razu, że problem dotyczy sterty. W takiej sytuacji trzeba sprawdzić, co jest
przyczyną błędu (zazwyczaj zawiera ją opis zgłoszonego wyjątku).
Brak wystarczającej ilości natywnej pamięci
Pierwsza wymieniona na powyższej liście przyczyna, brak wystarczającej ilości fizycznej pamięci, nie
jest związana ze stertą. Maszyna JVM w wersji 32-bitowej jest w stanie obsłużyć pamięć o wielkości
4 GB (w starszych wersjach systemu Windows było to 3 GB, a Linux — 3,5 GB). Skonfigurowanie
bardzo dużej sterty, np. 3,8 GB, powoduje niebezpieczne zbliżenie się do tej granicy. Nawet dla
64-bitowej maszyny JVM system operacyjny może nie mieć wystarczającej ilości wirtualnej pamięci.
Ten temat będzie dokładniej opisany w rozdziale 8. Należy jednak pamiętać, że gdy pojawi się
komunikat o braku wystarczającej ilości natywnej pamięci, strojenie sterty nic nie da. Zamiast tego
należy dokładniej przyjrzeć się komunikatowi. Na przykład poniższy informuje, że przepełniła się
natywna pamięć przeznaczona dla stosu wątków:
Exception in thread main java.lang.OutOfMemoryError:
unable to create new native thread
Czasami zdarza się, że maszyna JVM zgłasza problem z powodu zupełnie niezwiązanego z natywną
pamięcią. Aplikacje zazwyczaj mogą uruchamiać ograniczoną liczbę wątków, określoną przez
system operacyjny lub kontener. Na przykład w systemie Linux aplikacja może utworzyć 1024 wątki
(wartość tę można sprawdzić za pomocą polecenia ulimit -u). Przy próbie uruchomienia 1025.
wątku pojawi się błąd OutOfMemoryError i wygenerowany zostanie komunikat o niewystarczającej
ilości natywnej pamięci, choć rzeczywistą przyczyną problemu jest przekroczenie określonej przez
system operacyjny liczby wątków.
Analiza sterty
201
Poleć książkęKup książkęZapełnienie metaprzestrzeni
Błąd zapełnienia metaprzestrzeni również nie jest związany ze stertą. Pojawia się wtedy, gdy zapełni
się natywna pamięć zajmowana przez metaprzestrzeń. Ponieważ domyślnie maksymalna wielkość
metaprzestrzeni nie jest określona, błąd ten jest zazwyczaj efektem zdefiniowania tej granicy (powód,
dla którego się to robi, jest opisany w dalszej części rozdziału).
Są dwie główne przyczyny tego błędu. Pierwsza jest prosta — aplikacja wykorzystuje zbyt dużo klas,
które nie mieszczą się w zdefiniowanej metaprzestrzeni (patrz rozdział 5., „Dobór wielkości meta-
przestrzeni”). Druga przyczyna jest mniej oczywista: wyciek pamięci podczas ładowania klas. Naj-
częściej zdarza się to w serwerach dynamicznie ładujących klasy. Jednym z przykładów jest serwer
aplikacji Java EE. Każda zainstalowana w nim aplikacja wykorzystuje własny obiekt ładujący klasy,
dzięki czemu aplikacje są od siebie odizolowane, nie współdzielą klas i nie interferują ze sobą.
W środowisku programistycznym, za każdym razem, gdy aplikacja jest modyfikowana, jest ponow-
nie instalowana. Tworzony jest nowy obiekt ładujący klasy, a stary wychodzi poza zakres widocz-
ności. Gdy tak się stanie, metadane klas mogą zostać usunięte. Jeżeli jednak obiekt ładujący klasy
nie wyjdzie poza zakres widoczności, nie można usunąć metadanych klas. W efekcie metaprzestrzeń
zapełnia się i pojawia się błąd. W takim przypadku można powiększyć metaprzestrzeń, choć
w rzeczywistości spowoduje to tylko odłożenie problemu w czasie.
Jeżeli tego typu sytuacja ma miejsce w serwerze aplikacji, jedyne, co można zrobić, to poinformo-
wanie dostawcy serwera o konieczności opracowania poprawki zapobiegającej wyciekowi pamięci.
Jeżeli tworzona aplikacja wykorzystuje dużo obiektów ładujących klasy, należy upewnić się, czy je
poprawnie usuwa, a w szczególności, czy kontekst żadnego wątku nie obejmuje tymczasowego
obiektu ładującego klasy. Aby zdiagnozować tego typu przypadek, trzeba wykonać opisaną wcze-
śniej analizę zrzutu sterty. W histogramie należy odszukać wszystkie instancje klasy ClassLoader,
znaleźć ich obiekty główne i sprawdzić ich zawartość.
Kluczowe znaczenie w diagnostyce tego rodzaju przypadków ma — jak poprzednio — dokładna
analiza komunikatu o przepełnieniu pamięci. Jeżeli metaprzestrzeń zostanie zapełniona, pojawi się
następująca informacja:
Exception in thread main java.lang.OutOfMemoryError: Metaspace
Między innymi z powodu wycieku pamięci podczas ładowania klas należy określać maksymalną
wielkość metaprzestrzeni. Jeżeli się tego nie zrobi, wtedy z powodu wycieku obiektów ładujących
klasy zostanie zapełniona cała pamięć komputera.
Zapełnienie sterty
Gdy zapełni się sterta, pojawia się następujący komunikat:
Exception in thread main java.lang.OutOfMemoryError: Java heap space
Zazwyczaj przyczyna zapełnienia sterty jest taka sama, jak opisana wcześniej przyczyna zapełnienia
metaprzestrzeni. Aplikacja po prostu potrzebuje większej sterty, ponieważ na aktualnie zdefiniowanej
nie mieszczą się wszystkie wykorzystywane obiekty. Innym powodem może być wyciek pamięci, gdy
aplikacja tworzy kolejne obiekty, a stare nie wychodzą poza zakres widoczności. W pierwszym
przypadku powiększenie sterty może rozwiązać problem, natomiast w drugim tylko go odsunie
202
Rozdział 7. Dobre praktyki używania sterty
Poleć książkęKup książkęw czasie. W obu przypadkach jednak należy wykonać analizę zrzutu sterty w celu znalezienia obiek-
tów, które zajmują najwięcej pamięci, natomiast rozwiązaniem jest zmniejszenie liczby lub wielkości
tworzonych obiektów. Jeżeli w aplikacji ma miejsce wyciek pamięci, należy wykonać kilka zrzutów
pamięci w kilkuminutowych odstępach i porównać je ze sobą. W tym celu mona wykorzystać
wbudowaną funkcjonalność narzędzia Memory Analyzer. Po załadowaniu dwóch zrzutów można
utworzyć histogramy i wyliczyć różnice między nimi.
Automatyczne zrzucanie pamięci
Błędy przepełnienia pamięci pojawiają się niespodziewanie, więc nigdy nie wiadomo, kiedy należy utwo-
rzyć zrzut sterty. Dlatego pojawiły się następujące flagi:
-XX:+HeapDumpOnOutOfMemoryError
Włączenie tej flagi (o domyślnej wartości false) powoduje, że maszyna JVM będzie tworzyła zrzut
sterty za każdym razem, gdy pojawi się błąd przepełnienia pamięci.
-XX:HeapDumpPath= ścieżka
Za pomocą tej flagi określa się miejsce, w którym ma być utworzony zrzut. Domyślnie flaga zawiera
nazwę bieżącego katalogu aplikacji. Ścieżka może być nazwą katalogu lub pliku. W pierwszym
przypadku plik otrzymuje domyślną nazwę java_pid pid .hprof.
-XX:+HeapDumpAfterFullGC
Flaga powodująca utworzenie zrzutu po pełnym uporządkowaniu pamięci.
-XX:+HeapDumpBeforeFullGC
Flaga powodująca utworzenie zrzutu przed pełnym uporządkowaniem pamięci.
Jeżeli tworzonych jest kilka zrzutów, np. z powodu wielokrotnego pełnego porządkowania pamięci,
nazwy plików są uzupełniane o kolejne numery.
Powyższych flag należy używać w sytuacjach, gdy aplikacja niespodziewanie wyświetla komunikaty
o przepełnieniu pamięci i w celu ustalenia przyczyny problemu niezbędna jest analiza sterty. Należy
pamiętać, że tworzenie zrzutu wprowadza pauzę w działaniu aplikacji, ponieważ zawartość sterty jest
zapisywana na dysku.
Rysunek 7.5 przedstawia typowy przypadek wycieku pamięci spowodowany klasą kolekcji, tutaj
HashMap. Tego rodzaju klasy przyczyniają się do przepełnienia pamięci najczęściej. Aplikacja
umieszcza elementy w kolekcji, ale ich nie usuwa. Rysunek przedstawia histogram porównawczy,
zawierający różnice w liczbach poszczególnych obiektów w dwóch zrzutach. Na przykład drugi zrzut
zawiera 19 744 obiektów typu Integer więcej niż pierwszy.
Najskuteczniejszym sposobem rozwiązania problemu jest taka zmiana kodu aplikacji, aby elementy
kolekcji były sukcesywnie usuwane, gdy przestaną być potrzebne. Ewentualnie można stosować
kolekcje wykorzystujące słabe lub miękkie odwołania. Elementy takich kolekcji są automatycznie
usuwane, gdy żadne obiekty się do nich nie odwołują. Kolekcje te mają jednak swoją cenę, o czym
będzie mowa w dalszej części rozdziału.
Często zdarza się, że maszyna JVM po zgłoszeniu wyjątku kontynuuje działanie, ponieważ problem
dotyczy tylko jednego wątku. Przeanalizujmy przykład aplikacji wykonującej obliczenia za pomocą
dwóch wątków. W pewnym momencie jeden z nich zgłasza błąd OutOfMemoryError. Domyślnie
procedura obsługi wątku wyświetla stos wywołań, a sam wątek kończy działanie.
Analiza sterty
203
Poleć książkęKup książkęRysunek 7.5. Histogram porównawczy
Jednak drugi wątek pozostaje aktywny, więc maszyna JVM działa dalej. Ponieważ pierwszy wątek,
w którym wystąpił błąd, zakończył działanie, prawdopodobnie przy następnym porządkowaniu
pamięci duża jej ilość zostanie zwolniona. Będą to wszystkie obiekty wykorzystywane przez zakoń-
czony wątek, do których nie odwołuje się drugi, aktywny wątek. Zatem drugi wątek będzie działał
dalej i będzie dysponował wystarczająco dużą stertą, aby dokończyć swoje zadania.
W opisany wyżej sposób funkcjonują platformy serwerowe wykorzystujące pule wątków do obsługi
zapytań. Przechwytują błędy i zapobiegają dzięki temu przerywaniu działania wątków. Nie dotyczy
to jednak opisywanego tu problemu, ponieważ pamięć, którą wątek wykorzystuje w celu obsłużenia
zapytania, jest zwalniana podczas jej porządkowania.
Zatem błąd przepełnienia pamięci ma fatalne skutki jedynie wtedy, gdy dotyczy ostatniego wątku,
innego niż wykorzystywany wewnętrznie przez maszynę JVM. W przypadku platformy serwerowej
tak się jednak nie zdarza nigdy, a bardzo rzadko ma to miejsce w przypadku zwykłej aplikacji wyko-
rzystującej kilka wątków. Zazwyczaj mechanizm obsługi błędów działa dobrze, a pamięć zajęta na
potrzeby obsługi aktywnego zapytania jest często zwalniania podczas jej kolejnego porządkowania.
Jeżeli maszyna JVM ma kończyć działanie po przepełnieniu sterty, należy użyć flagi -XX:+ExitOnOut
OfMemoryError, która domyślnie ma wartość false.
Zbyt długi czas porządkowania pamięci
Opisany w poprzednim podrozdziale mechanizm obsługi błędów opiera się na założeniu, że gdy
zostanie zgłoszony błąd przepełnienia pamięci, cała wykorzystywana przez niego pamięć będzie
zwolniona przy jej najbliższym porządkowaniu. Okazuje się, że założenie to nie zawsze jest słuszne.
204
Rozdział 7. Dobre praktyki używania sterty
Poleć książkęKup książkęDochodzimy w ten sposób do ostatniej przyczyny błędu, tj. zbyt długiego czasu porządkowania
pamięci, sygnalizowanego następującym komunikatem:
Exception in thread main java.lang.OutOfMemoryError: GC overhead limit exceeded
Błąd pojawia się wtedy, gdy są spełnione wszystkie poniższe warunki:
Procentowy udział czasu poświęcanego na porządkowanie pamięci przekracza wartość okre-
śloną za pomocą flagi -XX:GCTimeLimit=N. Domyślnie flaga ta ma wartość 98, co oznacza, że
pamięć jest porządkowana przez 98 czasu.
Ilość pamięci zwalnianej podczas pełnego porządkowania pamięci jest mniejsza od wartości
określonej za pomocą flagi -XX:GCHeapFreeLimit=N. Domyślnie flaga ta ma wartość 2 oznaczającą,
że warunek jest spełniony, jeżeli zwalniane jest mniej niż 2 wielkości sterty.
Powyższe warunki są spełnione po pięciu kolejnych porządkowaniach pamięci. Nie ma moż-
liwości zmiany tej liczby.
Flaga -XX:+UseGCOverheadLimit ma wartość true (domyślną).
Pamiętaj, że wszystkie powyższe warunki muszą być spełnione. Często zdarza się, że po pięciu
pełnych porządkowaniach nie jest zgłaszany błąd przepełnienia pamięci, ponieważ mimo że aplikacja
spędza ponad 98 czasu na porządkowaniu pamięci, za każdym razem odzyskuje więcej niż 2
wielkości sterty. W takim wypadku należy zwiększyć wartość flagi GCHeapFreeLimit.
Warto również wiedzieć, że jeżeli pierwsze dwa warunki będą spełnione po czterech kolejnych
porządkowaniach, maszyna JVM w desperackiej próbie zwolnienia pamięci podczas piątego porząd-
kowania usunie wszystkie miękkie odwołania do obiektów (o ile aplikacja je wykorzystuje). Zapo-
biega w ten sposób pojawieniu się błędu, ponieważ w ostatnim cyklu zwalnia więcej pamięci niż 2
wielkości sterty.
Krótkie podsumowanie
Przyczyny błędu przepełnienia pamięci mogą być różne, dlatego nie można zakładać,
że jest on oznaką problemu ze stertą.
Najczęstszą przyczyną przepełnienia zarówno sterty, jak i metaprzestrzeni jest wyciek
pamięci, który można zdiagnozować za pomocą narzędzi do analizy sterty.
Zmniejszenie zajętości pamięci
Pierwszym krokiem w ograniczeniu ilości wykorzystywanej pamięci jest zmniejszenie zajętości
sterty. Stwierdzenie to nie powinno dziwić, bo mniejsze wykorzystanie sterty oznacza, że zapełnia
się ona wolniej i rzadziej trzeba ją porządkować. Ponadto efekty mogą się kumulować. Jeżeli cykle
porządkowania młodej generacji są rzadsze, obiekty dojrzewają wolniej, co z kolei oznacza mniejsze
prawdopodobieństwo przeniesienia ich do starej generacji. Rzadziej są też wykonywane cykle
pełnego porządkowania pamięci. Co więcej, mogą być wykonywane rzadziej, jeżeli podczas każdego
z nich zwalniane jest więcej pamięci.
W tym podrozdziale opisane są trzy techniki ograniczania zajętości pamięci: zmniejszanie obiektów,
ich leniwe inicjowanie i stosowanie kanonicznych wersji.
Zmniejszenie zajętości pamięci
205
Poleć książkęKup książkęZmniejszanie wielkości obiektów
Obiekty zajmują określoną część sterty, zatem najprostszym sposobem ograniczenia wykorzystania
pamięci jest ich pomniejszenie. Zwiększenie sterty o 10 nie zawsze jest możliwe, ale ten sam
efekt można osiągnąć pomniejszając połowę obiektów o 20 . Jak się dowiesz w rozdziale 12., wersja
Java 11 optymalizuje obiekty typu String, dzięki czemu maksymalna wielkość sterty w porównaniu
z wersją Java 8 jest nierzadko mniejsza o 25 . Optymalizacja nie wpływa na proces porządkowania
pamięci ani na wydajności aplikacji.
Ilość zajmowanej przez obiekty pamięci można najprościej ograniczyć poprzez zmniejszenie ich
liczby lub — co już nie jest takie proste — przez pomniejszenie samych obiektów. Tabela 7.1 przed-
stawia wielkości obiektów różnych typów.
Tabela 7.1. Wielkości (w bajtach) obiektów różnych typów
Typ
byte
char
short
int
float
long
double
Odwołanie
Wielkość
1
2
2
4
4
8
8
8 (lub 4 w 32-bitowej maszynie JVM dla systemu Windows)a
a Więcej szczegółowych informacji znajdziesz w podrozdziale „Skompresowane wskaźniki”.
Termin „odwołanie” w ostatnim wierszu dotyczy dowolnego obiektu, np. tablicy lub instancji klasy.
Podana liczba dotyczy pamięci zajmowanej tylko przez odwołanie. Wielkość obiektu zawierającego
odwołania do innych obiektów zależy od tego, czy w analizie uwzględniane są płytkie, głębokie,
czy zachowane obiekty. Ponadto obiekt zawiera niewidoczne pola nagłówków. W zwykłym obiekcie
takie pole ma wielkość 8 bajtów w przypadku 32-bitowej maszyny na systemie Windows lub 16
bajtów w maszynie 64-bitowej, niezależnie od wielkości sterty. W tablicy pole nagłówka zajmuje
16 bajtów w maszynie 32-bitowej lub 64-bitowej ze stertą mniejszą niż 32 GB, lub 24 bajty w pozo-
stałych przypadkach.
Przeanalizujmy poniższe definicje klas:
public class A {
private int i;
}
public class B {
private int i;
private Locale l = Locale.US;
}
public class C {
private int i;
private ConcurrentHashMap chm = new ConcurrentHashMap();
}
206
Rozdział 7. Dobre praktyki używania sterty
Poleć książkęKup książkęTabela 7.2 przedstawia wielkości pojedynczych instancji tych klas w przypadku 64-bitowej maszyny
JVM ze stertą mniejszą niż 32 GB.
Tabela 7.2. Wielkości (w bajtach) prostych obiektów
Płytki
Głęboki
Zachowany
16
24
24
16
216
200
16
24
200
A
B
C
Odwołanie typu Locale w klasie B powiększa obiekt o 8 bajtów. Jednak w tym przykładzie obiekt
typu Locale jest współdzielony przez inne obiekty. Jeżeli obiekt ten nie jest nigdzie wykorzysty-
wany, oznacza to, że wraz z odwołaniem niepotrzebnie zajmuje pamięć. Jeżeli aplikacja tworzy wiele
obiektów typu B, niewykorzystane bajty sumują się.
Następnym przykładem jest odwołanie typu ConcurrentHashMap, zajmujące określoną liczbę bajtów
wraz z odwołaniami do innych obiektów. Jeżeli instancje klasy C nie są wykorzystywane, niepo-
trzebnie zajmują mnóstwo miejsca w pamięci.
Jednym ze sposobów pomniejszania obiektów jest definiowanie tylko niezbędnych klas. Mniej
oczywisty sposób polega na korzystaniu z mniejszych typów danych. Jeżeli w klasie trzeba np.
przechowywać informacje o jednym z ośmiu możliwych stanów, należy użyć typu byte, a nie int,
dzięki czemu zaoszczędzi się 3 bajty. Stosując typ float zamiast double lub int zamiast long można
zaoszczędzić sporo pamięci, szczególnie jeżeli tworzonych jest wiele instancji danej klasy. Jak się
dowiesz z rozdziału 12., podobne oszczędności uzyskuje się stosując kolekcje o odpowiedniej
wielkości lub zwykłe instancje zamiast kolekcji.
Wyrównanie i wielkość obiektów
Wszystkie klasy przedstawione w tabeli 7.2 zawierają pole typu int, o którym do tej pory nie wspomi-
nałem. Po co ono jest?
Tylko po to, aby uprościć opis poszczególnych klas, np. że klasa B zajmuje 8 bajtów więcej niż A (jest to
zgodne z oczekiwaniami, a konkluzja jest bardziej zrozumiała).
Pojawia się tu pewien ważny szczegół: każdy obiekt jest powiększany o dodatkowe bajty, aby jego wiel-
kość była wielokrotnością liczby 8. Gdyby klasa A nie zawierała pola i, jej instancja i tak zajmowałaby
16 bajtów. Dodatkowe 4 bajty stanowiłyby uzupełnienie obiektu, aby jego wielkość była wielokrotnością
liczby 8. Gdyby klasa B nie zawierała pola i, jej instancja również zajmowałaby 16 bajtów, tj. tyle samo,
co A. Uzupełnienie to powoduje, że instancja klasy B jest o 8 bajtów większa od instancji klasy A, mimo
że B zawiera tylko jedno dodatkowe, 4-bajtowe odwołanie.
Maszyna JVM uzupełnia w ten sposób również obiekty zajmujące nieparzystą liczbę bajtów, dzięki czemu
tablice złożone z takich obiektów są dopasowane do granic segmentów pamięci wykorzystywanego
komputera.
Podsumowując, usunięcie niektórych pól lub zmniejszenie obiektu może, ale nie musi przysparzać
oszczędności, ale nie ma powodu, aby tego nie robić.
Ze strony projektu OpenJDK (https://oreil.ly/cSPvd) można pobrać narzędzie do obliczania wielkości obiektów.
Zmniejszenie zajętości pamięci
207
Poleć książkęKup książkęUsuwając pola z obiektu można go pomniejszyć, ale wciąż pozostaje dylemat: czy należy stosować
pola zawierające przetworzone dane? Jest to typowy w programowaniu kompromis czas-przestrzeń:
lepiej poświęcić więcej pamięci (przestrzeni), aby przechowywać przetworzone dane, czy więcej cykli
procesora (czasu) na przetwarzanie danych za każdym razem, gdy będzie zachodziła taka potrzeba?
W przypadku Javy przestrzeń jest związana z czasem, ponieważ porządkowanie dodatkowej pamięci
zajmuje więcej cykli procesora.
Na przykład kod mieszający obiektu typu String wylicza się sumując w określony sposób kody
wszystkich znaków, co trwa dłuższą chwilę. Dlatego wartość tę wylicza się tylko raz i zapisuje
w zmiennej instancji. Dzięki temu niemal zawsze, gdy wykorzystywana jest ta wartość, wzrost
wydajności aplikacji jest na tyle duży, że warto jest poświęcić dodatkową pamięć. Z drugiej strony
metoda toString() w większości klas nie zapisuje tekstowej reprezentacji obiektu w zmiennej instancji,
ponieważ zajmowana byłaby dodatkowa pamięć zarówno przez zmienną, jak i ciąg znaków.
W takim przypadku zazwyczaj większą wydajność uzyskuje się generując za każdym razem ciąg
znaków od nowa niż zapisując ciąg w pamięci. Należy też pamiętać, że kod mieszający obiektu typu
String jest wykorzystywany często, a wynik metody toString() dość rzadko.
Wszystko oczywiście zależy od sytuacji, a decyzja o rezygnacji z zapisywania wyników w pamięci
na rzecz ich wielokrotnego wyliczania lub odwrotnie jest bardzo płynna i uzależniona od wielu
czynników. Jeżeli celem jest skrócenie czasu porządkowania pamięci, bardziej korzystne okazuje się
wyliczanie wyników.
Krótkie podsumowanie
Często wydajność porządkowania pamięci można poprawić zmniejszając wielkości
obiektów.
Wielkość obiektów nie zawsze jest oczywista, ponieważ są one uzupełniane o dodat-
kowe bajty tak, aby były dopasowane do 8-bajtowych segmentów pamięci. Ponadto
odwołania do obiektów w przypadku 32-bitowej maszyny JVM mają inną wielkość
niż w maszynie 64-bitowej.
Pamięć zajmuje nawet pusta zmienna instancji.
Leniwe inicjowanie obiektu
Zazwyczaj decyzja o utworzeniu zmiennej instancji nie jest tak jednoznaczna, jak sugeruje poprzedni
podrozdział. Jeżeli na przykład klasa wykorzystuje obiekt typu Calendar tylko przez 10 czasu,
powinna go zapisać w pamięci, a nie tworzyć za każdym razem na nowo, ponieważ jest to bardzo
czasochłonna operacja. W takich sytuacjach można wykorzystać leniwe inicjowanie obiektów (ang.
lazy initialization).
We wszystkich dotychczasowych opisach przyjąłem założenie, że zmienne instancji są inicjowanie
aktywnie. Klasa wykorzystująca obiekt typu Calendar, która nie musi być wątkowo bezpieczna,
może wyglądać następująco:
public class CalDateInitialization {
private Calendar calendar = Calendar.getInstance();
private DateFormat df = DateFormat.getDateInstance();
208
Rozdział 7. Dobre praktyki używania sterty
Poleć książkęKup książkę private void report(Writer w) {
w.write( Godzina + df.format(calendar.getTime()) + : + this);
}
}
Leniwe inicjowanie obiektu pogarsza nieznacznie wydajność aplikacji, ponieważ przed każdym
użyciem obiektu trzeba sprawdzać jego stan. Poniżej przedstawiony jest odpowiednio zmieniony
kod klasy:
public class CalDateInitialization {
private Calendar calendar;
private DateFormat df;
private void report(Writer w) {
if (calendar == null) {
calendar = Calendar.getInstance();
df = DateFormat.getDateInstance();
}
w.write( Godzina + df.format(calendar.getTime()) + : + this);
}
}
Leniwe inicjowanie obiektu przynosi najwięcej korzyści wtedy, gdy operacje na obiekcie są wyko-
nywane rzadko. W przeciwnym wypadku oszczędność pamięci będzie żadna, ponieważ obiekt będzie
nieustannie alokowany, co dodatkowo, choć nieznacznie, pogorszy wydajność aplikacji.
Jeżeli kod musi być bezpieczny wątkowo, leniwe inicjowanie zaczyna się komplikować. W pierw-
szym kroku najprościej jest zastosować tradycyjne synchronizowanie wątków:
public class CalDateInitialization {
private Calendar calendar;
private DateFormat df;
private synchronized void report(Writer w) {
if (calendar == null) {
calendar = Calendar.getInstance();
df = DateFormat.getDateInstance();
}
w.write( Godzina + df.format(calendar.getTime()) + : + this);
}
}
Wprowadzenie synchronizacji może pogorszyć wydajność aplikacji, lecz jest to mało prawdopo-
dobne. Leniwe inicjowanie obiektów przynosi korzyści tylko wtedy, gdy obiekty są wykorzystywane
rzadko. Jeżeli jednak są zawsze inicjowane, nie ma żadnych oszczędności pamięci. Zatem synchro-
nizacja wraz z leniwym inicjowaniem obiektów pogarszają wydajność aplikacji, jeżeli obiekty te są
wykorzystywane przez wiele wątków jednocześnie. Takie sytuacje zdarzają się rzadko, ale nie można
ich wykluczać.
Problemu z synchronizacją nie ma jedynie wtedy, gdy leniwie inicjowane obiekty są bezpieczne
wątkowo. Klasa DateFormat nie spełnia tego warunku, więc w poniższym przykładzie nie ma znaczenia,
czy blokada obejmuje obiekt typu Calendar, czy nie. Jeżeli leniwie inicjowany obiekt jest intensywnie
wykorzystywany, niezbędna synchronizacja dostępu do obiektu typu DateFormat i tak jest proble-
mem. Kod bezpieczny wątkowo może wyglądać następująco:
Zmniejszenie zajętości pamięci
209
Poleć książkęKup książkęLeniwe inicjowanie obiektów a wydajność aplikacji
Nie zawsze leniwe inicjowanie obiektów pogarsza wydajność aplikacji. Przeanalizujmy przykład zawartej
w pakiecie JDK klasy ArrayList, która obsługuje zawartą w niej tablicę obiektów. W starszych wersjach
Javy pseudokod tej klasy wyglądał następująco:
public class ArrayList {
private Object[] elementData = new Object[16];
int index = 0;
public void add(Object o) {
ensureCapacity();
elementData[index++] = o;
}
private void ensureCapacity() {
if (index == elementData.length) {
...Ponowna alokacja tablicy i kopiowanie danych...
}
}
}
Kilka lat temu klasa ta została zmieniona i teraz tablica elementData jest leniwie inicjowana. Ponieważ
metoda ensureCapacity() sprawdza wcześniej wielkość tablicy, wydajność innych metod nie pogorszyła
się. Podczas inicjowania i powiększania tablicy wykorzystywany jest ten sam kod. Zmieniona klasa
wykorzystuje statyczną tablicę o zerowej wielkości, dzięki czemu jej wydajność nie zmieniła się:
public class ArrayList {
private static final Object[] EMPTY_ELEMENTDATA = {} ;
private Object[] elementData = EMPTY_ELEMENTDATA;
}
Oznacza to, że metodę ensureCapacity() można w zasadzie pozostawić bez zmian, ponieważ na początku
zarówno zmienna index, jak i elementData.length zawierają wartość 0.
public class CalDateInitialization {
private Calendar calendar;
private DateFormat df;
private void report(Writer w) {
unsychronizedCalendarInit();
synchronized(df) {
w.write( Goczina + df.format(calendar.getTime()) + : + this);
}
}
}
Jeżeli leniwie inicjowany obiekt nie jest bezpieczny wątkowo, można synchronizować dostęp do
zmiennej np. stosując opisaną wcześniej metodę z modyfikatorem synchronized.
Przeanalizujmy nieco inny przykład, w którym leniwie inicjowany jest duży obiekt typu Concurrent
HashMap:
public class CHMInitialization {
private ConcurrentHashMap chm;
public void doOperation() {
210
Rozdział 7. Dobre praktyki używania sterty
Poleć książkęKup książkę synchronized(this) {
if (chm == null) {
chm = new ConcurrentHashMap();
... Kod wypełniający mapę ...
}
}
... Kod wykorzystujący zmienną chm ...
}
}
Do obiektu typu ConcurrentHashMap może się odwoływać kilka wątków, więc jest to jeden z niewielu
przypadków poprawnego leniwego inicjowania obiektu, w którym wprowadzenie dodatkowej
synchronizacji może pogorszyć wydajność aplikacji. Jest to jednak rzadki przypadek i jeżeli obiekt nie
jest często wykorzystywany, warto się zastanowić, czy w ogóle trzeba go leniwie inicjować. Problem
można rozwiązać dwukrotnie sprawdzając blokadę:
public class CHMInitialization {
private volatile ConcurrentHashMap instanceChm;
public void doOperation() {
ConcurrentHashMap chm = instanceChm;
if (chm == null) {
synchronized(this) {
chm = instanceChm;
if (chm == null) {
chm = new ConcurrentHashMap();
... Kod wypełniający mapę ...
instanceChm = chm;
}
}
... Kod wykorzystujący zmienną chm ...
}
}
}
Pojawia się tu ważny problem związany z wątkami. Zmienna instancji musi być zadeklarowana
jako volatile. Wydajność można nieznacznie poprawić przypisując zmienną instancji lokalnej
zmiennej. Więcej szczegółowych informacji na ten temat będzie podanych w rozdziale 9. W rzad-
kich przypadkach, w których uzasadnione jest leniwe inicjowanie obiektów w wielowątkowym kodzie,
należy stosować opisaną technikę.
Aktywne zamykanie obiektów
Przeciwieństwem leniwego inicjowania obiektu jest jego aktywne zamykanie (ang. eager deini-
tialization) poprzez przypisanie zmiennej wartości null. Dzięki temu obiekt może być szybciej
usunięty przez kolektor. W teorii brzmi to nieźle, ale w praktyce sprawdza się w nielicznych, szcze-
gólnych przypadkach.
Mogłoby się wydawać, że obiekt, który można leniwie inicjować, można również aktywnie zamykać.
Użytym w poprzednim przykładzie zmiennym typów Calendar i DateFormat można przypisać
wartość null na końcu metody report(). Jeżeli jednak zmienne te nie będą wykorzystywane w kolej-
nych wywołaniach metody lub w innych miejscach klasy, nie ma powodu, aby w ogóle były to
zmienne instancji. Zamiast nich można wewnątrz metody użyć lokalnych zmiennych, które po
zakończeniu wykonywania kodu wyjdą poza zakres widoczności i zostaną usunięte przez kolektor.
Zmniejszenie zajętości pamięci
211
Poleć książkęKup książkęNajczęściej wyjątki od zasady unikania aktywnego zamykania obiektów zdarzają się w klasach,
które przez długi czas utrzymują odwołania do obiektów, po czym otrzymują sygnał, że obiekty te nie
są już potrzebne. Przeanalizujmy przedstawioną niżej implementację metody remove() w klasie
ArrayList zawartej w pakiecie JDK (część kodu została uproszczona):
public E remove(int index) {
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved 0)
System.arraycopy(elementData, index+1,
elementData, index, numMoved);
elementData[--size] = null; // Zamknięcie obiektu, aby kolektor mógł go usunąć.
return oldValue;
}
W kodzie źródłowym pakietu JDK, generalnie opatrzonego bardzo nielicznymi komentarzami,
znajduje się komentarz do wiersza, w którym zmiennej jest przypisywana wartość null. Jest to tak
nietypowy przypadek, że zasługuje na specjalny opis. Prześledźmy, co się dzieje, gdy usuwany jest
ostatni element tablicy. Zmniejsza się liczba jej elementów zapisana w zmiennej instancji size.
Załóżmy, że zmienna ta zmniejszyła swoją wartość z 5 do 4. Po tej operacji nie można się odwołać
do elementu elementData[4], ponieważ znajduje się on poza granicą tablicy. Zatem odwołanie
elementData[4] przestaje być nieaktualne. Sama tablica elementData będzie prawdopodobnie aktywna
jeszcze przez jakiś czas, więc odwołania do wszystkich obiektów, które nie są potrzebne, należy
aktywnie ustawić na null.
Kluczowe znaczenie ma pojęcie nieaktualnego odwołania. Jeżeli tworzona klasa będzie przez dłuższy
czas tworzyła i usuwała obiekty, należy zwrócić szczególną uwagę, aby nie pojawiały się w niej
nieaktualne odwołania. Jawne przypisanie odwołaniu wartości null pozwoli również nieznacznie
poprawić wydajność aplikacji.
Krótkie podsumowanie
Leniwe inicjowanie obiektów należy stosować tylko wtedy, gdy często wykorzystywany
kod nie odwołuje się do tych obiektów.
W bezpiecznym wątkowo kodzie rzadko pojawia się potrzeba leniwego inicjowania
obiektów i zazwyczaj można wykorzystać istniejącą synchronizację.
W kodzie bezpiecznym wątkowo należy dwukrotnie sprawdzać blokadę leniwie inicjo-
wanego obiektu.
Obiekty niemutowalne i kanoniczne
W Javie wiele typów jest niemutowalnych. Dotyczy to typów prymitywnych, takich jak Integer,
Double czy Boolean, jak również bardziej złożonych, np. BigDecimal. Najczęściej używanym, niemu-
towalnym typem jest oczywiście String. Z programistycznego punktu widzenia dobrą praktyką
jest definiowanie własnych, niemutowalnych klas.
Jeżeli obiekty wyżej wymienionych typów są często tworzone i natychmiast usuwane, ich wpływ
na porządkowanie obszaru młodej generacji jest niewielki, o czym się przekonałeś w rozdziale 5.
Jednak jeżeli obiekty niemutowalne są przenoszone do starej generacji, wydajność aplikacji może
się pogorszyć.
212
Rozdział 7. Dobre praktyki używania sterty
Poleć książkęKup książkęNie ma powodów, aby unikać stosowania typów niemutowalnych, nawet jeżeli wydaje się to nie-
logiczne, że obiektów tych nie można zmieniać i trzeba je wciąż tworzyć na nowo. Jedną z technik
optymalizacji stosowania tego rodzaju obiektów jest zapobieganie tworzeniu wielokrotnych kopii
tego samego obiektu.
Najlepszym przykładem jest klasa Boolean. Każda aplikacja potrzebuje tylko dwóch instancji tej
klasy, reprezentujących odpowiednio wartości true i false. Niestety, klasa ta została źle zaprojek-
towana. Ponieważ posiada publiczny konstruktor, można tworzyć dowolną liczbę jej instancji,
mimo że każda z nich może być tylko jednym z dwóch kanonicznych obiektów. Lepiej byłoby,
gdyby klasa ta posiadała prywatny konstruktor i statyczne metody zwracające w zależności od para-
metru wartości Boolean.TRUE lub Boolean.FALSE (a jeszcze lepiej, gdyby w ogóle nie trzeba było two-
rzyć obiektów typu Boolean). Własne niemutowalne klasy funkcjonujące w ten sposób nie zajmo-
wałyby sterty.
Tego rodzaju niemutowalne obiekty reprezentujące skończoną liczbę wartości są nazywane obiek-
tami kanonicznymi.
Tworzenie obiektów kanonicznych
Pamięć można oszczędzić stosując obiekty kanoniczne. Pakiet JDK oferuje specjalnie przeznaczoną
do tego celu funkcjonalność. Za pomocą metody intern() można znaleźć kanoniczną wersję ciągu
znaków. Więcej szczegółowych informacji na ten temat znajdziesz w rozdziale 12., natomiast teraz
dowiesz się, jak można ten efekt osiągnąć w przypadku własnych klas.
Aby utworzyć obiekt kanoniczny, należy zdefiniować mapę zawierającą jego kanoniczne wersje. Aby
zapobiec wyciekowi pamięci, odwołania do tych obiektów muszą być słabe. Poniżej przedstawiony
jest szkielet takiej klasy:
public class ImmutableObject {
private static WeakHashMap ImmutableObject, ImmutableObject
map = new WeakHashMap();
public ImmutableObject canonicalVersion(ImmutableObject io) {
synchronized(map) {
ImmutableObject canonicalVersion = map.get(io);
if (canonicalVersion == null) {
map.put(io, new WeakReference(io));
canonicalVersion = io;
}
return canonicalVersion;
}
}
}
W aplikacji wielowątkowej może pojawić się problem z synchronizacją dostępu, którego nie można
rozwiązać w prosty sposób, jeżeli wykorzystywane są klasy zawarte w pakiecie JDK. Pakiet ten nie
oferuje mapy ze słabymi odwołaniami, do której kilka wątków mogłoby się odwoływać jednocześnie.
Pojawiły się jednak sugestie, m.in. w żądaniu JSR (ang. Java Specification Request — żądanie zmiany
specyfikacji Javy) nr 166, aby rozszerzyć pakiet JDK o klasę CustomConcurrentHashMap. W internecie
można znaleźć kilka niezależnych implementacji tej klasy.
Zmniejszenie zajętości pamięci
213
Poleć książkęKup książkęKrótkie podsumowanie
Obiekty niemutowalne można kanonizować i zarządzać w specjalny sposób ich cyklem
życia.
Kanonizując obiekty można wyeliminować ich wielokrotne niemutowalne kopie
i w ten sposób znacznie ograniczyć wykorzystanie sterty.
Zarządzanie cyklem życia obiektów
Drugim obszernym tematem związanym z wykorzystaniem pamięci jest zarządzanie cyklem życia
obiektów. Java w dużej mierze zwalnia programistę z tego obowiązku. Gdy utworzone w aplikacji
obiekty przestają być potrzebne, wychodzą poza zakres widoczności i kolektor usuwa je z pamięci.
Czasami jednak taki cykl życia obiektów nie jest optymalny. Tworzenie obiektów niektórych typów
jest czasochłonne i samodzielnie zarządzając ich cyklami życia można zwiększyć wydajność aplikacji,
nawet jeżeli kolektor będzie musiał wykonywać dodatkowe operacje. W tym rozdziale opisuję, kiedy
należy zmieniać cykl życia obiektów poprzez ich wielokrotne wykorzystywanie lub odwoływanie się
do nich w specjalny sposób.
Obiekty wielokrotnego użytku
Obiekty można wykorzystywać wielokrotnie na dwa sposoby: tworząc pule obiektów lub lokalne
zmienne wątkowe. Inżynierowie zajmujący się porządkowaniem pamięci mogą w tym momencie
zaprotestować, ponieważ obie techniki obniżają skuteczność tego procesu. Szczególnie niepopularna
jest pierwsza technika, która z kilku innych powodów nie jest też lubiana przez innych programistów,
niezwiązanych z porządkowaniem pamięci.
Jeden z powodów tej niechęci wydaje się oczywisty: obiekty wielokrotnego użytku przez długi czas
zajmują stertę. Jeżeli jest ich dużo, mniej miejsca pozostaje dla nowych obiektów i częściej trzeba
porządkować stertę. Ale to dopiero początek problemów.
Jak pamiętasz z rozdziału 6., obiekt jest tworzony w edenie. Podczas kilku pierwszych cykli porząd-
kowania obszaru młodej generacji obiekt jest przenoszony z jednego do drugiego obszaru obiektów
ocalonych, po czym ostatecznie ląduje w obszarze starej generacji. Za każdym razem, gdy na nowym
lub ostatnio utworzonym obiekcie w puli są wykonywane jakieś operacje, obiekt ten jest kopiowany
i aktualizowane są odwołania do niego, do chwili ostatecznego umieszczenia go w starej generacji.
Przeniesienie obiektu do starej generacji pozornie oznacza koniec zamieszania, ale w rzeczywistości
pojawiają się wtedy problemy z wydajnością aplikacji. Czas potrzebny na pełne uporządkowanie
pamięci jest proporcjonalny do liczby aktywnych obiektów starej generacji. Większe znacznie od
wielkości sterty ma ilość danych. Krócej trwa porządkowanie obszaru starej generacji o wielkości
3 GB zajmowanego przez kilka aktywnych obiektów niż obszaru o wielkości 1 GB, w którym aktyw-
nych jest 75 wszystkich obiektów.
Stosowanie kolektora współbieżnego i zapobieganie pełnemu porządkowaniu pamięci nie poprawia
sytuacji, ponieważ czas potrzebny na oznakowanie obiektów jest podobnie proporcjonalny do ilości
aktywnych danych. Jeżeli wykorzystywany jest kolektor CMS, obiekty znajdujące się w puli mogą
214
Rozdział 7. Dobre praktyki używania sterty
Poleć książkęKup książkęSkuteczność procesu porządkowania pamięci
Jaki dokładnie wpływ ma wielkość aktywnych obiektów na czas porządkowania pamięci? Najprostsza
odpowiedź brzmi: ogromny.
Poniżej przedstawiony jest fragment dziennika utworzonego przez kolektor podczas testu aplikacji. Test
był wykonany za pomocą typowego komputera wyposażonego w system Linux i czterordzeniowy procesor.
Sterta miała wielkość 4 GB, z czego obszar młodej generacji miał stałą wielkość równą 1 GB.
[Full GC [PSYoungGen: 786432K- 786431K(917504K)]
[ParOldGen: 3145727K- 3145727K(3145728K)]
3932159K- 3932159K(4063232K)
[PSPermGen: 2349K- 2349K(21248K)], 0.5432730 secs]
[Times: user=1.72 sys=0.01, real=0.54 secs]
...
[Full GC [PSYoungGen: 786432K- 0K(917504K)]
[ParOldGen: 3145727K- 210K(3145728K)]
3932159K- 210K(4063232K)
[PSPermGen:
Pobierz darmowy fragment (pdf)