ThreadLocal 0 (0)

ThreadLocal jest trochę jak świnka morska…

Słowem wstępu

Bohaterem tego wpisu jest java.lang.ThreadLocal. Jak sama nazwa wskazuje klasa umożliwia trzymanie pewnej zmiennej w kontekście jednego wątku. Taką klasę można wykorzystać w różnych sytuacjach, a najbardziej typową jest tworzenie obiektów, które nie są thread-safe i przechowywanie takich obiektów osobno dla każdego wątku. Wówczas pozbywamy się wymaganej kosztownej synchronizacji.
Kanonicznym przykładem jest klasa SimpleDateFormatter, która nie jest thread-safe.

Istnieje jeszcze inna klasa zastosowań ThreadLocal. Polega ona na inicjalizacji na początku przetwarzania, następnie w czasie przetwarzania na pobraniu danej wartości (bądź – co gorsza – modyfikacji) a na końcu przetwarzania na usunięciu tej wartości. Przykładowo – Filtr Servletowy:

public class UserNameFilter implements Filter {
    public static final ThreadLocal USER_NAME = new ThreadLocal();
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        try {
            USER_NAME.set("Dobromir");
            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            USER_NAME.remove();
        }
    }
}

Takie zastosowanie to tak na prawdę taki lokalny dla wątku singleton. Taki mechanizm obrazuje mniej więcej:

Asdf movie

Kto takiego kodu nie popełnił, niech pierwszy rzuci kamień 😉

Czasem po prostu nie ma innej opcji, by przekazać coś z jednego miejsca w drugie, bo przykładowo ogranicza nas interfejs/zewnętrzna biblioteka. Jednak, gdy mamy możliwość przekazania czegoś w parametrze metody zamiast w ThreadLocal, warto z tej możliwości skorzystać. Nawet, gdy to będzie przepchanie przez 20 ramek głębiej w stacktrace’ie.

Escape analysis

Warto kontekst przekazywać w parametrach z wielu powodów. Najważniejszym jest jawne ukazanie zależności potem, testowalność itp. Ale gdzieś na sam końcu jest też wydajność.

Dla każdej metody skompilowanej C2 jest uruchamiane Escape Analysis, która pozwala na unikanie fizycznego tworzenia obiektów. Jeśli jednak taki obiekt jest udostępniony w jakimś polu, to automatycznie uniemożliwiamy ominięcie tworzenia obiektu.

Implementacja ThreadLocal

Najprostsza implementacja tej idei to zwykła mapa HashMap, która w kluczu przyjmuje Thread.getId(). To rozwiązanie jest jednak zasadniczą wadę – jeśli wątek by zakończył swoje działanie, a wpis nie zostałby usunięty, wówczas mielibyśmy klasyczny przykład wycieku pamięci w Javie. Trzymanie jakiegoś rodzaju uchwytu do tych wpisów dla ThreadLocal może i rozwiązało problem, ale mogłoby być kosztowne pamięciowo.

Dlatego OpenJDK robi to inaczej. W każdym obiekcie java.lang.Thread istnieje pole threadLocals będące instancją klasy ThreadLocal.ThreadLocalMap. W tym polu przetrzymywane są wartości dla wszystkich ThreadLocal. Jest to mapa, którą można określić jako HashMap.

Gdy wołamy o ThreadLocal.get() wywoływany jest następujący kawałek kodu:

    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocal.ThreadLocalMap map = this.getMap(t);
        if (map != null) {
            ThreadLocal.ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                T result = e.value;
                return result;
            }
        }

        return this.setInitialValue();
    }

    ThreadLocal.ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

Ta nieco zawiła implementacja trzyma wszystkie zmienne specyficzne dla wątku blisko reprezentacji tego wątku w Javie. Dzięki temu w czasie kończenia działania wątku, łatwo je udostępnić dla gc’ka (this.threadLocals = null).

Czy ThreadLocal ma jakieś super moce?

Pojęcie Thread-local storage jest pojęciem znanym i powszechnym w różnych językach programowania. Ponadto jest tak często nazywana pewna część pamięci natywnej wyłączna dla wątku systemu operacyjnego. Jednak w przypadku OpenJDK taka pamięć jest wykorzystywana co najwyżej przy jakichś metadanych GCka (wystarczy wyszukać w kodzie źródłowym OpenJDK terminu ThreadLocalStorage). Całość implementacji ThreadLocal bazuje na Heapie.

Co więcej, okazuje się, że ten ThreadLocal nie jest aż tak przywiązany do samego wątku, gdyż można go z poziomu innego wątku zmienić. Można to łatwo sprawdzić wykonując refleksyjną magię:

public class ThreadLocalExperiment {

    private static boolean work = true;
    private static final ThreadLocal THREAD_LOCAL = new ThreadLocal();

    public static void main(String[] args) throws Exception {
        var thread1 = new Thread(() -> {
            THREAD_LOCAL.set(12);
            while (work) { }
            System.out.println(THREAD_LOCAL.get());
        });

        thread1.start();

        var clazz = Thread.class;
        var field = clazz.getDeclaredField("threadLocals");
        field.setAccessible(true);
        var threadLocals = field.get(thread1);
        var method = threadLocals.getClass().getDeclaredMethod("set", ThreadLocal.class, Object.class);
        method.setAccessible(true);
        method.invoke(threadLocals, THREAD_LOCAL, 24);

        work = false;
    }
}

ThreadLocal vs local variable

Generalnie warto też porównać, jaka jest różnica w wydajności między zmiennymi lokalnymi, a ThreadLocal. Prosty benchmark ukazujący skalę różnicy wydajności:

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public Integer local() {
        Integer i = 1;

        return i;
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public Integer threadLocal() {
        THREAD_LOCAL.set(1);
        Integer i = THREAD_LOCAL.get();
        THREAD_LOCAL.remove();
        return i;
    }

Wyniki to 4,358 ± 0,039 ns/op dla zmiennej lokalnej oraz 41,359 ± 2,797 ns/op dla ThreadLocal (1 ns to jedna milionowa milisekundy zatem niewiele 😉 ). Jednak samo sięganie na stertę zamiast na stos wątku jest już pewnym minusem. Ponadto te różnice w pewien sposób zależą od GC, którym wartości ThreadLocal podlegają.

JITowi również nie jest łatwo zinterpretować wartości ThreadLocal jako niezmienialne przez inne wątki. Chociaż swoją drogą mogą być zmienione jak wcześniej zostało wykazane. Brak możliwości zastosowania Escape Analysis również nie pomaga…

Ale o co chodzi z tą świnką morską?

ThreadLocal to taka świnka morska, bo ani świnka, ani morska…

Ani nie są tą jakoś szczególnie wyłączne dane wątku, gdyż są na Heapie, współdzielone, a takie stricte dane wątku są na Off-Heapie. Ani też nie są to szczególnie lokalne dane – przeważnie to są singletony w kontekście wątku.

Niby blisko tego wątku to jest, ale jednak nie za bardzo…

Przepisujemy Jacksona z refleksji na LambdaMetafactory [ZOBACZ JAK] 0 (0)

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 ObjectMappera (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;
  • 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 do LambdaMetafactory), zatem tutaj zysku brak
  • sama treść metody wywoływanej jest za każdym razem zagadką – takiej metody nie da się zinline‚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 interfejsie BiConsumer. 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.

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.