Zum Inhalt springen

Java Deep Reflection: Wie man Integer und String hackt

Sanduhr mit goldenem Sand und Stahlkappen vor einem rahmenlosen Spiegel – im Spiegelbild ist der Sand blau

Im Buch „The Pragmatic Programmer“ von Andrew Hunt und David Thomas stellen die Autoren folgende Aufgabe:

Which of these „impossible“ things can happen?

[…]
3. In C++: a = 2; b = 3; if (a + b != 5) exit(1);
[…]

Eine der richtigen Antworten ist 3. In C++ gibt es mehrere Gründe, warum die Bedingung „a + b != 5“ erfüllt sein könnte:

  • Operator Overloading: z. B. kann man den „+“-Operator eine Multiplikation ausführen lassen.
  • Variable Aliasing: b ist ein Alias für a, damit setzt die Zuweisung b = 3 auch a auf 3, und die Summe ist 6.

Da es beides in Java nicht gibt, habe ich mich gefragt: Kann ich denselben Code auch in Java so schreiben, dass die Bedingung erfüllt ist? Die Antwort lautet: ja. Wie das möglich ist, erfährst du in diesem Artikel.

Die Code-Beispiele des Artikels findest du in meinem GitHub-Repository.

2 + 3 = 5

Fangen wir ganz einfach an: mit einer main()-Methode, in der zwei primitive ints, a und b, deklariert werden, gefolgt vom zu testenden Code:

import static java.lang.System.exit;

public class ImpossibleThings1 {
  public static void main(String[] args) {
    int a, b;
    a = 2; b = 3; if (a + b != 5) exit(1);
  }
}

Selbstverständlich gilt hier a + b = 5, so dass das Programm regulär, also mit Exit-Code 0, endet.

2 + 3 = 6: Deep Reflection mit Integer

Mit einer gar nicht allzu großen Änderung können wir dafür sorgen, dass die Bedingung wahr wird (also, dass 2 + 3 nicht 5 ist) und das Programm mit Exit-Code 1 endet:

public class ImpossibleThings2 {
  static {
    try {
      Field VALUE = Integer.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      VALUE.set(2, 3);
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }

  public static void main(String[] args) {
    Integer a, b;
    a = 2; b = 3; if (a + b != 5) exit(1);
  }
}

Hier ist der Beweis – das Programm endet mit Exit-Code 1:

$ java ImpossibleThings2
$ echo $?
1

Was haben wir getan? Wir verwenden hier Integer statt int und machen von reichlich Auto-Boxing und -Unboxing Gebrauch. Was hier genau passiert, beschreibe ich im nächsten Abschnitt.

Auto-(un)boxing aufgedeckt

Im Folgenden habe ich Auto-Boxing und -Unboxing durch explizites Boxing und Unboxing ersetzt. So wird deutlicher, was passiert. Neu sind die Zeile Integer two = Integer.valueOf(2), der Aufruf VALUE.set(two, 3) und die ausgeschriebenen Integer.valueOf()- und intValue()-Aufrufe in main():

public class ImpossibleThings3 {
  static {
    try {
      Field VALUE = Integer.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      Integer two = Integer.valueOf(2);
      VALUE.set(two, 3);
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }

  public static void main(String[] args) {
    Integer a, b;
    a = Integer.valueOf(2);
    b = Integer.valueOf(3);
    if (a.intValue() + b.intValue() != 5) exit(1);
  }
}

Integer.valueOf() liefert für die Werte -128 bis 127 gecachte Integer-Instanzen zurück.

Ein Integer-Objekt speichert den eigentlichen Wert in einem privaten value-Feld und gibt eben diesen über intValue() zurück.

Im statischen Initializer holen wir uns das gecachte Integer-Objekt für den Wert 2. Mittels Deep Reflection setzen wir dessen value auf die Zahl 3. Da value ein privates Feld ist, müssen wir den Zugriff darauf mit Field.setAccessible(true) zunächst gestatten.

Würden wir nun mit System.out.println(two) dieses Objekt ausgeben, würden wir diese „3“ sehen.

In der main()-Methode wird a = 2 zu a = Integer.valueOf(2) geboxt, was wiederum dieselbe gecachte Integer-Instanz liefert wie two, dessen Wert mittlerweile 3 ist. b ist ebenfalls 3 und somit ergibt a + b an dieser Stelle 6. Und das ist bekanntermaßen ungleich 5 (sofern die 5 nicht auch „gehackt“ wurde … was aber meines Wissens bei einem int-Primitiv nicht möglich ist).

Deep Reflection mit Strings

Dasselbe lässt sich auch mit Strings machen. Die folgenden Beispiele funktionieren so ab Java 9, eine Anpassung für ältere Versionen folgt weiter unten.

public class ImpossibleThings4 {
  static {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      VALUE.set("Hello world", "You have been hacked".getBytes());
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }

  public static void main(String[] args) {
    System.out.println("Hello world");
  }
}

Die Ausgabe dieses Programms lautet:

You have been hacked

So einfach, wie es in diesem Beispiel scheint, funktioniert es allerdings nicht immer. Inwieweit wir Strings mit Deep Reflection manipulieren können, hängt von drei Faktoren ab:

  • ob Strings als Konstanten vorliegen oder zur Laufzeit erstellt werden,
  • ob Strings Sonderzeichen enthalten, die nicht als Latin-1 kodiert werden können,
  • welche Java-Version wir verwenden.

Strings müssen als Konstanten vorliegen

Zunächst einmal müssen Strings als Konstanten im Code enthalten sein. Nur String-Konstanten werden, wenn sie gleich sind, durch dieselbe Objektreferenz ersetzt.

Folgendes funktioniert noch:

public class ImpossibleThings5 {
  static { ... }

  public static void main(String[] args) {
    System.out.println("Hello" + " " + "world");
  }
}

Hier fügt bereits der Compiler die drei Teile zu einem einzigen String zusammen – zur Laufzeit ist das dann derselbe String wie der, dessen value-Inhalt wir ändern.

Folgendes hingegen funktioniert nicht:

public class ImpossibleThings6 {
  static { ... }

  public static void main(String[] args) {
    System.out.println("Hello " + getName());
  }

  private static String getName() {
    return "world";
  }
}

Hier werden erst zur Laufzeit „Hello “ und „world“ verkettet. Dabei entsteht ein neues String-Objekt mit dem value-Inhalt „Hello world“.

Vergleich der Objekt-Identitäten

Etwas klarer wird es, wenn wir uns die Identitäten der String-Objekte anschauen, hier noch einmal am ersten String-Beispiel:

public class ImpossibleThings4WithIdentity {
  static {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      String s1 = "Hello world";
      System.out.println("identityHashCode(s1) = " + System.identityHashCode(s1));
      VALUE.set(s1, "You have been hacked".getBytes());
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }

  public static void main(String[] args) {
    String s2 = "Hello world";
    System.out.println("identityHashCode(s2) = " + System.identityHashCode(s2));
    System.out.println(s2);
  }
}

Die Ausgabe lautet:

identityHashCode(s1) = 2061475679
identityHashCode(s2) = 2061475679
You have been hacked

Das String-Objekt s1, das wir im statischen Initializer modifizieren, ist also identisch zum String-Objekt s2, das wir in der main()-Methode ausgeben. Wir geben also genau den String aus, den wir per Deep Reflection verändert haben.

Dasselbe prüfen wir noch einmal für den String, der im Quellcode aus String-Konstanten verkettet wird:

public class ImpossibleThings5WithIdentity {
  static { ... }

  public static void main(String[] args) {
    String s2 = "Hello" + " " + "world";
    System.out.println("identityHashCode(s2) = " + System.identityHashCode(s2));
    System.out.println(s2);
  }
}

Wir erhalten folgende Ausgabe:

identityHashCode(s1) = 2061475679
identityHashCode(s2) = 2061475679
You have been hacked

Auch bei diesem Beispiel sind die String-Objekte s1 und s2 identisch.

Und zuletzt überprüfen wir die Objekt-Identitäten bei der dritten Variante, bei der ein Teil des Strings durch eine Methode zurückgeliefert wird:

public class ImpossibleThings6WithIdentity {
  static { ... }

  public static void main(String[] args) {
    String s2 = "Hello " + getName();
    System.out.println("identityHashCode(s2) = " + System.identityHashCode(s2));
    System.out.println(s2);
  }

  private static String getName() {
    return "world";
  }
}

Hier die Ausgabe des dritten Tests:

identityHashCode(s1) = 2061475679
identityHashCode(s2) = 303563356
Hello world

Wir haben also die Bestätigung, dass es sich bei s1 und s2 um zwei unterschiedliche String-Objekte handelt. Die Änderung von s1 per Reflection wirkt sich daher nicht auf s2 aus.

String-Repräsentation: Latin-1 vs. UTF-16

Wenn wir das erste String-Beispiel leicht abändern, kommt ein zunächst ziemlich unerwartetes Ergebnis heraus. Wir ändern den auszugebenden String von „Hello world“ in „Hello world ✓“ (mit einem Häkchen am Ende):

public class ImpossibleThings7 {
  static {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      VALUE.set("Hello world ✓", "You have been hacked".getBytes());
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }

  public static void main(String[] args) {
    System.out.println("Hello world ✓");
  }
}

Was wird hier ausgegeben? Was denkst du? (Wir sind immer noch bei Java 9 oder höher.)

  1. „Hello world ✓“
  2. „You have been hacked“
  3. „You have been hacked ✓“
  4. „潙⁵慨敶戠敥慨正摥“

Die Antwort ist Nummer 4 – das Programm gibt tatsächlich chinesische Zeichen aus:

潙⁵慨敶戠敥慨正摥

Wie lässt sich das erklären?

Dazu müssen wir uns die interne Repräsentation eines Strings anschauen. Seit Java 9 wird ein String intern als byte[] dargestellt. Die Art der Kodierung von Characters in Bytes hängt dabei davon ab, ob der String ausschließlich Latin-1-kodierbare Zeichen enthält oder auch andere. Enthält der String nur Zeichen, die in Latin-1 kodiert werden können, wird pro Zeichen genau ein Byte verwendet. Enthält der String jedoch auch andere Zeichen, wird er als UTF-16 kodiert.

Dieses Feature nennt sich „String Compaction“, wurde im JEP 254 definiert und ist standardmäßig aktiviert. Es kann mit -XX:-CompactStrings deaktiviert werden – dann werden Strings grundsätzlich als UTF-16 gespeichert.

Was bedeutet das für unser Beispiel?

  • Der String „Hello world“ wird durch folgende Bytes repräsentiert:
    48 65 6c 6c 6f 20 77 6f 72 6c 64
    ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^
    H  e  l  l  o     w  o  r  l  d
  • Der String „Hello world ✓“ wird wie folgt gespeichert:
    48 00 65 00 6c 00 6c 00 6f 00 20 00 77 00 6f 00 72 00 6c 00 64 00 20 00 13 27
    ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^
      H     e     l     l     o           w     o     r     l     d           ✓
    (Hier im Little-Endian-Format – die JVM speichert UTF-16-Strings in der nativen Byte-Reihenfolge der Maschine, und x86 wie Apple Silicon sind Little-Endian.)

Die Information, wie der String kodiert wird, wird im Feld coder des Strings abgelegt. Dabei steht eine 0 für Latin-1 und eine 1 für UTF-16.

Im String „Hello world ✓“ enthält das Feld coder also aufgrund der UTF-16-Kodierung den Wert 1.

Im vorangegangenen Code-Beispiel setzen wir das Feld value des Strings „Hello world ✓“ auf "You have been hacked".getBytes(). Die Methode getBytes() liefert die Bytes in der Standard-Zeichenkodierung zurück. Seit Java 18 ist die Standard-Zeichenkodierung auf allen Plattformen UTF-8 (JEP 400); bis Java 17 hing sie vom Betriebssystem und der Locale ab – auf macOS und Linux meist UTF-8, auf Windows z. B. windows-1252.

Da der String „You have been hacked“ keinerlei Sonderzeichen enthält, ist dessen UTF-8-Kodierung identisch mit seiner Latin-1-Kodierung, belegt also genau ein Byte pro Zeichen.

Der String „Hello world ✓“ enthält somit im Feld value folgende Byte-Folge:

59 6f 75 20 68 61 76 65 20 62 65 65 6e 20 68 61 63 6b 65 64
^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^
Y  o  u     h  a  v  e     b  e  e  n     h  a  c  k  e  d

Da im „Hello world ✓“-Feld coder nach wie vor eine 1 steht (wegen der ursprünglichen UTF-16-Kodierung), wird das Byte-Array als UTF-16 interpretiert – und genau das führt dann zur Ausgabe der chinesischen Zeichen.

Grob gesagt haben wir also Folgendes getan:

byte[] bytes = "You have been hacked".getBytes(StandardCharsets.UTF_8);
String string = new String(bytes, StandardCharsets.UTF_16);

Wie können wir das Problem lösen?

Ziemlich einfach: Wir müssen lediglich neben dem Inhalt von value auch den Inhalt von coder kopieren. Bei der Gelegenheit ändern wir auch das Kopieren von value so ab, dass wir das entsprechende Feld aus dem String „You have been hacked“ auslesen, anstatt dessen getBytes()-Methode aufzurufen. Diese hat nämlich bisher nur zufällig das zugrunde liegende Byte-Array geliefert, weil „You have been hacked“ keine Sonderzeichen enthält und die Standard-Zeichenkodierung UTF-8 ist.

public class ImpossibleThings8 {
  static {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);

      Field CODER = String.class.getDeclaredField("coder");
      CODER.setAccessible(true);

      VALUE.set("Hello world ✓", VALUE.get("You have been hacked"));
      CODER.set("Hello world ✓", CODER.get("You have been hacked"));
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }

  public static void main(String[] args) {
    System.out.println("Hello world ✓");
  }
}

Statt chinesischer Zeichen bekommen wir nun wieder die erwartete Ausgabe:

You have been hacked

Dass die String-Konstanten hier zweimal angegeben werden, gehört sich so natürlich nicht. Das lösen wir, indem wir den Code in eine Methode auslagern und die zwei Strings als Parameter übergeben:

public class StringHacker_Java9 {
  public static void hackString(String victim, String replacement) {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);

      Field CODER = String.class.getDeclaredField("coder");
      CODER.setAccessible(true);

      VALUE.set(victim, VALUE.get(replacement));
      CODER.set(victim, CODER.get(replacement));
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }
}

Als Nächstes müssen wir noch einen Blick auf ältere Java-Versionen werfen.

Deep Reflection mit Strings in älteren Java-Versionen

String-Repräsentation: byte[] vs. char[]

Wie eingangs des String-Kapitels erwähnt, funktionieren die Beispiele erst ab Java 9. Der Grund dafür ist, dass bis Java 8 der Wert eines Strings nicht in einem byte[], sondern in einem char[] abgelegt wurde. Entsprechend existierte auch bis Java 8 das Feld coder nicht.

Würden wir die bisherigen Beispiele mit Java 8 starten, bekämen wir …

  • beim Aufruf von VALUE.set("...".getBytes()) eine IllegalArgumentException: Can not set final [C field java.lang.String.value to [B,
  • in den letzten zwei Beispielen (in denen wir nicht explizit ein Byte-Array setzen, sondern den Inhalt von value kopieren) beim darauf folgenden Aufruf von String.class.getDeclaredField("coder") eine NoSuchFieldException: coder.

Die IllegalArgumentException haben wir, wie gesagt, in den letzten zwei Beispielen schon verhindert. Die NoSuchFieldException können wir einfach ignorieren – wenn es das Feld coder nicht gibt, brauchen wir es auch nicht zu kopieren:

public class StringHacker_Java7 {
  public static void hackString(String from, String to) {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      VALUE.set(from, VALUE.get(to));

      // For "Compact Strings" introduced in Java 9
      try {
        Field CODER = String.class.getDeclaredField("coder");
        CODER.setAccessible(true);
        CODER.set(from, CODER.get(to));
      } catch (NoSuchFieldException e) {
        // Ignore
      }
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }
}

Dieser Code läuft auch unter Java 7 (getestet mit JDK 1.7.0_80) und gibt dort aus:

You have been hacked

Sub-Strings mit offset und count

Gehen wir weiter in der Java-Geschichte zurück, kommen wir zu einer weiteren Änderung der String-Interna von Java 6 zu Java 7. Bis Java 6 wurde das value-Character-Array wiederverwendet, wenn man mit String.substring() einen Teil-String erzeugt hat.

Dazu wurde das Character-Array des ursprünglichen Strings in den Teil-String unverändert übernommen. Und in den Feldern offset und count des Teil-Strings wurde gespeichert, welcher Abschnitt des Character-Arrays dessen Inhalt repräsentiert.

Ziel dieser Logik war es, Speicher zu sparen.

Häufiger passierte jedoch das Gegenteil: Wenn der ursprüngliche String nicht mehr benötigt wurde, hielt der kürzere Teil-String nach wie vor eine Referenz auf das ursprüngliche, dann unnötig längere Character-Array. Von daher haben die JDK-Entwickler:innen mit Java 7 Update 6 die Funktionalität von String.substring() so geändert, dass nur der benötigte Teil des Character-Arrays in den Teil-String kopiert wurde. Für diese Änderung gibt es keinen JEP; sie wird im Bug-Tracker unter JDK-6924259 aufgeführt.

Um unseren Code auf Java 6 und niedriger lauffähig zu machen, müssen wir also auch die Felder offset und count kopieren.

Vor Java 7 gab es leider auch weder die ReflectiveOperationException noch die Möglichkeit, mehrere Exception-Typen in einem catch-Block abzufangen, so dass dieser etwas umständlicher wird. Hier der auch unter Java 6 lauffähige Code:

public class StringHacker {
  public static void hackString(String from, String to) {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      VALUE.set(from, VALUE.get(to));

      // "offset" and "count" for Strings up to Java 6
      try {
        Field OFFSET = String.class.getDeclaredField("offset");
        OFFSET.setAccessible(true);
        OFFSET.setInt(from, OFFSET.getInt(to));

        Field COUNT = String.class.getDeclaredField("count");
        COUNT.setAccessible(true);
        COUNT.setInt(from, COUNT.getInt(to));
      } catch (NoSuchFieldException e) {
        // Ignore
      }

      // For "Compact Strings" introduced in Java 9
      try {
        Field CODER = String.class.getDeclaredField("coder");
        CODER.setAccessible(true);
        CODER.set(from, CODER.get(to));
      } catch (NoSuchFieldException e) {
        // Ignore
      }
    } catch (IllegalAccessException e) {
      e.printStackTrace();
    } catch (NoSuchFieldException e) {
      e.printStackTrace();
    }
  }
}

Unter Java 6 (getestet mit JDK 1.6.0_45) lautet die Ausgabe ebenfalls:

You have been hacked

Experiment „Compressed Strings“ in Java 6u21

Der Artikel wäre nicht vollständig, würden wir nicht kurz auf die in Java 6 als „experimental“ eingeführten „Compressed Strings“ eingehen (nicht zu verwechseln mit den o. g. in Java 9 eingeführten „Compact Strings“!).

Wenn über die VM-Option -XX:+UseCompressedStrings aktiviert, dann wird, sofern ein String nur Latin-1-Zeichen enthält, im value-Feld anstatt eines Character-Arrays ein Byte-Array gespeichert. Dies wurde jedoch nicht im String-Quellcode gemacht, sondern intern in der JVM. Diese Optimierung hat zwar Speicher gespart, war aber sehr unperformant, da das Byte-Array für fast alle String-Operationen in ein Character-Array konvertiert werden musste. In Java 7 wurde die Funktion wieder entfernt (JDK-7129417).

Da diese Optimierung JVM-intern durchgeführt wurde, ist unser Code ohne weitere Anpassung auch mit aktivierten „Compressed Strings“ lauffähig – mit JDK 1.6.0_45 und -XX:+UseCompressedStrings gestartet, gibt er aus:

You have been hacked

Deep Reflection seit Java 16

Als ich diesen Artikel im März 2020 geschrieben habe, liefen alle Beispiele auf Java 14 ohne Flags – lediglich mit einer Warnung. Das hat sich seitdem zweimal geändert.

Java 16 und 17: InaccessibleObjectException

Seit Java 9 gibt es das Modulsystem; java.lang gehört zum Modul java.base, das dieses Paket nicht für Deep Reflection öffnet. Bis Java 15 ließ die JVM den Zugriff trotzdem zu („Relaxed Strong Encapsulation“, --illegal-access=permit als Default) und gab beim ersten Zugriff eine Warnung aus:

WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by ImpossibleThings2 (file:/…/)
  to field java.lang.Integer.value
WARNING: Please consider reporting this to the maintainers of ImpossibleThings2
WARNING: Use --illegal-access=warn to enable warnings of further illegal
  reflective access operations
WARNING: All illegal access operations will be denied in a future release

Seit Java 16 ist --illegal-access=deny der Default (JEP 396); setAccessible(true) wirft dann eine InaccessibleObjectException:

Exception in thread "main" java.lang.ExceptionInInitializerError
Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make field
  private final int java.lang.Integer.value accessible: module java.base does
  not "opens java.lang" to unnamed module @2b2fa4f7

In Java 16 konnte man mit --illegal-access=permit noch zum alten Verhalten zurückschalten; seit Java 17 ignoriert die JVM das Flag (JEP 403). Seitdem führt nur noch ein Weg zu Integer.value und String.value: Das Paket muss beim Start der JVM explizit geöffnet werden, und zwar mit

--add-opens java.base/java.lang=ALL-UNNAMED

ALL-UNNAMED steht dabei für allen Code auf dem Classpath – also auch für unsere Beispiele.

Java 26: Warnung beim Schreiben finaler Felder (JEP 500)

Integer.value und String.value sind nicht nur privat, sondern auch final. Dass Field.set() ein finales Feld überhaupt überschreiben darf, ist seit Java 5 so – eingeführt für Serialisierungs-Frameworks, die Objekte ohne Konstruktoraufruf befüllen. Seit Java 26 warnt die JVM davor (JEP 500), auch wenn das Paket per --add-opens geöffnet ist:

WARNING: Final field value in class java.lang.Integer has been mutated
  reflectively by class ImpossibleThings2 in unnamed module @3af49f1c (file:/…/)
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final
  field mutation is enabled

Die Warnung erscheint einmal pro Modul; --enable-final-field-mutation=ALL-UNNAMED schaltet sie ab. Wie es weitergeht, steht in der Warnung: In einer künftigen Version wird --illegal-final-field-mutation=deny der Default, und Field.set() wirft dann eine IllegalAccessException. Mit Java 27 kannst du das heute schon ausprobieren:

$ java --add-opens java.base/java.lang=ALL-UNNAMED \
       --illegal-final-field-mutation=deny ImpossibleThings2
Exception in thread "main" java.lang.Error: java.lang.IllegalAccessException:
  class ImpossibleThings2 (in unnamed module @3af49f1c) cannot set final field
  java.lang.Integer.value (in module java.base), unnamed module @3af49f1c
  is not allowed to mutate final fields

Die Flags im Überblick

Die folgende Matrix zeigt pro Java-Version, was passiert, wenn der Code ohne Flags bzw. mit --add-opens startet:

Matrix: Deep Reflection auf Integer.value und String.value – ohne Flags in Java 9 bis 15 mit Warnung erlaubt, ab Java 16 InaccessibleObjectException; mit --add-opens in Java 9 bis 25 ohne Warnung erlaubt, in Java 26 und 27 mit JEP-500-Warnung, in einer künftigen Version IllegalAccessException
Deep Reflection auf Integer.value und String.value: was welche Java-Version erlaubt, warnt oder blockiert

Fazit

In der Praxis solltest du davon absehen, die internen Werte von gecachten Integer-Objekten oder von Strings zu verändern. Das könnte unvorhersehbare Konsequenzen haben: Die Änderung wirkt nicht nur auf deinen eigenen Code, sondern auf den gesamten Code des Projekts inklusive aller Libraries und Frameworks, die vom selben Classloader geladen werden.

Du solltest dich auch nicht auf die interne Repräsentation einer Klasse verlassen. Wie am String-Beispiel gezeigt, kann sie sich von einer Java-Version zur nächsten ändern.

Und das JDK hat aus seiner Ankündigung von 2017 („will be denied in a future release“) Ernst gemacht: Seit Java 16 kommt der Code ohne --add-opens nicht mehr an Integer.value und String.value heran, und seit Java 26 warnt die JVM auch mit --add-opens vor jedem Schreibzugriff auf ein finales Feld. Der nächste Schritt steht in JEP 500: In einer kommenden Version wirft Field.set() auf finalen Feldern standardmäßig eine IllegalAccessException. Wer die Beispiele dann noch ausführen will, muss es mit --enable-final-field-mutation ausdrücklich erlauben – und sollte sich dreimal fragen, warum.

Was soll ich als Nächstes vertiefen? Das erfahre ich am besten durch dein Feedback: Eine Bewertung auf meinem ProvenExpert-Profil zeigt mir, welche Themen dir wichtig sind – und motiviert mich, weitere Artikel zu schreiben.

👉 Bewertung abgeben

Lust auf noch mehr Wissen?

In meinem Blog findest du viele Artikel zu Java, Softwarearchitektur und Performance von grundlegenden Konzepten bis hin zu fortgeschrittenen Patterns.

Wenn du tiefer einsteigen willst, schau dir meine Trainings an: praxisnah, verständlich und direkt auf euren Projektalltag übertragbar. Statt Theorie vermittle ich Prinzipien, die euch helfen, Code langfristig besser, wartbarer und performanter zu schreiben.

Zu den Java-Schulungen

Werde ein:e bessere:r Java-Entwickler:in

Mit meinem kostenlosen Newsletter bleibst du vorn. Modernes Java: neue Versionen & Features, Performance und JVM-Insights – 1x im Monat.

Suche