Présentation

Au cours du processus de développement, il est souvent nécessaire de créer une instance d'une classe dont le nom est stocké dans le fichier de configuration XML, ou d'appeler une méthode dont le nom est écrit sous forme de chaîne comme valeur de l'attribut d'annotation. Dans de tels cas, la réponse est une: «Utilisez la réflexion!».
Dans la nouvelle version de CUBA Platform, l' une des tâches pour améliorer le framework était de se débarrasser de la création explicite de gestionnaires d'événements dans les classes de contrôleur des écrans d'interface utilisateur. Dans les versions précédentes, les déclarations du gestionnaire dans la méthode d'initialisation du contrôleur étaient très encombrées par le code, donc dans la septième version, nous avons décidé de tout nettoyer.
Un écouteur d'événements n'est qu'une référence à la méthode qui doit être appelée au bon moment (voir le modèle Observer ). Un tel modèle est assez simple à implémenter à l'aide de la classe java.lang.reflect.Method
. Au démarrage, il vous suffit d'analyser les classes, d'en extraire les méthodes annotées, d'enregistrer les références et d'utiliser les liens pour appeler la ou les méthodes lorsque l'événement se produit, comme cela se fait dans la plupart des frameworks. La seule chose qui nous a arrêtés, c'est que de nombreux événements sont traditionnellement générés dans l'interface utilisateur, et lorsque vous utilisez l'API de réflexion, vous devez payer un certain prix sous la forme de l'heure de l'appel de méthode. Par conséquent, nous avons décidé de voir comment vous pouvez créer des gestionnaires d'événements sans utiliser la réflexion.
Nous avons déjà publié des documents sur MethodHandles et LambdaMetafactory sur un habr , et ce matériel est une sorte de continuation. Nous examinerons les avantages et les inconvénients de l'utilisation de l'API de réflexion, ainsi que les alternatives - générer du code avec la compilation AOT et LambdaMetafactory, et comment il a été utilisé dans le cadre CUBA.
Réflexion: ancienne. Bon Fiable
En informatique, la réflexion ou la réflexion (l'holonyme de l'introspection, réflexion en anglais) désigne un processus au cours duquel un programme peut suivre et modifier sa propre structure et son comportement lors de l'exécution. (c) Wikipédia.
Pour la plupart des développeurs Java, la réflexion n'est jamais une nouveauté. Il me semble que sans ce mécanisme, Java ne serait pas devenu ce Java qui occupe désormais une large part de marché dans le développement de logiciels applicatifs. Pensez-y: proxy, liaison de méthodes aux événements via des annotations, injection de dépendance, aspects, et même instanciation du pilote JDBC dans les toutes premières versions de JDK! La réflexion partout, est la pierre angulaire de tous les cadres modernes.
Y a-t-il des problèmes avec la réflexion appliquée à notre tâche? Nous en avons identifié trois:
Vitesse - un appel de méthode via l'API Reflection est plus lent qu'un appel direct. Dans chaque nouvelle version de la JVM, les développeurs accélèrent constamment les appels par la réflexion, le compilateur JIT essaie d'optimiser encore plus le code, mais de toute façon, la différence par rapport à l'appel de méthode directe est notable.
Saisie - si vous utilisez java.lang.reflect.Method
dans le code, ce n'est qu'une référence à une méthode. Et nulle part il n'est écrit combien de paramètres sont passés et de quel type ils sont. Un appel avec des paramètres incorrects générera une erreur lors de l'exécution, et non au stade de la compilation ou du téléchargement de l'application.
Transparence - si la méthode appelée par réflexion échoue, nous devrons parcourir plusieurs appels invoke()
avant d'arriver au fond de la cause réelle de l'erreur.
Mais si nous regardons le code des gestionnaires d'événements Spring ou JPA dans Hibernate, il y aura à l'intérieur de bons vieux java.lang.reflect.Method
. Et dans un avenir proche, je pense que cela ne devrait pas changer. Ces cadres sont trop volumineux et trop liés à eux, et il semble que les performances des gestionnaires d'événements côté serveur soient suffisantes pour réfléchir à ce que vous pouvez remplacer les appels par le biais de la réflexion.
Et quelles sont les autres options?
Compilation AOT et génération de code - accélérez les applications!
Le premier candidat pour remplacer l'API de réflexion est la génération de code. Maintenant, des frameworks tels que Micronaut ou Quarkus ont commencé à apparaître, qui tentent de résoudre deux problèmes: réduire la vitesse de lancement de l'application et réduire la consommation de mémoire. Ces deux mesures sont essentielles à notre époque de conteneurs, de microservices et d'architectures sans serveur, et de nouveaux cadres tentent de résoudre ce problème par compilation AOT. En utilisant différentes techniques (vous pouvez lire ici par exemple), le code de l'application est modifié de telle sorte que tous les appels réflexifs aux méthodes, constructeurs, etc. remplacé par des appels directs. Ainsi, vous n'avez pas besoin d'analyser les classes et de créer des beans au démarrage de l'application, et JIT optimise le code plus efficacement au moment de l'exécution, ce qui augmente considérablement les performances des applications construites sur de telles infrastructures. Cette approche présente-t-elle des inconvénients? Réponse: bien sûr qu'il y en a.
Tout d'abord, vous n'exécutez pas le code que vous avez écrit. Le code source change pendant la compilation, donc si quelque chose ne va pas, il est parfois difficile de comprendre où se trouve l'erreur: dans votre code ou dans l'algorithme de génération (généralement dans le vôtre, bien sûr ) Et à partir de là, le problème de débogage se pose - vous devez déboguer votre propre code.
La seconde - pour exécuter une application écrite dans le framework avec la compilation AOT, vous avez besoin d'un outil spécial. Vous ne pouvez pas simplement obtenir et exécuter une application écrite en Quarkus, par exemple. Nous avons besoin d'un plugin spécial pour maven / gradle, qui pré-traitera votre code. Et maintenant, en cas d'erreurs dans le framework, vous devez mettre à jour non seulement les bibliothèques, mais aussi le plugin.
En vérité, la génération de code n'est pas non plus nouvelle dans le monde Java; elle n'est pas apparue avec Micronaut ou Quarkus . Sous une forme ou une autre, certains frameworks l'utilisent. Ici, nous pouvons rappeler lombok, aspectj avec sa génération préliminaire de code pour aspects ou eclipselink, qui ajoute du code aux classes d'entité pour une désérialisation plus efficace. Chez CUBA, nous utilisons la génération de code pour générer des événements sur les changements dans l'état d'une entité et pour inclure des messages de validateur dans le code de classe pour simplifier le travail avec les entités dans l'interface utilisateur.
Pour les développeurs CUBA, l'implémentation de la génération de code statique pour les gestionnaires d'événements serait une étape extrême car de nombreuses modifications devaient être apportées à l'architecture interne et au plugin pour la génération de code. Y a-t-il quelque chose qui ressemble à de la réflexion mais plus rapide?
Java 7 a introduit une nouvelle instruction pour la JVM - invokedynamic
. À son sujet, il y a un excellent rapport de Vladimir Ivanov sur jug.ru ici . Conçue à l'origine pour être utilisée dans des langages dynamiques comme Groovy, cette instruction était un excellent candidat pour invoquer des méthodes en Java sans utiliser la réflexion. Parallèlement à la nouvelle instruction, une API associée est apparue dans le JDK:
- Class
MethodHandle
- est réapparu en Java 7, mais n'est pas encore très souvent utilisé LambdaMetafactory
- cette classe est déjà de Java 8, elle est devenue un développement ultérieur de l'API pour les appels dynamiques, utilise MethodHandle
intérieur.
Il semblait que MethodHandle
, étant essentiellement un pointeur typé vers une méthode (constructeur, etc.), serait capable de jouer le rôle de java.lang.reflect.Method
. Et les appels seront plus rapides, car toutes les vérifications de type qui sont effectuées dans l'API Reflection avec chaque appel, dans ce cas, ne sont effectuées qu'une seule fois, lorsque le MethodHandle
.
Mais hélas, le pur MethodHandle
s'est avéré encore plus lent que les appels via l'API de réflexion. Des gains de performances peuvent être obtenus en rendant MethodHandle
statique, mais pas dans tous les cas. Il y a une excellente discussion sur la vitesse des appels MethodHandle
sur la liste de diffusion OpenJDK .
Mais lorsque la classe LambdaMetafactory
, il y avait une réelle chance d'accélérer les appels de méthode. LambdaMetafactory
permet de créer un objet lambda et d'envelopper un appel de méthode direct, qui peut être obtenu via le MethodHandle
. Et puis, en utilisant l'objet généré, vous pouvez appeler la méthode souhaitée. Voici un exemple de la génération qui encapsule la méthode getter passée en paramètre à BiFunction:
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; }
En conséquence, nous obtenons une instance de BiFunction au lieu de Method. Et maintenant, même si nous avons utilisé Method dans notre code, le remplacer par BiFunction n'est pas difficile. Prenez le vrai code (légèrement simplifié, vrai) pour appeler le gestionnaire de méthode, marqué @EventListener
dans 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); } }
Et voici le même code, mais qui utilise un appel de méthode via un lambda:
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); } }
Modifications minimes, la fonctionnalité est la même, mais elle présente des avantages:
Un lambda a un type - il est spécifié lors de la création, donc appeler "juste une méthode" échouera.
La pile de trace est plus courte - lors de l'appel d'une méthode via un lambda, un seul appel supplémentaire est ajouté - apply()
. Et c'est tout. Ensuite, la méthode elle-même est appelée.
Mais la vitesse doit être mesurée.
Mesurer la vitesse
Pour tester l'hypothèse, nous avons fait un microbenchmark en utilisant JMH pour comparer le temps d'exécution et le débit lors de l'appel de la même méthode de différentes manières: via l'API de réflexion, via LambdaMetafactory, et avons également ajouté un appel de méthode direct pour comparaison. Des liens vers la méthode et les lambdas ont été créés et mis en cache avant le début du test.
Paramètres de test:
@BenchmarkMode({Mode.Throughput, Mode.AverageTime}) @Warmup(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS) @Measurement(iterations = 10, time = 1000, timeUnit = TimeUnit.MILLISECONDS)
Le test lui-même peut être téléchargé depuis GitHub et exécuté par vous-même, si vous êtes intéressé.
Résultats des tests pour Oracle JDK 11.0.2 et JMH 1.21 (les chiffres peuvent varier, mais la différence reste notable et à peu près la même):
Test - Obtenez de la valeur | Débit (ops / us) | Temps d'exécution (us / op) |
---|
LambdaGetTest | 72 | 0,0118 |
ReflectionGetTest | 65 | 0,0177 |
DirectMethodGetTest | 260 | 0,0048 |
Test - Définir la valeur | Débit (ops / us) | Temps d'exécution (us / op |
LambdaSetTest | 96 | 0,0092 |
ReflectionSetTest | 58 | 0,0173 |
DirectMethodSetTest | 415 | 0,0031 |
En moyenne, il s'est avéré que l'appel d'une méthode via un lambda est environ 30% plus rapide que via une API de réflexion. Il y a une autre grande discussion sur les performances d'invocation de méthode ici si quelqu'un est intéressé par les détails. En bref - le gain de vitesse est obtenu, entre autres, du fait que les lambdas générés peuvent être insérés dans le code du programme, et les vérifications de type ne sont pas encore effectuées, contrairement à la réflexion.
Bien sûr, ce benchmark est assez simple, il n'inclut pas les méthodes d'appel dans une hiérarchie de classes ou mesure la vitesse d'appel des méthodes finales. Mais nous avons fait des mesures plus complexes, et les résultats ont toujours été en faveur de l'utilisation de LambdaMetafactory.
Utiliser
Dans le cadre de la version 7 de CUBA, dans les contrôleurs d'interface utilisateur, vous pouvez utiliser l'annotation @Subscribe
pour «signer» une méthode pour certains événements d'interface utilisateur. En interne, cela est implémenté sur LambdaMetafactory
, des liens vers les méthodes d'écoute sont créés et mis en cache lors du premier appel.
Cette innovation a permis de clarifier considérablement le code, notamment dans le cas de formulaires avec un grand nombre d'éléments, une interaction complexe et, par conséquent, avec un grand nombre de gestionnaires d'événements. Un exemple simple de CUBA QuickStart: Imaginez que vous devez recalculer le montant de la commande lors de l'ajout ou de la suppression d'articles de produit. Vous devez écrire du code qui exécute la méthode calculateAmount()
lorsque la collection change dans l'entité. À quoi cela ressemblait avant:
public class OrderEdit extends AbstractEditor<Order> { @Inject private CollectionDatasource<OrderLine, UUID> linesDs; @Override public void init( Map<String, Object> params) { linesDs.addCollectionChangeListener(e -> calculateAmount()); } ... }
Et dans CUBA 7, le code ressemble à ceci:
public class OrderEdit extends StandardEditor<Order> { @Subscribe(id = "linesDc", target = Target.DATA_CONTAINER) protected void onOrderLinesDcCollectionChange (CollectionChangeEvent<OrderLine> event) { calculateAmount(); } ... }
Conclusion: le code est plus propre et il n'y a pas de méthode magique init()
, qui a tendance à se développer et à se remplir de gestionnaires d'événements avec une complexité croissante du formulaire. Et pourtant - nous n'avons même pas besoin de créer un champ avec le composant auquel nous sommes abonnés, CUBA trouvera ce composant par ID.
Conclusions
Malgré l'émergence d'une nouvelle génération de frameworks avec compilation AOT ( Micronaut , Quarkus ), qui ont des avantages indéniables par rapport aux frameworks «traditionnels» (principalement, ils sont comparés à Spring ), il y a toujours une énorme quantité de code écrit en utilisant l'API de réflexion (et merci pour le même printemps). Et il semble que Spring Framework soit actuellement encore le leader parmi les frameworks de développement d'applications et nous travaillerons avec du code basé sur la réflexion pendant longtemps.
Et si vous songez à utiliser l'API Reflection dans votre code - qu'il s'agisse d'une application ou d'un framework - réfléchissez-y à deux fois. Tout d'abord, sur la génération de code, puis sur MethodHandles / LambdaMetafactory. La deuxième méthode peut s'avérer plus rapide et les efforts de développement ne seront pas dépensés plus que dans le cas de l'utilisation de l'API Reflection.
Quelques liens plus utiles:
Une alternative plus rapide à Java Reflection
Piratage d'expressions Lambda en Java
Handles de méthode en Java
Java Reflection, mais beaucoup plus rapide
Pourquoi LambdaMetafactory est 10% plus lent qu'un MethodHandle statique mais 80% plus rapide qu'un MethodHandle non statique?
Trop rapide, trop mégamorphique: qu'est-ce qui influence les performances des appels de méthode en Java?