Blog

Extreme Java: String-Komprimierung in Java 9

2 Feb 2018

Beitragsbild Extreme Java String-Komprimierung

Im dritten Teil der Reihe Extreme Java geht Dr. Heinz Kabutz auf die neue String-Komprimierung in Java 9 ein. In Java 6 wurde die Funktion eingeführt, ASCII-Zeichen mithilfe von byte[] statt char[] zu speichern. Dieses Feature wurde in Java 7 abgeschafft, kommt nun aber mit Version 9 wieder zurück. Allerdings ist diesmal eine Compaction-Funktion von vornherein verfügbar und byte[] wird immer verwendet.

String-Komprimierung in Java 9

Derzeit bin ich damit beschäftigt, all die coolen neuen Features aus Java 8 und 9 in meinen Kurs über Java Design Patterns einzuarbeiten. Normalerweise betrachte ich mit meinen Seminarteilnehmern in diesem Kurs die zusammenhängenden Elemente aller Arten von Java-Themen, sodass die Teilnehmer viel mehr lernen, als sie es aus einem einzelnen Buch könnten. Sie sehen, wo im JDK Patterns benutzt werden. Sie lernen gute Designrichtlinien. Ich zeige ihnen die neuesten Funktionen aus Java 8, auch wenn sie noch an JDK 6 oder 7 gebunden sind. Wir gehen sogar kurz auf Threading ein. Es ist so ein Kurs, zu dem eine Firma einmal einige Mitarbeiter schickt, und dann nicht mehr aufhört, neue zu schicken. Genau deshalb ist er auch sechzehn Jahre, nachdem ich den ersten Entwurf geschrieben habe, immer noch so beliebt bei Unternehmen.

Das Flyweight Pattern, das aus einer eher befremdlich anmutenden Zusammenstellung von Klassen besteht, ist eins der besonders seltsamen Patterns. Es ist nicht wirklich ein Design Pattern, sondern eher, wie das Facade Pattern, ein notwendiges Übel, das man gerade wegen Design Patterns braucht. Lassen Sie mich das erklären. Gutes objektorientiertes Design führt zu hoch konfigurierbaren Systemen, die doppelten Code minimieren. Das ist gut, heißt aber auch, dass es manchmal sehr viel Arbeit braucht, nur um das System überhaupt zu nutzen. Facade hilft dabei, ein komplexes Subsystem leichter nutzbar zu machen. Warum ist es überhaupt so komplex? Normalerweise deshalb, weil wir zu viele Möglichkeiten haben, wie wir es benutzen können – dank einer großzügigen Verwendung von Design Patterns. Flyweight hat eine ähnliche Daseinsberechtigung. Gute objektorientierte Designs haben üblicherweise mehr Objekte als schlechte monolithische Designs, in denen alles ein Singleton ist. Das Flyweight Pattern versucht die Anzahl von Objekten zu verringern, indem es diese mehrfach nutzbar macht, was dann sehr gut funktioniert, wenn wir den intrinsischen Zustand stattdessen extrinsisch definieren.

Ich habe mit String-Deduplikation experimentiert, also das char[] innerhalb des Strings gegen ein gemeinsam genutztes char[] ausgetauscht, wenn mehrere Strings die gleichen Werte enthalten. Dazu muss man den G1 Garbage Collector benutzen (-XX:+UseG1GC) und die String-Deduplikation aktivieren (-XX:+UseStringDediplication). Das funktionierte in Java 8 ganz wunderbar. Ich wollte überprüfen, ob all das in Java 9 schon standardmäßig verfügbar ist, auch da ich weiß, dass der G1 Collector jetzt der Standard-Collector ist. Aber natürlich führte es zu einer ClassCastException, als ich versuchte, die Werte im String auf das char[] zu übertragen.

In Java 6 gab es ab einem bestimmten Zeitpunkt komprimierte Strings, diese waren als Voreinstellung deaktiviert und konnten mit -XX:+UseCompressedStrings aktiviert werden. Wenn sie aktiv waren, wurden Strings, die nur ASCII-Zeichen (7 Bit) enthielten, automatisch so angepasst, dass sie byte[] verwendeten. Sobald man ein Zeichen benutzte, das größer als 7 Bit ist, wechselte es wieder zu char[]. Wirklich interessant wurde es mit UTF-16-Zeichen, z. B. für Devanagari Hindi. Man erhielt eine größere Anzahl an Objekten als ohne komprimierte Strings, weil zusätzliche Objekte erstellt wurden. Aber mit (US-)ASCII klappte alles wunderbar. Diese Java-6-Funktion wurde, aus welchen Gründen auch immer, in Java 7 nicht mehr unterstützt und schließlich in Java 8 ganz gestrichen.

In Java 9 wiederum wurde ein neues Flag eingeführt, -XX:+CompactStrings, das standardmäßig aktiviert ist. Wenn man sich die String-Klasse anschaut, fällt auf, dass die Zeichen eines Strings immer in einem byte[]und die Kodierung zusätzlich in einem neuen Byte-Feld gespeichert werden, derzeit ist das entweder Latin1 (0) oder UTF-16 (1). Es sind theoretisch auch andere Kodierungen in der Zukunft denkbar, aber aktuell benötigt ein String, der nur aus Latin1-Zeichen besteht am wenigsten Speicher.

Um das auszuprobieren und die Unterschiede deutlich zu machen, habe ich ein kleines Java-Programm geschrieben, das unter Java 6, 7 und 9 läuft (Listing 1).

Listing 1: Beispielprogramm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.lang.reflect.*;
public class StringCompactionTest {
  private static Field valueField;
  static {
    try {
      valueField = String.class.getDeclaredField("value");
      valueField.setAccessible(true);
    } catch (NoSuchFieldException e) {
      throw new ExceptionInInitializerError(e);
    }
  }
  public static void main(String... args)
    throws IllegalAccessException {
    showGoryDetails("hello world");
    showGoryDetails("hello w\u00f8rld"); // Scandinavian o
    showGoryDetails("he\u03bb\u03bbo wor\u03bbd"); // Greek l
  }
  private static void showGoryDetails(String s)
    throws IllegalAccessException {
    s = "" + s;
    System.out.printf("Details of String \"%s\"\n", s);
    System.out.printf("Identity Hash of String: 0x%x%n", System.identityHashCode(s));
    Object value = valueField.get(s);
    System.out.println("Type of value field: " + value.getClass().getSimpleName());
    System.out.println("Length of value field: " + Array.getLength(value));
    System.out.printf("Identity Hash of value: 0x%x%n", System.identityHashCode(value));
    System.out.println();
  }
}

Der erste Durchlauf erfolgt mit Java 6 und -XX:-UseCompressedStrings, also der Standardeinstellung. Es lässt sich gut beobachten, dass jeder String ein char[] enthält (Listing 2).

Listing 2: Standardeinstellung
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Java6 no compaction
java version "1.6.0_65"
Details of String "hello world"
Identity Hash of String: 0x7b1ddcde
Type of value field: char[]
Length of value field: 11
Identity Hash of value: 0x6c6e70c7
Details of String "hello wørld"
Identity Hash of String: 0x46ae506e
Type of value field: char[]
Length of value field: 11
Identity Hash of value: 0x5e228a02
Details of String "heλλo worλd"
Identity Hash of String: 0x2d92b996
Type of value field: char[]
Length of value field: 11
Identity Hash of value: 0x7bd63e39

Der zweite Durchlauf erfolgt mit Java 6 und -XX:+UseCompressedStrings. Der „hello world“-String enthält ein byte[], die beiden anderen ein char[], da nur (US-)ASCII-Zeichen (7 Bit) komprimiert werden (Listing 3).

Listing 3: Java 6 und „-XX:+UseCompressedStrings“
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Java6 compaction
java version "1.6.0_65"
Details of String "hello world"
Identity Hash of String: 0x46ae506e
Type of value field: byte[]
Length of value field: 11
Identity Hash of value: 0x7bd63e39
Details of String "hello wørld"
Identity Hash of String: 0x42b988a6
Type of value field: char[]
Length of value field: 11
Identity Hash of value: 0x22ba6c83
Details of String " heλλo worλd "
Identity Hash of String: 0x7d2a1e44
Type of value field: char[]
Length of value field: 11
Identity Hash of value: 0x5829428e

Die Flag zur String-Komprimierung wurde in Java 7 ignoriert und in Java 8 entfernt, die Ausführung einer JVM mit -XX:+UseCompressedStrings würde also fehlschlagen. Alle Strings beinhalten ein char[] (Listing 4).

Listing 4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Java7 compaction
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option
  UseCompressedStrings; support was removed in 7.0
java version "1.7.0_80"
Details of String "hello world"
Identity Hash of String: 0xa89848d
Type of value field: char[]
Length of value field: 11
Identity Hash of value: 0x57fd54c4
Details of String "hello wørld"
Identity Hash of String: 0x38c83cfd
Type of value field: char[]
Length of value field: 11
Identity Hash of value: 0x621c232a
Details of String "heλλo worλd "
Identity Hash of String: 0x2548ccb8
Type of value field: char[]
Length of value field: 11
Identity Hash of value: 0x4e785727

Java 9 hat das neue Flag -XX:+CompactStrings eingeführt, das standardmäßig aktiviert ist. Strings speichern ihre Daten jetzt immer und unabhängig von der Kodierung als byte[]. Im Falle von Latin1 sind diese Bytes komprimiert (Listing 5).

Listing 5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Java9 compaction
java version "9.0.1"
Details of String "hello world"
Identity Hash of String: 0x77f03bb1
Type of value field: byte[]
Length of value field: 11
Identity Hash of value: 0x7a92922
Details of String "hello wørld"
Identity Hash of String: 0x71f2a7d5
Type of value field: byte[]
Length of value field: 11
Identity Hash of value: 0x2cfb4a64
Details of String " heλλo worλd "
Identity Hash of String: 0x5474c6c
Type of value field: byte[]
Length of value field: 22
Identity Hash of value: 0x4b6995df

Natürlich kann man diese neue Funktion in Java 9 mit -XX:-CompactStrings deaktivieren, allerdings hat sich die Kodierung innerhalb der Strings geändert. Unabhängig vom Startparameter werden die Werte also immer als byte[] gespeichert (Listing 6).

Listing 6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Java9 no compaction
java version "9.0.1"
Details of String "hello world"
Identity Hash of String: 0x21a06946
Type of value field: byte[]
Length of value field: 22
Identity Hash of value: 0x25618e91
Details of String "hello wørld"
Identity Hash of String: 0x7a92922
Type of value field: byte[]
Length of value field: 22
Identity Hash of value: 0x71f2a7d5
Details of String " heλλo worλd "
Identity Hash of String: 0x2cfb4a64
Type of value field: byte[]
Length of value field: 22
Identity Hash of value: 0x5474c6c

Jeder, der nun mittels Reflection auf die unschönen Details innerhalb des Strings zugreifen möchte, wird ziemlich sicher eine ClassCastException erhalten. Doch die Anzahl solcher Programmierer ist hoffentlich verschwindend gering.

Ein größeres Problem stellt die Performance dar. Methoden wie String.charAt(int) waren einmal blitzschnell. Ich habe einmal eine deutliche Performanceverschlechterung in Java 9 beobachtet, aber dieses Problem soll wohl in der offiziellen Releaseversion 9.0.1 behoben sein, zumindest hat man mir das gesagt.

Auf einer unserer JCrete-Unkonferenzen [1] habe ich von einem Trick von Peter Lawrey gehört, der darauf beruht, dass die String-Klasse einen Konstruktor hat, der mit char[] und einem Boolean parametriert werden kann. Der Boolean wird dabei nie genutzt und sollte als true gesetzt werden, was bedeutet, dass char[] direkt innerhalb des Strings genutzt und nicht erst kopiert wird. Das ist der Code:

1
2
3
4
String(char[] value, boolean share) {
  // assert share : "unshared not supported";
  this.value = value;
}

Lawreys Trick bestand darin, Strings sehr schnell aus einem char[] heraus zu erstellen, indem er direkt den entsprechenden Konstruktor aufrief. Ich bin mir nicht sicher, was die Details angeht, aber höchstwahrscheinlich hat er das mit JavaLangAccess gemacht, das in der SharedSecrets-Klasse zu finden war. Vor Java 9 befand sie sich im Paket sun.misc, seit Java 9 in jdk.internal.misc. Hoffentlich verwenden Sie diese Methode nicht direkt, sonst müssten Sie Ihren Code für Java 9 umschreiben. Aber damit nicht genug: Weil char[] in Java 9 nicht mehr als Wert behandelt wird, funktioniert der Trick hier nicht. Ein String wird weiterhin jedes Mal, wenn er aufgerufen wird, ein neues byte[] erzeugen, und ist dabei (auf meinem Rechner) ungefähr 2,5-mal langsamer in Java 9. Listing 7 zeigt den Code, wobei Sie beim Import auf Ihre Java-Version achten müssen.

Listing 7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//import sun.misc.*;
// prior to Java 9, use this
import jdk.internal.misc.*; // since Java 9, use this instead
public class StringUnsafeTest {
  private static String s;
  public static void main(String... args) {
    char[] chars = "hello world".toCharArray();
    JavaLangAccess javaLang = SharedSecrets.getJavaLangAccess();
    long time = System.currentTimeMillis();
    for (int i = 0; i < 100 * 1000 * 1000; i++) {
      s = javaLang.newStringUnsafe(chars);
    }
    time = System.currentTimeMillis() - time;
    System.out.println("time = " + time);
  }
}

Zusammenfassend kann man sagen: Wenn Sie Englisch, Deutsch, Französisch oder Spanisch sprechen, sind Ihre Strings nun deutlich kompakter. Sprechen Sie Griechisch oder Chinesisch, hat sich nicht viel geändert. Und ganz insgesamt dürften Strings ein kleines bisschen langsamer sein.

Keine Infos mehr verpassen!