Cóż… Najlepiej nie rzucać 😉
Generalnie mógłbym podlinkować tylko Blog Alexey’a Shipileva i zakończyć wpis… Alexey dokładnie zbadał temat wydajności wyjątków.
Jednak dla osób wolących artykuły w języku polskim również i ja pokrótce temat opiszę.
Na samym końcu dla odmiany napiszę coś życiowego 😉
Uwaga! Dla odmiany w tym wpisie nie będzie listingu Bytecode’u 😛
Ile kosztuje nas rzut wyjątkiem?
Na początek warto sprawdzić, ile kosztuje w ogóle rzucenie wyjątkiem.
Stworzyłem zatem benchmark (oczywiście używając frameworku JMH), który w bloku try-catch
rzuca lub nie rzuca wyjątkiem. Sprawdziłem również, czy jakiś wpływ ma to, jaki wyjątek łapiemy.
Trzeba jednak wziąć pod uwagę, że taki kod w którym w jednej metodzie jest rzucany wyjątek, a ramkę niżej (wyżej?) go łapiemy byłby skompilowany przez C2 i z wysokim prawdopodobieństwem zinline’owany. To by doprowadziło do sytuacji, w której ten wyjątek nie byłby w kodzie maszynowym explicite rzucany – byłaby skompilowana wydajna symulacja rzucania wyjątku. Taka sytuacja nas jednak nie interesuje – wszak szukamy kosztu rzucenia wyjątku, a nie kosztu symulacji rzucania wyjątku.
Aby uniemożliwić inline’owanie, należało dodać dodatkową adnotacją @CompilerControl(CompilerControl.Mode.DONT_INLINE)
(udostępnioną przez JMH – normalnie tę akcję da się wymusić parametrem wywołania java
, co jest jednak mało wygodne).
Nie chcemy również badać, jak długo tworzony jest obiekt wyjątku, dlatego za każdym razem rzucamy tym samym wyjątkiem, który jest umieszczony w polu statycznym finalnym.
@Benchmark @CompilerControl(CompilerControl.Mode.PRINT) public int throwBenchmark() { try { throwRuntimeException(); } catch (RuntimeException e) { return 5; } return 1; } @Benchmark @CompilerControl(CompilerControl.Mode.PRINT) public int throwThrowableBenchmark() { try { throwRuntimeException(); } catch (Throwable e) { return 5; } return 1; } @CompilerControl(CompilerControl.Mode.DONT_INLINE) private void throwRuntimeException() { throw RUNTIME_EXCEPTION; } @Benchmark @CompilerControl(CompilerControl.Mode.PRINT) public int withoutthrowBenchmark() { try { dontThrowRuntimeException(); } catch (RuntimeException e) { return 5; } return 1; } @CompilerControl(CompilerControl.Mode.DONT_INLINE) private void dontThrowRuntimeException() { }
Wyniki tego benchmarku są następujące (dla OpenJDK w wersji 11):
Benchmark Mode Cnt Score Error Units SimpleThrowBenchmark.throwBenchmark avgt 10 144,234 ± 1,407 ns/op SimpleThrowBenchmark.throwThrowableBenchmark avgt 10 146,276 ± 1,083 ns/op SimpleThrowBenchmark.withoutthrowBenchmark avgt 10 5,684 ± 0,055 ns/op
Czyli takie rzucenie wyjątku spowalnia nam tę bardzo prostą metodę jakieś 30 razy…
Oczywiście ta metoda i tak nic nie robi, jednak różnica w czasie wykonania jest bardzo duża…
Zarzucenie wyjątkiem głęboko w stacktrace
Warto również sprawdzić sytuację, w której wyjątek musi przewędrować przez wiele ramek zanim trafi na odpowiedni blok try-catch
. To zachowanie jest sprawdzane przez kolejny benchmark. Polega on na rekurencyjnym odliczaniu do 10, 50 lub 100, aby ostatecznie rzucić wyjątkiem (albo go nie rzucać i zakończyć rekurencję).
@Benchmark @CompilerControl(CompilerControl.Mode.PRINT) public int throw100Benchmark() { try { throwRuntimeException(100); } catch (RuntimeException e) { return 5; } return 1; } @Benchmark @CompilerControl(CompilerControl.Mode.PRINT) public int throw50Benchmark() { try { throwRuntimeException(50); } catch (RuntimeException e) { return 5; } return 1; } @Benchmark @CompilerControl(CompilerControl.Mode.PRINT) public int throw10Benchmark() { try { throwRuntimeException(10); } catch (RuntimeException e) { return 5; } return 1; } private void throwRuntimeException(int i) { if (i == 0) { throwThrow(); return; } else { throwRuntimeException(i - 1); } } @CompilerControl(CompilerControl.Mode.DONT_INLINE) private void throwThrow() { throw RUNTIME_EXCEPTION; } @Benchmark @CompilerControl(CompilerControl.Mode.PRINT) public int without100throwBenchmark() { try { dontThrowRuntimeException(100); } catch (RuntimeException e) { return 5; } return 1; } @Benchmark @CompilerControl(CompilerControl.Mode.PRINT) public int without50throwBenchmark() { try { dontThrowRuntimeException(50); } catch (RuntimeException e) { return 5; } return 1; } @Benchmark @CompilerControl(CompilerControl.Mode.PRINT) public int without10throwBenchmark() { try { dontThrowRuntimeException(10); } catch (RuntimeException e) { return 5; } return 1; } private void dontThrowRuntimeException(int i) { if (i == 0) { dontThrowThrow(i); } else { dontThrowRuntimeException(i - 1); } } @CompilerControl(CompilerControl.Mode.DONT_INLINE) private void dontThrowThrow(int i) { i++; }
Wyniki benchmarku są następujące:
Benchmark Mode Cnt Score Error Units StacktraceThrowBenchmark.throw100Benchmark avgt 10 10047,087 ± 3463,884 ns/op StacktraceThrowBenchmark.throw10Benchmark avgt 10 922,528 ± 15,990 ns/op StacktraceThrowBenchmark.throw50Benchmark avgt 10 3846,247 ± 64,145 ns/op StacktraceThrowBenchmark.without100throwBenchmark avgt 10 152,059 ± 1,656 ns/op StacktraceThrowBenchmark.without10throwBenchmark avgt 10 17,163 ± 0,184 ns/op StacktraceThrowBenchmark.without50throwBenchmark avgt 10 92,268 ± 32,105 ns/op
Jak widać rzucenie wyjątkiem głęboko w stacktrace działa (w miarę) proporcjonalnie dłużej do odległości do najbliższego bloku try-catch
. Najprostszy wniosek z tego jest taki – jeśli już musimy rzucać wyjątek, to nie daleko od łapiącego go bloku try-catch
😉
Mimo wszystko to nie są jakieś kosmiczne wartości – rzucenie wyjątku na głębokości 100 ramek to „koszt” 10 000 nanosekund, czyli 10 mikrosekund, czyli 0,01 milisekundy. Nie ma dramatu…
Skąd te różnice?
Algorytm działania rzucania wyjątku jest następujący (za grupą Hotspot projektu OpenJDK).
1. Sprawdzamy, czy w danej ramce na stosie istnieje odpowiedni blok try-catch
. Informacja o tym znajduje się gdzieś na Off-Heapie.
2. Wśród znalezionych bloków try-catch
sprawdzamy, czy któryś pasuje do naszego miejsca, gdzie wyjątek został rzucony/propagowany w dół.Jeśli jest takie miejsce, trzeba sprawdzić, czy blok łapie odpowiedni typ wyjątku.
3. Jeśli nie znaleźliśmy w tej ramce obsługi błędu, wówczas zdejmujemy ramkę ze stackframe i wracamy do punktu 1.
Niby nie brzmi to skomplikowanie, a jednak z 35 instrukcji procesora robi się 835, gdy rzucimy wyjątkiem (a przynajmniej tak twierdzi .addProfiler(LinuxPerfNormProfiler.class)
). I to dla C2. W trybie interpretowanym ten stosunek to 550 do 2090 dla wykonania pojedynczej metody.
Jakby się dobrze zastanowić, to można by próbować jakoś optymalizować to rzucanie wyjątków. Można by utrzymywać jakąś tablicę, która dla danego typu wyjątku przechowuje adres skoku do którego ma trafić w przypadku rzucenia wyjątku… Może miałoby to sens, gdyby nie to, że wystąpienie wyjątku powinno być… wyjątkiem 😉 A utrzymywanie takiej tablicy tylko zajmowałoby czas i bezsensownie psuło wydajność…
A gdzie jest to coś życiowego?
Dość powszechną (i słuszną) praktyką jest walidacja parametrów wywołania. Często przy okazji walidacji można spotkać się z rzucaniem wyjątków jak choćby javax.validation.ValidationException
. Jeśli tych wyjątków występowałoby dużo mogłoby w jakiś sposób ograniczyć wydajność. Być może warto by to zbadać.
Z tą motywacją stworzyłem prosty Restowy serwis w Spring Boot. A w nim 3 endpointy:
1. Rzucenie wyjątku na głębokości 100 ramek i „łapanie” z @ExceptionHandler
,
2. Zagłębienie się na 100 ramek, powrót, a następnie rzucenie wyjątku na głębokości 1 ramki i „łapanie” z @ExceptionHandler
3. Zagłębienie się na 100 ramek, powrót i zwrócenie rezultatu walidacji w ResponseEntity.badRequest()
@SpringBootApplication @RestController public class ThrowExceptionSpringApplication { private static final String SORRY_NOT_VALID = "Sorry, not valid"; @GetMapping("/response/deep/exception") public String throwDeepException() { Supplier supplier = () -> { throw new ValidationException(SORRY_NOT_VALID); }; return deep(100, supplier); } @GetMapping("/response/shallow/exception") public String throwShallowException() { deep(100, () -> SORRY_NOT_VALID); throw new ValidationException(SORRY_NOT_VALID); } @GetMapping("/response/no-exception") public ResponseEntity dontThrowDeepException() { return ResponseEntity.badRequest() .body(deep(100, () -> SORRY_NOT_VALID)); } private String deep(int i, Supplier producer) { if (i != 0) { return deep(i-1, producer); } return producer.get(); } @ResponseStatus(code = HttpStatus.BAD_REQUEST) @ExceptionHandler(ValidationException.class) public String handleException(ValidationException e) { return SORRY_NOT_VALID; } public static void main(String[] args) { SpringApplication.run(ThrowExceptionSpringApplication.class); } }
Następnie wszystkie endpointy benchmarkowałem z użyciem komendy ab -n 10000 -c 100
. Sprawdzałem w jakim najkrótszym czasie skończy się test (sprawdzałem wielokrotnie po uprzednim rozgrzaniu serwisu). Rezultaty były następujące:
1.Time taken for tests: 1.573 seconds 2.Time taken for tests: 1.515 seconds 3.Time taken for tests: 1.365 seconds
Zatem rzucenie wyjątku na głębokości 100 ramek jest o około 4% mniej wydajne, niż rzucenie na głębokości 1 ramki. Niby bez szału, ale jeżeli jedyną różnicą jest tylko miejsce wykonania throw
to w sumie jest to różnica…
Jeszcze większą różnicę widać, jeśli zamiast rzucać wyjątek po prostu zwrócimy ResponseEntity.badRequest()
– tutaj różnica to około 13%.
Podsumowanie
Wracając do myśli z początku tekstu – najlepiej nie rzucać wyjątków. Takie podejście jest stosowane chociażby w Scali, gdzie zamiast rzucania wyjątku można zwracać Either.
Drugą myślą jest to, że wyjątki powinny być rzucane „wyjątkowo” 😉 Exception Driven Development raczej nie jest dobrym pomysłem.
Ostatnią myślą, którą chciałem się to ponowne polecenie Artykułu Alexey’a Shipileva. Można tam przeczytać, to o czym nawet nie wspomniałem, czyli:
- Inline’owanie na odpowiednim poziomie może polepszyć wydajność rzucania wyjątków,
- Tworzenie Stacktrace’a jest wolne i można z niego zrezygnować,
- Jak rzadkie rzucanie wyjątków jest wg Alexey’a dostatecznie rzadkie