Warum ist StringBuilder # append (int) in Java 7 schneller als in Java 8?

76

Während ich nach einer kleinen Debatte über die Verwendung "" + nund Integer.toString(int)Konvertierung eines ganzzahligen Grundelements in einen String suchte, schrieb ich dieses JMH- Mikrobenchmark:

@Fork(1)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class IntStr {
    protected int counter;


    @GenerateMicroBenchmark
    public String integerToString() {
        return Integer.toString(this.counter++);
    }

    @GenerateMicroBenchmark
    public String stringBuilder0() {
        return new StringBuilder().append(this.counter++).toString();
    }

    @GenerateMicroBenchmark
    public String stringBuilder1() {
        return new StringBuilder().append("").append(this.counter++).toString();
    }

    @GenerateMicroBenchmark
    public String stringBuilder2() {
        return new StringBuilder().append("").append(Integer.toString(this.counter++)).toString();
    }

    @GenerateMicroBenchmark
    public String stringFormat() {
        return String.format("%d", this.counter++);
    }

    @Setup(Level.Iteration)
    public void prepareIteration() {
        this.counter = 0;
    }
}

Ich habe es mit den Standard-JMH-Optionen für beide Java-VMs ausgeführt, die auf meinem Linux-Computer vorhanden sind (aktuelle Mageia 4 64-Bit, Intel i7-3770-CPU, 32 GB RAM). Die erste JVM war die mit Oracle JDK 8u5 64-Bit gelieferte:

java version "1.8.0_05"
Java(TM) SE Runtime Environment (build 1.8.0_05-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.5-b02, mixed mode)

Mit dieser JVM habe ich ziemlich genau das bekommen, was ich erwartet hatte:

Benchmark                    Mode   Samples         Mean   Mean error    Units
b.IntStr.integerToString    thrpt        20    32317.048      698.703   ops/ms
b.IntStr.stringBuilder0     thrpt        20    28129.499      421.520   ops/ms
b.IntStr.stringBuilder1     thrpt        20    28106.692     1117.958   ops/ms
b.IntStr.stringBuilder2     thrpt        20    20066.939     1052.937   ops/ms
b.IntStr.stringFormat       thrpt        20     2346.452       37.422   ops/ms

Das heißt, die Verwendung der StringBuilderKlasse ist langsamer, da das Erstellen des StringBuilderObjekts und das Anhängen einer leeren Zeichenfolge zusätzlichen Aufwand bedeutet . Die Verwendung String.format(String, ...)ist sogar um eine Größenordnung langsamer.

Der von der Distribution bereitgestellte Compiler basiert dagegen auf OpenJDK 1.7:

java version "1.7.0_55"
OpenJDK Runtime Environment (mageia-2.4.7.1.mga4-x86_64 u55-b13)
OpenJDK 64-Bit Server VM (build 24.51-b03, mixed mode)

Die Ergebnisse hier waren interessant :

Benchmark                    Mode   Samples         Mean   Mean error    Units
b.IntStr.integerToString    thrpt        20    31249.306      881.125   ops/ms
b.IntStr.stringBuilder0     thrpt        20    39486.857      663.766   ops/ms
b.IntStr.stringBuilder1     thrpt        20    41072.058      484.353   ops/ms
b.IntStr.stringBuilder2     thrpt        20    20513.913      466.130   ops/ms
b.IntStr.stringFormat       thrpt        20     2068.471       44.964   ops/ms

Warum StringBuilder.append(int)erscheint diese JVM so viel schneller? Ein Blick auf den StringBuilderKlassenquellcode ergab nichts besonders Interessantes - die fragliche Methode ist fast identisch mit Integer#toString(int). Interessanterweise scheint das Anhängen des Ergebnisses von Integer.toString(int)(der stringBuilder2Mikrobenchmark) nicht schneller zu sein.

Ist diese Leistungsdiskrepanz ein Problem mit dem Testkabel? Oder enthält meine OpenJDK-JVM Optimierungen, die sich auf dieses bestimmte Code- (Anti-) Muster auswirken würden?

BEARBEITEN:

Für einen einfacheren Vergleich habe ich Oracle JDK 1.7u55 installiert:

java version "1.7.0_55"
Java(TM) SE Runtime Environment (build 1.7.0_55-b13)
Java HotSpot(TM) 64-Bit Server VM (build 24.55-b03, mixed mode)

Die Ergebnisse ähneln denen von OpenJDK:

Benchmark                    Mode   Samples         Mean   Mean error    Units
b.IntStr.integerToString    thrpt        20    32502.493      501.928   ops/ms
b.IntStr.stringBuilder0     thrpt        20    39592.174      428.967   ops/ms
b.IntStr.stringBuilder1     thrpt        20    40978.633      544.236   ops/ms

Es scheint, dass dies ein allgemeineres Problem zwischen Java 7 und Java 8 ist. Vielleicht hatte Java 7 aggressivere String-Optimierungen?

EDIT 2 :

Der Vollständigkeit halber sind hier die stringbezogenen VM-Optionen für diese beiden JVMs aufgeführt:

Für Oracle JDK 8u5:

$ /usr/java/default/bin/java -XX:+PrintFlagsFinal 2>/dev/null | grep String
     bool OptimizeStringConcat                      = true            {C2 product}
     intx PerfMaxStringConstLength                  = 1024            {product}
     bool PrintStringTableStatistics                = false           {product}
    uintx StringTableSize                           = 60013           {product}

Für OpenJDK 1.7:

$ java -XX:+PrintFlagsFinal 2>/dev/null | grep String
     bool OptimizeStringConcat                      = true            {C2 product}        
     intx PerfMaxStringConstLength                  = 1024            {product}           
     bool PrintStringTableStatistics                = false           {product}           
    uintx StringTableSize                           = 60013           {product}           
     bool UseStringCache                            = false           {product}   

Die UseStringCacheOption wurde in Java 8 ohne Ersatz entfernt, daher bezweifle ich, dass dies einen Unterschied macht. Die restlichen Optionen scheinen dieselben Einstellungen zu haben.

EDIT 3:

Ein Side-by-Side - Vergleich des Quellcodes von den AbstractStringBuilder, StringBuilderund IntegerKlassen aus der src.zipDatei verrät nichts noteworty. Abgesehen von vielen Änderungen an Kosmetik und Dokumentation, unterstützt es Integerjetzt einige vorzeichenlose Ganzzahlen und StringBuilderwurde leicht überarbeitet, um mehr Code mit anderen zu teilen StringBuffer. Keine dieser Änderungen scheint sich auf die von verwendeten Codepfade auszuwirken StringBuilder#append(int), obwohl ich möglicherweise etwas übersehen habe.

Ein Vergleich des für IntStr#integerToString()und generierten Assembler-Codes IntStr#stringBuilder0()ist weitaus interessanter. Das Grundlayout des Codes, für den generiert wurde, IntStr#integerToString()war für beide JVMs ähnlich, obwohl Oracle JDK 8u5 aggressiver zu sein schien, wenn einige Aufrufe innerhalb des Integer#toString(int)Codes eingefügt wurden . Es gab eine klare Übereinstimmung mit dem Java-Quellcode, selbst für jemanden mit minimaler Montageerfahrung.

Der Assembler-Code für IntStr#stringBuilder0()war jedoch radikal anders. Der von Oracle JDK 8u5 generierte Code stand erneut in direktem Zusammenhang mit dem Java-Quellcode - ich konnte das gleiche Layout leicht erkennen. Im Gegenteil, der von OpenJDK 7 generierte Code war für das ungeübte Auge (wie meins) fast nicht wiederzuerkennen. Der new StringBuilder()Aufruf wurde anscheinend entfernt, ebenso wie die Erstellung des Arrays im StringBuilderKonstruktor. Außerdem konnte das Disassembler-Plugin nicht so viele Verweise auf den Quellcode bereitstellen wie in JDK 8.

Ich gehe davon aus, dass dies entweder das Ergebnis eines viel aggressiveren Optimierungsdurchlaufs in OpenJDK 7 ist oder eher das Ergebnis des Einfügens von handgeschriebenem Low-Level-Code für bestimmte StringBuilderOperationen. Ich bin mir nicht sicher, warum diese Optimierung in meiner JVM 8-Implementierung nicht auftritt oder warum dieselben Optimierungen Integer#toString(int)in JVM 7 nicht implementiert wurden . Ich denke, jemand, der mit den verwandten Teilen des JRE-Quellcodes vertraut ist, müsste diese Fragen beantworten ...

thkala
quelle
Meinten Sie nicht: new StringBuilder().append(this.counter++).toString();und einen dritten Test mit return "" + this.counter++;?
Assylias
4
@assylias: Die stringBuilderMethode übersetzt in genau den gleichen Bytecode wie return "" + this.counter++;. Ich werde sehen, wie man einen dritten Test hinzufügt, ohne die leere Zeichenfolge
anzuhängen
@assylias: los geht's. Kein wirklicher Unterschied, den ich sehen kann ...
Thkala
können Sie einen Test für hinzufügen String.format("%d",n);und
1
@ JarrodRoberson: Wie wäre es damit? String.format("%d",n)ist ungefähr eine Größenordnung langsamer als alles ...
thkala

Antworten:

97

TL; DR: Nebenwirkungen bei appendanscheinend defekten StringConcat-Optimierungen.

Sehr gute Analyse in der ursprünglichen Frage und Updates!

Der Vollständigkeit halber sind nachfolgend einige fehlende Schritte aufgeführt:

  • Sehen Sie sich das -XX:+PrintInliningfür 7u55 und 8u5 an. In 7u55 sehen Sie ungefähr Folgendes:

     @ 16   org.sample.IntStr::inlineSideEffect (25 bytes)   force inline by CompilerOracle
       @ 4   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)
       @ 18   java.lang.StringBuilder::append (8 bytes)   already compiled into a big method
       @ 21   java.lang.StringBuilder::toString (17 bytes)   inline (hot)
    

    ... und in 8u5:

     @ 16   org.sample.IntStr::inlineSideEffect (25 bytes)   force inline by CompilerOracle
       @ 4   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)
       @ 18   java.lang.StringBuilder::append (8 bytes)   inline (hot)
         @ 2   java.lang.AbstractStringBuilder::append (62 bytes)   already compiled into a big method
       @ 21   java.lang.StringBuilder::toString (17 bytes)   inline (hot)
         @ 13   java.lang.String::<init> (62 bytes)   inline (hot)
           @ 1   java.lang.Object::<init> (1 bytes)   inline (hot)
           @ 55   java.util.Arrays::copyOfRange (63 bytes)   inline (hot)
             @ 54   java.lang.Math::min (11 bytes)   (intrinsic)
             @ 57   java.lang.System::arraycopy (0 bytes)   (intrinsic)
    

    Möglicherweise stellen Sie fest, dass die 7u55-Version flacher ist und nach StringBuilderMethoden nichts aufgerufen wird. Dies ist ein guter Hinweis darauf, dass die Zeichenfolgenoptimierungen wirksam sind. Wenn Sie 7u55 mit ausführen -XX:-OptimizeStringConcat, werden die Unteraufrufe erneut angezeigt und die Leistung sinkt auf 8u5.

  • OK, also müssen wir herausfinden, warum 8u5 nicht die gleiche Optimierung durchführt. Suchen Sie unter http://hg.openjdk.java.net/jdk9/jdk9/hotspot nach "StringBuilder", um herauszufinden, wo VM die StringConcat-Optimierung übernimmt. das wird dich dazu bringensrc/share/vm/opto/stringopts.cpp

  • hg log src/share/vm/opto/stringopts.cppum die neuesten Änderungen dort herauszufinden. Einer der Kandidaten wäre:

    changeset:   5493:90abdd727e64
    user:        iveresov
    date:        Wed Oct 16 11:13:15 2013 -0700
    summary:     8009303: Tiered: incorrect results in VM tests stringconcat...
    
  • Suchen Sie nach den Überprüfungsthreads in OpenJDK-Mailinglisten (einfach genug, um eine Zusammenfassung der Änderungssätze zu googeln): http://mail.openjdk.java.net/pipermail/hotspot-compiler-dev/2013-October/012084.html

  • Spot "Die Optimierung der String-Concat-Optimierung reduziert das Muster [...] in eine einzelne Zuordnung eines Strings und bildet das Ergebnis direkt. Alle möglichen Deopts, die im optimierten Code auftreten können, starten dieses Muster von Anfang an neu (beginnend mit der StringBuffer-Zuordnung). . das bedeutet , dass das gesamte Muster muss ich Nebeneffekt frei. "Eureka?

  • Schreiben Sie den kontrastierenden Benchmark auf:

    @Fork(5)
    @Warmup(iterations = 5)
    @Measurement(iterations = 5)
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @State(Scope.Benchmark)
    public class IntStr {
        private int counter;
    
        @GenerateMicroBenchmark
        public String inlineSideEffect() {
            return new StringBuilder().append(counter++).toString();
        }
    
        @GenerateMicroBenchmark
        public String spliceSideEffect() {
            int cnt = counter++;
            return new StringBuilder().append(cnt).toString();
        }
    }
    
  • Messen Sie es auf JDK 7u55 und sehen Sie die gleiche Leistung für inline / gespleißte Nebenwirkungen:

    Benchmark                       Mode   Samples         Mean   Mean error    Units
    o.s.IntStr.inlineSideEffect     avgt        25       65.460        1.747    ns/op
    o.s.IntStr.spliceSideEffect     avgt        25       64.414        1.323    ns/op
    
  • Messen Sie es auf JDK 8u5 und sehen Sie den Leistungsabfall mit dem Inline-Effekt:

    Benchmark                       Mode   Samples         Mean   Mean error    Units
    o.s.IntStr.inlineSideEffect     avgt        25       84.953        2.274    ns/op
    o.s.IntStr.spliceSideEffect     avgt        25       65.386        1.194    ns/op
    
  • Senden Sie den Fehlerbericht ( https://bugs.openjdk.java.net/browse/JDK-8043677 ), um dieses Verhalten mit VM-Mitarbeitern zu besprechen. Die Begründung für die ursprüngliche Korrektur ist absolut solide. Es ist jedoch interessant, ob wir diese Optimierung in einigen trivialen Fällen wie diesen zurückerhalten können / sollten.

  • ???

  • PROFITIEREN.

Und ja, ich sollte die Ergebnisse für den Benchmark veröffentlichen, der das Inkrement aus der StringBuilderKette verschiebt und dies vor der gesamten Kette tut. Auch auf Durchschnittszeit und ns / op umgeschaltet. Dies ist JDK 7u55:

Benchmark                      Mode   Samples         Mean   Mean error    Units
o.s.IntStr.integerToString     avgt        25      153.805        1.093    ns/op
o.s.IntStr.stringBuilder0      avgt        25      128.284        6.797    ns/op
o.s.IntStr.stringBuilder1      avgt        25      131.524        3.116    ns/op
o.s.IntStr.stringBuilder2      avgt        25      254.384        9.204    ns/op
o.s.IntStr.stringFormat        avgt        25     2302.501      103.032    ns/op

Und das ist 8u5:

Benchmark                      Mode   Samples         Mean   Mean error    Units
o.s.IntStr.integerToString     avgt        25      153.032        3.295    ns/op
o.s.IntStr.stringBuilder0      avgt        25      127.796        1.158    ns/op
o.s.IntStr.stringBuilder1      avgt        25      131.585        1.137    ns/op
o.s.IntStr.stringBuilder2      avgt        25      250.980        2.773    ns/op
o.s.IntStr.stringFormat        avgt        25     2123.706       25.105    ns/op

stringFormatist in 8u5 tatsächlich etwas schneller und alle anderen Tests sind gleich. Dies bestätigt die Hypothese, dass der Nebenwirkungsbruch in SB-Ketten der Hauptschuldige in der ursprünglichen Frage ist.

Aleksey Shipilev
quelle
1
Sehr schön gemacht! Dies ist ein subtiles kleines Problem, das nicht ganz das ist, was die meisten Java-Programmierer erwarten. Ich hatte ein paar Referenzen für String-Optimierungen mit Korrektheitsproblemen gefunden, also hatte ich meinen Verdacht, aber ich hatte nicht die Zeit, ihn festzuhalten. Ich schätze auch den Fehlerbericht, auch wenn er nirgendwo hinkommt.
Thkala
1
Oh, ich habe Ihre Ergebnisse auch bestätigt, indem ich das Zählerinkrement vor den StringBuilderAufrufen und dem Benchmarking verschoben habe. Ich frage mich, welche anderen kleinen Edelsteine ​​dieser Art es vielleicht gibt ...
thkala
5

Ich denke, das hat mit dem CompileThresholdFlag zu tun, das steuert, wann der Bytecode von JIT in Maschinencode kompiliert wird.

Das Oracle JDK hat eine Standardanzahl von 10.000 als Dokument unter http://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html .

Wo OpenJDK konnte ich kein aktuelles Dokument auf dieser Flagge finden; Einige Mail-Threads schlagen jedoch einen viel niedrigeren Schwellenwert vor: http://mail.openjdk.java.net/pipermail/hotspot-compiler-dev/2010-November/004239.html

Versuchen Sie auch, die Oracle JDK-Flags wie -XX:+UseCompressedStringsund ein- und auszuschalten -XX:+OptimizeStringConcat. Ich bin mir nicht sicher, ob diese Flags in OpenJDK standardmäßig aktiviert sind. Könnte jemand bitte vorschlagen.

Ein Experiment, das Sie durchführen können, besteht darin, das Programm zunächst häufig auszuführen, z. B. 30.000 Schleifen, ein System.gc () auszuführen und dann zu versuchen, die Leistung zu überprüfen. Ich glaube, sie würden das gleiche ergeben.

Und ich gehe davon aus, dass Ihre GC-Einstellung auch dieselbe ist. Andernfalls weisen Sie viele Objekte zu, und der GC ist möglicherweise der Hauptteil Ihrer Laufzeit.

Alex Suo
quelle
6
JMH führt standardmäßig 20 Aufwärmiterationen durch, von denen jede mehrere Millionen Aufrufe für die Mikrobenchmark-Methoden in diesem Fall enthält. Theoretisch CompileThreshold sollte keine große Wirkung haben ...
Thkala
@thkala Ich frage mich, was das Ergebnis ist, wenn das OP hier versucht, sich aufzuwärmen. Aber ich stimme Ihnen zu, dass sein Code für einen großen Raum der Verbesserung zu einfach ist. Außerdem ersetzt ein JDK den allgemeinen Kernleistungscode, dh diejenigen mit Zeichenfolgenoperationen, durch systemeigenen Code. Ich bin mir jedoch nicht sicher über die Implementierung von OpenJDK.
Alex Suo
Sorry, habe gerade gemerkt, dass du der OP bist :)
Alex Suo
Es scheint, dass dies eher ein Java7 / Java8-Problem als ein OpenJDK / HotSpot-Problem ist - ich habe einen Benchmark für Oracle JDK 7u55 hinzugefügt ...
thkala
Es scheint, dass die Zeichenfolgen-bezogenen VM-Optionen in beiden Versionen gleich sind. Das heißt, Java 8 hat einen anderen GC-Mechanismus ...
thkala