We wpisie o takiej szybszej refleksji porównywałem różne podejścia do wywoływania setterów. Czas na to by tę teorię zastosować w Jacksonie oraz jaki jest wpływ na wydajność tego rozwiązania.
Jackson – wstęp
Jackson Databind to jedna z najpopularniejszych bibliotek do mapowania obiektów na tekst w formacie JSON
oraz JSON
ów na obiekty. Używana domyślnie przez Spring Boota, lecz również przez zyskującego na popularności Micronauta. Aktualnie wersją stabilną jest wersja 2.10 wymagająca do działania Javę 7. Nadchodząca wersja 3.0.0 będzie wymagała minimum Javy 8.
Ciąg znaków w postaci JSON
może być zmapowany na obiekt w Javie poprzez wywołanie konstruktora, ustawienie (refleksyjnie) pól lub (refleksyjne) wywołanie metod setterów. Ten ostatni sposób wykorzystujący publiczne settery jest sposobem domyślnym.
I to właśnie wywoływanie setterów jest mechanizmem do podrasowania. Wybranym sposobem usprawnienia setterów jest użycie LambdaMetafactory.metafactory()
, który wypadł najlepiej w testach opisanych we wspomnianym wpisie.
Trochę kodu
Klasą bezpośrednio odpowiadająca za ustawienie pól z użyciem setterów jest MethodProperty. Oprócz zapięcia się tam debuggerem można to wywnioskować również z komentarza dotyczącego klasy:
/** * This concrete sub-class implements property that is set * using regular "setter" method. */ public final class MethodProperty extends SettableBeanProperty
Działanie Jacksona w obrębie tej klasy (oraz mu podobnych ustawiających pola) można podzielić na przygotowanie metadanych wywoływanego settera (wykonywane jednokrotnie dla danej klasy) oraz wywołanie settera (przy każdym deserializowanym obiekcie klasy).
Zatem w fazie inicjalizacji musimy nie tylko zapisać obiekt Method
, lecz również wygenerować implementację interfejsu BiConsumer
. Ta implementacja dla podanego obiektu ustawi podaną wartość wywołując odpowiedni setter.
Generowanie takiego obiektu jest nietrywialne. Najpierw trzeba zamienić Method
w MethodHandle
, wyczarować skądś Lookup
, a następnie dopasować odpowiednią sygnaturę BiConsumer
wrzucić wszystko do LambdaMetafactory.metafactory()
a potem tylko magiczne getTarget()
i invoke()
.
Dużo trudnego kodu, o który nie pytają na rozmowach rekrutacyjnych, więc nie trzeba go znać, ni rozumieć. Jednak jeśli was jeszcze nie zniechęciłem, to można spojrzeć na plik, gdzie umieściłem tę całą magię.
Po zainicjalizowaniu implementacji BiConsumera
i zapisaniu jej obiektu w nowym polu klasy MethodProperty
można wziąć się za drugą fazę – wywoływanie. W tym przypadku zmiany ograniczyły się do zamiany _method.invoke(object, value)
na consumer.accept(instance, value)
.
I to wszystko?
Oczywiście, że nie 😉 obsłużyliśmy zaledwie ustawianie pól obiektowych (Stringów
). Zostało jeszcze 8 typów prymitywnych (czy wymienisz je wszystkie?) tzn. stworzenie 8 interfejsów odpowiadających BiConsumer
oraz ich obsługi.
Dodatkowo MethodProperty
odpowiada też za settery zwracające ustawione wartości (nie void
), które zatem całą pracę trzeba też wykonać dla BiFunction
.
I dla 8 typów prymitywnych również.
Na koniec mvn clean install
oraz sprawienie, by testy się zazieleniły.
Ostatecznie można przejść do sprawdzania wpływu na wydajność 🙂
Dla ciekawych tych wszystkich zmian – draft pull requesta.
Performance
Zrobiłem zatem porządne testy – dla OpenJDK w wersjach 8 oraz 11 uruchomiłem prosty benchmark – deserializację z użyciem wcześniej stworzonego ObjectMapper
a (czyli inicjalizacja już poczyniona). Do benchmarku zaprzęgnięty JMH
– najpierw porządne rozgrzanie JVMa i benchmarkowanej metody, potem 100 iteracji po 1s każda. Wszystko wykonywane na Ubuntu 18.04 bez trybu graficznego, bez dostępu do internetu, bez innych nadmiarowych procesów.
Zestawy testowe składały się z 3 podzestawów – obiektów z polami obiektowymi (Stringami
), obiektów z polami prymitywnymi oraz miks po połowie (String
/primitive
). Każdy z podzestawów posiadał klasy o 2,6, 10 lub 20 polach.
Wyniki są następujące (wyniki podane w ns/op):
Nazwa testu | OpenJDK 8 z refleksją | OpenJDK 8 z Lambda | OpenJDK 11 z refleksją | OpenJDK 11 z Lambda |
primitive 2 | 375,162 | 371,571 | 420,594 | 424,329 |
primitive 6 | 883,396 | 833,530 | 888,789 | 833,256 |
primitive 10 | 1423,683 | 1219,335 | 1407,713 | 1540,637 |
primitive 20 | 3294,129 | 3263,196 | 3598,230 | 3708,698 |
objects 2 | 369,348 | 371,997 | 430,879 | 429,898 |
objects 6 | 866,949 | 897,446 | 1045,449 | 984,428 |
objects 10 | 1340,502 | 1333,712 | 1562,467 | 1519,283 |
objects 20 | 2874,211 | 2723,356 | 3282,216 | 3286,685 |
mixed 2 | 383,846 | 382,690 | 454,834 | 447,254 |
mixed 6 | 865,195 | 818,739 | 975,578 | 970,954 |
mixed 10 | 1370,834 | 1359,150 | 1620,932 | 1598,931 |
mixed 20 | 3106,188 | 3056,029 | 3834,573 | 3573,692 |
Krótko mówiąc, może czasem jest coś szybciej, ale to niewiele szybciej (średnio 1-3%), jednak czasem nawet bywa wolniej.
Gdzie się podziała ta całą wydajność?
Z najprostszych obliczeń (oraz poprzedniego artykułu) dla „objects-20” czysta refleksja powinna zajmować 60,494ns * 20 pól = 1209,88ns. Wywołanie z LambdaMetafactory
powinno kosztować 18,037 * 20 pól = 360,74 ns.
Czyli walczyliśmy o 849,14ns/2874,211ns = 29,5%.
Uruchamiając ponownie benchmark JMH z dodatkowym profilowaniem .addProfiler(LinuxPerfAsmProfiler.class)
zobaczyć można, że rzeczywiście procentowo nieco odciążyliśmy metodę odpowiedzialną za przypisania wartości polu.
....[Hottest Methods (after inlining)].............................................................. 23,38% C2, level 4 com.fasterxml.jackson.databind.deser.impl.MethodProperty::deserializeAndSet, version 869 21,68% C2, level 4 com.fasterxml.jackson.databind.deser.impl.MethodProperty::deserializeAndSet, version 915
Gdzie jest reszta? Trzeba zweryfikować założenia.
W poprzednim wpisie podawałem takie zalety MethodHandle
/ LambdaMetafactory
:
- przy każdym wywołaniu
Method.invoke
sprawdzamy, czy dostępy się zgadzają- Tutaj rzeczywiście oszczędzamy – patrząc głęboko w kod
C2
można zauważyć brak sprawdzania dostępów;
- Tutaj rzeczywiście oszczędzamy – patrząc głęboko w kod
- gdzieś wewnątrz wywołania
Method.invoke
jest wywoływana metoda natywna – plotki głoszą, że to jest powolne…- W trybie interpretowanym rzeczywiście tak jest, jednak
C2
potrafi owinąć w klasę (podobnie doLambdaMetafactory
), zatem tutaj zysku brak
- W trybie interpretowanym rzeczywiście tak jest, jednak
- sama treść metody wywoływanej jest za każdym razem zagadką – takiej metody nie da się z
inline
‚ować treści tej metody do nadrzędnych metod. Wywoływana metoda nie jest rozumiana przez JITa.- W tym przypadku rzeczywiście
C2
mógłby próbować ziniline’ować treść metody. Niestety kontekst wywoływania metody jest zbyt wąski, a profilowanie typu settera prowadzi do wywnioskowania, że jest wywoływany jeden z 20 setterów o interfejsieBiConsumer
. Takiego wywołania „megamorficznego” nie można zinline’ować, przez co musimy wpierw sprawdzić typ, a nastęnie wykonać instrukcję skoku do treści metody.
Dokładnie to samo dzieje się przy refleksji – skaczemy do treści metody w owiniętej przez refleksję w klasę metodzie. Stąd i tutaj przyspieszenia brak.
- W tym przypadku rzeczywiście
No cóż… „Bo tutaj jest jak jest… Po prostu…”.
Podsumowanie
Pomysł na usprawnienie był całkiem dobry, jednak bardziej skomplikowana rzeczywistość rozmyła złudzenia o znacznie wydajniejszym Jacksonie.
Podane rozwiązanie ma jednak pewną wadę – dla każdego settera generujemy klasę. Przeważnie tych setterów jest dużo, co oznacza, że zaśmiecamy dość mocno Metaspace
bez brania pod uwagę, czy ten setter jest często wywoływany, czy rzadko. Warto tu zatem użyć zamiast tego MethodHandle
– przynajmniej przedwcześnie nie generuje klasy, a wydajność może być niegorsza niż podanego rozwiązania.
Czy da się szybciej?
Prawdopodobnie tak, jednak nie używając setterów, a konstruktorów i pól. Ale to temat na inny wpis 😉
Na koniec w noworocznym prezencie link do artykułu Shipileva o megamorphic calls. Bo to mądry człowiek jest 😉
Pax et bonum.