Článek přečtěte do 7 min.

Reflexe zpomaluje váš kód Java, proč tomu tak je?

Reflexe je mocná – a často nepochopená. Tento článek bude stavět na představení Core Reflection API představeném v Reflection for the modern Java programmer, bude o dvou hlavních tématech: jak je reflexe implementována v HotSpot JVM a změny provedené v reflexi v posledních verzích platformy Java.

Začneme prozkoumáním zjednodušené formy kódu reflexního mechanismu z JDK. Kód v následujících příkladech se podobá implementaci v Javě 8, ale kvůli přehlednosti byla odstraněna určitá složitost. Tato základní verze se používá ve všech verzích Java kromě nejnovějších.

Začněte tím, že se podíváte na invoke() metodu na Method, která vypadá takto:

public Object invoke(Object obj, Object… args)
    throws IllegalAccessException, IllegalArgumentException,
           InvocationTargetException {

  if (!override) {
    if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
      Class<?> caller = Reflection.getCallerClass();
      checkAccess(caller, clazz, obj, modifiers);
    }
  }
  MethodAccessor ma = methodAccessor;
  if (ma == null) {
    ma = acquireMethodAccessor();
  }
  return ma.invoke(obj, args);
}

Kód nejprve zkontroluje, zda override byl nastaven příznak; bude nastaven, pokud setAccessible() byl zavolán. Dále je získán odkaz na objekt MethodAccessor a pak invoke() je na něj delegováno volání.

(Poznámka: Mnoho tříd v této části není v balíčku API java.base , takže je nelze volat přímo v moderním kódu Java. Například MethodAccessor je v jdk.internal.reflect .)

Rozhraní MethodAccessor je klíčem ke schopnosti reflektivního vyvolání. Působí jako přímý delegát.

public interface MethodAccessor {
    /** Matches specification in {@link java.lang.reflect.Method} */
    public Object invoke(Object obj, Object[] args)
        throws IllegalArgumentException, InvocationTargetException;
}

Při prvním volání tohoto kódu metoda acquireMethodAccessor() vytvoří instanci typu DelegatingMethodAccessorImpl, která implementuje MethodAccessor následovně:

import java.lang.reflect.InvocationTargetException;

/** Delegates its invocation to another MethodAccessorImpl and can
    change its delegate at runtime. */

class DelegatingMethodAccessorImpl extends MethodAccessorImpl {
  private MethodAccessorImpl delegate;

  DelegatingMethodAccessorImpl(MethodAccessorImpl delegate) {
    setDelegate(delegate);
  }

  public Object invoke(Object obj, Object[] args)
        throws IllegalArgumentException, InvocationTargetException {
    return delegate.invoke(obj, args);
  }

  void setDelegate(MethodAccessorImpl delegate) {
    this.delegate = delegate;
  }
}

Jak vysvětluje komentář, účelem této třídy je působit jako vhodné množství nepřímého přístupu a poskytovat delegovací bod, který lze aktualizovat za běhu. Počáteční delegát je instancí třídy NativeMethodAccessorImpl.

class NativeMethodAccessorImpl extends MethodAccessorImpl {
  private Method method;
  private DelegatingMethodAccessorImpl parent;
  private int numInvocations;

  // ...

  public Object invoke(Object obj, Object[] args)
          throws IllegalArgumentException, InvocationTargetException {

    if (++numInvocations >
          ReflectionFactory.inflationThreshold()) {
      MethodAccessorImpl acc = (MethodAccessorImpl)
          new MethodAccessorGenerator()
            .generateMethod(method.getDeclaringClass(),
                            method.getName(),
                            method.getParameterTypes(),
                            method.getReturnType(),
                            method.getExceptionTypes(),
                            method.getModifiers());
        parent.setDelegate(acc);
    }

    return invoke0(method, obj, args);
  }

  private static native Object invoke0(Method m, Object obj, Object[] args);

  // ...
}

Tento kód obsahuje if blok, který bude zadán po dosažení prahu vyvolání, například poté, co byla reflektivní metoda zavolána určitý počet opakování. Pokud ještě nebylo dosaženo prahu vyvolání, kód pokračuje v nativním volání.

Jakmile je dosaženo prahové hodnoty, NativeMethodAccessorImpl použije továrnu na generování kódu obsaženou v MethodAccessorGenerator.generateMethod() , k vytvoření vlastní třídy, která obsahuje bajtový kód, který volá cíl reflexního volání.

Po vytvoření instance této dynamicky vytvořené třídy volání funkce setDelegate() použije odkaz vyšší úrovně na nadřazený přístupový objekt k nahrazení aktuálního objektu acc, nově vytvořeného vlastního objektu.

Z technických důvodů souvisejících s ověřováním třídy si JVM musí být vědom zvláštní povahy tříd reflexních přístupových prvků. Z tohoto důvodu existuje v hierarchii dědičnosti speciální třída přístupového objektu, která funguje jako značka pro JVM. Přesné podrobnosti vás nemusí zajímat, takže se nebojte.

Celkově popsaný mechanismus představuje kompromis ve výkonu – některá reflektivní volání jsou uskutečněna jen několikrát, takže proces generování kódu může být velmi drahý nebo plýtvání. Na druhou stranu je přechod z Javy do nativního volání pomalejší než setrvání v čisté Javě. Tento přístup umožňuje běhovému prostředí vyhnout se generování kódu, dokud se nezdá pravděpodobné, že reflektivní volání bude prováděno relativně často.

Výsledkem je, že náklady na generování kódu mohou být amortizovány po celou dobu životnosti programu, přičemž stále poskytuje lepší výkon pro pozdější volání, než může dosáhnout nativní implementace.

Zamyšlení nad odrazem

Můžete vidět více reflexního subsystému v akci (a některé nedávné změny), když se zamyslíte nad subsystémem samotným. Předpokládejme, že třída má na sobě následující dvě metody – jedna je jednoduchá metoda, která pouze vytiskne zprávu, a jedna je pro ni reflexní přístupový objekt:

public static void printStr() {
    System.out.println("Hello world");
}

public Method getMethodObj() throws NoSuchMethodException {
    var selfClazz = getClass();
    var toStr = selfClazz.getMethod("printStr");
    return toStr;
}

Navíc je zde následující volací kód (zpracování výjimek bylo kvůli přehlednosti vynecháno):

var m = self.getMethodObj();
Class<?> mClazz = m.getClass();
System.out.println(mClazz);
// This is necessary due to some aspects of lazy evaluation
m.invoke(null);

var f = mClazz.getDeclaredField("methodAccessor");
f.setAccessible(true);
Object ma = f.get(m);
System.out.println(ma.getClass());

Výše uvedený kód vytváří při spuštění s Java 11 následující výstup:

$ java javamag.reflection.ex2.ReflectTheReflect
class java.lang.reflect.Method
Hello world
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by javamag.reflection.ex2.ReflectTheReflect (file:/Users/ben/projects/writing/Oracle/Articles/reflection/src/main/java/) to field java.lang.reflect.Method.methodAccessor
WARNING: Please consider reporting this to the maintainers of javamag.reflection.ex2.ReflectTheReflect
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release
class jdk.internal.reflect.DelegatingMethodAccessorImpl

Zde je třeba si uvědomit dvě věci.

  • Nejprve je vyžadováno počáteční reflektivní vyvolání metody. Pokud je toto počáteční vyvolání vynecháno, kód se nezdaří. To je způsobeno pomalou inicializací reflexního subsystému: Z důvodů výkonu nejsou objekty Method naplněny, pokud nejsou potřeba.
  • Za druhé, pokud nyní přepnete na Java 17, kód selže s následujícím výstupem:
$ java javamag.reflection.ex2.ReflectTheReflect
class java.lang.reflect.Method
Hello world
java.lang.NoSuchFieldException: methodAccessor
       at java.base/java.lang.Class.getDeclaredField(Class.java:2610)
       at javamag.reflection.ex2.ReflectTheReflect.main(ReflectTheReflect.java:15)

K selhání dochází kvůli změnám ve viditelnosti, řízení přístupu a reflexi, jak je popsáno v Nahlédnutí do Java 17: Zapouzdření vnitřních prvků Java runtime. Už nemůžete předpokládat, že odraz vám umožní šťourat se uvnitř platformy bez omezení.

Jak odraz ovlivňuje výkon

Je snadné si představit, že flexibilita odrazu má svou cenu ve smyslu provozního výkonu. Zřejmá, okamžitá otázka, která mě napadá, je tato: Jak velká je ta cena? Tato otázka však v sobě nese skrytý předpoklad – že otázka je především smysluplná a dobře zpracovaná. Není to vždy tak snadné.

Můžete samozřejmě napsat benchmark Java Microbenchmark Harness (JMH), který porovnává reflektivní volání s přímým. Takový benchmark by mohl vypadat trochu jako následující. Anotace JMH naleznete v balíčku org.openjdk.jmh.annotations.

@State(Scope.Thread)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
public class SimpleReflectionBench {

    private static Method getTime = null;
    private static Object o = null;

    static {
        try {
            var clazz = ReflectionHolder.class;
            var ctor = clazz.getConstructor();
            o = ctor.newInstance();
            getTime = clazz.getMethod("getTime");
        } catch (Exception x) {
            throw new RuntimeException(x);
        }
    }

    @Benchmark
    public long runReflective() throws InvocationTargetException, IllegalAccessException {
        Object ret = getTime.invoke(o);
        return (long)ret;
    }

    @Benchmark
    public long runDirect() {
        var opt = (ReflectionHolder)o;
        return opt.getTime();
    }

    static class ReflectionHolder {
        public ReflectionHolder() {}

        public long getTime() {
            return System.currentTimeMillis();
        }
    }
}

Podrobnosti se budou trochu lišit v závislosti na hardwarové platformě, kterou používáte, ale typickým výsledkem bude řádově 23% zásah do výkonu.

Žádný skutečný Java program se ale neskládá pouze z jediného volání. Ve skutečnosti tento kód běží v rámci aplikačního procesu (JVM) a nemůžete snadno izolovat výkon spouštěného aplikačního kódu od JIT kompilátoru, správy paměti a dalších runtime subsystémů přítomných v tomto aplikačním procesu.

Kromě toho kompilátor JIT provádí náročnou optimalizaci a transformaci programu – a přesné podrobnosti o tom, jak je program transformován, velmi závisí na programu.

Např: jednou z nejvýkonnějších transformací, které kompilátor JIT provádí, je automatické vkládání metod. Toto je ve skutečnosti jedna z prvních transformací, které mají být provedeny, protože kombinování těl metod přináší více kódu do pohledu kompilátoru JIT. To potenciálně umožňuje optimalizace, které by nebyly možné, kdyby se kompilátor JIT mohl dívat pouze na kód jedné metody najednou.

Reflexní hovory bohužel nejsou v obecném případě obvykle vloženy kvůli jejich dynamické povaze. Všimněte si slova typicky, protože na toto prohlášení se vztahují upozornění.

Jak jste již viděli, implementace reflexe generuje bytekód Java (pomocí MethodAccessorGenerator.generateMethod() )  pro volání. To může kompilátoru JIT usnadnit vložení reflektivního volání, pokud jsou splněny určité podmínky, jako je například objekt Method zakořeněný ve statickém konečném poli a cílová metoda je statická (nebo má jednoznačně známý typ přijímače).

Celkově to znamená, že skutečná režie reflektivních hovorů není snadné uvažovat z prvních principů, ale snadno může být mnohem, mnohem větší než 23 % kvůli provozu inliningu u přímých hovorů, což u ekvivalentních hovorů není ve skutečnosti možné, reflexní hovory.

Hlavním závěrem, k němuž to vede, je, že si musíte položit otázku, co znamená zjednodušené číslo, jako je 23 % – nebo jestli to vůbec něco znamená.

Abychom citovali Briana Goetze: „Na mikrobenchmarkech je děsivé, že vždy vygenerují číslo, i když toto číslo nemá smysl. Něco měří; jen si nejsme jisti co.“

Vlivy na výkon v malém měřítku jsou efektivně vyhlazeny tím, že se zabýváme větším agregátem (tj. celým systémem nebo subsystémem), ale to velmi ztěžuje nebo znemožňuje vytvářet obecná doporučení na úrovni kódu pro psaní výkonných aplikací.

Můžete říci, že výkon aplikace je naléhavý jev, protože vyplývá z přirozeného rozsahu vašich aplikací (potenciálně tisíce tříd nebo miliony řádků kódu), a nemusí nutně existovat žádná jediná hlavní příčina (nebo malá sada hlavních příčin). ) pro konkrétní zisk nebo ztrátu.

Tato skutečnost vede profesionály v oblasti výkonu k tomu, aby naléhali na vývojáře, aby jednoduše napsali dobrý a jasný kód a nechali běhové prostředí, aby se postaralo o optimalizaci.

Výkonový tip je dost často jen řešením pro nějakou zvláštnost běhového prostředí – a pokud si vývojáři aplikací uvědomují tuto zvláštnost, je docela pravděpodobné, že i vývojáři JVM – a že pracují na nápravě. Chcete-li to ilustrovat, v případě reflexe se můžete zeptat: „Jaké mechanismy na úrovni JVM jsou zapojeny, které by mohly zkomplikovat celkový obraz účinku?

Obrázek 1 ukazuje jeden řádek kódu Java, který provádí reflektivní volání objektu Method označeného jako M, s argumenty x a y. Byl opatřen anotací, aby ukázal hlavní aspekty chování za běhu, které mohou mít vliv na výkon při provádění volání.

Účinky JVM na odraz
Obrázek 1. Účinky JVM na odraz

Zde jsou čtyři velké oblasti, které by mohly ovlivnit výkon.

  • Box se vyskytuje na několika různých místech.
  • Místo volání pro reflektivní volání se říká, že je megamorfní (mnoho možných implementací metody), protože v implementaci až po Java 17 má každá instance Method jiný, dynamicky spřádaný objekt přístupového objektu metody.
  • Pole methodAccessor v metodě je nestálé, takže je nutné jej znovu přečíst. Volání tedy invoke() vede k přesměrování a virtuálnímu odeslání na delegáta.
  • Kontrola přístupnosti metod se provádí při každém volání.

Chcete-li se ponořit trochu hlouběji, prozkoumejte podpis metody invoke() na metodě.

public Object invoke(Object obj, Object... args)
        throws IllegalAccessException, IllegalArgumentException, InvocationTargetException

Toto je nejobecnější podpis pro metodu Java, který můžeme napsat. Reflexní hovory mohou mít jakýkoli podpis a informace tohoto typu obecně nebudou k dispozici až do běhu. Pokud tedy Method představuje schopnost volat metodu a invoke() představuje skutečné volání, je zcela logické, že podpis musí být tento obecný.

Typový systém Java není jednokořenový, takže jakákoli primitiva, která se objeví, budou řešena pomocí boxu.

Samostatně je důležitá kontrola přístupnosti, protože řízení přístupu se normálně kontroluje v době načítání třídy (a žádné špatně chované třídy, které se pokusí je porušit, se nenačtou). Použití reflexního kódu mění tento obrázek a Core Reflection API má několik slabin (nebo nutných zel, podle toho, jak se k nim cítíte).

  • Můžete získat objekt odrazu odpovídající metodě, kterou byste nemohli volat přímo.
  • Pravidla jazyka Java můžete porušit tím, že povolíte volajícímu kódu selektivně zakázat řízení přístupu pomocí setAccessible() .

Proto musí implementace Method zkontrolovat, zda objekt Method vyžaduje kontrolu řízení přístupu při každém volání (a provést ji, pokud ano). Je nutné to provést při každém volání, protože objekt Method jej mohl setAccessible() zavolat po předchozím volání.

Tyto kontroly přístupnosti ovlivňují výkon, jak můžete vidět úpravou srovnávacího testu takto:

static {
        try {
            var clazz = ReflectionHolder.class;
            var ctor = clazz.getConstructor();
            o = ctor.newInstance();
            getTime = clazz.getMethod("getTime");
            // Disable access control checking
            getTime.setAccessible(true);
        } catch (Exception x) {
            throw new RuntimeException(x);
        }
    }

Porovnání výsledků z tohoto benchmarku s reflektivním benchmarkem v předchozím případě ukazuje, že volání, které není zkontrolováno pro řízení přístupu, zřejmě běží rychleji. Celkový agregační efekt je však stále neznámý, takže se musíte vyvarovat jakýchkoli pochybných závěrů o obecné užitečnosti deaktivace řízení přístupu pro reflexivní volání.

Jak již bylo řečeno, správným předmětem studia pro výkon je celá aplikace. To naznačuje, že byste mohli například znovu zkompilovat JDK se změnou kódu, aby bylo řízení přístupu vždy ignorováno. To by teoreticky poskytlo lepší číslo pro alespoň jeden aspekt odrazového výkonu v souhrnu.

Pokud jste to však udělali, můžete si být opravdu jisti, že globální deaktivace reflexních kontrol řízení přístupu nezmění sémantiku nikde v aplikaci – nebo v knihovnách, na kterých závisí? Koneckonců, možná rámec, který používáte, má chytrou strategii, která zkoumá možné metody pro reflektivní vyvolání a spoléhá na sémantiku řízení přístupu ke zlepšení výkonu.

Java 18 vše mění

Vše, co je popisováno v tomto článku, se týká Javy až do verze 17 včetně.

Zdroj: Oracle