Blog

blog

21 Jan 2019

Java 7 hat die Struktur von Strings heimlich, still und leise verändert. Anstelle von offset und count beinhaltet der String nur noch char[]. Das hat einige negative Auswirkungen auf jene, die erwarten, substring() würde immer das darunterliegende char[] teilen.

String ist in Java-Programmen allgegenwärtig. Er hat sich über die Java-Generationen hinweg auf verschiedenen Ebenen verändert. In sehr frühen Versionen z.B. hätte der generierte Code aus der Verkettung nicht konstanter Strings entweder concat() oder StringBuffer ergeben können. In Java 1.0 und 1.1 hätte die Methode hashCode() die Größe des Strings überprüft. Wäre er zu lang gewesen, hätte sich jedes achte Zeichen statt jedes einzelnen addiert. Angesichts des Speicherlayouts wirkt diese Optimierung ohnehin nicht besonders effizient. Unter Java 2 änderte sich das Prinzip zu „jedes Zeichen immer“, und unter Java 3 wurde der HashCode zwischengespeichert.

Die Liste der Wünsche klingt vernünftig, nicht wahr? Es gibt fast keine Fälle in realem Code, bei denen uns das Prinzip weiterhilft. Wir gehen davon aus, dass es unwahrscheinlich ist, der HashCode sei gleich null. Stimmt’s? Wer einmal eine Zeichenkette gefunden hat, deren Hash Code gleich null ist, kann eine beliebig lange Serie generieren. Eine konstante Zeitoperation wie hashCode(), die gecachte Werte nutzt, wird jetzt potenziell zu O(n). Im Zusammenhang mit Java 7 wurde versucht, die hash32()-Berechnung zu fixen. Das würde einen Nullwert ausschließen. Allerdings ist es in Java 8 auch wieder verschwunden.

Kürzlich haben mein Co-Trainer Maurice Naftalin (der Mann hinter Mastering Lambdas) und ich unseren Extreme-Java-8-Kurs gehalten, der sich auf Concurrency und Performance konzentriert. Ich räume dem Thema String immer etwas Zeit ein, da es häufig verwendet wird und dazu tendiert, die Spitze des Eisbergs vieler Probleme zu sein. Von Java 1.0 bis 1.6 versuchte String zu vermeiden, neue Zeichenarrays (char[]) anlegen zu müssen. Die substring()-Methode würde dasselbe darunter liegende char[] teilen, mit anderem Offset und anderer Länge. In StringChars haben wir z.B. zwei Strings, mit hello einen Substring von hello_world. Die beiden teilen sich jedoch das gleiche Zeichenarray (Listing 1).

import java.lang.reflect.*;

public class StringChars {
  public static void main(String... args)
      throws NoSuchFieldException, IllegalAccessException {
    Field value = String.class.getDeclaredField("value");
    value.setAccessible(true);

    String hello_world = "Hello world";
    String hello = hello_world.substring(0, 5);
    System.out.println(hello);

    System.out.println(value.get(hello_world));
    System.out.println(value.get(hello));
  }
}

Von Java 1 bis einschließlich Java 6 sähen wir diesen Output:

Hello
[C@721cdeff
[C@721cdeff

Unter Java 7 und 8 bekämen wir ein anderes char[] zurück:

Hello
[C@49476842
[C@78308db1

„Wozu diese Änderung?“, werden Sie sich jetzt vielleicht fragen. Es hat sich gezeigt, dass zu viele Entwickler substring() nutzten, um Speicher zu sparen. Stellen wir uns vor, wir hätten einen 1-MB-String, aber bräuchten nur die ersten 5 KB. Wir könnten einen Substring anlegen und erwarten, dass der Rest unseres 1-MB-Strings verworfen wird. Wird er aber nicht. Der neue String würde dasselbe darunter liegende char[] verwenden. Letztlich würden wir keinen Speicherplatz sparen. Das korrekte Codeidiom bestünde darin, den Substring an einen leeren String anzuhängen. Das hätte den Nebeneffekt, immer wieder ein neues, ungeteiltes char[] zu generieren, sollte die Stringlänge nicht zur char[]-Länge passen: String hello = "" + hello_world.substring(0, 5);.

Seit Java 7 bzw. 8 gibt es einen neuen Ansatz, wie Substrings erzeugt werden. Während unseres Kurses haben uns Teilnehmer darauf hingewiesen, dass sie damit ein Problem haben. Früher dachten sie, ein Substring produziere nur ein Minimum an Speichermüll. Mit dem neuen Ansatz könnte das kostspielig werden. Um herauszufinden, wie viele Bytes belegt werden, habe ich eine Memory-Klasse geschrieben, die ein recht unbekanntes ThreadMXBean-Feature nutzt (Listing 2).

import javax.management.*;
import java.lang.management.*;

public class Memory {
  public static long threadAllocatedBytes() {
    try {
      return (Long) ManagementFactory.getPlatformMBeanServer()
        .invoke(
          new ObjectName(
              ManagementFactory.THREAD_MXBEAN_NAME),
          "getThreadAllocatedBytes",
          new Object[]{Thread.currentThread().getId()},
          new String[]{long.class.getName()}
      );
    } catch (Exception e) {
      throw new IllegalArgumentException(e);
    }
  }
}

Angenommen, wir haben einen langen String, den wir in kleine Stücke aufteilen (Listing 3).

import java.util.*;

public class LargeString {
  public static void main(String... args) {
    char[] largeText = new char[10 * 1000 * 1000];
    Arrays.fill(largeText, 'A');
    String superString = new String(largeText);

    long bytes = Memory.threadAllocatedBytes();
    String[] subStrings = new String[largeText.length / 1000];
    for (int i = 0; i < subStrings.length; i++) {
      subStrings[i] = superString.substring(
        i * 1000, i * 1000 + 1000);
    }
    bytes = Memory.threadAllocatedBytes() - bytes;
    System.out.printf("%,d%n", bytes);
  }
}

Unter Java 6 generiert die LargeString-Klasse 360 984 Bytes. Bei Java 7 sind es 20 441 536!

Wollen wir Speicherplatz unter Java 6 sparen, müssen wir unsere eigene Stringklasse schreiben. Mithilfe des CharSequence-Interface ist das nicht schwierig. Wichtig: Meine SubbableString-Klasse ist nicht Thread-safe, soll sie auch nicht. Ich habe Brian Goetz’ Annotation benutzt, wenn auch nur in einem Kommentar (Listing 4).

//@NotThreadSafe
public class SubbableString implements CharSequence {
  private final char[] value;
  private final int offset;
  private final int count;

  public SubbableString(char[] value) {
    this(value, 0, value.length);
  }

  private SubbableString(char[] value, int offset, int count) {
    this.value = value;
    this.offset = offset;
    this.count = count;
  }

  public int length() {
    return count;
  }

  public String toString() {
    return new String(value, offset, count);
  }

  public char charAt(int index) {
    if (index < 0 || index >= count)
      throw new StringIndexOutOfBoundsException(index);
    return value[index + offset];
  }

  public CharSequence subSequence(int start, int end) {
    if (start < 0) {
      throw new StringIndexOutOfBoundsException(start);
    }
    if (end > count) {
      throw new StringIndexOutOfBoundsException(end);
    }
    if (start > end) {
      throw new StringIndexOutOfBoundsException(end - start);
    }
    return (start == 0 && end == count) ? this :
        new SubbableString(value, offset + start, end - start);
  }
}

Nutzen wir im Test statt String CharSequence, vermeiden wir, diese unnötigen char[]s zu erzeugen (Listing 5).

import java.util.*;

public class LargeSubbableString {
  public static void main(String... args) {
    char[] largeText = new char[10000000];
    Arrays.fill(largeText, 'A');
    CharSequence superString = new SubbableString(largeText);

    long bytes = Memory.threadAllocatedBytes();
    CharSequence[] subStrings = new CharSequence[
      largeText.length / 1000];
    for (int i = 0; i < subStrings.length; i++) {
      subStrings[i] = superString.subSequence(
        i * 1000, i * 1000 + 1000);
    }
    bytes = Memory.threadAllocatedBytes() - bytes;
    System.out.printf("%,d%n", bytes);
  }
}

Die Fäden zusammenführen

Durch diese Verbesserung verbrauchen wir knapp 281 000 Bytes unter Java 6, 7 und 8. In Bezug auf Version 7 und 8 bedeutet das eine Verbesserung um den Faktor 72. Ich empfehle, dieses neue Feature im Hinterkopf zu behalten, wenn ihr von Java 6 auf 8 migriert. Abgesehen von den Syntaxvorteilen unter Java 7 und 8 heißt eine Migration auch, sich von den Bugs in Version6 zu verabschieden. Je eher, desto besser.

Keine Infos mehr verpassen!