Einführung

Während des Entwicklungsprozesses ist es häufig erforderlich, eine Instanz einer Klasse zu erstellen, deren Name in der XML-Konfigurationsdatei gespeichert ist, oder eine Methode aufzurufen, deren Name als Zeichenfolge als Wert des Anmerkungsattributs geschrieben ist. In solchen Fällen lautet die Antwort: „Reflektion verwenden!“.
In der neuen Version von CUBA Platform bestand eine der Aufgaben zur Verbesserung des Frameworks darin, die explizite Erstellung von Ereignishandlern in den Controller-Klassen der UI-Bildschirme zu beseitigen. In früheren Versionen waren die Handler-Deklarationen in der Controller-Initialisierungsmethode sehr überladen mit dem Code. In der siebten Version haben wir uns daher entschlossen, alles zu bereinigen.
Ein Ereignis-Listener ist nur ein Verweis auf die Methode, die zum richtigen Zeitpunkt aufgerufen werden muss (siehe Observer-Vorlage ). Eine solche Vorlage ist mit der Klasse java.lang.reflect.Method
recht einfach zu implementieren. Beim Start müssen Sie nur die Klassen scannen, die mit Anmerkungen versehenen Methoden aus ihnen herausziehen, die Verweise auf sie speichern und die Links (oder Methoden) verwenden, um die Methode (oder Methoden) aufzurufen, wenn das Ereignis auftritt, wie dies in den meisten Frameworks der Fall ist. Das einzige, was uns daran gehindert hat, war, dass traditionell viele Ereignisse in der Benutzeroberfläche generiert werden. Wenn Sie die Reflection-API verwenden, müssen Sie einen Preis in Form des Zeitpunkts des Methodenaufrufs zahlen. Aus diesem Grund haben wir uns überlegt, wie Sie Event-Handler ohne Reflexion erstellen können.
Wir haben bereits Materialien über MethodHandles und LambdaMetafactory auf einem Habr veröffentlicht , und dieses Material ist eine Art Fortsetzung. Wir werden die Vor- und Nachteile der Verwendung der Reflection-API sowie Alternativen untersuchen - das Generieren von Code mit AOT-Kompilierung und LambdaMetafactory sowie dessen Verwendung im CUBA-Framework.
Reflexion: Alt. Gut Zuverlässig
In der Informatik bedeutet Reflexion oder Reflexion (das Holonym für Selbstbeobachtung, englische Reflexion) einen Prozess, bei dem ein Programm zur Laufzeit seine eigene Struktur und sein eigenes Verhalten verfolgen und ändern kann. (c) Wikipedia.
Für die meisten Java-Entwickler ist Reflexion nie neu. Es scheint mir, dass Java ohne diesen Mechanismus nicht zu Java geworden wäre, das jetzt einen großen Marktanteil in der Entwicklung von Anwendungssoftware einnimmt. Denken Sie nur: Proxy, Binden von Methoden an Ereignisse durch Anmerkungen, Abhängigkeitsinjektion, Aspekte und sogar Instanziieren des JDBC-Treibers in den ersten Versionen von JDK! Reflexion überall ist der Eckpfeiler aller modernen Rahmenbedingungen.
Gibt es Probleme mit der Reflexion in Bezug auf unsere Aufgabe? Wir haben drei identifiziert:
Geschwindigkeit - Ein Methodenaufruf über die Reflection-API ist langsamer als ein direkter Aufruf. In jeder neuen Version der JVM beschleunigen Entwickler Aufrufe ständig durch Reflektion, der JIT-Compiler versucht, den Code noch weiter zu optimieren, aber der Unterschied zum direkten Methodenaufruf ist spürbar.
Eingabe - Wenn Sie im Code java.lang.reflect.Method
verwenden, ist dies nur ein Verweis auf eine Methode. Und nirgends steht geschrieben, wie viele Parameter übergeben werden und welcher Typ sie sind. Ein Aufruf mit den falschen Parametern führt zur Laufzeit zu einem Fehler und nicht zum Zeitpunkt des Kompilierens oder Herunterladens der Anwendung.
Transparenz - Wenn die durch Reflektion aufgerufene Methode fehlschlägt, müssen wir mehrere invoke()
-Aufrufe durchlaufen invoke()
bevor wir der eigentlichen Fehlerursache auf den Grund gehen.
Wenn wir uns jedoch den Code der Spring- oder JPA-Ereignishandler in Hibernate ansehen, befindet sich die gute alte java.lang.reflect.Method
im Inneren. Und in naher Zukunft wird sich dies wahrscheinlich nicht ändern. Diese Frameworks sind zu groß und zu stark an sie gebunden, und es scheint, dass die Leistung der Ereignishandler auf der Serverseite ausreicht, um darüber nachzudenken, durch was Sie Aufrufe durch Reflexion ersetzen können.
Und welche anderen Möglichkeiten gibt es?
AOT-Kompilierung und Codegenerierung - geben Sie Anwendungen Geschwindigkeit zurück!
Der erste Kandidat, der die Reflection-API ersetzt, ist die Codegenerierung. Jetzt tauchen Frameworks wie Micronaut oder Quarkus auf, die versuchen, zwei Probleme zu lösen: Reduzierung der Startgeschwindigkeit der Anwendung und Reduzierung des Speicherverbrauchs. Diese beiden Metriken sind in unserem Zeitalter von Containern, Microservices und serverlosen Architekturen von entscheidender Bedeutung, und neue Frameworks versuchen, dies durch AOT-Kompilierung zu lösen. Mit verschiedenen Techniken (die Sie hier zum Beispiel lesen können) wird der Anwendungscode so geändert, dass alle reflexiven Aufrufe von Methoden, Konstruktoren usw. erfolgen. durch direkte Anrufe ersetzt. Daher müssen Sie beim Start der Anwendung keine Klassen scannen und Beans erstellen, und JIT optimiert den Code zur Laufzeit effizienter, wodurch die Leistung von Anwendungen, die auf solchen Frameworks basieren, erheblich gesteigert wird. Hat dieser Ansatz Nachteile? Antwort: Natürlich gibt es.
Erstens führen Sie den von Ihnen geschriebenen Code nicht aus. Der Quellcode ändert sich während der Kompilierung. Wenn also etwas schief geht, ist es manchmal schwierig zu verstehen, wo der Fehler liegt: in Ihrem Code oder im Generierungsalgorithmus (normalerweise natürlich in Ihrem ) Und von hier aus tritt das Debugging-Problem auf - Sie müssen Ihren eigenen Code debuggen.
Die zweite - um eine Anwendung auszuführen, die im Framework mit AOT-Kompilierung geschrieben wurde, benötigen Sie ein spezielles Tool. Sie können beispielsweise nicht einfach eine in Quarkus geschriebene Anwendung abrufen und ausführen. Wir benötigen ein spezielles Plugin für maven / gradle, das Ihren Code vorverarbeitet. Und jetzt müssen Sie bei Fehlern im Framework nicht nur die Bibliotheken, sondern auch das Plugin aktualisieren.
In Wahrheit ist die Codegenerierung auch in der Java-Welt nicht neu, sie ist bei Micronaut oder Quarkus nicht aufgetreten . In der einen oder anderen Form wird es von einigen Frameworks verwendet. Hier können wir uns an lombok, aspectj mit seiner vorläufigen Generierung von Code für Aspekte oder Eclipselink erinnern, der Entitätsklassen Code für eine effizientere Deserialisierung hinzufügt. Bei CUBA verwenden wir die Codegenerierung, um Ereignisse über Änderungen im Status einer Entität zu generieren und Validierungsnachrichten in den Klassencode aufzunehmen, um die Arbeit mit Entitäten in der Benutzeroberfläche zu vereinfachen.
Für CUBA-Entwickler wäre die Implementierung der statischen Codegenerierung für Ereignishandler ein extremer Schritt, da viele Änderungen an der internen Architektur und am Plugin für die Codegenerierung vorgenommen werden mussten. Gibt es etwas, das wie Reflexion aussieht, aber schneller ist?
Java 7 hat eine neue Anweisung für die JVM eingeführt - invokedynamic
. Über sie gibt es hier einen ausgezeichneten Bericht von Vladimir Ivanov auf jug.ru. Ursprünglich für die Verwendung in dynamischen Sprachen wie Groovy konzipiert, war diese Anweisung ein hervorragender Kandidat für den Aufruf von Methoden in Java ohne Verwendung von Reflexion. Gleichzeitig mit der neuen Anweisung wurde im JDK eine zugehörige API angezeigt:
- Class
MethodHandle
- erschien bereits in Java 7, wird aber immer noch nicht sehr oft verwendet LambdaMetafactory
- diese Klasse ist bereits aus Java 8, es wurde eine Weiterentwicklung der API für dynamische Aufrufe, verwendet MethodHandle
Inneren.
Es schien, dass MethodHandle
, das im Wesentlichen ein typisierter Zeiger auf eine Methode (Konstruktor usw.) ist, die Rolle von java.lang.reflect.Method
erfüllen kann. Die Aufrufe sind schneller, da alle Typprüfungen, die in der Reflection-API bei jedem Aufruf durchgeführt werden, in diesem Fall nur einmal ausgeführt werden, wenn das MethodHandle
.
MethodHandle
erwies sich das reine MethodHandle
als noch langsamer als Aufrufe über die Reflection-API. Leistungssteigerungen können erzielt werden, indem MethodHandle
statisch gemacht wird, jedoch nicht in allen Fällen. Es gibt eine ausgezeichnete Diskussion über die Geschwindigkeit von MethodHandle
Aufrufen auf der OpenJDK-Mailingliste .
Aber als die LambdaMetafactory
Klasse LambdaMetafactory
, gab es eine echte Chance, Methodenaufrufe zu beschleunigen. LambdaMetafactory
können LambdaMetafactory
ein Lambda-Objekt erstellen und einen direkten Methodenaufruf darin MethodHandle
, der über das MethodHandle
abgerufen werden MethodHandle
. Anschließend können Sie mit dem generierten Objekt die gewünschte Methode aufrufen. Hier ist ein Beispiel für die Generierung, die die als Parameter an BiFunction übergebene Getter-Methode umschließt:
private BiFunction createGetHandlerLambda(Object bean, Method method) throws Throwable { MethodHandles.Lookup caller = MethodHandles.lookup(); CallSite site = LambdaMetafactory.metafactory(caller, "apply", MethodType.methodType(BiFunction.class), MethodType.methodType(Object.class, Object.class, Object.class), caller.findVirtual(bean.getClass(), method.getName(), MethodType.methodType(method.getReturnType(), method.getParameterTypes()[0])), MethodType.methodType(method.getReturnType(), bean.getClass(), method.getParameterTypes()[0])); MethodHandle factory = site.getTarget(); BiFunction listenerMethod = (BiFunction) factory.invoke(); return listenerMethod; }
Als Ergebnis erhalten wir eine Instanz von BiFunction anstelle von Method. Und jetzt, selbst wenn wir Method in unserem Code verwendet haben, ist es nicht schwierig, sie durch BiFunction zu ersetzen. Nehmen Sie den echten (leicht vereinfachten, wahren) Code zum Aufrufen des Methodenhandlers mit der @EventListener
aus dem Spring Framework:
public class ApplicationListenerMethodAdapter implements GenericApplicationListener { private final Method method; public void onApplicationEvent(ApplicationEvent event) { Object bean = getTargetBean(); Object result = this.method.invoke(bean, event); handleResult(result); } }
Und hier ist der gleiche Code, der jedoch einen Methodenaufruf über ein Lambda verwendet:
public class ApplicationListenerLambdaAdapter extends ApplicationListenerMethodAdapter { private final BiFunction funHandler; public void onApplicationEvent(ApplicationEvent event) { Object bean = getTargetBean(); Object result = funHandler.apply(bean, event); handleResult(result); } }
Minimale Änderungen, die Funktionalität ist die gleiche, aber es gibt Vorteile:
Ein Lambda hat einen Typ - er wird bei der Erstellung angegeben, sodass der Aufruf von „nur eine Methode“ fehlschlägt.
Der Trace-Stack ist kürzer - beim Aufrufen einer Methode über ein Lambda wird nur ein zusätzlicher Aufruf hinzugefügt - apply()
. Und alle. Als nächstes wird die Methode selbst aufgerufen.
Aber die Geschwindigkeit muss gemessen werden.
Messen Sie die Geschwindigkeit
Um die Hypothese zu testen, haben wir mit JMH ein Mikrobenchmark erstellt, um die Ausführungszeit und den Durchsatz zu vergleichen, wenn dieselbe Methode auf unterschiedliche Weise aufgerufen wird: über die Reflection-API, über LambdaMetafactory, und einen direkten Methodenaufruf zum Vergleich hinzugefügt. Vor Beginn des Tests wurden Links zu Methode und Lambdas erstellt und zwischengespeichert.
Testparameter:
@BenchmarkMode({Mode.Throughput, Mode.AverageTime}) @Warmup(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS) @Measurement(iterations = 10, time = 1000, timeUnit = TimeUnit.MILLISECONDS)
Der Test selbst kann von GitHub heruntergeladen und bei Interesse selbst ausgeführt werden.
Testergebnisse für Oracle JDK 11.0.2 und JMH 1.21 (die Zahlen können variieren, aber der Unterschied bleibt spürbar und ungefähr gleich):
Test - Wert erhalten | Durchsatz (ops / us) | Ausführungszeit (us / op) |
---|
LambdaGetTest | 72 | 0,0118 |
ReflectionGetTest | 65 | 0,0177 |
DirectMethodGetTest | 260 | 0,0048 |
Test - Wert einstellen | Durchsatz (ops / us) | Ausführungszeit (us / op |
LambdaSetTest | 96 | 0,0092 |
ReflectionSetTest | 58 | 0,0173 |
DirectMethodSetTest | 415 | 0,0031 |
Im Durchschnitt stellte sich heraus, dass das Aufrufen einer Methode über ein Lambda etwa 30% schneller ist als über eine Reflection-API. Es gibt hier eine weitere großartige Diskussion über die Leistung des Methodenaufrufs , wenn sich jemand für die Details interessiert. Kurz gesagt - der Geschwindigkeitsgewinn wird unter anderem dadurch erzielt, dass die generierten Lambdas in den Programmcode eingefügt werden können und im Gegensatz zur Reflexion noch keine Typprüfungen durchgeführt werden.
Natürlich ist dieser Benchmark recht einfach. Er beinhaltet nicht das Aufrufen von Methoden in einer Klassenhierarchie oder das Messen der Geschwindigkeit beim Aufrufen endgültiger Methoden. Wir haben jedoch komplexere Messungen durchgeführt, und die Ergebnisse waren immer für die Verwendung von LambdaMetafactory.
Verwenden Sie
Im CUBA-Framework der Version 7 können Sie in UI-Controllern die Annotation @Subscribe
, um eine Methode für bestimmte Benutzeroberflächenereignisse zu „signieren“. Intern wird dies in LambdaMetafactory
implementiert, Links zu Listener-Methoden werden beim ersten Aufruf erstellt und zwischengespeichert.
Diese Innovation ermöglichte es, den Code stark zu löschen, insbesondere bei Formularen mit einer großen Anzahl von Elementen, einer komplexen Interaktion und dementsprechend mit einer großen Anzahl von Ereignishandlern. Ein einfaches Beispiel aus CUBA QuickStart: Stellen Sie sich vor, Sie müssen den Bestellbetrag neu berechnen, wenn Sie Produktartikel hinzufügen oder entfernen. Sie müssen Code schreiben, der die Methode calculateAmount()
ausführt, wenn sich die Auflistung in der Entität ändert. Wie es vorher aussah:
public class OrderEdit extends AbstractEditor<Order> { @Inject private CollectionDatasource<OrderLine, UUID> linesDs; @Override public void init( Map<String, Object> params) { linesDs.addCollectionChangeListener(e -> calculateAmount()); } ... }
Und in CUBA 7 sieht der Code folgendermaßen aus:
public class OrderEdit extends StandardEditor<Order> { @Subscribe(id = "linesDc", target = Target.DATA_CONTAINER) protected void onOrderLinesDcCollectionChange (CollectionChangeEvent<OrderLine> event) { calculateAmount(); } ... }
Fazit: Der Code ist sauberer und es gibt keine magische init()
-Methode, die mit zunehmender Komplexität des Formulars tendenziell wächst und sich mit Ereignishandlern füllt. Und doch - wir müssen nicht einmal ein Feld mit der Komponente erstellen, die wir abonnieren. CUBA findet diese Komponente anhand der ID.
Schlussfolgerungen
Trotz des Aufkommens einer neuen Generation von Frameworks mit AOT-Kompilierung ( Micronaut , Quarkus ), die unbestreitbare Vorteile gegenüber „traditionellen“ Frameworks haben (hauptsächlich im Vergleich zu Spring ), wird immer noch eine große Menge Code mithilfe der Reflection-API geschrieben (und danke für den gleichen Frühling). Und es sieht so aus, als ob das Spring Framework derzeit immer noch führend unter den Frameworks für die Anwendungsentwicklung ist, und wir werden noch lange mit reflexionsbasiertem Code arbeiten.
Und wenn Sie darüber nachdenken, die Reflection-API in Ihrem Code zu verwenden - sei es eine Anwendung oder ein Framework -, überlegen Sie es sich zweimal. Zuerst über die Codegenerierung und dann über MethodHandles / LambdaMetafactory. Die zweite Methode kann sich als schneller herausstellen, und der Entwicklungsaufwand wird nicht höher sein als bei Verwendung der Reflection-API.
Einige weitere nützliche Links:
Eine schnellere Alternative zu Java Reflection
Lambda-Ausdrücke in Java hacken
Methodenhandles in Java
Java Reflection, aber viel schneller
Warum ist LambdaMetafactory 10% langsamer als ein statischer MethodHandle, aber 80% schneller als ein nicht statischer MethodHandle?
Zu schnell, zu megamorph: Was beeinflusst die Leistung von Methodenaufrufen in Java?