Blog

String.format() 3x schneller in Java 17

23 Feb 2022

Eine der einfachsten Methoden, um komplexe Strings zu konstruieren, ist die Nutzung von String.format(). Früher war dies extrem langsam, aber in Java 17 wurde String.format() etwa dreimal so schnell. In diesem Artikel betrachten wir näher, wo die Unterschiede liegen und wie String.format() Ihnen helfen kann – also wann Sie format() statt einer einfachen String-Addition mit + benutzen sollten.

Vor ein paar Jahren haben ich und mein Freund Dmitry Vyazelenko einen Vortrag auf der JavaOne gehalten, in dem wir etwa eine Stunde lang über java.lang.String gesprochen haben. Seitdem haben wir diese Session auf der Devoxx, Geecon, Geekout, JAX, Voxxed Days, GOTO und auf unterschiedlichen JUGs überall auf der Welt vorgetragen. Wer hätte gedacht, dass man so einfach eine Stunde mit java.lang.String füllen kann?
Üblicherweise beginne ich den Vortrag mit einem Quiz. Welche der in Listing 1 gezeigten Methoden ist am schnellsten beim Anhängen von Strings?

Listing 1

public class StringAppendingQuiz {
   public String appendPlain(String question,
String answer1,
String answer2) {
   return „<h1>“ + question + „</h1><ol><li>“ + answer1 +

„</li><li>“ + answer2 + „</li></ol>“;
}

public String appendStringBuilder(String question,

String answer1,

String answer2) {

return new StringBuilder().append(„<h1>“).append(question)

.append(„</h1><ol><li>“).append(answer1)

.append(„</li><li>“).append(answer2)

.append(„</li></ol>“).toString();

}

 

public String appendStringBuilderSize(String question,

String answer1,

String answer2) {

   int len = 36 + question.length() + answer1.length() +

answer2.length();

return new StringBuilder(len).append(„<h1>“).append(question)

.append(„</h1><ol><li>“).append(answer1)

.append(„</li><li>“).append(answer2)

.append(„</li></ol>“).toString();

}

}

Das Publikum wird aufgefordert, zwischen den drei Optionen, appendPlain, appendStringBuilder und appendStringBuilderSize zu wählen. Die meisten sind zwiegespalten zwischen der Plain- und der Sized-Version. Aber es ist eine Fangfrage. Für einen so simplen Fall des Anhängens von einfachen Strings ist die Leistung gleichwertig, egal ob wir das einfache + oder den StringBuilder verwenden, mit oder ohne vordefinierte Größe. Dies ändert sich jedoch, wenn wir gemischte Typen anhängen, z. B. einige lange Werte und Strings. In diesem Fall ist der StringBuilder mit vorgegebener Größe bis Java 8 am schnellsten, und ab Java 9 ist das einfache + am schnellsten.
Im Kontrast haben wir gezeigt, dass String.format um mehrere Faktoren langsamer war. In Java 8 zum Beispiel war ein korrekt dimensionierter StringBuilder mit append 17x schneller als ein entsprechender String.format(), während in Java 11 das einfache + 39x schneller war als format(). Trotz dieser großen Unterschiede lautete unsere Empfehlung am Ende des Vortrags wie folgt:

Verketten mit String.format()
• Nutzen Sie String.format(), denn es ist einfacher zu lesen und zu pflegen.
• Wenn die Perfomance kritisch ist, zunächst + benutzen.
• In Loops weiterhin StringBuilder.append() benutzen.

In gewisser Weise war diese Empfehlung allerdings schwer zu verkaufen. Warum sollte ein Programmierer wissentlich etwas tun, das 40 Mal langsamer ist? Der Vorbehalt war, dass die Entwickler bei Oracle sich bewusst waren, dass String.format() langsam ist und an einer Verbesserung arbeiteten. Wir haben sogar eine Version von Project Amber gefunden, die den format()-Code so kompiliert hat, dass er genauso schnell war wie der einfache +-Operator.

Als Java 17 veröffentlicht wurde, beschloss ich, alle unsere Gesprächs-Benchmarks erneut durchzuführen. Anfangs schien es eine Zeitverschwendung zu sein. Schließlich waren die Benchmarks bereits abgeschlossen. Warum sie noch einmal durchführen? Zum einen wurde der Rechner, den wir ursprünglich verwendet hatten, außer Betrieb genommen, und ich wollte während des gesamten Vortrags konsistente Ergebnisse sehen, indem ich alles auf meinem Leistungstestrechner laufen ließ. Zum anderen wollte ich sehen, ob es irgendwelche Änderungen in der JVM gab, die sich auf die Ergebnisse auswirken würden. Ich hatte nicht erwartet, dass letzteres ein Faktor sein würde.

Sie können sich vorstellen, wie überrascht ich war, als ich gemerkt habe, dass String.format() sich drastisch verbessert hatte. Statt den 2170 ns/op in Java 11 brauchte es nun „nur“ 705 ns/op. Das heißt, statt 40x langsamer als das einfache + zu sein, war String.format() nur noch zwölfmal so langsam. Oder aus einer anderen Perspektive betrachtet: Java 17 String.format() ist 3x so schnell wie in Java 16.

Das ist eine hervorragende Nachricht, aber unter welchen Umständen ist es schneller? Als ich meine Entdeckung mit Dmitry Vyazelenko teilte, wies er mich auf die Arbeit von Claes Redestad in JDK-8263038 hin: „Optimize String.format for simple specifiers“ [1]. Der Code ist erhältlich in GitHub OpenJDK [2].

Claes war so freundlich, auf meine Anfrage zu antworten, und bestätigte, dass wir erwarten können, dass die Formatierung für einfache Bezeichner schneller geht – mit anderen Worten, das Prozentzeichen % gefolgt von einem einzelnen Buchstaben im Bereich „bBcCtTfdgGhHaAxXno%eEsS“. Wenn Sie weitere Formatierungen wie Breite, Genauigkeit oder Blocksatz haben, ist es nicht unbedingt schneller.

Wie funktioniert diese Magie? Jedes Mal, wenn wir z.B. String.format(„%s, %d%n“, name, age) aufrufen, muss der String „%s, %d%n“ geparst werden. Dies geschieht in der Methode java.util.Formatter#parse(), die den folgenden Regex verwendet, um die Formatierungselemente zu zerlegen:

 

// %[argument_index$][flags][width][.precision][t]conversion

private static final String formatSpecifier

    = „%(\\d+\\$)?([-#+ 0,(\\<]*)?(\\d+)?(\\.\\d+)?([tT])?([a-zA-Z%])“;

private static final Pattern fsPattern = Pattern.compile(formatSpecifier);

 

Im Pre-17-Code würde parse() immer damit anfangen, den Regex auf den Format-String anzuwenden. In Java 17 versuchen wir hingegen, den Format-String manuell zu parsen. Wenn alle FormatSpecifiers „einfach“ sind, können wir vermeiden, den Regex zu parsen. Wenn wir einen finden, der nicht einfach ist, dann fängt es von dort an zu parsen. Dies beschleunigt das Parsing um den Faktor 3 für einfache Format-Strings. Hier ist ein Testprogramm, bei dem ich die folgenden Strings parse:

 

// should be faster

„1. this does not have any percentages at all“

// should be faster

„2. this %s has only a simple field“

// might be slower

„3. this has a simple field %s and then a complex %-20s“

// no idea

„4. %s %1s %2s %3s %4s %5s %10s %22s“

 

Wir übergeben diese Strings an die private Methode Formatter#parse unter Verwendung von MethodHandles und messen, wie lange es in Java 16 und 17 dauert. Mit Java 16 haben wir auf unserem Testserver folgende Ergebnisse bekommen:

Best results:

    1. this does not have any percentages at all

   137ms

    1. this %s has only a simple field

   288ms

    1. this has a simple field %s and then a complex %-20s

   487ms

    1. %s %1s %2s %3s %4s %5s %10s %22s

   1557ms

 

Mit Java 17 erhalten wir folgende Ergebnisse:

Best results:

    1. this does not have any percentages at all

   21ms     // 6.5x faster

    1. this %s has only a simple field

   32ms     // 9x faster

    1. this has a simple field %s and then a complex %-20s

   235ms    // 2x faster

    1. %s %1s %2s %3s %4s %5s %10s %22s

   1388ms   // 1.12x faster

 

Wir können also einen großen Unterschied zwischen den Formatstrings erkennen, die einfache Felder haben, was den Großteil der Fälle beinhaltet. Gute Arbeit von Claes Redestad an der Beschleunigung dieses Prozesses! Ich werde bei meinem Ratschlag bleiben, String.format(), oder besser noch, die relativ neue formatted()-Methode zu benutzen, und es den JDK-Entwicklern zu überlassen, es für uns zu beschleunigen.
Hier ist der Testcode, falls Sie es selbst ausprobieren wollen. Wir benutzen die folgenenden JVM-Parameter: -showversion –add-opens java.base/java.util=ALL-UNNAMED -Xmx12g -Xms12g -XX:+UseParallelGC -XX:+AlwaysPreTouch-verbose:gc.

import java.lang.invoke.*;

import java.util.*;

import java.util.concurrent.*;

import java.util.concurrent.atomic.*;

// run with

// -showversion –add-opens java.base/java.util=ALL-UNNAMED

// -Xmx12g -Xms12g -XX:+UseParallelGC -XX:+AlwaysPreTouch

// -verbose:gc

public class MixedAppendParsePerformanceDemo {

  private static final Map<String, LongAccumulator> bestResults =

      new ConcurrentSkipListMap<>();

  public static void main(String… args) {

    String[] formats = {

        // should be faster

        „1. this does not have any percentages at all“,

        // should be faster

        „2. this %s has only a simple field“,

        // might be slower

        „3. this has a simple field %s and then a complex %-20s“,

        // no idea

        „4. %s %1s %2s %3s %4s %5s %10s %22s“,

    };

    System.out.println(„Warmup:“);

    run(formats, 5);

    System.out.println();

    bestResults.clear();

    System.out.println(„Run:“);

    run(formats, 10);

    System.out.println();

    System.out.println(„Best results:“);

    bestResults.forEach((format, best) ->

        System.out.printf(„%s%n\t%dms%n“, format,

            best.longValue()));

  }

  private static void run(String[] formats, int runs) {

    for (int i = 0; i < runs; i++) {

      for (String format : formats) {

        Formatter formatter = new Formatter();

        test(formatter, format);

      }

      System.gc();

      System.out.println();

    }

  }

  private static void test(Formatter formatter, String format) {

    System.out.println(format);

    long time = System.nanoTime();

    try {

      for (int i = 0; i < 1_000_000; i++) {

        parseMH.invoke(formatter, format);

      }

    } catch (Throwable throwable) {

      throw new AssertionError(throwable);

    } finally {

      time = System.nanoTime() – time;

      bestResults.computeIfAbsent(format, key ->

              new LongAccumulator(Long::min, Long.MAX_VALUE))

          .accumulate(time / 1_000_000);

      System.out.printf(„\t%dms%n“, (time / 1_000_000));

    }

  }

  private static final MethodHandle parseMH;

  static {

    try {

      parseMH = MethodHandles.privateLookupIn(Formatter.class,

              MethodHandles.lookup())

          .findVirtual(Formatter.class, „parse“,

              MethodType.methodType(List.class, String.class));

    } catch (ReflectiveOperationException e) {

      throw new Error(e);

    }

  }

}

Weitere gute Nachrichten über Leistungsverbesserungen in Java 17 sind zu erwarten.

Links und Literatur
[1] https://bugs.java.com/bugdatabase/view_bug.do?bug_id=8263038
[2] https://github.com/openjdk/jdk/commit/f71b21b0e7f29c59de36fc013bfee7cda3815274

Keine Infos mehr verpassen!