Taka refleksja tylko szybsza…

„Refleksja jest wolna…”

Ile razy o tym słyszeliśmy… „Nie używaj refleksji, bo to mało wydajne, lepiej użyć bezpośrednio metody, jeśli możesz”.

Wiecie, że Jackson korzysta z refleksji na setterach do ustawiania wartości pól? A jak już wspomniałem „refleksja jest wolna”… Może dałoby się ją zamienić na coś innego?

Cel

Naszym celem będzie wykonanie nieznanego nam settera w nieznanym obiekcie w sposób najbardziej wydajny. Warto na początek stwierdzić, co będzie naszym celem, do którego będziemy dążyć.

Załóżmy, że mamy taki zwykły obiekt, w którym chcemy ustawić 4 pola różnych typów: int, long, Object, String. Każde pole posiada setter, który nic nie zwraca (void). Załóżmy, że nie chcemy się bawić Garbage Collection, więc będziemy używać wciąż jednego obiektu, któremu będziemy ustawiać wciąż te same pola jednego obiektu.

Zatem najszybszy sposób ustawiania to będzie proste wywołanie setterów:

    @Benchmark
    public Pojo directSetters(PojoHolder pojoHolder) {
        Pojo pojo = pojoHolder.pojo;
        pojo.setI1(i1);
        pojo.setL1(l1);
        pojo.setO1(o1);
        pojo.setS1(s1);
        return pojo;
    }

Wynik na moim laptopie: 7,993 ± 0,182 ns/op.

Jak już wspomniałem, potencjalnie największe zło to refleksja:

    @Benchmark
    public Pojo reflectionSetters(PojoHolder pojoHolder) throws InvocationTargetException, IllegalAccessException {
        Pojo pojo = pojoHolder.pojo;
        methods[0].invoke(pojo, i1);
        methods[1].invoke(pojo, l1);
        methods[2].invoke(pojo, o1);
        methods[3].invoke(pojo, s1);
        return pojo;
    }

Wynik na moim laptopie: 60,494 ± 4,026 ns/op. Czyli 7 razy więcej… To dość dużo… Dlaczego tak dużo? Przyczyn jest wiele:

  • przy każdym wywołaniu Method.invoke:
    • sprawdzamy, czy dostępy się zgadzają… (lub czy setAccessable było wywołane),
    • sprawdzamy, czy obiekt na którym wykonujemy operację jest odpowiedniego typu,
    • czy ilość parametrów się zgadza i czy są odpowiednich typów,
    • jeśli parametry są typu prymitywnego, to trzeba wykonać autoboxing,
  • gdzieś wewnątrz wywołania Method.invoke jest wywoływana metoda natywna – plotki głoszą, że to jest powolne…
  • 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.
    Jedynie można wykonać skok do skompilowanej przez C2 treści metody…

Gdyby tylko istniała taka lepsza wersja refleksji…

Generalnie refleksja się sprawdza, do czasu, gdy musimy przeiterować po klasie, żeby dowiedzieć się, jakie pola w niej istnieją. W założeniu nie miała być podstawą działania frameworków…

Postanowiono jednak stworzyć mechanizm, który jest jest bardziej odpowiedni dla opisu oraz wywoływania metod. Dzięki temu do Javy 7 wprowadzono MethodHandle. Sprawdźmy zatem jak działa.

    private static MethodHandles.Lookup lookup = MethodHandles.lookup();
    (...)
        methodHandles[0] = lookup.unreflect(methods[0]);
        methodHandles[1] = lookup.unreflect(methods[1]);
        methodHandles[2] = lookup.unreflect(methods[2]);
        methodHandles[3] = lookup.unreflect(methods[3]);

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public Pojo methodHandles(PojoHolder pojoHolder) throws Throwable {
        Pojo pojo = pojoHolder.pojo;
        methodHandles[0].invokeExact(pojo, i1);
        methodHandles[1].invokeExact(pojo, l1);
        methodHandles[2].invokeExact(pojo, o1);
        methodHandles[3].invokeExact(pojo, s1);;
        return pojo;
    }

Wynik na moim laptopie: 33,540 ± 0,410 ns/op.Jest lepiej, choć szału nie ma… 4x wolniejsze niż bezpośrednie wywołanie, ale 2 razy szybsze niż refleksja.

Dlaczego to działa szybciej? Przede wszystkim sprawdzenie dostępów odbywa się na etapie tworzenia MethodHandle (do tego służy wspomniane Lookup).

Czy można jeszcze bardziej przyspieszyć wywoływanie setterów?

W poście o Lambdach wspomniałem o LambdaMetafactory. Pozwala on wygenerować implementację danego interfejsu podając uchwyt do metody, którą chcemy wywołać. W przypadku settera ustawiającego pole nieprymitywne można by użyć istniejącego interfejsu java.util.function.BiConsumer, gdzie pierwszym parametrem jest obiekt, któremu ustawiamy pole, a drugim – wartość, którą ustawiamy. Dla typów prymitywnych należałoby użyć dedytkowanych analogicznych interfejsów, tzn. dla pól typu intjava.util.function.ObjIntConsumer, a pól typu longjava.util.function.ObjLongConsumer.
I tak dla każdego pola wygenerowalibyśmy implementację odpowiedniego interfejsu, który wywołuje setter na podanym obiekcie.

Jak widać stworzenie takich implementacji już nie jest tak trywialne jak method handle, ale może warto ze względu na performance się o to pokusić…

    private static ObjIntConsumer getIntSetter(MethodHandle methodHandle) throws Throwable {
        final Class functionKlaz = ObjIntConsumer.class;
        Object o = getSetter(methodHandle, functionKlaz);
        return (ObjIntConsumer) o;
    }

    private static ObjLongConsumer getLongSetter(MethodHandle methodHandle) throws Throwable {
        final Class functionKlaz = ObjLongConsumer.class;
        Object o = getSetter(methodHandle, functionKlaz);
        return (ObjLongConsumer) o;
    }

    private static BiConsumer getObjectSetter(MethodHandle methodHandle) throws Throwable {
        final Class functionKlaz = BiConsumer.class;
        Object o = getSetter(methodHandle, functionKlaz);
        return (BiConsumer) o;
    }

    private static Object getSetter(MethodHandle methodHandle, Class functionKlaz) throws Throwable {
        final String functionName = "accept";
        final Class functionReturn = void.class;
        Class aClass = !methodHandle.type().parameterType(1).isPrimitive()
                ? Object.class
                : methodHandle.type().parameterType(1);
        final Class[] functionParams = new Class[] { Object.class,
                aClass};

        final MethodType factoryMethodType = MethodType
                .methodType(functionKlaz);
        final MethodType functionMethodType = MethodType.methodType(
                functionReturn, functionParams);

        final CallSite setterFactory = LambdaMetafactory.metafactory( //
                lookup, // Represents a lookup context.
                functionName, // The name of the method to implement.
                factoryMethodType, // Signature of the factory method.
                functionMethodType, // Signature of function implementation.
                methodHandle, // Function method implementation.
                methodHandle.type() // Function method type signature.
        );

        final MethodHandle setterInvoker = setterFactory.getTarget();
        return setterInvoker.invoke();
    }
    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public Pojo generatedSetter(PojoHolder pojoHolder) throws Throwable {
        Pojo pojo = pojoHolder.pojo;
        ((ObjIntConsumer)setters[0]).accept(pojo, i1);
        ((ObjLongConsumer)setters[1]).accept(pojo, l1);
        ((BiConsumer)setters[2]).accept(pojo, o1);
        ((BiConsumer)setters[3]).accept(pojo, s1);
        return pojo;
    }

Wynik na moim laptopie: 10,839 ± 0,077 ns/op.
Narzut na takie wywoływanie setterów, to zaledwie 0.3x, zatem w porównaniu zarówno do refleksji jak i method handle jest to doskonały wynik.

Wywołujemy jednak różne interfejsy, co może być nieco problematyczne… Chcielibyśmy wywoływać jeden interfejs, żeby się przypadkami skrajnymi nie zajmować… Dlatego warto też sprawdzić jakie będą rezultaty dla jednego interfejsu – BiConsumer.

Tutaj muszę zrobić pewną dygresję. Byłoby naiwnością twierdzenie, że jako jedyny w internetach zgłębiam temat „wydajnej refleksji”. Istnieje niejeden artykuł na ten temat.
Przy szukaniu rozwiązania problemu unifikacji wywołań do wywołań interfejsu BiConsumer natrafiłem na ten artykuł, który pozwolił na proste rozwiązanie problemu castowania typów prymitywnych na Obiekt. Jest to zastosowanie zastosowanie zwykłej lambdy.

            biConsumerSetters[0] = (a, b) -> ((ObjIntConsumer) setters[0]).accept(a, (int) b);
            biConsumerSetters[1] = (a, b) -> ((ObjLongConsumer) setters[1]).accept(a, (long) b);
            biConsumerSetters[2] = getBiConsumerObjectSetter(lookup.unreflect(methods[2]));
            biConsumerSetters[3] = getBiConsumerObjectSetter(lookup.unreflect(methods[3]));
    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public Pojo biConsumerOnly(PojoHolder pojoHolder) throws Throwable {
        Pojo pojo = pojoHolder.pojo;
        biConsumerSetters[0].accept(pojo, i1);
        biConsumerSetters[1].accept(pojo, l1);
        biConsumerSetters[2].accept(pojo, o1);
        biConsumerSetters[3].accept(pojo, s1);
        return pojo;
    }

Jak można się spodziewać taka warstwa pośrednia nieco spowalnia. Jednak wynik jest wciąż zadawalający – 18,037 ± 0,132 ns/op.

To be continued…

Wstępne rozpoznanie możliwości zamiany refleksji jest zrobione. Jednak przeprowadzone badania były w dosyć „sterylnym” środowisku – wszystkie settery były wywoływane „po indeksie”. Można się spodziewać gorszych wyników w przypadku wywołań setterów „w pętli”.

Dodatkowo, zastosowanie tych metod na kodzie biblioteki ma znacznie szerszy kontekst, który może być trudniejszy do analizy dla C2. Może się też okazać, że refleksja jest tak małą częścią całej deserializacji, że refleksja jest „wystarczająco dobra”.

Zanim jednak opiszę swoje doświadczenia musi trochę czasu upłynąć. Aktualnie muszę skupić się przygotowaniu do prezentacji na 4developers w Poznaniu.

Zatem cierpliwości 😉

// Edit: Pojawił się wpis z zastosowaniem treści tego artykułu dla Jacksona

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

Brak ocen.