Question 21 · Section 12

Can You Modify String Contents via Reflection?

String is designed as an immutable class. This means after creation, a string cannot be modified. But Java Reflection allows you to "look inside" any object and change even priv...

Language versions: English Russian Ukrainian

🟢 Junior Level

Technically — yes, but it’s an extremely bad idea.

String is designed as an immutable class. This means after creation, a string cannot be modified. But Java Reflection allows you to “look inside” any object and change even private and final fields.

Example (don’t do this!):

String s = "Hello";
Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true);

// Java 9+: value is byte[]
byte[] value = (byte[]) valueField.get(s);
value[0] = (byte) 'J'; // Change bytes directly

System.out.println(s); // "Jello" — string has changed!

Simple analogy: String is like a sealed bottle of water. The manufacturer expects you won’t open it. But if you unscrew the cap (reflection) and dye the water — the bottle still looks like “water”, but the content is different.

Why this is dangerous:

  1. All references to this string will also change — other parts of the program will see “Jello” instead of “Hello”
  2. String Pool will break — the literal "Hello" in code may now mean “Jello”
  3. Security is at risk — password hashes, keys, tokens can be altered

Conclusion: Never modify String via reflection in real code.


🟡 Middle Level

How it works

Before Java 9:

// String stored char[] value
Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true);
char[] value = (char[]) valueField.get(s);
value[0] = 'J';

Java 9+ (Compact Strings):

// String stores byte[] value + byte coder
Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true);
byte[] value = (byte[]) valueField.get(s);
value[0] = (byte) 'J';

The coder field determines how bytes are interpreted. If the string was Latin-1 (coder=0) and you write a byte outside the Latin-1 range — the string remains Latin-1, but data becomes corrupt.

Practical consequences

String key = "password";
String cached = key; // Another reference to the same string

// Modify via reflection
Field f = String.class.getDeclaredField("value");
f.setAccessible(true);
byte[] v = (byte[]) f.get(key);
v[0] = (byte) 'P';

System.out.println(key);    // "Password"
System.out.println(cached); // "Password" — also changed!

Table of typical mistakes

Mistake Consequences Solution
Modifying String in String Pool All references to literal see modified value Don’t modify literals; work only with new String(...)
Modifying hash field hashCode() returns old value, HashMap.get() won’t find key Don’t touch hash field; if value changed — reset hash
Modification in multithreaded Data race, undefined behavior String immutable — this is the contract for all threads
Java 9+ module system setAccessible(true) throws InaccessibleObjectException --add-opens java.base/java.lang=ALL-UNNAMED

Approach comparison

Method Works? Danger Java 8 Java 9+
Modify value[] Yes Critical ✅ (byte[])
Modify coder Yes Critical N/A
Modify hash Yes High
Create mutable String No N/A

When NOT to use reflection on String

  • Production code — never
  • Libraries — never (you’ll break someone else’s code)
  • Multithreaded systems — data race guaranteed
  • Security-sensitive code — bypass checks, injection
  • Only valid scenario: unit tests, mocking frameworks, deep serialization

🔴 Senior Level

Internal Implementation — Reflection and AccessibleObject

Reflection mechanism in JVM:

// Field.setAccessible(true) internally:
// 1. Checks module access (Java 9+)
// 2. Sets override flag in AccessibleObject
// 3. Disables access checks on get/put via JNI

Java 9+ Module System (JEP 261): With the modular system, access to internal fields is restricted:

// Without JVM flag — throws InaccessibleObjectException
Field f = String.class.getDeclaredField("value");
f.setAccessible(true); // ❌ InaccessibleObjectException

// Requires JVM flag:
// --add-opens java.base/java.lang=ALL-UNNAMED

String fields (Java 9+):

public final class String {
    @Stable
    private final byte[] value;  // immutable after construction
    private final byte coder;    // 0 = LATIN1, 1 = UTF16
    private int hash;            // lazy: 0 = not computed yet
    // serialPersistentFields, caseInsensitiveComparator, ...
}

@Stable — JVM annotation (from jdk.internal.vm.annotation) that tells the JIT compiler: the field is set once in the constructor and never changes. This enables:

  • Constant folding: JIT substitutes value directly into machine code
  • Register caching: value is cached in CPU register
  • Escape analysis: optimizations based on immutability

Modifying @Stable field breaks JIT optimizations: compiled code may use old (cached) value.

Edge Cases (minimum 3)

1. String Pool contamination:

String s1 = "Hello"; // Literal → String Pool
String s2 = "Hello"; // Same reference from pool

Field f = String.class.getDeclaredField("value");
f.setAccessible(true);
byte[] v = (byte[]) f.get(s1);
v[0] = (byte) 'J';

System.out.println(s2); // "Jello" — literal in code now means "Jello"!
// Any new code String s = "Hello" may get the modified object
// JVM behavior is defined — literal in pool is modified. But JIT-compiled code that
// constant-folded the original value may see inconsistent results.

2. HashMap key corruption:

String key = "key";
Map<String, String> map = new HashMap<>();
map.put(key, "value");

// Modify key via reflection
Field f = String.class.getDeclaredField("value");
f.setAccessible(true);
byte[] v = (byte[]) f.get(key);
v[0] = (byte) 'K'; // "Key"

// hash field still contains hash of "key"
map.get("Key");  // null — hash doesn't match
map.get("key");  // null — such string no longer exists (value changed)
// HashMap is broken!

3. Coder mismatch — writing UTF-16 byte into Latin-1 string:

String s = "Hello"; // coder = LATIN1
Field vf = String.class.getDeclaredField("value");
Field cf = String.class.getDeclaredField("coder");
vf.setAccessible(true);
cf.setAccessible(true);

byte[] v = (byte[]) vf.get(s);
v[0] = (byte) 0xD0; // Cyrillic byte into Latin-1 string!

// s.charAt(0) returns (char) 0xD0 = 208 (invalid character)
// s.getBytes(UTF_8) returns corrupt bytes
// equals() may work unpredictably

4. Concurrent modification — data race:

// Thread A reads string
char c = s.charAt(0);

// Thread B simultaneously changes value[] via reflection
v[0] = (byte) 'X';

// Thread A: value c may be old or new — data race
// happens-before is broken: no synchronization for final fields
// modified via reflection

5. Security Manager and Reflection (Java 17+): In Java 17+ SecurityManager is deprecated, and module access is strict by default. Even with --add-opens some JVM-internal fields may be protected at native code level.

Performance

Operation Time Note
setAccessible(true) ~50–200ns One-time, but with module check more expensive
Field.get() ~20–50ns JNI call overhead
Field.set() on final field ~20–50ns But breaks JIT optimizations
String.hashCode() after mutation Incorrect hash field is not recalculated
JIT deoptimization ~1–10μs If JIT compiled code with constant-folded String

JIT deoptimization: If JIT already compiled a method using this string (e.g., via constant folding "Hello" → inline), then changing the value causes deoptimization — JVM discards compiled code and reinterprets. This costs 1–10μs and may happen repeatedly.

Thread Safety

String is not thread-safe after reflection mutation. The immutable contract is broken:

  • No volatile on value[] — other threads may not see the change (or see it partially)
  • No memory barriers — happens-before is not established
  • @Stable annotation allows JIT to cache value in registers — thread may never see the change

Production War Story

Scenario: Testing framework (PowerMock/Mockito) used reflection to mock String in unit tests.

// Test changes string constant
Field f = String.class.getDeclaredField("value");
f.setAccessible(true);
byte[] v = (byte[]) f.get("CONSTANT");
// ... modifies value

Problem: tests passed individually, but when running in parallel (Maven Surefire, forkCount > 1) one test changed the string in String Pool, and another test failed with AssertionError. String Pool is shared across all tests in the same JVM.

Fix: Test isolation in separate JVMs (forkMode=always) or avoid mocking immutable objects.

Scenario 2: Deep serialization library (Kryo) optimized String serialization by modifying internal value[] directly. After migration to Java 17 with --add-opens not configured → InaccessibleObjectException in production. Fix: switch to standard serialization.

Monitoring

# Check module access
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar

# JFR — reflection access events
java -XX:StartFlightRecording=filename=recording.jfr ...
# In JDK Flight Recorder: no special event for reflection,
# but can be caught via custom events

# GC logs — if mutation causes memory leaks
java -Xlog:gc*:file=gc.log ...

# jcmd — check flags
jcmd <pid> VM.flags | grep add-opens
// Runtime detection of reflection access
// (Java 9+: can set custom ReflectionFilter)
// Or via java.lang.instrument — intercept Field.setAccessible()

// Check field accessibility
try {
    Field f = String.class.getDeclaredField("value");
    f.setAccessible(true);
    System.out.println("Accessible: " + f.canAccess(""));
} catch (InaccessibleObjectException e) {
    System.out.println("Blocked by module system");
}

Best Practices for Highload

  • Never modify String via reflection in production code
  • For unit tests: use isolated JVMs or avoid mocking immutable objects
  • For serialization: use standard mechanisms (Serializable, JSON, Protobuf)
  • If absolutely necessary (framework development):
    • Use --add-opens only for required modules
    • Don’t modify literals from String Pool
    • Reset hash field after modifying value[]
    • Document the requirement for all framework users
  • For security-sensitive apps: set Security Manager (up to Java 17) or use JVM flags to block reflection
  • Alternative: MethodHandles.privateLookupIn() (Java 9+) — more controlled field access
  • For mutable strings: use char[], byte[], StringBuilder, or ByteBuffer — they’re designed for this

🎯 Interview Cheat Sheet

Must know:

  • Technically can modify byte[] value via reflection, but extremely dangerous
  • Java 9+: module system blocks setAccessible(true) without --add-opens java.base/java.lang=ALL-UNNAMED
  • @Stable annotation on value — JIT uses constant folding, modification breaks optimizations
  • Modifying a literal in String Pool affects ALL references to that literal across the entire app
  • HashMap key corruption: modifying key via reflection → hashCode() returns old value → get() won’t find
  • Only legitimate scenario: unit tests, mocking frameworks, deep serialization

Frequent follow-up questions:

  • What happens when modifying a literal via reflection? — All references to that literal see the changed value. String s = "Hello" may become "Jello".
  • Why is @Stable annotation important? — JIT caches value in registers, does constant folding. Modification causes deoptimization (~1-10μs).
  • How does module system (Java 9+) protect from reflection?setAccessible(true) throws InaccessibleObjectException without --add-opens flag.
  • What happens to HashMap when key is modified? — hash field is not recalculated, get() won’t find the key, HashMap is broken.

Red flags (DON’T say):

  • ❌ “Reflection on String — normal practice” — this is an antipattern, violation of immutable contract
  • ❌ “You can modify a string without consequences” — breaks String Pool, HashMap, JIT optimizations
  • ❌ “setAccessible() always works” — blocked by module system in Java 9+
  • ❌ “This is thread-safe if modified in one thread” — @Stable allows JIT to cache, other threads may see old value

Related topics:

  • [[4. Why String is Immutable]]
  • [[1. How String Pool Works]]
  • [[19. What are Compact Strings in Java 9+]]