Pourquoi renvoyer une référence d’object Java beaucoup plus lentement que de renvoyer une primitive

Nous travaillons sur une application sensible à la latence et avons testé toutes sortes de méthodes (en utilisant jmh ). Après avoir microbenchérié une méthode de recherche et être satisfait des résultats, j’ai implémenté la version finale, pour constater que la version finale était 3 fois plus lente que ce que je venais de tester.

Le coupable était que la méthode implémentée retournait un object enum au lieu d’un int . Voici une version simplifiée du code de référence:

 @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Thread) public class ReturnEnumObjectVersusPrimitiveBenchmark { enum Category { CATEGORY1, CATEGORY2, } @Param( {"3", "2", "1" }) Ssortingng value; int param; @Setup public void setUp() { param = Integer.parseInt(value); } @Benchmark public int benchmarkReturnOrdinal() { if (param < 2) { return Category.CATEGORY1.ordinal(); } return Category.CATEGORY2.ordinal(); } @Benchmark public Category benchmarkReturnReference() { if (param < 2) { return Category.CATEGORY1; } return Category.CATEGORY2; } public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder().include(ReturnEnumObjectVersusPrimitiveBenchmark.class.getName()).warmupIterations(5) .measurementIterations(4).forks(1).build(); new Runner(opt).run(); } } 

Les résultats de référence pour ci-dessus:

 # VM invoker: C:\Program Files\Java\jdk1.7.0_40\jre\bin\java.exe # VM options: -Dfile.encoding=UTF-8 Benchmark (value) Mode Samples Score Error Units benchmarkReturnOrdinal 3 thrpt 4 1059.898 ± 71.749 ops/us benchmarkReturnOrdinal 2 thrpt 4 1051.122 ± 61.238 ops/us benchmarkReturnOrdinal 1 thrpt 4 1064.067 ± 90.057 ops/us benchmarkReturnReference 3 thrpt 4 353.197 ± 25.946 ops/us benchmarkReturnReference 2 thrpt 4 350.902 ± 19.487 ops/us benchmarkReturnReference 1 thrpt 4 339.578 ± 144.093 ops/us 

Juste changer le type de retour de la fonction a changé la performance d’un facteur de presque 3.

Je pensais que la seule différence entre retourner un object enum et un entier est que l’un renvoie une valeur de 64 bits (référence) et l’autre renvoie une valeur de 32 bits. Un de mes collègues était en train de deviner que le retour de l’enum avait ajouté des frais généraux supplémentaires en raison de la nécessité de suivre la référence pour un GC potentiel. (Mais étant donné que les objects enum sont des références finales statiques, il semble étrange que cela soit nécessaire).

Quelle est l’explication de la différence de performance?


METTRE À JOUR

J’ai partagé le projet Maven ici pour que tout le monde puisse le cloner et exécuter le benchmark. Si quelqu’un a le temps / l’intérêt, il serait utile de voir si d’autres peuvent reproduire les mêmes résultats. (Je l’ai répliqué sur 2 machines différentes, Windows 64 et Linux 64, toutes deux utilisant des versions de JVM Oracle Java 1.7). @ZhekaKozlov dit qu’il n’a vu aucune différence entre les méthodes.

Pour exécuter: (après le référentiel de clonage)

 mvn clean install java -jar .\target\microbenchmarks.jar function.ReturnEnumObjectVersusPrimitiveBenchmark -i 5 -wi 5 -f 1 

    TL; DR: Vous ne devriez pas mettre la confiance BLIND dans quelque chose.

    Tout d’abord, il est important de vérifier les données expérimentales avant d’en tirer des conclusions. Le simple fait de prétendre que quelque chose est 3 fois plus rapide / plus lent est étrange, car vous devez vraiment suivre la raison de la différence de performance, pas seulement faire confiance aux chiffres. Ceci est particulièrement important pour les nano-tests comme vous.

    Deuxièmement, les expérimentateurs doivent clairement comprendre ce qu’ils contrôlent et ce qu’ils ne contrôlent pas. Dans votre exemple particulier, vous retournez la valeur des méthodes @Benchmark , mais pouvez-vous être raisonnablement sûr que les appelants extérieurs feront la même chose pour les primitives et la référence? Si vous vous posez cette question, vous réaliserez que vous êtes en train de mesurer l’infrastructure de test.

    Jusqu’au bout. Sur ma machine (i5-4210U, Linux x86_64, JDK 8u40), le test donne:

     Benchmark (value) Mode Samples Score Error Units ...benchmarkReturnOrdinal 3 thrpt 5 0.876 ± 0.023 ops/ns ...benchmarkReturnOrdinal 2 thrpt 5 0.876 ± 0.009 ops/ns ...benchmarkReturnOrdinal 1 thrpt 5 0.832 ± 0.048 ops/ns ...benchmarkReturnReference 3 thrpt 5 0.292 ± 0.006 ops/ns ...benchmarkReturnReference 2 thrpt 5 0.286 ± 0.024 ops/ns ...benchmarkReturnReference 1 thrpt 5 0.293 ± 0.008 ops/ns 

    Bon, les tests de référence apparaissent 3 fois plus lents. Mais attendez, il utilise un ancien JMH (1.1.1), mettons à jour avec la dernière version (1.7.1):

     Benchmark (value) Mode Cnt Score Error Units ...benchmarkReturnOrdinal 3 thrpt 5 0.326 ± 0.010 ops/ns ...benchmarkReturnOrdinal 2 thrpt 5 0.329 ± 0.004 ops/ns ...benchmarkReturnOrdinal 1 thrpt 5 0.329 ± 0.004 ops/ns ...benchmarkReturnReference 3 thrpt 5 0.288 ± 0.005 ops/ns ...benchmarkReturnReference 2 thrpt 5 0.288 ± 0.005 ops/ns ...benchmarkReturnReference 1 thrpt 5 0.288 ± 0.002 ops/ns 

    Oups, maintenant ils sont à peine plus lents. BTW, cela nous dit également que le test est lié à l’infrastructure. Ok, pouvons-nous voir ce qui se passe vraiment?

    Si vous construisez les @Benchmark et examinez ce qui appelle exactement vos méthodes @Benchmark , vous verrez quelque chose comme:

     public void benchmarkReturnOrdinal_thrpt_jmhStub(InfraControl control, RawResults result, ReturnEnumObjectVersusPrimitiveBenchmark_jmh l_returnenumobjectversusprimitivebenchmark0_0, Blackhole_jmh l_blackhole1_1) throws Throwable { long operations = 0; long realTime = 0; result.startTime = System.nanoTime(); do { l_blackhole1_1.consume(l_longname.benchmarkReturnOrdinal()); operations++; } while(!control.isDone); result.stopTime = System.nanoTime(); result.realTime = realTime; result.measuredOps = operations; } 

    Ce l_blackhole1_1 a une méthode de consume , qui “consum” les valeurs (voir Blackhole pour la justification). Blackhole.consume a des surcharges pour les références et les primitives , et cela suffit à justifier la différence de performance.

    Il y a une raison pour laquelle ces méthodes sont différentes: elles essaient d’être aussi rapides que possible pour leurs types d’arguments. Ils ne présentent pas nécessairement les mêmes caractéristiques de performance, même si nous essayons de les faire correspondre, d’où le résultat plus symésortingque avec les nouveaux JMH. Maintenant, vous pouvez même aller à -prof perfasm pour voir le code généré pour vos tests et voir pourquoi les performances sont différentes, mais cela dépasse le point ici.

    Si vous voulez vraiment comprendre en quoi le retour de la primitive et / ou de la référence diffère sur le plan des performances, vous devrez entrer dans une grande zone grise et effrayante d’évaluation comparative des performances. Par exemple, quelque chose comme ce test:

     @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) @Fork(5) public class PrimVsRef { @Benchmark public void prim() { doPrim(); } @Benchmark public void ref() { doRef(); } @ComstackrControl(ComstackrControl.Mode.DONT_INLINE) private int doPrim() { return 42; } @ComstackrControl(ComstackrControl.Mode.DONT_INLINE) private Object doRef() { return this; } } 

    … qui donne le même résultat pour les primitives et les références:

     Benchmark Mode Cnt Score Error Units PrimVsRef.prim avgt 25 2.637 ± 0.017 ns/op PrimVsRef.ref avgt 25 2.634 ± 0.005 ns/op 

    Comme je l’ai dit plus haut, ces tests nécessitent un suivi des raisons des résultats. Dans ce cas, le code généré pour les deux est presque le même, ce qui explique le résultat.

    prim:

      [Verified Entry Point] 12.69% 1.81% 0x00007f5724aec100: mov %eax,-0x14000(%rsp) 0.90% 0.74% 0x00007f5724aec107: push %rbp 0.01% 0.01% 0x00007f5724aec108: sub $0x30,%rsp 12.23% 16.00% 0x00007f5724aec10c: mov $0x2a,%eax ; load "42" 0.95% 0.97% 0x00007f5724aec111: add $0x30,%rsp 0.02% 0x00007f5724aec115: pop %rbp 37.94% 54.70% 0x00007f5724aec116: test %eax,0x10d1aee4(%rip) 0.04% 0.02% 0x00007f5724aec11c: retq 

    ref:

      [Verified Entry Point] 13.52% 1.45% 0x00007f1887e66700: mov %eax,-0x14000(%rsp) 0.60% 0.37% 0x00007f1887e66707: push %rbp 0.02% 0x00007f1887e66708: sub $0x30,%rsp 13.63% 16.91% 0x00007f1887e6670c: mov %rsi,%rax ; load "this" 0.50% 0.49% 0x00007f1887e6670f: add $0x30,%rsp 0.01% 0x00007f1887e66713: pop %rbp 39.18% 57.65% 0x00007f1887e66714: test %eax,0xe3e78e6(%rip) 0.02% 0x00007f1887e6671a: retq 

    [sarcasme] Voyez comme c’est facile! [/sarcasme]

    Le schéma est le suivant: plus la question est simple, plus vous devez travailler pour obtenir une réponse plausible et fiable.

    Pour effacer la conception erronée de la référence et de la mémoire, certains sont tombés dans (@Mzf), penchons-nous sur la spécification de la machine virtuelle Java. Mais avant d’y aller, une chose doit être clarifiée: un object ne peut jamais être récupéré de la mémoire, seuls ses champs le peuvent . En fait, il n’y a pas d’opcode qui pourrait effectuer une opération aussi étendue.

    Ce document définit la référence comme un type de stack (afin qu’il puisse être un résultat ou un argument pour des instructions effectuant des opérations sur stack) de 1ère catégorie – la catégorie de types prenant un seul mot de stack (32 bits). Voir tableau 2.3 Une liste de types de piles Java .

    De plus, si l’invocation de la méthode se termine normalement conformément à la spécification, une valeur extraite du haut de la stack est insérée dans la stack de l’invocateur de la méthode (section 2.6.4).

    Votre question est ce qui cause la différence de temps d’exécution. Chapitre 2: Préface:

    Les détails d’implémentation qui ne font pas partie des spécifications de la machine virtuelle Java limiteraient inutilement la créativité des développeurs. Par exemple, la disposition en mémoire des zones de données d’exécution, l’algorithme de récupération de place utilisé et toute optimisation interne des instructions de la machine virtuelle Java (par exemple, leur conversion en code machine) sont laissés à la discrétion de l’implémenteur.

    En d’autres termes, parce qu’il n’existe pas de pénalité de performance concernant l’utilisation de la référence dans le document pour des raisons logiques (c’est simplement un mot de stack comme int ou float ), il vous rest à rechercher le code source de votre implémentation. ou ne jamais trouver du tout.

    En fin de compte, nous ne devrions pas toujours blâmer la mise en œuvre, il y a quelques indices à prendre en compte lorsque vous recherchez vos réponses. Java définit des instructions séparées pour manipuler les nombres et les références. Les instructions de manipulation des références commencent par a ( astore , aload ou areturn ) et sont les seules instructions autorisées à fonctionner avec des références. En particulier, vous pourriez être intéressé par la mise en œuvre de areturn .