🔤 Section 12 · Question #21

Can You Modify String Contents via Reflection

The java.lang.String class is architected to be strictly immutable. However, historical Java Reflection allowed bypassing encapsulation, acquiring access to private fields via s...


🟢 Junior Level

Technically, in legacy Java versions (Java 8 and earlier) this was possible, but modern Java (Java 17, 21+) strictly forbids it by default.

The java.lang.String class is architected to be strictly immutable. However, historical Java Reflection allowed bypassing encapsulation, acquiring access to private fields via setAccessible(true), and directly modifying the backing array.

In a Nutshell (30 Seconds)

  1. In Java 8 and Earlier: One could obtain the private field value using getDeclaredField("value"), invoke setAccessible(true), and overwrite elements in the backing array.
  2. In Java 17 and 21+: Strong encapsulation introduced by the Java Platform Module System (JEP 403: Strongly Encapsulate JDK Internals) blocks reflective access to internal JDK fields by default. Calling setAccessible(true) throws a fatal java.lang.reflect.InaccessibleObjectException. Bypassing this requires the explicit command-line flag: --add-opens java.base/java.lang=ALL-UNNAMED.
// Demonstration (runs on Java 8, or Java 17+ strictly with --add-opens):
String greeting = "Hello";

Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true); // Fails with InaccessibleObjectException on Java 17+ without flag!

byte[] value = (byte[]) valueField.get(greeting); // In Java 9+; char[] in Java 8
value[0] = 'J'; // Overwrite 'H' with 'J'

System.out.println(greeting); // Prints "Jello"!

Architectural Verdict: Mutating strings in production is strictly prohibited. It breaks JVM security, contaminates the String Pool, corrupts HashMap and HashSet data structures, and leads to undefined behavior.


🟡 Middle Level

Catastrophic Side Effects of Mutating Strings

1. String Pool Contamination

String literals are stored in the String Pool as unique canonical instances. If you mutate the literal "admin", every reference to "admin" across the entire JVM changes simultaneously:

String s1 = "admin";
// Mutate s1 via reflection to "guest"

String s2 = "admin"; 
System.out.println(s2); // Prints "guest"!
// Any authentication check: if ("admin".equals(user.getRole())) is completely compromised!

2. Destruction of the HashMap and HashSet Contract

The hashcode of a String is calculated lazily and cached in the private field private int hash upon the first invocation of hashCode(). If a string was placed as a key in a HashMap and subsequently mutated via reflection:

  • The cached hash field is not invalidated or recalculated.
  • Searching by the new value (map.get("Jello")) returns null: it calculates the new hash of "Jello", searches a different hash bucket, and finds nothing.
  • Searching by the old value (map.get("Hello")) also returns null: it accesses the original bucket, but equals() performs a byte-by-byte comparison and detects a mismatch.
  • Result: The entry is permanently orphaned inside the HashMap as an unreachable memory leak.

3. Coder Flag Desynchronization (Java 9+ Compact Strings)

If a string was instantiated with coder = LATIN1 (1 byte per character), and reflection writes Cyrillic or non-Latin bytes into the byte[] array, the string becomes internally malformed. Methods like charAt(), length(), and getBytes() return corrupted gibberish.


🔴 Senior Level

Evolution of Encapsulation: JEP 261, JEP 396, and JEP 403

The OpenJDK architecture team methodically closed reflection loopholes across recent releases:

  • Java 9 (JEP 261): Introduced JPMS (Project Jigsaw). The java.lang package of java.base was declared closed to deep reflection, though warnings were emitted by default (--illegal-access=permit).
  • Java 16 (JEP 396): Switched illegal reflective access to deny by default (--illegal-access=deny).
  • Java 17 (JEP 403): Permanently removed the --illegal-access flag. Attempting to call setAccessible(true) on private JDK fields now unconditionally throws InaccessibleObjectException. The only remaining escape hatch is explicitly configuring JVM launch arguments: --add-opens java.base/java.lang=ALL-UNNAMED

HotSpot C2 Compiler, @Stable, and Constant Folding

In java.lang.String, the backing array is annotated with @Stable:

public final class String {
    @Stable
    private final byte[] value;
    ...
}

Impact of @Stable on HotSpot C2 Optimization: The @Stable annotation guarantees to the JIT compiler that array elements will never change after object construction:

  1. When a string is identified as a compile-time constant or static final reference, the C2 compiler performs Constant Folding: it inlines the character bytes directly into generated machine code or CPU registers.
  2. If reflection mutates the value array in the heap, C2-compiled machine code remains unaware of the mutation. It continues utilizing the pre-compiled, inlined constants.
  3. This creates a severe desynchronization: interpreted bytecode sees the mutated string, while JIT-compiled native code sees the original, unmutated string!

Java Memory Model Violations (JLS §17.5 Final Field Semantics)

The Java Language Specification (JLS §17.5) guarantees Safe Publication for final fields: any thread receiving a reference to an initialized object is guaranteed to observe the properly initialized values of its final fields without explicit synchronization barriers. Mutating final arrays via reflection:

  • Violates the freeze-action performed at the end of object constructors.
  • Breaks happens-before relationships.
  • Exposes other concurrent threads to partial writes, uninitialized memory, and data races.

4 Tricky Questions

1. What happens when executing String.class.getDeclaredField("value").setAccessible(true) in Java 17/21 by default?

Answer: It throws a fatal runtime exception: java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module Starting with Java 17 (JEP 403), the JVM strictly enforces modular encapsulation on java.base. This restriction cannot be bypassed from within Java application code; it can only be overridden by the system administrator during JVM startup using the CLI argument: --add-opens java.base/java.lang=ALL-UNNAMED.

2. If a key in a HashMap is mutated via reflection, why does map.get("new_value") return null?

Answer: Because String caches its hash code in private int hash:

  1. When the string was initially inserted, its hash was calculated from the original text and cached in the hash field, assigning the key-value pair to a specific hashtable bucket.
  2. Mutating the byte[] value array via reflection does not reset the cached hash field.
  3. Calling map.get("new_value") calculates the hash of "new_value" and searches in a different bucket where no entry exists, returning null.
  4. Calling map.get("old_value") targets the correct bucket, but the bucket’s collision resolution checks key.equals("old_value"), which fails because the underlying bytes no longer match, also returning null.

3. Why might a C2 JIT-compiled method continue returning the old string value even after the heap array has been modified via reflection?

Answer: Because of the @Stable annotation on String.value. The HotSpot C2 compiler treats @Stable arrays as immutable constants during optimization passes. When methods referencing static or invariant strings are compiled, C2 applies Constant Folding, embedding the character sequence directly into assembly instructions or hardware CPU registers. Because heap mutation via reflection does not trigger JIT dependency invalidations, the compiled machine code never re-reads the modified memory block from the Java heap.

4. Can you bypass Java 17 module encapsulation without --add-opens by using sun.misc.Unsafe?

Answer: Historically, Unsafe.putObject() could write directly into heap memory at the field’s offset (unsafe.objectFieldOffset(...)). However, in modern Java:

  1. objectFieldOffset throws an UnsupportedOperationException when called on final fields of classes in java.base.
  2. Under JEP 471 (Java 22+), memory-access methods in sun.misc.Unsafe are deprecated for removal, producing warnings and runtime errors.
  3. Accessing internal fields without explicit JVM enablement flags is no longer possible in modern OpenJDK releases.

🎯 Interview Cheat Sheet

30-Second Summary

“In Java 8, one could modify a String’s backing array via reflection (Field.setAccessible(true) on value). In modern Java (Java 17, 21+), this is strictly blocked by the module system (JEP 403) and throws InaccessibleObjectException, unless overridden via --add-opens java.base/java.lang=ALL-UNNAMED. In practice, mutating a String is a catastrophic anti-pattern: it corrupts the global String Pool, breaks HashMap key retrieval because cached hashcodes are not updated, violates JMM final field semantics, and desynchronizes JIT-compiled code due to the @Stable annotation.”

Key Takeaways for Senior Interviews

  • JEP 403 (Java 17+): Strong encapsulation blocks reflective access to java.lang.String internals by default.
  • Escape Hatch: Can only be opened via the CLI option --add-opens java.base/java.lang=ALL-UNNAMED.
  • @Stable Hazard: C2 inlines array contents as CPU constants; reflective mutations cause interpreted and JIT-compiled code to diverge.
  • Cached Hash Destruction: hashCode() is not recomputed, permanently corrupting HashMap bucket indexing.
  • String Pool Contamination: Mutating a literal corrupts all references to that literal across the entire JVM.

Red Flags to Avoid

  • ❌ Claiming that setAccessible(true) works effortlessly in Java 17 or 21 without JVM flags.
  • ❌ Recommending string reflection as an acceptable technique for clearing passwords in memory (use mutable char[] or byte[] instead).
  • ❌ Assuming that mutating the string’s backing array automatically recalculates its hashCode().
  • ❌ Ignoring the JIT Constant Folding effects caused by @Stable.