web-dev-qa-db-fra.com

Pourquoi le renvoi d'une référence d'objet Java est tellement plus lent que le renvoi d'une primitive

Nous travaillons sur une application sensible à la latence et avons analysé toutes sortes de méthodes (en utilisant jmh ). Après avoir effectué une analyse comparative d'une méthode de recherche et étant 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 comparer.

Le coupable était que la méthode implémentée renvoyait un objet 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" })
    String 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 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

Le simple changement du type de retour de la fonction a modifié les performances d'un facteur de près de 3.

Je pensais que la seule différence entre le retour d'un objet enum et un entier est que l'un renvoie une valeur 64 bits (référence) et l'autre renvoie une valeur 32 bits. Un de mes collègues devinait que le retour de l'énumération ajoutait des frais supplémentaires en raison de la nécessité de suivre la référence pour un GC potentiel. (Mais étant donné que les objets 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 performances?


[~ # ~] mise à jour [~ # ~]

J'ai partagé le projet maven ici afin que n'importe qui 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. (J'ai répliqué sur 2 machines différentes, Windows 64 et Linux 64, toutes deux utilisant des versions d'Oracle Java 1.7 JVMs). @ZhekaKozlov dit qu'il n'a vu aucune différence entre les méthodes.

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

mvn clean install
Java -jar .\target\microbenchmarks.jar function.ReturnEnumObjectVersusPrimitiveBenchmark -i 5 -wi 5 -f 1
73
Sam Goldberg

TL; DR: Vous ne devez pas faire confiance à BLIND en quoi que ce soit.

Tout d'abord: il est important de vérifier les données expérimentales avant d'en tirer les conclusions. Il est étrange de prétendre que quelque chose est 3 fois plus rapide/plus lent, car vous avez vraiment besoin de suivre la raison de la différence de performance, pas seulement de faire confiance aux chiffres. Ceci est particulièrement important pour les nano-benchmarks 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 renvoyez la valeur de @Benchmark méthodes, mais pouvez-vous être raisonnablement sûr que les appelants extérieurs feront la même chose pour la primitive et la référence? Si vous vous posez cette question, vous vous rendrez compte que vous mesurez essentiellement l'infrastructure de test.

Jusqu'au point. 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

D'accord, les tests de référence apparaissent donc 3 fois plus lentement. Mais attendez, il utilise un ancien JMH (1.1.1), passons à la dernière version actuelle (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 indique également que le test est lié à l'infrastructure. D'accord, pouvons-nous voir ce qui se passe vraiment?

Si vous construisez les repères et regardez ce qui appelle exactement votre @Benchmark méthodes, vous verrez alors 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;
}

Cette l_blackhole1_1 a une méthode consume, qui "consomme" les valeurs (voir Blackhole pour la justification). Blackhole.consume a des surcharges pour références et primitives , et cela suffit à lui seul pour justifier la différence de performances.

Il y a une raison pour laquelle ces méthodes semblent 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étrique avec le JMH plus récent. 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 c'est au-delà du point ici.

Si vous avez vraiment voulez pour comprendre en quoi le retour de la primitive et/ou de la référence diffère en termes de performances, vous devrez entrer un gros gris effrayant zone de benchmark de performances nuancées. 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();
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private int doPrim() {
        return 42;
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private Object doRef() {
        return this;
    }

}

... ce 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 ci-dessus, ces tests nécessitent suivi des raisons des résultats. Dans ce cas, le code généré pour les deux est presque le même, et cela 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.

151
Aleksey Shipilev

Pour effacer l'idée fausse de référence et mémoire , certains sont tombés dans (@ Mzf), plongeons-nous dans la Java Virtual Machine Specification. Mais avant d'y aller, une chose doit être clarifiée - un objet ne peut jamais être récupéré de la mémoire, seuls ses champs peuvent . En fait, il n'y a pas d'opcode qui effectuerait une opération aussi étendue.

Ce document définit la référence comme un type de pile (afin qu'il puisse être un résultat ou un argument aux instructions effectuant des opérations sur la pile) de 1ère catégorie - la catégorie de types prenant un seul mot de pile (32 bits) . Voir tableau 2.3 A list of Java Stack Types.

De plus, si l'invocation de la méthode se termine normalement conformément à la spécification, une valeur surgie du haut de la pile est poussée sur la pile de l'appelant de la méthode (section 2.6.4).

Votre question est de savoir ce qui cause la différence de temps d'exécution. Avant-propos du chapitre 2:

Les détails d'implémentation qui ne font pas partie de la spécification de la machine virtuelle Java virtuelle limiteraient inutilement la créativité des implémenteurs. Par exemple, la disposition de la mémoire des zones de données d'exécution, l'algorithme de collecte des ordures utilisé et toute optimisation interne des instructions Java Virtual Machine (par exemple, les traduire en code machine) est laissée à la discrétion de l'implémentateur.

En d'autres termes, comme aucune pénalité de performance concernant l'utilisation de la référence n'est indiquée dans le document pour des raisons logiques (il s'agit finalement simplement d'une pile Word comme int ou float are), you ' re avec la recherche du code source de votre implémentation ou sans jamais le découvrir du tout.

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

6
user35443