String Substring in Java

Keine Kommentare

Strings sind in Java-Programmen allgegenwärtig. Jedoch hat sich die Implementierung der Klasse String in den letzten Jahren immer mal wieder geändert.

Ein Gastbeitrag von Dr. Heinz Kabutz und Sven Ruppert

Wenn in einer der ersten Versionen beispielsweise mehrere nicht konstante Strings zusammengefügt wurden, führte dies zu Aufrufen der Methode concat() oder der Verwendung der Klasse StringBuffer.

In Java 1.0 und 1.1 überprüfte die Methode hashCode() die Länge des Strings. War dieser zu lang, wurde nur jedes achte Element verwendet. Wenn man sich an dieser Stelle das Speicher-Layout ansieht, so stellt man fest, dass das nicht sonderlich effektiv war. In Java 2 wurde jedes Zeichen verwendet, und ab der Version 3 wurde der Hashcode dann gecached.

Nun könnte man annehmen, dass damit logischerweise ein großer Performancegewinn einhergehe. Leider ist das in produktiven Anwendungen aber nur recht selten der Fall. Verschärfend kommt hinzu, dass in den Fällen, in denen es einen Unterschied machen würde, die Möglichkeit besteht, das System extrem zu belasten, wenn der Hashcode 0 ist. Das liegt daran, dass in dem Fall jedes Mal der Hashcode wieder ausgerechnet werden muss. Wenn man eine Zeichenkombination findet, bei der ein Hashcode 0 ist, so kann man eine willkürliche Anzahl dieser Zeichen nacheinander schreiben, und am Ende kommt man immer wieder auf 0.

Wenn man nun eine Kombination von Zeichen hat, deren Hashcode null ergibt, kann man eine willkürliche Serie von long-Werten erzeugen. Das hat zur Folge, dass eine Operation wie hashCode(), die einen gespeicherten Wert verwendet, nun eine Komplexität von O(n) aufweisen wird.

In Java 7 wurde eine Lösung mit der Methode hash32() angeboten, die jedoch in Java 8 wieder entfernt wurde. Neulich – mein Co-Trainer Maurice Naftalin (Mastering of Lambdas) und ich (Dr. Heinz Kabutz) unterrichteten gerade zusammen unseren „Extreme Java 8“-Kurs, der sich mit Performance und Concurrency beschäftigt – dachte ich ein wenig über die Klasse String nach. Ich widme dieser Klasse immer ein wenig mehr Zeit, da sie beim Profiling meist unter den Top-Klassen zu finden ist, sehr häufig verwendet wird und aus diesem Grund einer hohen Aufmerksamkeit bedarf.

Von der Java-Version 1 bis zur Version 6 versucht die Implementierung der Klasse String das Erzeugen von Objekten mittels new char[] zu vermeiden. Unter anderem, indem ein Substring das bereits verwendete char-Array (char[]) wiederverwendet. Es wird lediglich ein anderer Offset und eine andere Länge verwendet. In der Klasse StringChars bspw. haben wir zwei Strings mit dem Inhalt “hello_world” und dem Substring “hello”. Diese verwenden dasselbe char-Array.

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));
  }
}

In Java 6 bekommen wir z. B. die Ausgabe

Hello
[C@721cdeff
[C@721cdeff

In Java 7 und Java 8 erhalten wir hingegen die folgende Ausgabe:

Hello
[C@49476842
[C@78308db1

Der Grund für die Änderung

Es zeigte sich, dass Entwickler zu oft die Methode substring() verwenden, um Speicher zu sparen. Nehmen wir an, es gibt einen String von der Größe 1 MB. Allerdings benötigen wir lediglich die ersten 5 KB. Wenn wir nun einen Substring erzeugen und annehmen, der Rest würde verworfen, irren wir uns. Und zwar deshalb, weil der neue String das darunter liegende char-Array verwendet. Demnach kann der Garbage Collector nichts freigeben, und es wird kein Speicher weniger verwendet. Um genau dieses zu erreichen, muss man das folgende Idiom verwenden.

Wir erzeugen einen neuen String, indem wir einen leeren String mit dem Substring mittels + konkatenieren.

String hello = "" + hello_world.substring(0, 5);

Im Verlauf unseres Kurses merkte der Kunde an, dass er ein sehr großes Problem mit dem neuen Java-7- und Java-8-Ansatz habe. In der Vergangenheit wurde davon ausgegangen, dass das Erzeugen von Substrings wenig Garbage erzeugt. Nun jedoch sind die Kosten sehr hoch. Um herauszufinden, wie viele Bytes genau erzeugt werden, schrieb ich eine kleine Klasse mit dem Namen Memory. Dort verwendete ich ein eher unbekanntes Feature der Klasse ThreadMXBean. Dieses wird Inhalt eines zukünftigen Newsletters sein.

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);
    }
  }
}

Nehmen wir an, dass wir einen großen String haben. Diesen wollen wir in kleinere Stücke zerteilen.

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);
  }
}

In Java 6 werden durch die Klasse LargeString 360.984 Bytes erzeugt. In Java 7 jedoch steigt der Verbrauch auf 20.411.536 Bytes. Die Differenz ist sehr deutlich. Probieren Sie es einfach mal auf Ihrer Maschine aus.

Wenn wir nun in Java 6 Speicher einsparen möchten, müssen wir unsere eigene String-Klasse implementieren. Das ist nicht so anstrengend, wie eventuell befürchtet, wenn wir das CharSequence-Interface verwenden. Hier möchte ich anmerken, dass diese Implementierung SubbableString nicht threadsafe ist. Um das zu verdeutlichen, wurde die Annotation von Brian Goetz @NotThreadSafe als Kommentar verwendet.

//@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 + count];
  }
 
  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);
  }
}

Wenn wir nun diese Verbesserung verwenden, benötigen wir unter Java 6 bis Java 8 281.000 Bytes. Unter Java 7 und Java 8 ergibt dies eine Verbesserung um den Faktor 72.

Abschließend möchte ich bitten, dies im Hinterkopf zu behalten, wenn eine Migration von Java 6 auf Java 8 ansteht. Ich weiß, dass zu viele meiner Kunden bei Java 6 stehengeblieben sind, da sie keine Argumente für eine Finanzierung der Migration gefunden haben. Abgesehen von den syntaktischen Vorzügen in Java 7 und Java 8 ist es ratsam, die Bugs aus Java 6 loszuwerden. Je schneller, desto besser.

Dieser Beitrag ist eine autorisierte Übersetzung des Java Specialist Newsletters Nr. 230 von Dr. Heinz Kabutz. Deutsche Fassung von Sven Ruppert.

Vom 8.-11. Dezember gibt Dr. Heinz Kabutz am codecentric-Standort Düsseldorf seinen bewährten „Java Specialists Master Course“. Mehr Informationen und eine Kursbeschreibung befinden sich auf der entsprechenden Schulungsseite.

Tags

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.