Wer nicht weiß, was er misst, misst Mist!

Performanceoptimierung mit Microbenchmarks

Björn Stahl

©Shutterstock / Yuganov Konstantin

Performanceoptimierung ist ein sehr spannendes Feld in der Softwareentwicklung. Es muss nicht immer gleich bis runter zu Assembler gehen, Java selbst bietet jede Menge Möglichkeiten, Code so zu schreiben, dass er sehr performant wird.

Erst einmal soll der Frage auf den Grund gegangen werden, was nun ein Microbenchmark ist und wann sein Einsatz sinnvoll ist.

Ein Microbenchmark ist ein kleiner künstlicher Benchmark, der dazu gedacht ist, eine Methode oder einen Algorithmus zu testen und verschiedene Szenarien und/oder Implementierungen miteinander zu vergleichen. Bei einem Microbenchmark geht es darum, die Performance eines kleinen, ganz bestimmten Codeteils zu messen, im Gegensatz zu ganzen Programmen oder Projekten. Das ist vor allem dann sinnvoll, wenn einzelne Methoden oder Codeabschnitte getestet werden sollen, die auf einem kritischen Pfad liegen. Beispiele sind die Verwendung von verschiedenen Datenstrukturen, der Vergleich von Sortiermethoden und Ähnliches. Für ein paar Fingerübungen sind Fragen à la Stack Overflow „Was ist schneller, X oder Y?“ ein guter Start.

Gerade in diesen Situationen ist es unerlässlich, bestimmte Implementierungen miteinander zu vergleichen. Wenn es dabei um kleine Codeabschnitte geht, die auch noch sehr schnell durchlaufen, wird es allerdings auch schnell kompliziert. Jede Messung beeinflusst das Ergebnis, und jeder kleine Fehler verfälscht das Ergebnis massiv.

W-JAX 2019 Java-Dossier für Software-Architekten

Kostenlos: Java-Dossier für Software-Architekten 2019

Auf über 30 Seiten vermitteln Experten praktisches Know-how zu den neuen Valuetypes in Java 12, dem Einsatz von Service Meshes in Microservices-Projekten, der erfolgreichen Einführung von DevOps-Praktiken im Unternehmen und der nachhaltigen JavaScript-Entwicklung mit Angular und dem WebComponents-Standard.

 

Je kleiner die Zeiteinheiten werden, desto größer wird der störende Effekt von Umgebungsfaktoren, die sich nicht immer kontrollieren lassen. Es kann beispielsweise sein, dass die CPU gerade heruntergetaktet ist und erst auf volle Leistung umschalten muss. Im Hintergrund startet vielleicht gerade der Download eines Softwareupdates, oder der Virenscanner beginnt seine täglichen Aufgaben. Im Rahmen der Messungen sollten solche Faktoren so gut es geht ausgeschaltet werden. Sämtliche Programme und Services, die nicht absolut notwendig sind, sollten beendet werden.

Die Clubregeln des Microbenchmarks

Ein Problem mit Microbenchmarks ist die Tatsache, dass das Problem umso komplizierter wird, je mehr man sich damit auseinandersetzt und je geringer die Ausführungszeiten werden. Daher die folgenden Regeln:

  1. Traue niemals den Ergebnissen
  2. Benchmarks lügen mehr als Statistiken
  3. Überprüfe immer deine Ergebnisse

Wenn die Ergebnisse eines Microbenchmarks nicht plausibel erscheinen, sind sie es meist auch nicht. In diesem Fall sollte man nach Fehlern im Benchmark Ausschau halten; dazu gleich mehr.

Regel 2 zielt darauf ab, dass man natürlich Benchmarks in jede Richtung beeinflussen kann, ob absichtlich oder unabsichtlich. Gerade wenn jemand einfach behauptet, dass X schneller ist als Y, schaut euch den dazugehörigen Benchmark genau an und führt ihn am besten selbst aus. Wenn kein Benchmark vorhanden ist, schreibt selbst einen, wenn möglich, um die Aussagen zu überprüfen.

Schaut euch auch eure eigenen Ergebnisse genau an und stellt die Frage, ob ihr tatsächlich das Richtige getestet habt. Schaltet während des Tests möglichst alle Störfaktoren ab und lasst den Test am besten noch auf anderen Computern laufen. Der einfachste Ansatz ist der in Listing 1 dargestellte.

List<Integer> aList = new ArrayList<>();
//fill list with values
  long t1 = System.currentTimeMillis();
  Collections.sort(aList);
  long t2 = System.currentTimeMillis();
  System.out.println("Time Required using Collections.sort() "+(t2-t1)+"ms");

Das erste Argument gegen den Ansatz ist, dass der Overhead durch die Messung in Relation zu einem einzigen Methodenaufruf zu hoch ist. Selbst ein so einfacher Aufruf wie System.nanoTime() ist für eine Operation im Laufzeitbereich von 2-3 ms immer noch recht teuer. Weiterhin ist der Name irreführend, da die Präzision der Operation nicht im einstelligen Nanosekundenbereich liegt. Die beste Auflösung, die man bekommen kann, liegt um die 30 ns, und das ist auch noch abhängig vom Betriebssystem. Wichtig ist an diesem Punkt noch zu erwähnen, dass System.nanoTime() nichts mit der Systemuhrzeit zu tun hat. Diese Methode sollte man nur zur Messung von Zeitdifferenzen verwenden, nicht für einen Vergleich mit der realen Uhrzeit.

Jetzt optimieren wir den Ansatz, indem die Operation in einer Schleife viele Male durchgeführt und dann der Mittelwert errechnet wird. Hierbei gilt es zu beachten, dass die Listen vor der Schleife initialisiert werden, da wir sonst mehr messen als wir eigentlich vorhaben. Der Code ist in Listing 2 nur verkürzt dargestellt, auf GitHub findet ihr ein vollständiges Beispiel.

int loopCount = 1_000_000;
List<List<Integer>> allLists = new ArrayList<>();
//Hier müssen jetzt alle Listen erzeugt und mit Werten gefüllt werden
long start = System.nanoTime();​
for (int i = 0; i < loopCount; i++) {
  Collections.sort(allLists.get(i));
}  
long end = System.nanoTime();​
System.out.println("Duration: " + (end - start)/loopCount);

Es werden nur zwei Messpunkte genommen, das heißt, der Overhead durch die Messung ist sehr gering. Jetzt wird es richtig interessant: Warum wird die Operation plötzlich schneller? Auf meinem Testsystem ging die durchschnittliche Zeit zum Sortieren einer Liste von ~38 ms auf ~4 ms herunter. Hier kommt die erste sehr wichtige Optimierung durch die JVM zum Tragen, der JIT-Compiler. Dieser sorgt dafür, dass die Teile des Codes, die sehr häufig ausgeführt werden, auch möglichst gut optimiert werden. Hierfür greift der JIT-Compiler auf eine Vielzahl von Möglichkeiten zurück, unter anderem Loop Unrolling, Dead Code Elimination und Method Inlining. Hinzu kommen die Seiteneffekte durch die JVM. Die größten sind hier der JIT-Compiler und der Garbage Collector.

JIT-Compiler

Der JIT-Compiler optimiert den Code, während er läuft. Für die Messung ist interessant, dass sämtliche Optimierungen bereits vorgenommen wurden, bevor die Messdaten erhoben werden. Nur dann bekommen wir Daten, die auch etwas mit dem realen Anwendungsfall zu tun haben. Das Problem ist, dass die Optimierungen erst nach mehreren tausend Durchläufen abgeschlossen sind. Deshalb ist es wichtig, vor jeder Messung die JVM „aufzuwärmen“. Per Default muss ein Codestück 10 000mal aufgerufen werden, bevor die erste Optimierung durch den JIT gestartet wird. Das ist auch der Grund, warum uns im Produktivbetrieb nicht die Performance im kalten Zustand interessiert, sondern wenn die JVM komplett aufgewärmt ist.

It’s getting hot in here

Die Java Hotspot Performance Engine ist die JVM-Implementierung von Sun, die jetzt durch Oracle maintained wird. Namensgebend ist hierbei das Feature, dass bestimmte Codeteile während der Ausführung optimiert werden, der HotSpot-Compiler. Tatsächlich gibt es nicht nur einen Compiler, sondern gleich zwei, und natürlich den Interpreter. Früher konnte man die Compiler auch direkt mit den -client(C1)/-server(C2)-Schaltern auswählen. Seit Java 8 ist die „Tiered Compilation“ Standard, die Compiler kommen hier nacheinander zum Zug. Es gibt insgesamt fünf Stufen, mit denen der Code optimiert werden kann (siehe Kasten „Java-Compiler“).

Java-Compiler

  • Stufe 0: Interpreter
  • Stufe 1: C1 – einfacher Modus
  • Stufe 2: C1 – begrenzte Optimierungen (einfaches Profiling)
  • Stufe 3: C1 – volle C1-Optimierung (volles Profiling)
  • Stufe 4: C2 – volle C2-Optimierung (verwendet C1-Profilingdaten)

Der Interpreter ist der erste Compiler, der immer für die Ausführung des originären Bytecodes zum Einsatz kommt. Diese Form der Codeausführung ist die langsamste und trug unter anderem Java den schlechten Ruf als langsame Programmiersprache ein, der sich zum Teil bis heute hält.

Während der Ausführung wird das Programm kontinuierlich analysiert, und die am häufigsten ausgeführten Codeteile werden markiert. Im Hintergrund werden Counter für diese Codeteile hochgezählt, und wenn ein Grenzwert (default 10 000, konfigurierbar mit -XX:CompileThreshold=x) überschritten wurde, dann wird dieser Teil als hot markiert.

Der nächste Compiler (C1) versucht jetzt den markierten Teil zu optimieren. Die nächste Ausführung wird damit um einiges schneller sein, da die JVM sich den optimierten Code im Code Cache merkt und dieser nicht mehr interpretiert werden muss. Der C1 kommt dabei selbst in drei verschiedenen Stufen zum Einsatz, jedoch wird bis auf einige Ausnahmen eigentlich fast immer direkt der C1 mit vollem Profiling verwendet (Stufe 3).

Die nächste Stufe ist der C2-Compiler, der die aufwendigsten Optimierungsoptionen ausreizt und dabei auch die Profiling-Daten des C1 mitbenutzt. Der Grund für diese stufenweise Optimierung ist die Tatsache, dass die Optimierungen und die Kompilierung in Maschinencode Zeit kosten. Niemand will erst minutenlang auf den Programmstart warten und schon gar nicht für Optimierungen, die möglicherweise gar nicht notwendig sind. Ein sehr vereinfachtes Beispiel zu Demonstrationszwecken findet sich in Listing 3.

public static void main(String[] args) {​
      int a = 10;​
      int b = 20;​
      int c = 0;​
      for (int i = 0; i < 100; i++) {​
          c = addMe(a, b);​
      }​
      System.out.println("result is: " + c);​
}​
public static int addMe(int a, int b) {​
    return a+b;​
}

Dieser Benchmark soll die Geschwindigkeit der Addition von zwei primitiven int-Variablen messen. Würde man das mit dem hier gezeigten Microbenchmark versuchen, käme ein völlig falsches Ergebnis dabei heraus. Die Einbettung in einer einfachen main-Methode dient dazu, dass der jetzt gezeigte Output vom JIT-Compiler möglichst kurz und einfach ist. Erhöht man den Loop Counter auf 1 000 000, wird die Operation plötzlich schneller, wenn man die Zeit für die gesamte Schleife misst. Lassen wir das Programm noch einmal mit dem Schalter -XX:+PrintCompilation laufen, können wir den JIT-Compiler bei der Arbeit beobachten (Listing 4).

<task_queued compile_id='34' method='com/lucanet/training/benchmark/JitTest addMe (II)I' bytes='3' count='128' iicount='128' level='1' blocking='1' stamp='0.330' comment='tiered' hot_count='128’/>​
​
  334   35 %  b  3 com.lucanet.training.benchmark.JitTest::main @ 11 (56 bytes)​
    @ 20   com.lucanet.training.benchmark.JitTest::addMe (3 bytes)​
    @ 37  java/lang/StringBuilder::<init> (not loaded)   not inlineable​
    @ 42  java/lang/StringBuilder::append (not loaded)   not inlineable​
    @ 46  java/lang/StringBuilder::append (not loaded)   not inlineable​
    @ 49  java/lang/StringBuilder::toString (not loaded)   not inlineable​
    @ 52  java/io/PrintStream::println (not loaded)   not inlineable​
​
  356   37 %  b  4  com.lucanet.training.benchmark.JitTest::main @ 11 (56 bytes)​
    @ 20   com.lucanet.training.benchmark.JitTest::addMe (3 bytes)   inline (hot)

Hier kann man in der zweiten Zeile erkennen, dass es sich um eine Tiered Compilation handelt und der hot_count hochgezählt wird. Kurze Zeit später erkennt der Compiler, dass die Methode addMe hot ist und inlined werden sollte. Für eine genaue Erläuterung des Outputs: blog.joda.org.

Wurde die Schleife oft genug durchlaufen, erkennt der Compiler, dass sich das Ergebnis der Methode nie ändert und wird den Code entsprechend „optimieren“. Das bedeutet in diesem Fall allerdings, dass die Schleife vollständig entfernt wird. Mit ein paar Tricks kann man sich auch den resultierenden Assembly-Code ausgeben lassen. Hierfür muss man das Programm mit den JVM-Parametern -XX:+UnlockDiagnosticVMOptions und -XX:PrintAssembly starten und die Datei hsdis-amd64.dll in das lib-Verzeichnis des JRE packen.

0x00000000051291c0: sub  $0x18,%rsp​
0x00000000051291c7: mov  %rbp,0x10(%rsp)  ;*synchronization entry​
    ; - com.lucanet.training.benchmark.JitTest::addMe@-1 (line 17)​
​0x00000000051291cc: mov  $0x1e,%eax​
0x00000000051291d1: add  $0x10,%rsp​
0x00000000051291d5: pop  %rbp​
0x00000000051291d6: test   %eax,-0x5091dc(%rip)      # 0x0000000004c20000​
  ;   {poll_return}​
0x00000000051291dc: retq

In Listing 5 sieht man, dass es keine Schleife mehr gibt und einfach das Ergebnis 30 zurückgegeben wird. Verbessern kann man diesen Test, indem man die Ausgangswerte für a und b in Arrays packt und dann in der eigentlichen Messung verwendet – ungefähr wie in Listing 6 dargestellt.

public static void main(String[] args) {​
      int a = 10;​
      int b = 20;​
      int c = 0;​
      for (int i = 0; i < 100; i++) {​
        c = addMe(a, b);​
      }​
      System.out.println("result is: " + c);​
}​
public static int addMe(int a, int b) {​
    return a+b;​
}

Da die Werte sich jetzt bei jedem Schleifendurchlauf ändern, ist es dem Compiler nicht mehr möglich, die Werte zu cachen bzw. die Schleife wegzuoptimieren.

Deoptimierung

Bei den Optimierungen kann aber auch mal etwas schieflaufen. Das bedeutet nicht, dass der resultierende Code falsch ist, sondern dass Codeteile herausoptimiert wurden, die aber tatsächlich gar nicht toter Code sind. Solch eine Deoptimierung kann während Tests oder sogar im Livebetrieb der Grund sein, warum ganz plötzlich eine Methode langsamer ausgeführt wird als sonst. Verantwortlich können Eingabeparameter sein, die in der Methode einen anderen Ausführungspfad durchlaufen. Ein sehr einfaches Beispiel, wieder nur zu Demonstrationszwecken, ist in Listing 7 zu sehen.

Calculator calculator;​
  if (x == 3) {​
  calculator = new SpecialCalculator();​
  } else {​
  calculator = new StandardCalculator();​
  }​
  calculator.calculate();
  }

Diese Methode wird jetzt in einem Test mehrere tausend Male mit x!=3 aufgerufen. Der JIT-Compiler wird diesen Codeteil Stück für Stück optimieren. Als Typ für das Objekt calculator wird der StandardCalculator angenommen, die If-else-Verzweigung komplett entfernt und schließlich die Methode calculate() inlined. Wird diese Methode plötzlich mit x=3 aufgerufen, sind alle Annahmen des JIT-Compilers über die Codeausführung invalide, er wird sämtlich Optimierungen wegwerfen und wieder den originalen Bytecode interpretieren (Stufe 0). Dieser Methodenaufruf wird auf einmal sehr viel langsamer sein als alle vorherigen.

Eine Deoptimierung kann also zu einer kurzfristigen Reduzierung der Performance führen. Das klingt jetzt super dramatisch, sollte aber im realen Betrieb so gut wie nie auffallen. Der Compiler merkt sich auch solche Stellen und wird, solange die JVM läuft, die Verzweigung nicht mehr entfernen. Bei einem Microbenchmark kann dieses Verhalten jedoch zu einem völlig verzerrten Messergebnis führen. Das ist einer der Gründe, warum man sich auch für einen Microbenchmark vernünftige Testparameter überlegen sollte, damit ein solches Szenario möglichst vermieden wird.

Der Müll muss raus

Der zweite große Faktor beim Microbenchmarking ist der Garbage Collector. Es ist unbestimmt, wann er während einer Messung läuft. Wenn er anläuft, dann wird die gesamte Messung verfälscht. Der Garbage Collector wird häufig als gegeben hingenommen, aber nur wenige Entwickler wissen genauer, was er tut bzw. wie er die Performance eines Programms beeinflussen kann. Es gibt mittlerweile vier verschiedene Garbage Collectors (Serial GC, Parallel GC, (mostly) Concurrent Mark and Sweep (CMS) GC und der Garbage First Collector (G1GC)), die produktiv eingesetzt werden können. Allen gemein ist das Prinzip der Generational Garbage Collection.

Hier soll nur ein kurzer Überblick gegeben werden. Wichtig ist hier das Grundverständnis, was ein Garbage Collector ist, da dieser bei Microbenchmarks eine große (meist negative) Rolle spielen kann – vor allem, wenn er unerwartet anläuft und das Ergebnis verzerrt.

Der Serial GC ist der älteste der Garbage Collectors und arbeitet auch nur Single-threaded. Das heißt, dass bei jeder Collection die gesamte JVM steht (stop the World) und nichts mehr tut, bis die Phase beendet wurde. Der Parallel GC und der CMS GC versuchen das mehr oder weniger gut mit mehreren Threads. Das größte Problem hier ist allerdings immer noch die Garbage Collection in der Old Generation, da diese immer auch Stop-the-World-Phasen beinhalten. Der neueste Garbage Collector G1GC kann den GC auch in der Old Generation sehr viel effizienter durchführen, somit sind die Stop-the-World-Phasen sehr kurz. Die ultimative Katastrophe für die Performance einer Applikation ist allerdings immer eine Full GC. Dies ist die Ultima Ratio der JVM, wenn kein neuer zusammenhängender Speicher in der richtigen Größe allokiert werden kann. Da bei einer Full GC der gesamte Speicher durchlaufen werden muss, kann dieser Vorgang sehr lange dauern. Kurzum, der Garbage Collector kann in einem Microbenchmark die Messung ebenfalls massiv beeinflussen.

Die Rettung: JMH

Bisher haben wir nur betrachtet, was man in einem Microbenchmark alles falsch machen kann. Doch wie kann man jetzt einen brauchbaren Microbenchmark schreiben? Hier kommt uns der JMH zu Hilfe. JMH steht für Java Microbenchmarking Harness, der sich um genau die Probleme kümmert, die weiter oben beschrieben wurden.

Der JMH ist ein offizieller Bestandteil des OpenJDK, das auch eine Sammlung von Beispielen enthält, die zeigen, wie man JMH richtig verwendet. Das einfachste Set-up für einen Microbenchmark mit JMH ist ein Maven-Projekt. Sämtliche Funktionalitäten des JMH können über Annotationen in den Testcode eingefügt werden (Listing 8).

@Benchmark​
@OutputTimeUnit(TimeUnit.MILLISECONDS)​
@Fork(5)​
@Warmup(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 10, time = 10, timeUnit = TimeUnit.SECONDS)
@BenchmarkMode(Mode.AverageTime)​
public List<Integer> testSort1() {​
  Collections.sort(aList);
  return aList;
}

Die erste Annotation @Benchmark sorgt dafür, dass auch ein Benchmark für diese Methode generiert wird. Die anderen Annotationen sind optional, d. h. es werden dort einfach Standardwerte verwendet.

  • @OutputTimeUnit bestimmt, in welcher Zeiteinheit die spätere Ausgabe generiert wird.
  • @Fork gibt an, wie oft eine neue JVM gestartet wird. Hier im Beispiel werden fünf JVMs mit je dreißig Iterationen gestartet, um die Laufzeitvariationen zu reduzieren.
  • @Warmup bestimmt, wie viele Iterationen wie lange vor der Messung durchlaufen werden, um die JVM aufzuwärmen.
  • @Measurement gibt an, wie viele Iterationen in der Messung wie lange durchlaufen werden sollen.
  • @BenchmarkMode bestimmt, in welchem Modus der Benchmark durchlaufen werden soll, und Mode.AverageTime gibt an, dass von allen Durchläufen am Ende der Mittelwert verwendet werden soll.

Der Testfall hier soll zeigen, wie lange das Sortieren einer Liste dauert. Man ist nach den vorherigen Experimenten versucht, auch hier wieder eine Schleife einzubauen, damit möglichst viele Durchläufe mit wenig Overhead erzeugt werden. Das sollte man jedoch so gut es geht vermeiden, damit nicht doch noch Optimierungseffekte des JIT-Compilers greifen. Der JMH sorgt dafür, dass die Methode entsprechend den Annotationen so häufig aufgerufen wird, ohne dass es Seiteneffekte durch eine Schleife gibt. Damit der Compiler nicht doch auf die Idee kommt, dass toter Code in der Methode ist, sollte immer das Ergebnis zurückgegeben werden, auch wenn damit nichts weiter passiert.

Mit mvn clean package kann man jetzt eine .jar-Datei bauen und dann ausführen. Im Beispielprojekt wird eine benchmarks.jar-Datei gebaut, die dann im /target-Verzeichnis landet. Diese kann dann einfach mit java -jar target/benchmarks.jar ausgeführt werden. Das gekürzte JMH-Benchmark-Endergebnis sieht folgendermaßen aus:

Benchmark                       Mode  Cnt    Score   Error  Units
SortBenchmark.testSort_1  avgt   10   16,840 ± 0,781  ms/op
SortBenchmark.testSort_2  avgt   10  127,458 ± 5,268  ms/op
SortBenchmark.testSort_3  avgt   10   16,522 ± 0,358  ms/op

Die Ausgabe stammt aus einem Benchmark, bei dem drei verschiedene Methoden mit @Benchmark annotiert sind. Führt die Benchmarks am besten immer auf der Kommandozeile aus, um eventuelle Seiteneffekte aus der IDE zu vermeiden.

Der erste Microbenchmark mit JMH ist ein guter Anfang, aber noch nicht richtig gut. Wir sortieren einmal die Liste und danach immer wieder die bereits sortierte Liste. Das entspricht natürlich nicht den realen Bedingungen und verfälscht somit das Ergebnis. JMH bietet hierfür eine praktische State– und Setup-Funktion (Listing 9). Hier können Objekte, Listen etc. initialisiert werden, ohne dass sie die Messung beeinflussen.

@State(Scope.Thread)
public static class MyState {
  List<Integer> aList = new ArrayList<>(LIST_SIZE);
  List<Integer> bList = new ArrayList<>(LIST_SIZE);
  List<Integer> cList = new ArrayList<>(LIST_SIZE);
  @Setup(Level.Invocation)
  public void createLists() {
    aList.clear();
    bList.clear();
    cList.clear();
    for (int i = 0; i < LIST_SIZE; i++) {
      int randInt = RandomUtils.nextInt();
      aList.add(randInt);
      bList.add(randInt);
      cList.add(randInt);
    }
  }
}

Die @State-Annotation sorgt dafür, dass die innere statische Klasse MyState als Objekt an die Benchmarkmethoden übergeben werden kann. Der Scope bestimmt, ob alle Benchmarks auf das gleiche Objekt zugreifen oder wann ein neues Objekt erstellt wird. Scope.Thread bedeutet, dass jeder Thread, der den Benchmark ausführt, eine neue Instanz des Objekts erstellt. Bei Scope.Benchmark greifen alle Threads auf das gleiche Objekt zu. @Setup sorgt dafür, dass die Listen jedes Mal neu erzeugt werden, wenn das MyState-Objekt den Benchmarkmethoden übergeben wird. Die Signatur der Methoden ändert sich nämlich auch, sodass sie das MyState-Objekt als Argument übergeben bekommen. Dem Set-up kann auch eine Annotation mitgegeben werden, die bestimmt, wie oft die Objekte neu erzeugt werden. Level.Invocation führt für jeden Aufruf die mit @Setup annotierte Methode aus und initialisiert hier beispielsweise für jeden Aufruf die Listen neu.Level.Iteration hingegen würde die Setup-Methode nur einmal pro Iteration ausführen:

public List<Integer> testSort1(MyState myState) throws Exception {
  Collections.sort(myState.aList);
  return myState.aList;
}

Man kann auch hier wieder über entsprechende Annotationen steuern, wie oft die Setup-Methode aufgerufen werden soll, also entweder für einen ganzen Benchmark, pro Iteration oder pro direkten Aufruf (Level.Trial, Level.Iteration, Level.Invocation). Der @Fork-Annotation kann man auch noch JVM-Parameter mitgeben, z. B.:

@Fork(value = 1, jvmArgs = {"-Xms2G", "-Xmx2G"}).

Damit kann man einem Benchmark mehr Speicher geben oder auch einen anderen Garbage Collector. Der letzte Punkt wird sehr interessant, wenn man bereits mit Java 11 arbeitet. Hier können wir unser Problem mit dem Garbage Collector in Microbenchmarks noch besser angehen und den neuen EpsilonGC verwenden:

@Fork(value = 1, jvmArgs = {"-XX:+UnlockExperimentalVMOptions", "-XX:+UseEpsilonGC", "-Xmx2g"})

Der EpsilonGC ist ein GC, der nur für Tests und Benchmarks entwickelt wurde, da nur Speicher allokiert, aber nie abgeräumt wird und somit der GC-Overhead fast nicht vorhanden ist. Deshalb ist es wichtig, dass der Test genug Speicher bekommt, damit genug Platz für alle zu erzeugenden Objekte vorhanden ist. Dieser GC ist niemals im Livebetrieb einzusetzen, da die Applikation garantiert out of Memory läuft.

Fazit

Dieser Artikel hat gezeigt, was ein Microbenchmark ist, wann man ihn durchführen sollte und was die Best Practices sind. Microbenchmarks sind und bleiben auch immer nur synthetische Tests. Das Ergebnis muss sich immer noch im Produktivbetrieb beweisen. Nur dann haben wir wirklich etwas erreicht, wenn das Ergebnis sich am Ende in der Produktion messbar verbessert hat. Es kann immer noch passieren, dass die positiven Effekte durch andere Seiteneffekte außerhalb des getesteten Bereichs wieder ausradiert werden und im schlimmsten Fall die Verbesserung überhaupt nicht spürbar ist. Oder die entsprechende Methode wird so selten ausgeführt, dass sich der positive Effekt überhaupt nicht bemerkbar macht. Die Messergebnisse dürfen auch nur zwischen Microbenchmarks, die auf dem gleichen System ausgeführt wurden, verglichen werden.

Wichtig bleibt zum Schluss noch zu erwähnen, dass man sich nicht blind auf Aussagen wie „X ist schneller als Y“ verlassen sollte. Im Zweifel messt den Use Case selbst nach, und zwar auch mit den Specs des Zielsystems. Das JDK und die Bibliotheken unterliegen ständigen Änderungen, sodass sich früher einmal gültige Aussagen durchaus geändert haben können.

Verwandte Themen:

Geschrieben von
Björn Stahl
Björn Stahl
Björn Stahl ist Senior Java Developer bei der LucaNet AG und arbeitet im Team Perform. Dort kümmert er sich um die Analyse von Performanceproblemen und testet und entwickelt Tools, die die Analysen vereinfachen. Nebenbei beschäftigt er sich mit Microservices, High-Traffic-Low-Latency-Applications und Garbage Collection sowie Performancetuning.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: