Wyjątki z poziomu JVM, czyli co każdy Senior Java Developer powinien wiedzieć

Mechanizm wyjątków Javy każdy zna i na pozór nie ma tu jakiejś wielkiej filozofii: co zostało rzucone, prędzej czy później zostaje złapane (choćby przez standardowy handler wątku). Co jednak w przypadku, gdy w bloku try, catch, finally pojawi się wyjątek niezłapany przez klauzulę catch, a na dodatek zostanie rzucony kolejny wyjątek z finally? Obsłużone zostaną oba? Jeden z nich? Jeśli znasz i rozumiesz odpowiedź na to pytanie, możesz od razu przejść do sekcji 4 Jeśli jednak nie, to zachęcam do dalszego czytania. Najpierw trochę podstaw działania JVM niezbędnych do zrozumienia zagadnienia.

Michał Zimnicki. Java Tech Lead w ALTEN Polska. Java Technical Leader z wieloletnim doświadczeniem w programowaniu w języku Java oraz projektowaniu systemów rozproszonych. Specjalność: szeroko pojęty “performance”. Przygodę z programowaniem zaczynał od Pascala, będąc w pierwszej klasie gimnazjum. Żaden problem go nie przeraża. Uczestniczył w tworzeniu i rozwoju systemów do: transportu gotówki, sprzedaży owoców, lokalizacji, obsługi bankowej, a nawet reklamowania kremu na zmarszczki. Gdy najdzie go wena, lubi zrobić prezentacje na jakiś cięższy temat.


1. Podstawy JVM

Wirtualna maszyna Javy jest maszyną wykonującą operacje na stosie. Tyle że stosy są dwa:

Stos wywołań – za każdym razem, gdy wywoływana jest jakakolwiek metoda na ten stos wrzucana jest ramka składająca się w uproszczeniu z:

  • kopii wartości parametrów wywołania[1], 
  • przestrzeni na zmienne lokalne, 
  • stosu dla argumentów
  • dodatkowych danych ramki. 

Każdy wątek ma swój osobny stos wywołań.

Stos dla argumentów – 32 bitowa[2] kolejka LIFO, na którą wrzucane są argumenty potrzebne do wykonywania instrukcji. Dla przykładu dodawania jest operacją dwuargumentową, zatem aby dodać 3 do 4 najpierw na stos wrzucane są wartości 3 i 4, a potem wykonywana jest operacja dodawania która pobiera 2 górne wartości ze stosu, sumuje je, a wynik wrzuca na górę. Dla uproszczenia w dalszej części artykułu będę go nazywał po prostu „stosem”.

Istotnym dla nas jest jeszcze plik klasy, który oprócz kodu bajtowego metod zawiera również masę dodatkowych informacji potrzebnych do ich prawidłowego wykonania.

Rys. 1.1 Przykładowa klasa

Rys. 1.2. Kod bajtowy Javy dla klasy z rys. 1.1

W pokazanym fragmencie interesującą częścią jest tabela wyjątków (exception table) związana z blokiem try. Składa się ona z: numeru instrukcji początkowej (from), numeru instrukcji końcowej (to), klasy wyjątku (type) i miejsca, od którego należy przejść w przypadku wystąpienia danej klasy wyjątku (target). Wartym uwagi jest fakt, że blok try nie ma wyraźnej reprezentacji w kodzie bajtowym metody. Widać więc jasno, iż wejście do bloku try nie jest związane z żadną operacją, co z punktu widzenia wydajności udowadnia, że samo użycie try jest darmowe.

Dodatkowo widzimy tabele definiującą, które operacje odpowiadają któremu numerowi linii w pliku .java. Jest to potrzebne by zbudować stack trace dający nam bezcenne informacje o dokładnym miejscu wystąpienia wyjątku w czytelnej dla programisty postaci. Rozczytanie tej tabeli na pierwszy rzut oka nie jest łatwe. Pierwsza wartość mówi o numerze linii z pliku .java, następna to początek przedziału instrukcji, dla którego ta linia się zaczyna. Przedział kończy się na wartości następnego wpisu w tabeli. Tak więc wartości 0-7 odpowiadają System.out.println(„inside try”) (linia 7) wartości 8-15 throw new ThreadDeath() (linia 8) itd.

By znaleźć numer linii, z której został rzucony wyjątek wystarczy przeszukać tabelę od końca sprawdzając czy numer operacji jest większy niż druga wartość z tabeli. Tyle „suchej” wiedzy nam wystarczy do prześledzenia jak działa mechanizm wyjątków.

2. Działanie

Załóżmy, że gdzieś została wywołana nasza metoda „tryCatchFinally()”. W wątku, w którym została ona uruchomiona tworzona jest ramka, kopiowane są wartości parametrów [3] oraz inicjalizowane dodatkowe pola. Następnie ramka wrzucana jest na stos wywołań. Wskaźnik wykonywanej instrukcji [4] przenoszony jest na pierwszą instrukcję w naszej metodzie. Instrukcja jest wykonywana, wskaźnik przechodzi do następnej i tak aż do wystąpienia jednej z dwóch sytuacji: 

  • napotkania operacji return,
  • wystąpienia bądź rzucenia wyjątku.

W pierwszym przypadku ramka jest usuwana ze stosu wywołań, a zwracana wartość metody (jeśli istnieje) jest wrzucana na stos dla argumentów ramki poprzedniej (czyli tej, która odpowiada metodzie, z której została wywołana nasza „tryCatchFinally()”) i ten proces jest kontynuowany. Co jednak, gdy wyjątek zostanie rzucony? Wtedy z pomocą przychodzi nam tabela wyjątków. Iterując po kolejnych wierszach tabeli porównywana jest wartość wskaźnika obecnie wykonywanej instrukcji z zakresem zdefiniowanym w rekordzie. Dodatkowo porównywana jest klasa wyjątku z tą w wierszu – jeśli jest jej pochodną, wtedy następuje przesunięcie wskaźnika instrukcji do miejsca z rekordu i wykonywanie operacji dalej, jakby nic się nie stało – wyjątek wszakże został złapany.

Innymi słowy: gdy zostanie rzucony wyjątek, maszyna patrzy, czy został wyrzucony z linii, która objęta jest mechanizmem jego „łapania”, następnie sprawdza, czy dla jego typu jest przewidziany jakiś kod, który należy wykonać. Jeśli tak, to przeskakuje na jego początek i zaczyna go wykonywać. Co jednak w przypadku, gdy wyjątek nie zostanie dopasowany do żadnego z rekordów w tabeli? Ramka zostaje usunięta i proces zostaje ponawiany dla następnej ramki na stosie wywołań. Dzieje się tak aż wyjątek zostanie złapany, lub dojdziemy do końca stosu. Wtedy uruchamiany jest handler dla niezłapanych wyjątków, a gdy się on wykona, wątek jest kończony.

No dobra. Tłumaczy to działanie try, catch ale co z finally? A no finally to takie sprytne użycie tego samego mechanizmu tylko przez kompilator. Ale po kolei. Specyfikacja mówi, że finally zawsze się nam wykona [5]. Żeby było to możliwe kompilator [6] faktycznie kopiuje treść bloku finally do każdej z części try, catch. Obrazuje to przykład poniżej.

Rys. 2.1. Kod przykładowej klasy

Rys. 2.2. Kod bajtowy Javy dla metody z rys. 2.1

Tak więc przy normalnym działaniu blok finally zachowuje się jakby był po prostu zwyczajną częścią metody i jego wykonanie nie wiąże się z żadnym spadkiem wydajności. Jako że jest on również kopiowany do bloków catch, jeśli wyjątek zostanie złapany kod z finally wykona się normalnie. Co jednak, gdy wyjątek nie zostanie złapany lub wyjątek zostanie rzucony z bloku catch? Właśnie od tego jest ostatni blok (zaznaczony na zielono) i wpisy w tabeli wyjątków:

Rys. 2.3. Tabela wyjątków dla metody z rys. 2.1

Mówią nam dokładnie tak: jeśli z bloku try, lub dowolnego catch zostanie rzucony wyjątek, który nie jest obsługiwany przez żadne inne mechanizmy przejdź do ostatniego finally. Jak widzimy zawiera on, oprócz funkcjonalności napisanej przez developera, dodatkowe instrukcje. Potrzebne są one do tymczasowego przetrzymania rzuconego wyjątku (wszakże skierował nas tutaj jakiś niezłapany), a następnie po sukcesywnym wykonaniu wnętrza bloku jego ponowne rzucenie. Wróćmy więc do pytania z początku artykułu: co się stanie, kiedy z bloku finally zostanie rzucony wyjątek? Najpierw przeszukana zostanie tablica wyjątków, gdzie nie zostanie on znaleziony [7], więc ramka zostanie zwinięta (wszelkie dane znajdujące się na stosie dla argumentów, zmienne lokalne itd. zostaną usunięte) i kontrola przejdzie do następnej ramki na stosie wywołań, gdzie proces zostanie ponowiony itd.

Co jednak, gdy akurat rzucony on zostanie z naszego dodatkowego bloku, w którym jak pamiętamy mamy czasowo przetrzymany niezłapany wyjątek do ponownego rzucenia? A tu, zgodnie z logiką wcześniej opisaną, informacja o nim zostanie bezpowrotnie utracona wraz z usunięciem ramki. Mechanizm obsługi wyjątków zajmował się będzie bowiem tym, który właśnie został rzucony. Zauważcie, że z punktu widzenia wirtualnej maszyny nie istnieje rozgraniczenie na rodzaje wyjątków ani nawet specjalne traktowanie błędów errors, jest to coś o czym warto pamiętać.

Czy to już koniec? Jeśli chodzi o sam mechanizm to tak. Java 7 wprowadziła nam coś takiego jak „stłumiony wyjątek” (suppressed exception). I teraz będzie o nim mowa.

3. Stłumiony wyjątek

Specyfikacja języka Java 7 definiuje mechanizm try with resources, który znacząco upraszcza korzystanie z bibliotek, które po inicjalizacji mogą pozostawiać zasoby w nieokreślonym stanie. Dla nich trzeba było zawsze definiować blok finally, w którym zasoby te były odpowiednio zamykane. Powodowało to produkcję identycznego kodu, a często nawet dodatkowe zagnieżdżenia bloków try finally w finally. Z pomocą przyszło try with resources, które po wyjściu z bloku try, catch uruchamia implementacje metody close() z interfejsu AutoCloseable niejako w ukrytym bloku finally. Jak to z Javą bywa, tutaj też możemy spodziewać się jakiejś magii kompilatora w miejsce zmian na poziomie wirtualnej maszyny. Najlepiej zobrazuje to przykład: 3.1 przedstawiający metodę napisaną w Javie, a następnie poddaną procesowi kompilacji i dekompilacji.


Rys. 3.1. Kod przykładowej metody wykorzystującej mechanizm try with resources



Rys. 3.2. Kod metody z rys. 3.1. poddany procesowi kompilacji i dekompilacji

Jak widzimy efekt finalny znacząco różni się od tego co faktycznie deweloper „zakodził”. Spróbujmy rozczytać co tak właściwie się tutaj dzieje.

Pierwszą rzeczą, która rzuca nam się w oczy jest dodatkowo zagnieżdżony blok try, catch finally. Ale zanim do niego przejdziemy zaczniemy od góry. Pierwsze 2 linijki zawierają kod, który zawarliśmy w inicjalizacji bloku, co jest raczej logiczne, bo gdzieś on wykonać się musi. Zastanawia deklaracja zmiennej „var2”, która będzie stanowić tymczasowy kubełek na dodatkowy wyjątek, który może zostać rzucony w trakcie wykonywania naszych operacji. Deklarowany jest on przed blokiem try abyśmy mieli do niego dostęp w następujących blokach catch i finally.

Przejdźmy dalej do faktycznego wywołania naszego kodu. Jeśli zostanie rzucony jakikolwiek wyjątek przy jego wykonywaniu, trafiamy do jedynego bloku catch, gdzie cała logika opiera się na zapisaniu jego do naszej lokalnej zmiennej „var2” i rzuceniu go ponownie. Gdy sterowanie przejdzie dalej, do bloku finally, zanim wykona się metoda close() nastąpi sprawdzenie czy jest to normalne wykonanie, czy może jednak coś poszło nie tak i już jakiś wyjątek został rzucony sprawdzając istnienie zmiennej „var2”. Zgodnie z prawem Murphiego „Jeżeli coś może się nie udać, to się nie uda” musimy zabezpieczyć się przed sytuacją, w której nasza metoda close() rzuci wyjątkiem. Powstaje wtedy problem rzucenia wyjątkiem w przypadku, gdy już jakiś rzucony został. W takiej sytuacji wyjątek z close() nie jest usuwany wraz z ramką jak w przypadku opisywanym wcześniej, a jest podpinany pod niego jako suppressed.

Następne kroki wykonania są takie same jak w opisywanym wyżej mechanizmie. Ktoś mógłby zapytać: „po co to wszystko?”. Odpowiedź jest prosta: w powyższym podejściu nic nie ginie. Wszystkie informacje o wyjątkach pozostają zachowane, a wszystko jest przezroczyste dla dewelopera. Dlaczego więc nie wprowadzić czegoś podobnego dla klasycznego bloku try, catch, finally? Tutaj sprawa nie jest już taka oczywista. Mimo że wymagałoby to zmiany tylko na poziomie kompilatora, wprowadzało by to nieścisłości. Ten sam kod skompilowany pod Javę 6 działałby inaczej niż ten zbudowany za pomocą kompilatora Javy 7+, a to mogłoby prowadzić do problemów z migracją, nie mówiąc już o pułapce dla nieświadomych programistów.

4. Koszt obliczeniowy wyjątków

Z punktu widzenia wydajności przejście normalna ścieżką (bez rzucania wyjątków) nie kosztuje nic, gdyż wejście i wyjście z bloku try nie jest związane z żadną operacją wirtualnej maszyny. Sytuacja zmienia się w momencie pojawienia się wyjątku. Przeszukanie tablicy nie jest jakoś specjalnie kosztowne, gdyż znajduje się na niej zwykle tylko kilka rekordów. Koszt skoku do odpowiedniej instrukcji również jest pomijalny [8]. Czy więc możemy rzucać wyjątkami do woli nie przejmując się narzutem obliczeniowym? I tak i nie.

Sam mechanizm nie kosztuje nas zbyt wiele, ale za to wyjątek, który należy stworzyć kosztuje bardzo dużo. Prześledźmy więc, gdzie znikają nasze cykle procesora. Za każdym razem, kiedy tworzymy wyjątek jego ślad stosu wywołań stack trace musi zostać wypełniony [9]. Żeby to zrobić wirtualna maszyna musi przejść po każdej ramce znajdującej się na stosie wywołań, pobrać wskaźnik na obecnie wywoływaną instrukcję, dopasować linijkę kodu Java do tej instrukcji i za ich pomocą zbudować niemutowany rekord w tabeli śladu stosu wywołań. Jest to sporo roboty i jej ilość jest bezpośrednio zależna od rozmiaru stosu wywołań. Ile to znaczy „sporo”?

Aleksey Shipilëv przeprowadził bardzo dokładne pomiary [10], z którymi polecam się zapoznać (literatura nie należy do najlżejszych, ale moim zdaniem warto), i wynika z nich, że rzucenie nowo utworzonym wyjątkiem jest średnio między 10^3, a 10^4 razy wolniejsze niż wybranie ścieżki za pomocą if else. Z tego też powodu powstały rozwiązania takie jak re-używanie obiektu wyjątku, które znacząco redukują ten problem jednak nie rozwiązują go w całości. Co nam jednak dają te dane? Powszechnie powtarza się, że wyjątki powinny być używane w sytuacjach „wyjątkowych”. Czasami ciężko jednak określić, czy sytuacja jest naprawdę wyjątkowa. Teraz mamy już pewną receptę: jeśli dana sytuacja występuje częściej niż raz na 1000 przypadków, to sytuacją wyjątkową nie jest.

I tak oto dobrnęliśmy do końca. Dla tych co wytrwali zdradzę, że dzięki wiedzy przedstawionej w tym artykule udało mi się zagiąć niejednego weryfikatora technicznego i efektywnie wynegocjować większą pensję. Więc jeśli zastanawiacie do czego ta wiedza może wam się przydać, to chociaż do tego 🙂


Adnotacje:

[1] W przypadku obiektów, kopiowane są ich referencje

[2] Specyfikacja JVM definiuje stos jako 32 bitowy, nie mniej jednak, żeby radzić sobie z 64 bitowymi adresami pamięci większość obecnych implementacji używa 64 bitowego stosu.

[3] Mimo że metoda deklaruje, że nie przyjmuje żadnych parametrów, kopiowana jest wartość referencji this, gdyż ciągle pozostaje ona metoda wirtualną. Taka operacja nie zaszłaby w przypadku, gdyby była metodą statyczną

[4] Tak naprawdę jest to rejestr trzymający index obecnie wywoływanej operacji

[5] Oprócz wyjątkowych sytuacji w których i tak aplikacja nie byłaby już do odratowania

[6] Od Javy 7, wcześniej używał niezbyt eleganckiej operacji _jsr

[7] No chyba ze mieliśmy zagnieżdżony blok finally w innym bloku try, catch

[8] Gdyby nie był, to nikt z JVM by nie korzystał bo jest on podstawą wielu instrukcji począwszy od if else.

[9] Można to wyłączyć flagą JVM, jednak jest wysoce niezalecane, bo efektywnie tracone są informacje dość niezbędne przy próbie debugowania.

[10] shipilev.net.

Zdjęcie główne artykułu pochodzi z burst.shopify.com.

Patronujemy

 
 
Polecamy
Jak okiełznać typy w Pythonie, czyli Python 3 i type annotation