Jak wydajnie rzucać wyjątki?

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

Średnia ocen. 0 / 5. Liczba głosów. 0

Brak ocen.