Odczyt finalnych pól instancyjnych

Zaraz koniec roku, trzeba zamknąć pewne tematy.

Ostatni wpis w tej serii jest o optymalizacjach związanych z finalnymi polami instancyjnymi. Zacznę od obiecanego powrotu do tematu z pól statycznych finalnych, czyli obiektu trzymanego w polu statycznym finalnym oraz jego pól.

Gdy właściciel pola finalnego sam jest polem static final

Weźmy pod uwagę hipotetyczną sytuację: hipotetyczny obiekt Owner, który jest przetrzymywany w polu static final posiada pole finalne int value. W jakiejś innej klasie odwołujemy się przez to pole static final do klasy Owner, a następnie do tego int value.
W czasie kompilacji JIT mamy informację dokładnie o ustalonej wartości tego pola value, zatem można by oczekiwać, że w ramach optymalizacji zostanie wykonany Constant Folding. Jaka jest rzeczywistość? Wykonajmy benchmark:

@State(Scope.Benchmark)
public class InstanceFinalFieldBenchmark {
    public static final ConstantClass CONSTANT_CLASS = new ConstantClass(12);

    public static class ConstantClass {
        private final int value;
        public ConstantClass(int value) { this.value = value; }
        public int getValue() { return value; }
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public int local() {
        return CONSTANT_CLASS.getValue();
    }
}

Adnotacja @CompilerControl(CompilerControl.Mode.PRINT) powoduje wypisanie kodu maszynowego dla tej metody. Spoglądając w jej treść odnajdziemy fragment:

  0x00007f487be1f05c: movabs  $0x716202b90,%r10  ;   {oop(a 'pl/jgardo/field/instance/InstanceFinalFieldBenchmark$ConstantClass'{0x0000000716202b90})}
  0x00007f487be1f066: mov     0xc(%r10),%eax    ;*getfield value {reexecute=0 rethrow=0 return_oop=0}
                                                ; - pl.jgardo.field.instance.InstanceFinalFieldBenchmark$ConstantClass::getValue@1 (line 23)
                                                ; - pl.jgardo.field.instance.InstanceFinalFieldBenchmark::local@3 (line 58)

Te dwie instrukcje odpowiadają za pobranie wartości z pola klasy, co jest też objaśnione komentarzem getfield value.
Innymi słowy brakuje tutaj tej optymalizacji, której byśmy oczekiwali. Dlaczego tak się dzieje?

OpenJDK ma problem z zaufaniem do instancyjnych pól finalnych.

Dzieje się tak z powodu, że istnieje kilka sposobów na popsucie pola final. Te sposoby to:

  • metody natywne,
  • refleksja,
  • method handles,
  • sun.misc.Unsafe.

(m. in. o tych sposobach jest prezentacja Volkera Simonisa „How final is final”, którą polecam 😉 ).

Niestety póki co nie jesteśmy w stanie za dużo zrobić, by final domyślnie odzyskał pełną wiarygodność. Jakkolwiek są pewne dość brudne sposoby, by zmusić JVMa do zaufania finalom.

-XX:+TrustFinalNonStaticFields

Pierwszy to eksperymentalna flaga -XX:+TrustFinalNonStaticFields. Niestety istnieje ona w OpenJDK w wersji 8 i późniejszych, lecz w OracleJDK była w wersji 8, a w 11 już nie…

Jeśli chodzi o skuteczność tej flagi, to w OpenJDK działa ona zgodnie z przewidywaniem, tzn zwraca od razu żądaną wartość:

  0x00007f95c4440bdc: mov     $0xc,%eax

Jeśli ktoś chciałby co nieco poczytać na temat tej flagi, to polecam spojrzeć na tę korespondencję mailową.

@jdk.internal.vm.annotation.Stable

Drugim sposobem na zmuszenie JVMa do zaufania final jest użycie adnotacji @Stable na danym polu finalnym. Taka adnotacja istnieje od OpenJDK w wersji 9, jednak została ona zaprojektowana tylko i wyłącznie do użytku wewnętrznego JVM i nie jest zbyt łatwo dostępna dla zwykłych śmiertelników.

Nie oznacza to jednak, że się nie da jej użyć… 😉
Istnieją dwa ograniczenia zniechęcające do użycia jej:

  • Adnotacja jest dostępna tylko dla modułów: java.base, jdk.internal.vm.ci, jdk.unsupported
    • Ale jeśli dodamy przy kompilacji obiektu korzystającego ze @Stable parametry --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED to się skompiluje,
  • Obiekt korzystający ze @Stable musi być załadowany przez bootclassloader
    • Zatem jeśli dodamy przy uruchomieniu parametr -Xbootclasspath/a:”<ścieżka do jar>”, to też zadziała 😉

Ludzie listy piszą…

Na temat @Stable również istnieje korespondencja mailowa, na którą warto spojrzeć. Dotyczyła ona pytania, dlaczego by nie udostępnić takiej adnotacji dla użytkowników. W tej korespondencji jest wiele ciekawych wątków i linków.

W jednej z odpowiedzi można znaleźć trzeci sposób na zasymulowanie @Stable. Jednak nie testowałem, więc się nie wypowiem.

Co robić, jak żyć?

Jest pewna nadzieja – na samym końcu wspomnianej wyżej korespondencji jest taka wypowiedź:

For optimizing final fields there are much more promising approaches:
   (1) optimistic optimizations in JITs: treat finals as constants and 
track updates invalidating code which relies on that (there were some 
experiments, e.g. [1]);
   (2) forbid final field updates at runtime after initialization is 
over and freely optimize them in JITs.

Both approaches still have some roadblocks on their way (deserialization relies on modifying final fields, hard to track final field values of individual objects in JVM, etc), but the platform steadily moves in the direction of treating final fields as truly final by default.

Zatem trzeba to przyjąć z pokorą i cierpliwością, bo pisanie JVMów do łatwych nie należy…

Chyba, że się jest bogatym, to zamiast cierpliwie czekać, można zainwestować w Azul Zing – tam jest wiele ciekawych flag do użycia z „final” w treści (na stronie chriswhocodes.com można podejrzeć, jakie są dostępne opcje/flagi dla różnych JVMów; można wyszukać po nazwie opcji).
Chociaż osobiście jeszcze nie zgłębiałem możliwości tej JVMki.

A co z polami instancyjnymi?

Jak się okazuje, śledzenie finalnych pól obiektu static final jest nietrywialne, a jeszcze trudniejsze jest śledzenie wartości w polach niestatycznych… Nie znalazłem niestety żadnych optymalizacji dla pola finalnego…

Tym smutnym faktem kończę całą tę sagę o final. Ostatecznie Frodo nie dotarł do Góry Przeznaczenia, Golum odebrał mu pierścień, a słowo kluczowe final dla pól instancyjnych nie ma de facto pozytywnego wpływu na wydajność…

Ale głowy do góry, nadchodzi nowy rok 2020, a z nim nowe, czternaste wydanie OpenJDK, gdzie ma zostać pokazany światu po raz pierwszy nowy sposób dostępu do Off-Heapa. Jednocześnie to może być kolejny krok w stronę zmniejszenia znaczenia sun.misc.Unsafe. A to może w skończonym czasie doprowadzić do wzrostu znaczenia finala.
Cytując klasyka -„Make final final again”. Czy coś podobnego… 😉

Oceń wpis

Tworzenie obiektów z finalnymi polami instancyjnymi

Ten przedostatni wpis na temat final jest o tworzeniu obiektów z finalnymi polami instancyjnymi (poprzednie są klasach/metodach finalnych, argumentach metod lub zmiennych lokalnych oraz o polach statycznych).

Tworzenie obiektów

Jest pewna cecha dość kluczowa final dla pól instancyjnych.

Weźmy na przykład sytuację, w której jeden wątek tworzy obiekt, wypełnia jego pola, a następnie publikuje do „przestrzeni między wątkowej” (przypisuje do innego pola). Drugi wątek cały czas na bieżąco korzysta z nowotworzonych obiektów oraz jego „zainicjalizowanych” pól.

Okazuje się, że ze względu na możliwość zmiany kolejności wykonywania instrukcji (zarówno przez procesor jak i przez JVM) domyślnie nie ma gwarancji, że przy publikacji nie zawsze wszystkie pola są zainicjalizowane.

Daną sytuację można sprawdzić następującym kodem:

@JCStressTest
@Outcome(id = "-1", expect = ACCEPTABLE, desc = "Object is not seen yet.")
@Outcome(id = {"0", "1", "2", "3", "4", "5", "6", "7"}, expect = ACCEPTABLE_INTERESTING, desc = "Seeing partially constructed object.")
@Outcome(id = "8", expect = ACCEPTABLE,  desc = "Seen the complete object.")
@State
public class NonFinalStressTest {
    int v = 1;
    MyObject o;

    @Actor
    public void actor1() {
        o = new MyObject(v);
    }

    @Actor
    public void actor2(IntResult1 r) {
        MyObject o = this.o;
        if (o != null) {
            r.r1 = o.x8 + o.x7 + o.x6 + o.x5 + o.x4 + o.x3 + o.x2 + o.x1;
        } else {
            r.r1 = -1;
        }
    }

    public static class MyObject {
        int x1, x2, x3, x4;
        int x5, x6, x7, x8;
        public MyObject(int v) {
            x1 = v;
            x2 = v;
            x3 = v;
            x4 = v;
            x5 = v;
            x6 = v;
            x7 = v;
            x8 = v;
        }
    }
}

Kod ten jest tak naprawdę test napisanym w frameworku JCStress. Framework ten przede wszystkim ułatwia znajdywanie błędów w programowaniu wielowątkowym. Po uruchomieniu tego testu zauważymy następujące wyniki:

  1 matching test results. 
      [OK] com.vmlens.stressTest.tests.NonFinalStressTest
    (JVM args: [-Dfile.encoding=UTF-8, -server, -XX:+UnlockDiagnosticVMOptions, -XX:+StressLCM, -XX:+StressGCM])
  Observed state   Occurrences              Expectation  Interpretation                                              
              -1    43 381 257               ACCEPTABLE  Object is not seen yet.                                     
               0             0   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               1             0   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               2             0   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               3             0   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               4            62   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               5            42   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               6           360   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               7           437   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               8     5 015 292               ACCEPTABLE  Seen the complete object.                                   

Jak widać na listingu takie sytuacje, choć rzadko, jednak się zdarzają.

I wtedy wkracza final cały na biało. Jeśli go dodamy przy polach klasy MyObject, wówczas Java Memory Model zapewnia, że taki obiekt będzie opublikowany tylko z zainicjalizowanymi polami finalnymi.

Poprawę sytuacji potwierdza wynik testu JCStress:

(ETA:        now) (Rate: 1,65E+07 samples/sec) (Tests: 1 of 1) (Forks:  8 of 8) (Iterations: 40 of 40; 40 passed, 0 failed, 0 soft errs, 0 hard errs) 

Opisana sytuacja jest dość znana i opisana w różnych zakątkach internetu. Zatem jeśli ktoś pragnie zgłębić ten temat, polecam artykuł z DZone.
Kod testu JCStress, który potwierdza istnienie tego zjawiska, również nie jest moją inwencją twórczą. Jest to jeden z przykładów tego użycia frameworku.

Nic za darmo

Jeśli dostajemy gwarancję publikacji zainicjalizowanego obiektu, to jednocześnie zabraniamy wykonywania pewnych optymalizacji lub – co gorsza – czekamy. Zatem coś musi się działać wolniej.

Osobiście zauważyłem to przy pewnym eksperymencie – porównywałem czas inicjalizacji kontekstu Springa w zależności od sposobu Dependency Injection. Początkowo porównywałem wstrzykiwanie zależności przez settery, pola oraz przez konstruktor. Na samym końcu sprawdziłem, jaki wpływ ma dodanie/usunięcie finala przy wstrzykiwaniu przez konstruktor. Eksperyment dotyczył 800 beanów z łącznie 10 000 polami. Wyniki są następujące:

Constructor Injection with finals5,3
Constructor Injection without finals4,72
Field Injection4,46
Setter Injection6,1

Zatem widać wspomniany narzut zwiększający czas tworzenia obiektu. Wątpię, żeby Spring – jako framework DI – przy inicjalizacji kontekstu wymagał finali na polach aby zapewnić, że kontekst jest kompletny. Jeśli więc komuś bardzo zależy na czasie podnoszenia aplikacji, to jest to jakiś pomysł na skrócenie tego czasu. Jakkolwiek, dla mnie to klasyczny przykład przedwczesnej optymalizacji.

To znaczy, że final tylko spowalnia?

Nie.
Ale o tym będzie kolejny wpis – ostatni z tej serii 😉

Oceń wpis

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<String> 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<String> dontThrowDeepException() {
        return ResponseEntity.badRequest()
                .body(deep(100, () -> SORRY_NOT_VALID));
    }

    private String deep(int i, Supplier<String> 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 <endpoint>. 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

Oceń wpis

Interfejs w JVMie – niskopoziomowo

Standardowym pytaniem rekrutacyjnym jest: czym się różni interfejs od klasy abstrakcyjnej w Javie. O ile większość odpowiedzi zaczyna się od strony specyfikacji, o tyle w tym wpisie podejdę od strony implementacji.

Bytecode

Standardowo zaczniemy od spojrzenia w bytecode. Najpierw podejrzymy co siedzi w abstrakcyjnej klasie:

public abstract class AbstractClass {
    public abstract int doSth(int a);
    public int defaultDoSth(int a) {
        return doSth(a);
    }
}

Następnie podejrzymy analogiczny do niej interfejs:

public interface Interface {
    int doSth(int a);
    default int defaultDoSth(int a) {
        return doSth(a);
    }
}

Porównując obydwa twory zaczniemy od stwierdzenia banału, że po skompilowaniu otrzymujemy pliki z rozszerzeniem .class Gdzie zatem jest trzymana informacja o tym, że jedno jest interfejsem a drugie klasą? Na to pytanie odpowiada listing wywołania na tych plikachjavap -v -p.

A w nim zauważymy, że klasa abstrakcyjna posiada flagi flags: (0x0421) ACC_PUBLIC, ACC_SUPER, ACC_ABSTRACT, gdy interfejs posiada flagi flags: (0x0601) ACC_PUBLIC, ACC_INTERFACE, ACC_ABSTRACT.
Zatem o byciu interfejsem świadczy dodatkowa flaga ACC_INTERFACE.
Flaga ACC_SUPERw klasie abstrakcyjnej jest aktualnie nadmiarowa i istnieje tylko w celu wstecznej kompatybilności.

Drugą różnicą, którą zauważyć porównując bytecode obu plików .class jest obecność konstruktora w klasie abstrakcyjnej. Jak wiemy, jeśli jawnie nie stworzymy konstruktora w kodzie źródłowym, kompilator wygeneruje nam domyślny konstruktor danej klasy.

Trzecią różnicą dość oczywistą, aczkolwiek niewidoczną w tym porównaniu jest to, że klasa w przeciwieństwie do interfejsu może posiadać pola.

Ostatnią różnicę, którą dokładniej opiszę jest różnica w implementacji metody defaultDoSth(int).

Dla klasy abstrakcyjnej bytecode wygląda następująco:

 public int defaultDoSth(int);
    descriptor: (I)I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: iload_1
         2: invokevirtual #2                  // Method doSth:(I)I
         5: ireturn
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   Ldev/jgardo/jvm/miscellaneous/interfaces/AbstractClass;
            0       6     1     a   I
}

Domyślna metoda interfejsu Interface defaultDoSth(int) zdekomilowana wygląda tak:

  public int defaultDoSth(int);
    descriptor: (I)I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: iload_1
         2: invokeinterface #1,  2            // InterfaceMethod doSth:(I)I
         7: ireturn
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       8     0  this   Ldev/jgardo/jvm/miscellaneous/interfaces/Interface;
            0       8     1     a   I
}

W implementacji metody defaultDoSth(int) w przypadku klasy abstrakcyjnej widzimy instrukcję kodu bajtowego invokevirtual, a za nią parametr, w którym jest wskazywana metoda, którą chcemy wywołać.
W przypadku interfejsu zauważymy invokeinterface z dwoma parametrami.
A jakie są różnice między nimi? Czy były konieczne dwa różne kody operacji?

Invoke(…)

Ogólnie mówiąc, wywoływanie metod można wykonać za pomocą jednego z 5 różnych instrukcji kodu bajtowego (w zależności od kontekstu):

  • invokestatic – używamy go za każdym razem, kiedy wywołujemy metodę statyczną, czyli taką, w której nie potrzebujemy przekazywać wskaźnika na obiekt z którego jest ona wywoływana (nie ma przykazywanego w żaden sposób obiektu this).
  • invokespecial – tej instrukcji używamy, kiedy chcemy wywołać konkretną metodę danego obiektu. Nie zastanawiamy się, czy ta metoda jest nadpisana w klasie dziedziczącej, lecz wskazujemy dokładną metodę. Przykładem jest wywoływanie metody prywatnej, metody z nadklasy lub konstruktora. Przekazujemy niejawnie this jako pierwszy parametr metody.
  • invokedynamic – ta instrukcja jest raczej niedostępna w Javie i rzadko spotkana (oprócz lambd). Kluczowe jest w niej to, że generalnie na etapie kompilacji nie wiemy, co chcemy wywołać 😉 Na etapie kompilacji jedynie wskazujemy, kto może wiedzieć, co chcemy wywołać.
    Trochę poruszam temat we wpisie o lambdach.
  • invokevirtual – w tym przypadku mamy do czynienia z wywołaniami metod publicznych, chronionych i prywatnych w ramach pakiety (generalnie nieprywatnych). Na etapie kompilacji przeważnie nie wiemy, czy wywołujemy metodę z danej klasy, czy klasy po niej dziedziczącej (ze względu na polimorfizm). W runtime‚ie JVM ewaluuje, jaką metodę wykonać.
    W tej operacji równierz przemycamy this. Argumentem tego kodu bajtowego jest indeks metody, którą wywołujemy.
  • invokeinteface – bardzo podobna instrukcja do invokevirtual z tą różnicą, że nie znamy nawet klasy, której metodę wywołujemy. W tym przypadku jednak mamy dwa argumenty tego kodu bajtowego – pierwszym jest jawne przekazanie uchwytu („wskaźnika”) do interfejsu, którego metodę chcemy wywołać, a drugim – indeks tej metody w danym interfejsie.

Różnice między invokevirtual a invokeinterface

Jak wywnioskować, którą metodę należy wykonać?
Najprościej byłoby sprawdzić klasę (wskaźnik na klasę jest w każdym obiekcie w jego nagłówku), której metodę chcemy wywołać i poszukać, czy istnieje implementacja poszukiwanej metody. Jeśli nie ma jej w tej klasie, szukamy jej w nadklasie. Jeśli i w niej nie ma, szukamy dalej w zwyż w hierarchii.

Jednak takie podejście jest mało efektywne. Mogłoby się zdarzyć, że wielokrotnie obliczamy to samo wywołując wciąż tę samą metodę. Zatem lepiej by było na etapie ładowania klasy obliczyć od razu, jaką dokładnie metodę trzeba wykonać dla każdej dostępnej metody. Wynik takich obliczeń można składować w metadanych danej klasy.
I właśnie taka tablica, która przechowuje dla każdej metody wskaźnik do kodu, jest nazywana vtable. Zatem za każdym razem, gdy używamy invokevirtual, najpierw sprawdzamy klasę obiektu, następnie odczytujemy z klasy adres do pola z tablicą vtable, a następnie uruchamiamy kod znaleziony pod indeksem danej metody (indeks ten jest podany w argumencie invokevirtual).

Warto tutaj nadmienić, że indeksy metod dla danej hierarchii klas są takie same, tzn. jeśli jakaś klasa miała tablicę vtable, to każda jej podklasa będzie miała wszystkie jej metody z nadklasy w dokładnie tej samej kolejności, a ewentualne dodatkowe metody specyficzne dla podklasy będą znajdować się na końcu tabeli. Zatem jeden argument z indeksem metody wystarczy do precyzyjnego określenia metody, gdyż indeks metody w danej hierarchii klas jest stały.

Nieco inna sytuacja jest w przypadku invokeinterface. Jeśli klasa implementuje interfejs, to metody go implementujące mogą być w vtable w różnej kolejności. Zatem dla interfejsu potrzebujemy analogicznej struktury zwanej itable. Kolejność metod jest specyficzna dla danego interfejsu.
„Niestety” klasy mogą implementować wiele interfejsów, dlatego każda klasa posiada tablicę tablic itable. Skoro jest wiele itable, to trzeba je rozróżniać, stąd konieczność przekazywania wraz z kodem invokeinterface argumentu w postaci odniesienia do interfejsu, jak i metody.
Pewną konsekwencją możliwości implementacji wielu interfejsów jest to, że żeby znaleźć odpowiednią itable trzeba przeiterować po tablicy tablic itable, co jest mało wydajne.

Jeśli ten opis jest zbyt mglisty, warto zajrzeć pod ten pomocny w zrozumieniu link.

A to tylko początek

Można na ten temat jeszcze wiele pisać. Póki co jednak zostawiam garść linków omawiających tematy wokół. Dotyczą one przede wszystkim:

  • optymalizacji invokeinterface/invokevirtual.
    Należy wspomnieć, że znaczna większość interfejsów/klas posiada tylko jedną klasę implementującą lub jedną podklasę. Można się wówczas pokusić o optymalizację i skuteczne inline’owanie.
    Słowa klucze dla tego tematu to: monomorphic, bimorphic, megamorphic.
    Osobiście polecam wpis Aleksey’a Shipilev’a oraz Richarda Warburtona
  • opisu wywołań wirtualnych/interfejsu prosto ze strony OpenJDK
  • porównania wywołań wirtualnych w różnych językach
  • wydajności obu wywołań oraz o bugu, który nie pozwala inline’ować jedynej implementacji interfejsu

A na razie to by było ode mnie na tyle. Jeśli chcecie sami coś po kombinować z interfejsami źródła moich eksperymentów są na githubie. Dajcie znać w komentarzach, co o tym wszystkim myślicie 😉

Oceń wpis

Enum w JVM – szczegóły implementacyjne

Kolejnym słowem kluczowym, które chciałbym omówić jest enum. Ta konstrukcja została wprowadzona w Javie 1.5. Może się wydawać, że intuicyjnie wiemy, jak technicznie enum jest zaimplementowany, jednak warto zweryfikować domysły. Być może to słowo kluczowe niesie ze sobą jakieś dodatkowe „magiczne” właściwości, których zwykła klasa nie posiada…

Pierwsze spojrzenie na bytecode

Na początek stwórzmy i skompilujmy prostą klasę:

public enum Enum {
    VAL_1(1),
    VAL_2(2);

    private final int abc;

    Enum(int abc) {
        this.abc = abc;
    }
}

Następnie dekompilujmy ją z użyciem javap -v -p. Dekompilator wyświetla dosyć dużo linii, więc skupię się na tych ciekawszych rzeczach i krótko skomentuję. Cały listing na samym spodzie postu.

public final class dev.jgardo.jvm.miscellaneous.enums.Enum extends java.lang.Enum<dev.jgardo.jvm.miscellaneous.enums.Enum>

Jak widzimy, enum jest w czasie kompilacji do bytecodu zamieniany na „zwykłą” klasę dziedziczącą z java.lang.Enum. Jednak różni się od „zwykłej” klasy flagami:

  flags: (0x4031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER, ACC_ENUM

Obecność flagi ACC_ENUM może potencjalnie odpowiadać za jakieś zachowania, więc wrócimy do niej później.

  public static final dev.jgardo.jvm.miscellaneous.enums.Enum VAL_1;
    descriptor: Ldev/jgardo/jvm/miscellaneous/enums/Enum;
    flags: (0x4019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_ENUM

  public static final dev.jgardo.jvm.miscellaneous.enums.Enum VAL_2;
    descriptor: Ldev/jgardo/jvm/miscellaneous/enums/Enum;
    flags: (0x4019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_ENUM

Widzimy również dwa pola statyczne finalne z dodatkową flagą ACC_ENUM.

  private final int abc;
  private static final dev.jgardo.jvm.miscellaneous.enums.Enum[] $VALUES;
  public static dev.jgardo.jvm.miscellaneous.enums.Enum[] values();
  public static dev.jgardo.jvm.miscellaneous.enums.Enum valueOf(java.lang.String);
  private dev.jgardo.jvm.miscellaneous.enums.Enum(int);

W kolejnych liniach widzimy pole, które zadeklarowaliśmy w enumie, a następnie wygenerowane pole statyczne finalne z wszystkimi wartościami enuma o nazwie $VALUES, a po nim kolejne dwie wygenerowane statyczne metody. Pierwsza zwraca wszystkie możliwe wartości, a druga zwraca wartość enuma dla podanego Stringa.
Następnie widzimy zadeklarowany wcześniej konstruktor.

Na samym końcu listingu z javap znajduje się wygenerowana inicjalizacja wartości enumów (pól statycznych finalnych) oraz wypełnienie wartościami wspomnianej wcześniej tablicy $VALUES.

Czyli to zwykła klasa?

Można by się pokusić o stwierdzenie, że tak właściwie to słowo kluczowe enum służy do ograniczenia boilerplate’u poprzez wygenerowanie zwykłej klasy Javowej. Być może równie dobrze taki boilerplate możnaby ograniczyć jakąś adnotacją Lombokową….
Czy więc zatem można by taki enum stworzyć „ręcznie”? Warto spróbować zamienić klasę Enum

public enum Enum {
    VAL_1(1),
    VAL_2(2);

    private final int abc;
    Enum(int abc) {
        this.abc = abc;
    }
}

na odpowiadającą jej implementację wygenerowanej klasy Enuma czyli:

public class Enum extends java.lang.Enum<Enum> {
    public static final Enum VAL_1 = new Enum("VAL_1", 1, 1);
    public static final Enum VAL_2 = new Enum("VAL_2", 2, 2);

    private final int abc;
    private static final Enum[] $VALUES = new Enum[] {VAL_1, VAL_2 };
    public static Enum[] values() {
        return $VALUES;
    }

    public static Enum valueOf(String name) {
        return valueOf(Enum.class, name);
    }
    Enum(String name, int ordinal, int abc) {
        super(name, ordinal);
        this.abc = abc;
    }
}

Okazuje się, że enum jest uprzywilejowany na kilka sposobów.

1. switch pozwala na używanie enumów w case. Polega to na wywołaniu metody ordinal() enuma, co jest równe liczbie porządkowej wartości danego enuma. Dzięki temu case może dotyczyć już zwykłych intów co jest standardowym mechanizmem (zamiana wartości Enuma na wartości ordinal(), również jest automatyczna i nie widać tego w kodzie, choć w bytecodzie jest to widoczne).
Jeśli chcielibyśmy stworzyć własnoręcznie klasę, wywołanie ordinal() musialo by być jawne, co zmniejsza czytelność kodu.

2. Tworzenie obiektem z użyciem refleksji jest dla enumów zablokowane. Szybki test:

    public static void main(String[] args) throws Exception {
        var constructor = Enum.class.getDeclaredConstructors()[0];
        constructor.setAccessible(true);
        var generated = constructor.newInstance("VAL_G", 2, 2);
        System.out.println(generated);
    }

powoduje równie szybki błąd:

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
	at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:484)
	at dev.jgardo.jvm.miscellaneous.enums.EnumExperiment.main(EnumExperiment.java:18)

Jeśli spojrzymy w implementację (Constructor.newInstance(Constructor.java:484)), to za to rzucenie wyjątku jest uwarunkowane obecnością wspomnianej wcześniej flagi ACC_ENUM dla danej klasy.

3. Instancje enumów można wykorzystywać w adnotacjach, instancje zwykłych klas – nie. Generalnie to jest duża przewaga, a osiągana jest ona znów dzięki fladze ACC_ENUM dla klasy.

4. Enumy są dobrze przystosowane do serializacji obiektów, które je posiadają – po deserializacji otrzymywany jest istniejący enum, a nie jakiś kolejny nowo stworzony enum (a tak by było przy w przypadku zwykłej klasy).

5. W zasadzie na końcu najważniejsze – tego sie normalnie nie da skompilować 😛 Kompilator javac uniemożliwia „ręczne” stworzenie klasy dziedziczącej po java.lang.Enum
Oświadcza to dosadnie komunikatem przy kompilacji:
Enum.java:3: error: classes cannot directly extend java.lang.Enum

Podsumowanie

Można by w skrócie powiedzieć, że enum niby jest taką zwykła klasą, ale jednak nie 😉 Bez wsparcia ze strony JVMa i kompilatora nie można by go używać w tak elastyczny sposób (w adnotacjach, switchu, serializacji). Z drugiej strony można też powiedzieć, że całość implementacji jest dosyć intuicyjna i przewidywalna i że nie ma tam jakiejś specjalnej „magii”.

Z perspektywy czasu można śmiało powiedzieć, że dodanie osobnego słowa kluczowego było krokiem w dobrą stronę.

I na koniec obiecany cały listing javap -v -p:

Classfile /home/gardziol/repository/jvm-miscellaneous/target/classes/dev/jgardo/jvm/miscellaneous/enums/Enum.class
  Last modified 6 paź 2019; size 1137 bytes
  MD5 checksum 18c950a8da67456a2509b83e2dfe7d36
  Compiled from "Enum.java"
public final class dev.jgardo.jvm.miscellaneous.enums.Enum extends java.lang.Enum<dev.jgardo.jvm.miscellaneous.enums.Enum>
  minor version: 0
  major version: 55
  flags: (0x4031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER, ACC_ENUM
  this_class: #4                          // dev/jgardo/jvm/miscellaneous/enums/Enum
  super_class: #13                        // java/lang/Enum
  interfaces: 0, fields: 4, methods: 4, attributes: 2
Constant pool:
   #1 = Fieldref           #4.#40         // dev/jgardo/jvm/miscellaneous/enums/Enum.$VALUES:[Ldev/jgardo/jvm/miscellaneous/enums/Enum;
   #2 = Methodref          #41.#42        // "[Ldev/jgardo/jvm/miscellaneous/enums/Enum;".clone:()Ljava/lang/Object;
   #3 = Class              #20            // "[Ldev/jgardo/jvm/miscellaneous/enums/Enum;"
   #4 = Class              #43            // dev/jgardo/jvm/miscellaneous/enums/Enum
   #5 = Methodref          #13.#44        // java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
   #6 = Methodref          #13.#45        // java/lang/Enum."<init>":(Ljava/lang/String;I)V
   #7 = Fieldref           #4.#46         // dev/jgardo/jvm/miscellaneous/enums/Enum.abc:I
   #8 = String             #14            // VAL_1
   #9 = Methodref          #4.#47         // dev/jgardo/jvm/miscellaneous/enums/Enum."<init>":(Ljava/lang/String;II)V
  #10 = Fieldref           #4.#48         // dev/jgardo/jvm/miscellaneous/enums/Enum.VAL_1:Ldev/jgardo/jvm/miscellaneous/enums/Enum;
  #11 = String             #16            // VAL_2
  #12 = Fieldref           #4.#49         // dev/jgardo/jvm/miscellaneous/enums/Enum.VAL_2:Ldev/jgardo/jvm/miscellaneous/enums/Enum;
  #13 = Class              #50            // java/lang/Enum
  #14 = Utf8               VAL_1
  #15 = Utf8               Ldev/jgardo/jvm/miscellaneous/enums/Enum;
  #16 = Utf8               VAL_2
  #17 = Utf8               abc
  #18 = Utf8               I
  #19 = Utf8               $VALUES
  #20 = Utf8               [Ldev/jgardo/jvm/miscellaneous/enums/Enum;
  #21 = Utf8               values
  #22 = Utf8               ()[Ldev/jgardo/jvm/miscellaneous/enums/Enum;
  #23 = Utf8               Code
  #24 = Utf8               LineNumberTable
  #25 = Utf8               valueOf
  #26 = Utf8               (Ljava/lang/String;)Ldev/jgardo/jvm/miscellaneous/enums/Enum;
  #27 = Utf8               LocalVariableTable
  #28 = Utf8               name
  #29 = Utf8               Ljava/lang/String;
  #30 = Utf8               <init>
  #31 = Utf8               (Ljava/lang/String;II)V
  #32 = Utf8               this
  #33 = Utf8               Signature
  #34 = Utf8               (I)V
  #35 = Utf8               <clinit>
  #36 = Utf8               ()V
  #37 = Utf8               Ljava/lang/Enum<Ldev/jgardo/jvm/miscellaneous/enums/Enum;>;
  #38 = Utf8               SourceFile
  #39 = Utf8               Enum.java
  #40 = NameAndType        #19:#20        // $VALUES:[Ldev/jgardo/jvm/miscellaneous/enums/Enum;
  #41 = Class              #20            // "[Ldev/jgardo/jvm/miscellaneous/enums/Enum;"
  #42 = NameAndType        #51:#52        // clone:()Ljava/lang/Object;
  #43 = Utf8               dev/jgardo/jvm/miscellaneous/enums/Enum
  #44 = NameAndType        #25:#53        // valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
  #45 = NameAndType        #30:#54        // "<init>":(Ljava/lang/String;I)V
  #46 = NameAndType        #17:#18        // abc:I
  #47 = NameAndType        #30:#31        // "<init>":(Ljava/lang/String;II)V
  #48 = NameAndType        #14:#15        // VAL_1:Ldev/jgardo/jvm/miscellaneous/enums/Enum;
  #49 = NameAndType        #16:#15        // VAL_2:Ldev/jgardo/jvm/miscellaneous/enums/Enum;
  #50 = Utf8               java/lang/Enum
  #51 = Utf8               clone
  #52 = Utf8               ()Ljava/lang/Object;
  #53 = Utf8               (Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
  #54 = Utf8               (Ljava/lang/String;I)V
{
  public static final dev.jgardo.jvm.miscellaneous.enums.Enum VAL_1;
    descriptor: Ldev/jgardo/jvm/miscellaneous/enums/Enum;
    flags: (0x4019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_ENUM

  public static final dev.jgardo.jvm.miscellaneous.enums.Enum VAL_2;
    descriptor: Ldev/jgardo/jvm/miscellaneous/enums/Enum;
    flags: (0x4019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_ENUM

  private final int abc;
    descriptor: I
    flags: (0x0012) ACC_PRIVATE, ACC_FINAL

  private static final dev.jgardo.jvm.miscellaneous.enums.Enum[] $VALUES;
    descriptor: [Ldev/jgardo/jvm/miscellaneous/enums/Enum;
    flags: (0x101a) ACC_PRIVATE, ACC_STATIC, ACC_FINAL, ACC_SYNTHETIC

  public static dev.jgardo.jvm.miscellaneous.enums.Enum[] values();
    descriptor: ()[Ldev/jgardo/jvm/miscellaneous/enums/Enum;
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: getstatic     #1                  // Field $VALUES:[Ldev/jgardo/jvm/miscellaneous/enums/Enum;
         3: invokevirtual #2                  // Method "[Ldev/jgardo/jvm/miscellaneous/enums/Enum;".clone:()Ljava/lang/Object;
         6: checkcast     #3                  // class "[Ldev/jgardo/jvm/miscellaneous/enums/Enum;"
         9: areturn
      LineNumberTable:
        line 3: 0

  public static dev.jgardo.jvm.miscellaneous.enums.Enum valueOf(java.lang.String);
    descriptor: (Ljava/lang/String;)Ldev/jgardo/jvm/miscellaneous/enums/Enum;
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: ldc           #4                  // class dev/jgardo/jvm/miscellaneous/enums/Enum
         2: aload_0
         3: invokestatic  #5                  // Method java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
         6: checkcast     #4                  // class dev/jgardo/jvm/miscellaneous/enums/Enum
         9: areturn
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  name   Ljava/lang/String;

  private dev.jgardo.jvm.miscellaneous.enums.Enum(int);
    descriptor: (Ljava/lang/String;II)V
    flags: (0x0002) ACC_PRIVATE
    Code:
      stack=3, locals=4, args_size=4
         0: aload_0
         1: aload_1
         2: iload_2
         3: invokespecial #6                  // Method java/lang/Enum."<init>":(Ljava/lang/String;I)V
         6: aload_0
         7: iload_3
         8: putfield      #7                  // Field abc:I
        11: return
      LineNumberTable:
        line 19: 0
        line 20: 6
        line 21: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      12     0  this   Ldev/jgardo/jvm/miscellaneous/enums/Enum;
            0      12     3   abc   I
    Signature: #34                          // (I)V

  static {};
    descriptor: ()V
    flags: (0x0008) ACC_STATIC
    Code:
      stack=5, locals=0, args_size=0
         0: new           #4                  // class dev/jgardo/jvm/miscellaneous/enums/Enum
         3: dup
         4: ldc           #8                  // String VAL_1
         6: iconst_0
         7: iconst_1
         8: invokespecial #9                  // Method "<init>":(Ljava/lang/String;II)V
        11: putstatic     #10                 // Field VAL_1:Ldev/jgardo/jvm/miscellaneous/enums/Enum;
        14: new           #4                  // class dev/jgardo/jvm/miscellaneous/enums/Enum
        17: dup
        18: ldc           #11                 // String VAL_2
        20: iconst_1
        21: iconst_2
        22: invokespecial #9                  // Method "<init>":(Ljava/lang/String;II)V
        25: putstatic     #12                 // Field VAL_2:Ldev/jgardo/jvm/miscellaneous/enums/Enum;
        28: iconst_2
        29: anewarray     #4                  // class dev/jgardo/jvm/miscellaneous/enums/Enum
        32: dup
        33: iconst_0
        34: getstatic     #10                 // Field VAL_1:Ldev/jgardo/jvm/miscellaneous/enums/Enum;
        37: aastore
        38: dup
        39: iconst_1
        40: getstatic     #12                 // Field VAL_2:Ldev/jgardo/jvm/miscellaneous/enums/Enum;
        43: aastore
        44: putstatic     #1                  // Field $VALUES:[Ldev/jgardo/jvm/miscellaneous/enums/Enum;
        47: return
      LineNumberTable:
        line 4: 0
        line 10: 14
        line 3: 28
}
Signature: #37                          // Ljava/lang/Enum<Ldev/jgardo/jvm/miscellaneous/enums/Enum;>;
SourceFile: "Enum.java"
Oceń wpis

Switch

Pierwsze, co może kojarzyć się ze switch-case to szereg następujących po sobie bloków if else. Z pewnością taki blok switch case jest bardziej czytelny aniżeli szereg if else. Jednak każdy zna jakąś sytuację, w której jakiś znajomy w pracy zapomniał dodać break na koniec bloku case, co prowadziło do błędów biznesowych, technicznych lub błędów bezpieczeństwa. A skoro ten break jest przeważnie konieczny, to nie do końca pasuje do teorii o ciągu if elseów. Jaka jest prawda o switch?

Code

Ok, czas na trochę kodu. Zacznijmy od prostej metody, która w zależności od argumentu zwraca różne wartości. Zaimplementowana będzie dwukrotnie – najpierw za pomocą switch, następnie z użyciem ifów.

    public int switchInt9(CountToNine countToNine) {
        int i = countToNine.i;
        switch (i) {
            case 0: return 0;
            case 1: return 8;
            case 2: return 16;
            case 3: return 24;
            case 4: return 32;
            case 5: return 40;
            case 6: return 48;
            case 7: return 56;
            default:
                return 64;
        }
    }

    public int ifInt9(CountToNine countToNine) {
        int i = countToNine.i;
        if (i == 0) {
            return 0;
        } else if (i == 1) {
            return 8;
        } else if (i == 2) {
            return 16;
        } else if (i == 3) {
            return 24;
        } else if (i == 4) {
            return 32;
        } else if (i == 5) {
            return 40;
        } else if (i == 6) {
            return 48;
        } else if (i == 7) {
            return 56;
        } else  {
            return 64;
        }
    }

Stworzyłem też analogiczne metody z 33 wpisami zamiast 9.

Po skompilowaniu takich metod, a następnie zdekompilowaniu z użyciem javap -v widzimy obydwie metody. Pierwsza z użyciem switch wygląda tak:

  public int switchInt9(dev.jgardo.jvm.miscellaneous.switches.SwitchBenchmark$CountToNine);
    descriptor: (Ldev/jgardo/jvm/miscellaneous/switches/SwitchBenchmark$CountToNine;)I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=3, args_size=2
         0: aload_1
         1: invokestatic  #22                 // Method dev/jgardo/jvm/miscellaneous/switches/SwitchBenchmark$CountToNine.access$000:(Ldev/jgardo/jvm/miscellaneous/switches/SwitchBenchmark$CountToNine;)I
         4: istore_2
         5: iload_2
         6: tableswitch   { // 0 to 7
                       0: 52
                       1: 54
                       2: 57
                       3: 60
                       4: 63
                       5: 66
                       6: 69
                       7: 72
                 default: 75
            }
        52: iconst_0
        53: ireturn
        54: bipush        8
        56: ireturn
        57: bipush        16
        59: ireturn
        60: bipush        24
        62: ireturn
        63: bipush        32
        65: ireturn
        66: bipush        40
        68: ireturn
        69: bipush        48
        71: ireturn
        72: bipush        56
        74: ireturn
        75: bipush        64
        77: ireturn

W tym przypadku widzimy instrukcję kodu bajtowego tableswitch z pożądanymi wartościami podawanymi przy case i numerem instrukcji do której ma „skoczyć” jeśli wartość się zgadza. (o tableswitch więcej poniżej)

Dla ifów bytecode wygląda następująco:

  public int ifInt9(dev.jgardo.jvm.miscellaneous.switches.SwitchBenchmark$CountToNine);
    descriptor: (Ldev/jgardo/jvm/miscellaneous/switches/SwitchBenchmark$CountToNine;)I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=2
         0: aload_1
         1: invokestatic  #22                 // Method dev/jgardo/jvm/miscellaneous/switches/SwitchBenchmark$CountToNine.access$000:(Ldev/jgardo/jvm/miscellaneous/switches/SwitchBenchmark$CountToNine;)I
         4: istore_2
         5: iload_2
         6: ifne          11
         9: iconst_0
        10: ireturn
        11: iload_2
        12: iconst_1
        13: if_icmpne     19
        16: bipush        8
        18: ireturn
        19: iload_2
        20: iconst_2
        21: if_icmpne     27
        24: bipush        16
        26: ireturn
        27: iload_2
        28: iconst_3
        29: if_icmpne     35
        32: bipush        24
        34: ireturn
        35: iload_2
        36: iconst_4
        37: if_icmpne     43
        40: bipush        32
        42: ireturn
        43: iload_2
        44: iconst_5
        45: if_icmpne     51
        48: bipush        40
        50: ireturn
        51: iload_2
        52: bipush        6
        54: if_icmpne     60
        57: bipush        48
        59: ireturn
        60: iload_2
        61: bipush        7
        63: if_icmpne     69
        66: bipush        56
        68: ireturn
        69: bipush        64
        71: ireturn

W przypadku ciągu if elseów widzimy… ciąg if elseów… Czyli na poziomie bytecode’u switch nie jest ukrytą opcją if elseową.

Trochę teorii

Otóż w zamyśle do obsługi słowa kluczowego switch stworzono specjalnie dwie instrukcje bytecodu – tableswitch oraz lookupswitch.
Zamysł był prosty: zamiast wielokrotnie porównywać z coraz innymi wartościami, na etapie kompilacji stworzymy tablicę par „wartość-adres skoku do instrukcji”. Następnie wystarczyłoby poszukać odpowiedniej wartości w tablicy i skoczyć do tej instrukcji, którą wskazuje.
Dla tableswitch wyszukiwanie jest proste – wystarczy spojrzeć pod index tablicy, której wartości szukamy. Jeśli szukamy wartości 5, to skaczemy do tej instrukcji, którą wskazuje tablica pod indeksem 5. Wówczas czas obliczenia miejsca kolejnej instrukcji jest stały tzn. O(1).

Niestety nie zawsze w case szukamy kolejnych liczb porządkowych zaczynając od zera. Czasem są to różne wartości, które nie są w żaden sposób uporządkowane, ani powiązane. Dla takich wartości została stworzona instrukcja lookupswitch. Na etapie kompilacji wszystkie wartości są sortowane. Następnie w runtimie szukamy odpowiedniej wartości używając algorytmu wyszukiwania binarnego znajdywana jest odpowiednia wartość. Dzięki takiemu mechanizmowi możemy znaleźć odpowiednią wartość w czasie logarytmicznym tzn. O(log2(n)).

Oczekiwania vs rzeczywistość

Wydaje się, że taka optymalizacja ma szanse prowadzić do szybszego działania kodu. Oszczędzamy przede wszystkim na wielokrotnym porównywaniu.

A jaka jest rzeczywistość?
Uruchomiłem odpowiednie wspomniane na początku metody jako benchmarki (kod na moim githubie). Mierzyłem przepustowość, czyli ilość operacji na sekundę (im więcej tym lepiej).
Wyniki na moim lapku na JVM OpenJDK w wersji 8 (java-8-openjdk-amd64) są następujące:

SwitchBenchmark.ifInt33      thrpt   10  22022234,398 ± 247287,924  ops/s
SwitchBenchmark.switchInt33  thrpt   10  20090372,745 ± 105013,436  ops/s

SwitchBenchmark.ifInt9       thrpt   10  28632436,517 ± 107714,521  ops/s
SwitchBenchmark.switchInt9   thrpt   10  27754974,543 ± 177176,911  ops/s

Okazuje się, że ciąg if elseów jest szybszy, aniżeli sprawdzenie w tabeli miejsca do instrukcji skoku. Dlaczego?

Otóż taka implementacja switch miała sens w początkach Javy – w drugiej połowie lat 90. Wtedy procesory były dość wolne jak na dzisiejsze standardy, a odczyty z pamięci RAM były względem procesorów całkiem szybkie. Jeśli odczyt z pamięci trwał wówczas kilka cykli procesora wówczas miało to sens. Z biegiem lat wymyślono takie mechanizmy jak wielordzeniowość, pipelining, branch prediction, które znacznie przyspieszyły wykonywanie instrukcji kodu maszynowego nie przyspieszając taktowania (a równocześnie taktowanie zwiększyło się kilku(nasto)krotnie).

O ile skoki warunkowe if mogły w miarę bezboleśnie podlegać tym usprawnieniom, o tyle skok bezwarunkowy do adresu odczytanego z tabeli pod indexem wyliczonym w poprzedniej instrukcji dość skutecznie blokuje owe usprawnienia. Zatem zaleta stała się wadą, co skutkuje gorszą wydajnością…

Jeśli spojrzymy na kod maszynowy skompilowany przezc2 w openjdk8 zauważymy wspomniany fragment kodu maszynowego (skok pod adres wskazany przez wartość w rejestrze)

  0x00007f872101185d: jmpq    *(%r8,%r10)       ;*tableswitch
                                                ; - dev.jgardo.jvm.miscellaneous.switches.SwitchBenchmark::switchInt33@6 (line 96)

OpenJDK vs OracleJDK

Szczęśliwie twórcy OracleJdk 8 stwierdzili, że to może być pewien mało wydajny mechanizm w dzisiejszych czasach, więc po kompilacji c2 instrukcje switch case zostają zamienione na ciąg if elseów (w końcu za coś każą płacić za licencję komercyjną :p).

Niestety switch jest zamieniany na ify dla 9 case, gdy dla 33 caseów dalej jest domyślna implementacja switch

Myśli ostateczne

Wiele o switch można by jeszcze mówić. Można wspomnieć o:

  • implementacji switch na Enum i String (o tym dużo w internecie)
  • zamianie kolejności ifów przy konwersji switch -> if w zależności od statystyki wywołań
  • switch expression
  • i bazującym na nim pattern matching

No nic… Koniec postu, zostawcie łapkę w górę i kliknijcie dzwoneczek, czy inne takie 😉

Oceń wpis