Java 8: Class.getName () ralentit la chaîne de concaténation des chaînes

13

Récemment, j'ai rencontré un problème concernant la concaténation de chaînes. Ce benchmark le résume:

@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class BrokenConcatenationBenchmark {

  @Benchmark
  public String slow(Data data) {
    final Class<? extends Data> clazz = data.clazz;
    return "class " + clazz.getName();
  }

  @Benchmark
  public String fast(Data data) {
    final Class<? extends Data> clazz = data.clazz;
    final String clazzName = clazz.getName();
    return "class " + clazzName;
  }

  @State(Scope.Thread)
  public static class Data {
    final Class<? extends Data> clazz = getClass();

    @Setup
    public void setup() {
      //explicitly load name via native method Class.getName0()
      clazz.getName();
    }
  }
}

Sur JDK 1.8.0_222 (OpenJDK 64-Bit Server VM, 25.222-b10), j'ai les résultats suivants:

Benchmark                                                            Mode  Cnt     Score     Error   Units
BrokenConcatenationBenchmark.fast                                    avgt   25    22,253 ±   0,962   ns/op
BrokenConcatenationBenchmark.fastgc.alloc.rate                     avgt   25  9824,603 ± 400,088  MB/sec
BrokenConcatenationBenchmark.fastgc.alloc.rate.norm                avgt   25   240,000 ±   0,001    B/op
BrokenConcatenationBenchmark.fastgc.churn.PS_Eden_Space            avgt   25  9824,162 ± 397,745  MB/sec
BrokenConcatenationBenchmark.fastgc.churn.PS_Eden_Space.norm       avgt   25   239,994 ±   0,522    B/op
BrokenConcatenationBenchmark.fastgc.churn.PS_Survivor_Space        avgt   25     0,040 ±   0,011  MB/sec
BrokenConcatenationBenchmark.fastgc.churn.PS_Survivor_Space.norm   avgt   25     0,001 ±   0,001    B/op
BrokenConcatenationBenchmark.fastgc.count                          avgt   25  3798,000            counts
BrokenConcatenationBenchmark.fastgc.time                           avgt   25  2241,000                ms

BrokenConcatenationBenchmark.slow                                    avgt   25    54,316 ±   1,340   ns/op
BrokenConcatenationBenchmark.slowgc.alloc.rate                     avgt   25  8435,703 ± 198,587  MB/sec
BrokenConcatenationBenchmark.slowgc.alloc.rate.norm                avgt   25   504,000 ±   0,001    B/op
BrokenConcatenationBenchmark.slowgc.churn.PS_Eden_Space            avgt   25  8434,983 ± 198,966  MB/sec
BrokenConcatenationBenchmark.slowgc.churn.PS_Eden_Space.norm       avgt   25   503,958 ±   1,000    B/op
BrokenConcatenationBenchmark.slowgc.churn.PS_Survivor_Space        avgt   25     0,127 ±   0,011  MB/sec
BrokenConcatenationBenchmark.slowgc.churn.PS_Survivor_Space.norm   avgt   25     0,008 ±   0,001    B/op
BrokenConcatenationBenchmark.slowgc.count                          avgt   25  3789,000            counts
BrokenConcatenationBenchmark.slowgc.time                           avgt   25  2245,000                ms

Cela ressemble à un problème similaire à JDK-8043677 , où une expression ayant un effet secondaire rompt l'optimisation de la nouvelle StringBuilder.append().append().toString()chaîne. Mais le code en Class.getName()lui-même ne semble pas avoir d'effets secondaires:

private transient String name;

public String getName() {
  String name = this.name;
  if (name == null) {
    this.name = name = this.getName0();
  }

  return name;
}

private native String getName0();

La seule chose suspecte ici est un appel à une méthode native qui ne se produit en fait qu'une seule fois et son résultat est mis en cache dans le champ de la classe. Dans mon benchmark, je l'ai explicitement mis en cache dans la méthode de configuration.

Je m'attendais à ce que le prédicteur de branche comprenne qu'à chaque invocation de référence, la valeur réelle de this.name n'est jamais nulle et optimise l'expression entière.

Cependant, alors que pour le BrokenConcatenationBenchmark.fast()j'ai ceci:

@ 19   tsypanov.strings.benchmark.concatenation.BrokenConcatenationBenchmark::fast (30 bytes)   force inline by CompileCommand
  @ 6   java.lang.Class::getName (18 bytes)   inline (hot)
    @ 14   java.lang.Class::initClassName (0 bytes)   native method
  @ 14   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)
  @ 19   java.lang.StringBuilder::append (8 bytes)   inline (hot)
  @ 23   java.lang.StringBuilder::append (8 bytes)   inline (hot)
  @ 26   java.lang.StringBuilder::toString (35 bytes)   inline (hot)

c'est-à-dire que le compilateur est capable de tout intégrer, car BrokenConcatenationBenchmark.slow()c'est différent:

@ 19   tsypanov.strings.benchmark.concatenation.BrokenConcatenationBenchmark::slow (28 bytes)   force inline by CompilerOracle
  @ 9   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)
    @ 3   java.lang.AbstractStringBuilder::<init> (12 bytes)   inline (hot)
      @ 1   java.lang.Object::<init> (1 bytes)   inline (hot)
  @ 14   java.lang.StringBuilder::append (8 bytes)   inline (hot)
    @ 2   java.lang.AbstractStringBuilder::append (50 bytes)   inline (hot)
      @ 10   java.lang.String::length (6 bytes)   inline (hot)
      @ 21   java.lang.AbstractStringBuilder::ensureCapacityInternal (27 bytes)   inline (hot)
        @ 17   java.lang.AbstractStringBuilder::newCapacity (39 bytes)   inline (hot)
        @ 20   java.util.Arrays::copyOf (19 bytes)   inline (hot)
          @ 11   java.lang.Math::min (11 bytes)   (intrinsic)
          @ 14   java.lang.System::arraycopy (0 bytes)   (intrinsic)
      @ 35   java.lang.String::getChars (62 bytes)   inline (hot)
        @ 58   java.lang.System::arraycopy (0 bytes)   (intrinsic)
  @ 18   java.lang.Class::getName (21 bytes)   inline (hot)
    @ 11   java.lang.Class::getName0 (0 bytes)   native method
  @ 21   java.lang.StringBuilder::append (8 bytes)   inline (hot)
    @ 2   java.lang.AbstractStringBuilder::append (50 bytes)   inline (hot)
      @ 10   java.lang.String::length (6 bytes)   inline (hot)
      @ 21   java.lang.AbstractStringBuilder::ensureCapacityInternal (27 bytes)   inline (hot)
        @ 17   java.lang.AbstractStringBuilder::newCapacity (39 bytes)   inline (hot)
        @ 20   java.util.Arrays::copyOf (19 bytes)   inline (hot)
          @ 11   java.lang.Math::min (11 bytes)   (intrinsic)
          @ 14   java.lang.System::arraycopy (0 bytes)   (intrinsic)
      @ 35   java.lang.String::getChars (62 bytes)   inline (hot)
        @ 58   java.lang.System::arraycopy (0 bytes)   (intrinsic)
  @ 24   java.lang.StringBuilder::toString (17 bytes)   inline (hot)

La question est donc de savoir s'il s'agit du comportement approprié de la JVM ou du bogue du compilateur?

Je pose la question parce que certains des projets utilisent toujours Java 8 et si cela ne sera pas résolu sur aucune des mises à jour de version, alors pour moi, il est raisonnable de hisser les appels vers Class.getName()manuellement depuis les points chauds.

PS Sur les derniers JDK (11, 13, 14 eap), le problème n'est pas reproduit.

Sergey Tsypanov
la source
Vous avez là un effet secondaire - l'affectation à this.name.
RealSkeptic
@RealSkeptic, l'affectation ne se produit qu'une seule fois lors de la toute première invocation de Class.getName()et dans la setUp()méthode, et non dans le corps de la référence.
Sergey Tsypanov

Réponses:

7

HotSpot JVM collecte des statistiques d'exécution par bytecode. Si le même code est exécuté dans différents contextes, le profil de résultat agrégera les statistiques de tous les contextes. Cet effet est connu sous le nom de pollution de profil .

Class.getName()est évidemment appelé non seulement à partir de votre code de référence. Avant que JIT ne commence à compiler le benchmark, il sait déjà que la condition suivante a Class.getName()été remplie plusieurs fois:

    if (name == null)
        this.name = name = getName0();

Au moins, suffisamment de fois pour traiter cette branche statistiquement importante. Ainsi, JIT n'a pas exclu cette branche de la compilation et n'a donc pas pu optimiser la concaténation des chaînes en raison d'un effet secondaire possible.

Cela n'a même pas besoin d'être un appel de méthode native. Une simple affectation régulière sur le terrain est également considérée comme un effet secondaire.

Voici un exemple de la façon dont la pollution des profils peut nuire à d'autres optimisations.

@State(Scope.Benchmark)
public class StringConcat {
    private final MyClass clazz = new MyClass();

    static class MyClass {
        private String name;

        public String getName() {
            if (name == null) name = "ZZZ";
            return name;
        }
    }

    @Param({"1", "100", "400", "1000"})
    private int pollutionCalls;

    @Setup
    public void setup() {
        for (int i = 0; i < pollutionCalls; i++) {
            new MyClass().getName();
        }
    }

    @Benchmark
    public String fast() {
        String clazzName = clazz.getName();
        return "str " + clazzName;
    }

    @Benchmark
    public String slow() {
        return "str " + clazz.getName();
    }
}

Il s'agit essentiellement de la version modifiée de votre benchmark qui simule la pollution du getName()profil. Selon le nombre d' getName()appels préliminaires sur un nouvel objet, les performances supplémentaires de la concaténation de chaînes peuvent différer considérablement:

Benchmark          (pollutionCalls)  Mode  Cnt   Score   Error  Units
StringConcat.fast                 1  avgt   15  11,458 ± 0,076  ns/op
StringConcat.fast               100  avgt   15  11,690 ± 0,222  ns/op
StringConcat.fast               400  avgt   15  12,131 ± 0,105  ns/op
StringConcat.fast              1000  avgt   15  12,194 ± 0,069  ns/op
StringConcat.slow                 1  avgt   15  11,771 ± 0,105  ns/op
StringConcat.slow               100  avgt   15  11,963 ± 0,212  ns/op
StringConcat.slow               400  avgt   15  26,104 ± 0,202  ns/op  << !
StringConcat.slow              1000  avgt   15  26,108 ± 0,436  ns/op  << !

Plus d'exemples de pollution de profil »

Je ne peux pas appeler cela un bug ou un "comportement approprié". C'est ainsi que la compilation adaptative dynamique est implémentée dans HotSpot.

apangin
la source
1
qui d'autre sinon Pangin ... savez-vous si Graal C2 a la même maladie?
Eugene
1

Légèrement sans rapport mais depuis Java 9 et JEP 280: Indiquez la concaténation de chaînes, la concaténation de chaînes est maintenant effectuée avec invokedynamicet non StringBuilder. Cet article montre les différences de bytecode entre Java 8 et Java 9.

Si le test de référence sur une nouvelle version de Java ne montre pas le problème, il n'y a probablement pas de bogue javaccar le compilateur utilise maintenant un nouveau mécanisme. Je ne sais pas si plonger dans le comportement de Java 8 est bénéfique s'il y a un tel changement substantiel dans les nouvelles versions.

Karol Dowbecki
la source
1
Je suis d'accord que c'est probablement un problème de compilation, pas un problème lié à javacbien. javacgénère du bytecode et ne fait aucune optimisation sophistiquée. J'ai exécuté le même benchmark avec -XX:TieredStopAtLevel=1et reçu cette sortie: Benchmark Mode Cnt Score Error Units BrokenConcatenationBenchmark.fast avgt 25 74,677 ? 2,961 ns/op BrokenConcatenationBenchmark.slow avgt 25 69,316 ? 1,239 ns/op Donc, lorsque nous n'optimisons pas beaucoup les deux méthodes produisent les mêmes résultats, le problème ne se révèle que lorsque le code est compilé en C2.
Sergey Tsypanov
1
est maintenant fait avec invokedynamic et non StringBuilder est tout simplement faux . invokedynamicindique seulement au runtime de choisir comment faire la concaténation, et 5 stratégies sur 6 (y compris la stratégie par défaut) utilisent toujours StringBuilder.
Eugene
@Eugene merci de l'avoir signalé. Quand vous dites des stratégies, voulez-vous dire StringConcatFactory.Strategyenum?
Karol Dowbecki
@KarolDowbecki exactement.
Eugene