Java String-Unveränderlichkeit
Warum Javas String-Klasse unveränderlich ist — Auswirkungen auf Sicherheit, Caching, Hashing und Thread-Sicherheit.
Ein String in Java kann nach seiner Erstellung nicht mehr verändert werden. Sobald "hello" existiert, kann keine Methode, kein Reflection-Trick und keine clevere Zuweisung die Zeichen dieses bestimmten Objekts überschreiben. Jede Operation, die einen String „verändert", gibt tatsächlich einen neuen String zurück. Die Klasse erzwingt dies: Das Feld, das die Bytes enthält, ist private final, die Klasse selbst ist final, und es gibt keinen öffentlichen Setter, kein append, kein clear.
Diese Entscheidung — Unveränderlichkeit — ist keine stilistische Präferenz. Sie ist die tragende Entscheidung, die den String-Pool sicher macht, Hashing zuverlässig, das Teilen in mehreren Threads kostenfrei und eine Handvoll subtiler Sicherheitsgarantien überhaupt erst möglich macht.
Was „unveränderlich" wirklich bedeutet
String s = "hello";
s.toUpperCase(); // returns "HELLO" — the return value is dropped
System.out.println(s); // prints "hello"
s = s.toUpperCase(); // s now *points at* a different String
System.out.println(s); // prints "HELLO"Die Variable s kann neu zugewiesen werden — das ist eine Eigenschaft der Variablen, nicht des Objekts. Das ursprünglich mit "hello" erstellte Objekt ist überall, für immer unverändert, unabhängig davon, worauf s später zeigt. Wenn eine andere Variable noch darauf verweist, sieht diese Variable immer noch "hello".
String a = "hello";
String b = a;
a = a.toUpperCase();
System.out.println(a); // "HELLO"
System.out.println(b); // "hello" — still the originalDas meinen die Leute, wenn sie sagen, Strings verhalten sich wie Werte: Der Inhalt einer String-Referenz ist so stabil wie der Inhalt eines int.
Warum die JVM-Designer sich für Unveränderlichkeit entschieden haben
Aus der Unveränderlichkeit ergeben sich einige Eigenschaften, und jede ist echter Performance oder echter Sicherheit wert.
Der String-Pool ist sicher. Wenn "hello" an Ort und Stelle geändert werden könnte, wäre das Teilen einer gepoolten Instanz im gesamten Programm eine Katastrophe: Eine Änderung an einer Stelle würde sie stillschweigend überall ändern. Unveränderlichkeit ist das, was den String-Pool überhaupt erst möglich macht.
hashCode() kann gecacht werden. String berechnet seinen Hash beim ersten Aufruf und speichert ihn in einem privaten Feld. Dieser gecachte Wert wäre eine Lüge, wenn die Zeichen sich später ändern könnten, was jede HashMap<String, ?> mit diesem String als Schlüssel beschädigen würde. Da der Inhalt stabil ist, ist der Cache dauerhaft.
Gleichzeitige Lesevorgänge benötigen keine Synchronisation. Zwei Threads, die dieselbe String-Referenz lesen, können nie einen halbveränderten Wert beobachten. Es gibt kein synchronized, kein volatile, keinen Memory-Barrier-Tanz — es gibt nichts, das sich ändern könnte. Vergleichen Sie das mit einem veränderbaren Puffer, bei dem Sie kopieren, sperren oder die Eigentümerschaft einschränken müssten.
Klassenladen, Reflection und Sicherheitsprüfungen können String-Argumenten vertrauen. Ein ClassLoader löst Klassennamen aus Strings auf, die vom Aufrufer übergeben wurden. Wenn der String von einem anderen Thread zwischen der Sicherheitsprüfung und dem Öffnen der Datei geändert werden könnte, hätte man eine Race-Condition-Schwachstelle — den klassischen Time-of-Check / Time-of-Use-Fehler. Bei unveränderlichen Strings ist der validierte Wert identisch mit dem verwendeten Wert.
Methodenargumente benötigen keine defensiven Kopien. Wenn Sie einen String an eine Methode übergeben, müssen Sie sich keine Sorgen machen, dass er verändert wird und Sie beim Rückgabewert überrascht. Der Empfänger kann die Referenz direkt speichern; der Aufrufer kann seine Referenz ebenfalls weiterhin verwenden.
Der Preis: Massenveränderungen sind teuer
Es gibt einen Preis. Einen 10.000 Zeichen langen String zeichenweise mit += aufzubauen, alloziert bei jedem Schritt einen brandneuen String und kopiert dabei jedes bereits vorhandene Zeichen plus das neue. Das ist quadratischer Aufwand — O(n²) für eine O(n)-Aufgabe.
// Don't do this for large n
String s = "";
for (int i = 0; i < n; i++) {
s += i + ",";
}Die Antwort der Standardbibliothek sind veränderliche Puffer — StringBuilder für Single-Thread-Code und StringBuffer für den seltenen gemeinsam genutzten Fall. Sie halten ein dynamisches Array, hängen in amortisiertem O(1) an und produzieren am Ende mit toString() einen einzigen unveränderlichen String. Das ist das kanonische Muster zum Zusammensetzen von Strings.
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(i).append(',');
}
String s = sb.toString();Moderne JDKs optimieren kurze, statisch geformte +-Ketten über StringConcatFactory, sodass "hello, " + name + "!" in Ordnung ist. Der zu vermeidende Fall ist += innerhalb einer Schleife über eine unbekannte Anzahl von Iterationen.
Versuche, es zu brechen
Reflection kann technisch gesehen das private value-Feld erreichen und es ersetzen. Aus Sicht der JVM handelt es sich dabei um undefiniertes Verhalten: Der JIT geht davon aus, dass Strings unveränderlich sind, und wird den gecachten hashCode inlinen, Referenzen über den Pool teilen und Read-Barriers aufgrund dieses Versprechens überspringen. Das reflektive Verändern eines String kann stillschweigend nicht zusammenhängenden Code beschädigen, der eine Referenz auf dasselbe Objekt hält. Tun Sie es nicht. Wenn Sie Veränderlichkeit benötigen, haben Sie dafür StringBuilder.
Sicherheitsimplikationen
Zwei konkrete Fälle, in denen Unveränderlichkeit für die Sicherheit wichtig ist:
- Dateipfade und Klassennamen. Sie werden an APIs übergeben, die eine Zugriffsprüfung durchführen, bevor sie öffnen oder laden. Wenn ein Pfad sich zwischen Prüfung und Verwendung ändern könnte, wären Sandboxes überwindbar.
ClassLoader-Schlüssel undString-Map-Schlüssel. Stabile Hash-Codes bedeuten, dass ein Angreifer keinen Schlüssel konstruieren kann, der an einer Stelle „passt" und sich stillschweigend an eine andere verlagert.
Die Kehrseite: Passwörter in einem String zu speichern ist aus dem gegenteiligen Grund schlechte Praxis. Sobald ein Passwort in einem String liegt, kann man es nicht mehr nullen — die Bytes bleiben im Heap-Speicher, bis der GC sie zurückfordert, möglicherweise nachdem ein Heap-Dump geschrieben wurde. Für Passwörter verwenden Sie char[] (das Sie manuell mit Nullen füllen können) oder — besser — javax.crypto.SecretKey und Verwandte. Die JDK's Console.readPassword() gibt genau aus diesem Grund char[] zurück.
Ein ausgearbeitetes Beispiel
Dieses Programm erstellt einen String, gibt ihn an mehrere Aufrufer weiter, lässt jeden ihn „verändern" und gibt danach aus, was jede Variable sieht. Das ursprüngliche Objekt wird von vier Referenzen besucht und bleibt unverändert. Der einzelne veränderliche Puffer am Ende ist die kanonische Alternative, wenn man wirklich einen String aufbauen muss.
Betrachten Sie die beiden ==-Vergleiche. original und alias sind buchstäblich dasselbe Objekt, daher bleibt die Identität erhalten. original und upper haben verwandten Inhalt, aber upper ist ein neues Objekt — es ist unmöglich, dass upperCase das ihm übergebene Objekt geändert haben könnte. Das ist die Garantie, auf die sich jeder Java-Entwickler verlässt, ohne darüber nachzudenken.
Was kommt als Nächstes
Wenn Sie tatsächlich einen String benötigen, den Sie ändern können, hat die Standardbibliothek einen veränderbaren Verwandten zu String. Er ist das Arbeitspferd hinter jeder +-Kette, die der Compiler optimiert, und die richtige Antwort, wenn Sie sonst in einer Schleife nach += greifen würden. Weiter zu Java StringBuilder.