JMH: Microbenchmarking auf der Java Virtual Machine

Werkzeuge  –  4 Kommentare

Es ist schwierig, aussagekräftige Benchmarks auf der Java Virtual Machine zu schreiben. Neben einem guten Verständnis der JVM-Arbeitsweise sind Kenntnisse der Maschinenarchitektur und des Betriebssystems notwendig. Um das Erstellen korrekter Microbenchmarks zu vereinfachen, entstand der Java Microbenchmarking Harness.

Es gibt verschiedene Methoden zum Ermitteln der Performance von Software, zum Beispiel Lasttests, Profiler-Analysen oder Benchmarks. Letztere sind keine Erfindung von Software-Ingenieuren; sie werden zum Beispiel auch in der Finanzwelt oder bei der Unternehmensbewertung verwendet. Ein Benchmark ermittelt eine Metrik, zum Beispiel "Anzahl an Requests pro Sekunde" für mehrere Testkandidaten unter vergleichbaren Bedingungen, um deren relative Performance einzuschätzen. In der Softwareentwicklung unterscheidet man zwei Arten:

  • Macrobenchmarks: Sie vergleichen das gesamte System beziehungsweise dessen Konfigurationen, etwa die Performance der Anwendungsserver Glassfish und JBoss in einem bestimmten Szenario. Manche wie SPECjbb2013 sind standardisiert, um eine hohe Aussagekraft für verschiedene Szenarien zu haben. Da bei Macrobenchmarks komplette Systeme getestet werden, ist sowohl die Konfiguration als auch die Ausführung relativ zeitaufwendig. Macrobenchmarks haben üblicherweise eine Laufzeit von mehreren Minuten oder deutlich länger.
  • Microbenchmarks: Sie vergleichen einzelne Komponenten, beispielsweise Implementierungen von Serialisierungsalgorithmen oder JSON-Parsern. Im Gegensatz zu Macrobenchmarks haben sie eine kürzere Laufzeit; außerdem vermeidet man im Allgemeinen I/O-Operationen.

In Java ließe sich ein Microbenchmark für drei verschiedene Set-Implementierungen folgendermaßen implementieren:

public class FlawedSetMicroBenchmark {
private static final int MAX_ELEMENTS = 10_000_000;

public static void main(String[] args) {
List<? extends Set<Integer>> testees =
Arrays.asList(
new HashSet<Integer>(),
new TreeSet<Integer>(),
new ConcurrentSkipListSet<Integer>());
for (Set<Integer> testee : testees) {
doBenchmark(testee);
}

}

private static void doBenchmark(Set<Integer> testee) {
long start = System.currentTimeMillis();
for (int i = 0; i < MAX_ELEMENTS; i++) {
testee.add(i);
}
long end = System.currentTimeMillis();

System.out.printf("%s took %d ms%n",
testee.getClass(),
(end - start));
}
}

Bei diesem scheinbar einfachen Benchmark zeigen sich die Tücken leider erst bei näherer Betrachtung. Um die tatsächliche Performance des Kandidaten zu messen, müssen Programmierer Engpässe oder andere störende Einflüsse vermeiden. Dabei sind einige Punkte zu beachten:

  • Ungenauigkeit der Zeitmessung: In Java gibt es zwei Methoden, um Zeitpunkte zu ermitteln – System.currentTimeMillis() und System.nanoTime(). Beide Methoden haben ihre Tücken bei der Messung kurzer Laufzeiten. Erstere hat je nach Betriebssystemversion eine Auflösung von etwa 15 ms Timerauflösung in Windows, bei älteren Betriebssystemen auch mehr. Selbst wenn sich Entwickler mit System.nanoTime() in Sicherheit wähnen, sollten sie einen Blick in die OpenJDK-Implementierung wagen. Je nach Hardware und Betriebssystem kann System.nanoTime() dieselbe Auflösung wie System.currentTimeMillis() haben. Unabhängig von der Messmethode hat ein Benchmark keine Aussagekraft, wenn er nur für wenige Millisekunden ausgeführt wird.
  • hohe Grundsystemlast: Es ist eine schlechte Idee, einen Benchmark im Hintergrund auszuführen, während man ein Projekt kompiliert oder ein Systemupdate durchführt. Das System sollte außer dem Benchmark keine nennenswerte Systemlast aufweisen.
  • CPU-Typ: Sogenannte Multithreaded Benchmarks sollten auf einer Multicore-CPU ausgeführt werden. Andernfalls könnten viele Probleme gar nicht auftreten.
  • Energiespareinstellungen des Betriebssystems: Beim Durchführen eines Benchmarks auf einem Notebook im Akkubetrieb kann das Betriebssystem die Taktgeschwindigkeit des Prozessors verringern, was sich auf die Benchmark-Ergebnisse auswirkt.

Bei Benchmarks ist zudem die Programmiersprache beziehungsweise die zugehörige Laufzeitumgebung nicht außer Acht zu lassen. In Sprachen wie C übersetzt ein Compiler den Programmcode in Maschinencode, der sich zur Laufzeit nicht mehr ändert. Java-Programme führt die JVM aus, die aus drei Komponenten besteht: Runtime inklusive Interpreter, JIT-Compiler (Just in Time) und Garbage Collector. Ihr Zusammenspiel sorgt dafür, dass bei Microbenchmarks auf der JVM noch weitere Punkte zu beachten sind. Nachfolgend werden drei häufige Probleme selbst implementierter Microbenchmarks besprochen, die auch der gezeigte Microbenchmark FlawedSetMicroBenchmark aufweist.