Alternative de réflexion Java plus rapide

Bonjour à tous. Aujourd'hui, nous voulons partager avec vous une traduction d'un article préparé spécialement pour les étudiants du cours Java Developer .

Dans mon article sur le modèle de spécification , je n'ai pas spécifiquement mentionné le composant sous-jacent qui a beaucoup aidé à la mise en œuvre. Ici, je vais parler davantage de la classe JavaBeanUtil que j'ai utilisée pour obtenir la valeur d'un champ d'objet. Dans cet exemple, c'était FxTransaction .



Bien sûr, vous direz que vous pouvez utiliser Apache Commons BeanUtils ou l'une de ses alternatives pour obtenir le même résultat. Mais j'étais intéressé à approfondir cela et ce que j'ai appris fonctionne beaucoup plus rapidement que n'importe quelle bibliothèque construite sur la base de la célèbre Java Reflection .

Une technologie qui évite une réflexion très lente est l' invokedynamic bytecode invokedynamic . En bref, la manifestation de la invokedynamic (ou «indy») a été l'innovation la plus sérieuse de Java 7, qui a ouvert la voie à l'implémentation de langages dynamiques au-dessus de la JVM à l'aide d'invocations de méthodes dynamiques. Plus tard, dans Java 8, il a également permis des expressions lambda et des références de méthode, ainsi qu'une amélioration de la concaténation des chaînes dans Java 9.

En bref, la technique que je vais décrire ci-dessous utilise LambdaMetafactory et MethodHandle pour créer dynamiquement une implémentation de l'interface Function . La fonction est la seule méthode qui délègue un appel à la méthode cible réelle avec du code défini à l'intérieur du lambda.

Dans ce cas, la méthode cible est un getter qui a un accès direct au champ que nous voulons lire. De plus, je dois dire que si vous connaissez bien les innovations apparues dans Java 8, vous trouverez les extraits de code ci-dessous assez simples. Sinon, le code peut sembler compliqué à première vue.

Jetez un œil au JavaBeanUtil de fortune


La méthode getFieldValue - getFieldValue est une méthode utilitaire utilisée pour lire les valeurs d'un champ JavaBean. Il prend un objet JavaBean et un nom de champ. Le nom du champ peut être simple (par exemple, fieldA ) ou imbriqué, séparé par des points (par exemple, nestedJavaBean.nestestJavaBean.fieldA ).

 private static final Pattern FIELD_SEPARATOR = Pattern.compile("\\."); private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); private static final ClassValue<Map<String, Function>> CACHE = new ClassValue<Map<String, Function>>() { @Override protected Map<String, Function> computeValue(Class<?> type) { return new ConcurrentHashMap<>(); } }; public static <T> T getFieldValue(Object javaBean, String fieldName) { return (T) getCachedFunction(javaBean.getClass(), fieldName).apply(javaBean); } private static Function getCachedFunction(Class<?> javaBeanClass, String fieldName) { final Function function = CACHE.get(javaBeanClass).get(fieldName); if (function != null) { return function; } return createAndCacheFunction(javaBeanClass, fieldName); } private static Function createAndCacheFunction(Class<?> javaBeanClass, String path) { return cacheAndGetFunction(path, javaBeanClass, createFunctions(javaBeanClass, path) .stream() .reduce(Function::andThen) .orElseThrow(IllegalStateException::new) ); } private static Function cacheAndGetFunction(String path, Class<?> javaBeanClass, Function functionToBeCached) { Function cachedFunction = CACHE.get(javaBeanClass).putIfAbsent(path, functionToBeCached); return cachedFunction != null ? cachedFunction : functionToBeCached; } 


Pour améliorer les performances, je mets en cache une fonction créée dynamiquement qui lira réellement une valeur dans un champ nommé fieldName . Dans la méthode getCachedFunction , comme vous pouvez le voir, il existe un chemin «rapide» qui utilise ClassValue pour la mise en cache et un chemin «slow» createAndCacheFunction , qui s'exécute si aucune valeur n'est trouvée dans le cache.

La méthode createFunctions appelle une méthode qui retourne une liste de fonctions qui seront chaînées à l'aide de Function::andThen . Les fonctions de liaison les unes aux autres dans une chaîne peuvent être représentées comme des appels imbriqués, similaires à getNestedJavaBean().getNestJavaBean().getNestJavaBean().getFieldA() . Après cela, nous mettons simplement la fonction dans le cache en appelant la méthode cacheAndGetFunction .
Si vous regardez de près la création de la fonction, nous devons parcourir les champs dans path comme suit:

 private static List<Function> createFunctions(Class<?> javaBeanClass, String path) { List<Function> functions = new ArrayList<>(); Stream.of(FIELD_SEPARATOR.split(path)) .reduce(javaBeanClass, (nestedJavaBeanClass, fieldName) -> { Tuple2<? extends Class, Function> getFunction = createFunction(fieldName, nestedJavaBeanClass); functions.add(getFunction._2); return getFunction._1; }, (previousClass, nextClass) -> nextClass); return functions; } private static Tuple2<? extends Class, Function> createFunction(String fieldName, Class<?> javaBeanClass) { return Stream.of(javaBeanClass.getDeclaredMethods()) .filter(JavaBeanUtil::isGetterMethod) .filter(method -> StringUtils.endsWithIgnoreCase(method.getName(), fieldName)) .map(JavaBeanUtil::createTupleWithReturnTypeAndGetter) .findFirst() .orElseThrow(IllegalStateException::new); } 


La méthode createFunctions ci-dessus pour chaque champ fieldName et la classe dans laquelle il est déclaré appelle la méthode createFunction , qui recherche le getter souhaité à l'aide de javaBeanClass.getDeclaredMethods() . Une fois le getter trouvé, il est converti en tuple Tuple (Tuple de la bibliothèque Vavr ), qui contient le type renvoyé par le getter, et une fonction créée dynamiquement qui se comportera comme s'il s'agissait d'un getter lui-même.
Un tuple est créé avec la méthode createTupleWithReturnTypeAndGetter en combinaison avec la méthode createCallSite comme suit:

 private static Tuple2<? extends Class, Function> createTupleWithReturnTypeAndGetter(Method getterMethod) { try { return Tuple.of( getterMethod.getReturnType(), (Function) createCallSite(LOOKUP.unreflect(getterMethod)).getTarget().invokeExact() ); } catch (Throwable e) { throw new IllegalArgumentException("Lambda creation failed for getterMethod (" + getterMethod.getName() + ").", e); } } private static CallSite createCallSite(MethodHandle getterMethodHandle) throws LambdaConversionException { return LambdaMetafactory.metafactory(LOOKUP, "apply", MethodType.methodType(Function.class), MethodType.methodType(Object.class, Object.class), getterMethodHandle, getterMethodHandle.type()); } 


Dans les deux méthodes ci-dessus, j'utilise une constante appelée LOOKUP , qui n'est qu'une référence à MethodHandles.Lookup . Avec lui, je peux créer un lien direct vers une méthode (poignée de méthode directe) basée sur un getter trouvé précédemment. Enfin, le MethodHandle créé est transmis à la méthode createCallSite , dans laquelle le corps lambda est créé pour la fonction à l'aide de LambdaMetafactory . De là, en fin de compte, nous pouvons obtenir une instance de CallSite , qui est le «gardien» de la fonction.
Notez que pour les setters, vous pouvez utiliser une approche similaire en utilisant BiFunction au lieu de Function .

Benchmark


Pour mesurer les performances, j'ai utilisé le merveilleux outil JMH ( Java Microbenchmark Harness ), qui fait probablement partie de JDK 12 ( Note du traducteur: oui, jmh est inclus dans java 9 ). Comme vous le savez probablement, le résultat dépend de la plate-forme, donc pour référence: j'utiliserai 1x6 i5-8600K 3,6 Linux x86_64, Oracle JDK 8u191 GraalVM EE 1.0.0-rc9 .
À titre de comparaison, j'ai choisi la bibliothèque Apache Commons BeanUtils , largement connue de la plupart des développeurs Java, et l'une de ses alternatives appelée Jodd BeanUtil , qui serait presque 20% plus rapide .

Le code de référence est le suivant:

 @Fork(3) @Warmup(iterations = 5, time = 3) @Measurement(iterations = 5, time = 1) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Thread) public class JavaBeanUtilBenchmark { @Param({ "fieldA", "nestedJavaBean.fieldA", "nestedJavaBean.nestedJavaBean.fieldA", "nestedJavaBean.nestedJavaBean.nestedJavaBean.fieldA" }) String fieldName; JavaBean javaBean; @Setup public void setup() { NestedJavaBean nestedJavaBean3 = NestedJavaBean.builder().fieldA("nested-3").build(); NestedJavaBean nestedJavaBean2 = NestedJavaBean.builder().fieldA("nested-2").nestedJavaBean(nestedJavaBean3).build(); NestedJavaBean nestedJavaBean1 = NestedJavaBean.builder().fieldA("nested-1").nestedJavaBean(nestedJavaBean2).build(); javaBean = JavaBean.builder().fieldA("fieldA").nestedJavaBean(nestedJavaBean1).build(); } @Benchmark public Object invokeDynamic() { return JavaBeanUtil.getFieldValue(javaBean, fieldName); } /** * Reference: http://commons.apache.org/proper/commons-beanutils/ */ @Benchmark public Object apacheBeanUtils() throws Exception { return PropertyUtils.getNestedProperty(javaBean, fieldName); } /** * Reference: https://jodd.org/beanutil/ */ @Benchmark public Object joddBean() { return BeanUtil.declared.getProperty(javaBean, fieldName); } public static void main(String... args) throws IOException, RunnerException { Main.main(args); } } 


Le benchmark définit quatre scénarios pour différents niveaux d'imbrication du champ. Pour chaque champ, JMH effectuera 5 itérations de 3 secondes pour s'échauffer, puis 5 itérations de 1 seconde pour la mesure réelle. Chaque scénario sera répété 3 fois pour obtenir de meilleures mesures.

Résultats


Commençons par les résultats compilés pour le JDK 8u191 :


Oracle JDK 8u191

Le pire des scénarios utilisant l'approche invokedynamic est beaucoup plus rapide que le plus rapide des deux autres bibliothèques. C'est une énorme différence, et si vous doutez des résultats, vous pouvez toujours télécharger le code source et jouer avec comme vous le souhaitez.

Voyons maintenant comment fonctionne le même test avec GraalVM EE 1.0.0-rc9.


GraalVM EE 1.0.0-rc9

Les résultats complets peuvent être vus ici avec le magnifique visualiseur JMH.

Observations


Une telle différence est due au fait que le compilateur JIT connaît bien CallSite et MethodHandle et peut les MethodHandle , contrairement à l'approche par réflexion. De plus, vous pouvez voir à quel point GraalVM est prometteur. Son compilateur fait un travail vraiment génial qui peut améliorer considérablement les performances de réflexion.

Si vous êtes curieux et que vous souhaitez approfondir, je vous encourage à récupérer le code de mon référentiel Github . Gardez à l'esprit, je ne vous conseille pas de faire un JavaBeanUtil self-made pour l'utiliser en production. Mon objectif est simplement de montrer mon expérience et les possibilités que nous pouvons tirer de la invokedynamic .

La traduction est terminée, et nous invitons tout le monde à un webinaire gratuit le 13 juin, dans lequel nous examinerons comment le Docker peut être utile pour un développeur Java: comment créer une image docker avec une application java et comment interagir avec elle.

Source: https://habr.com/ru/post/fr455122/


All Articles