🔤 Section 12 · Question #13

What substring() Does and How It Worked Before Java 7

The substring() method in java.lang.String returns a new string that represents a slice (substring) of the original string, delimited by the specified begin and end character in...


🟢 Junior Level

The substring() method in java.lang.String returns a new string that represents a slice (substring) of the original string, delimited by the specified begin and end character indices.

There are two overloaded variants:

  • substring(int beginIndex) — returns the substring from beginIndex (inclusive) to the end of the string.
  • substring(int beginIndex, int endIndex) — returns the substring from beginIndex (inclusive) to endIndex (exclusive, meaning the character at endIndex is not included).

In a Nutshell (30 Seconds)

The central interview topic surrounding this method is its architectural redesign in Java 7 Update 6:

  • Before Java 7u6 (Java 1.0 – 6): The method ran in $O(1)$ constant time with zero array copying. The substring shared the exact same backing char[] array as the parent string, merely storing a custom offset and count. However, this caused severe memory leaks: a 5-character substring could keep a 100 MB parent character array pinned in the heap indefinitely.
  • Java 7u6 and Later (Java 8, 11, 17, 21+): The method runs in $O(N)$ linear time. A new array of the exact required length is allocated, and characters are copied. The substring is fully decoupled from the parent string, allowing the parent’s memory to be garbage-collected immediately.
String text = "Java Interview Guide";

// 1. Extract indices 0 to 4 (characters 0, 1, 2, 3)
String sub1 = text.substring(0, 4); // "Java"

// 2. Extract from index 5 to the end
String sub2 = text.substring(5);    // "Interview Guide"

// 3. Edge case: Substring covering the entire range
String same = text.substring(0, text.length());
System.out.println(same == text); // true (HotSpot fast path returns 'this' without allocation!)

🟡 Middle Level

Internal Structure: Evolution of String Class Fields

To understand the mechanics of the old vs modern substring(), compare the internal field layout of java.lang.String across JDK generations.

1. Java 6 Implementation (Shared Array Architecture)

In Java 6, java.lang.String contained four instance fields:

public final class String {
    private final char[] value; // Backing character array (often shared across strings)
    private final int offset;   // Starting index within the array
    private final int count;    // Number of characters in this string
    private int hash;           // Cached hash code
}

When calling substring(), a package-private constructor was invoked:

// Java 6 package-private constructor:
String(int offset, int count, char[] value) {
    this.value = value; // DIRECT REFERENCE! No array copy!
    this.offset = offset;
    this.count = count;
}
  • Advantage: Blazing fast $O(1)$ execution, zero array allocations.
  • Disaster (Memory Leak): If an application parsed a 100 MB text file and stored line.substring(0, 5), that tiny 5-character string held a strong reference to the entire 100 MB char[] array. Even if the original 100 MB string variable was nulled out, the garbage collector could never reclaim the 100 MB array!

2. Java 7u6+ and Java 8 Implementation (Isolated Copy)

In Java 7u6, JVM architects eliminated offset and count, leaving only:

public final class String {
    private final char[] value; // Backing array sized EXACTLY to this string
    private int hash;
}

The substring() method was updated to perform an array copy via Arrays.copyOfRange():

public String substring(int beginIndex, int endIndex) {
    checkBoundsBeginEnd(beginIndex, endIndex, length());
    int subLen = endIndex - beginIndex;
    if (beginIndex == 0 && endIndex == length()) {
        return this; // Optimization: returns itself
    }
    return new String(value, beginIndex, subLen); // Allocates a new char[] and copies
}

3. Java 9+ Implementation (Compact Strings)

With Compact Strings (JEP 254), char[] was replaced by byte[] value paired with an encoding flag byte coder:

  • For Latin-1 strings (coder = 0), 1 byte per character is copied.
  • For UTF-16 strings (coder = 1), 2 bytes per character are copied.

Generational Comparison Table

Parameter Java 1.0 – 6 (pre-7u6) Java 7u6 – 8 Java 9 – 21+
Backing Storage char[] (shared with parent) char[] (new isolated array) byte[] (new isolated array)
Instance Fields value, offset, count, hash value, hash value, coder, hash
Time Complexity $O(1)$ $O(N)$ (where $N = \text{subLen}$) $O(N)$ (where $N = \text{subLen}$)
Array Allocation 0 bytes (only String header) New char[subLen] New byte[subLen \times \text{width}]
Memory Leak Risk ⚠️ High (pins large parent arrays) ❌ Eliminated ❌ Eliminated
String Object Size 32 bytes (Compressed OOPs) 24 bytes (Compressed OOPs) 24 bytes (Compressed OOPs)

🔴 Senior Level

Why Removing offset and count Was an Architectural Victory

At first glance, trading $O(1)$ time complexity for $O(N)$ copying looks like a performance regression. However, across enterprise Java workloads, this redesign delivered substantial net memory savings:

  1. 8-Byte Reduction per String Instance:
    • On a 64-bit JVM with Compressed OOPs (-XX:+UseCompressedOops), removing the two 4-byte int fields (offset and count) shrank the memory footprint of every String instance from 32 bytes to 24 bytes (accounting for 8-byte object alignment padding).
    • In production Java applications, String instances typically constitute 20% to 40% of all heap objects. Reducing the footprint of every single string instance by 25% instantly saved hundreds of megabytes of heap without requiring any application code changes.
  2. Elimination of Pervasive Memory Leaks:
    • In real-world parsing pipelines (JSON, XML, CSV, HTTP headers, log processing), strings are predominantly short-lived and short in length. Retaining massive parent input buffers across thousands of transient substrings was a frequent cause of production OutOfMemoryErrors.

Fast Path Optimization & Object Identity

OpenJDK’s HotSpot implementation includes an important micro-optimization in substring():

if (beginIndex == 0 && endIndex == length()) {
    return this;
}

If the requested range matches the full string length, the method avoids allocating a new object on the heap and simply returns this. Because String is immutable, returning this is completely safe:

String s1 = "benchmark";
String s2 = s1.substring(0, s1.length());
System.out.println(s1 == s2); // true (Identical reference, zero allocation)

Zero-Copy Alternatives in Modern Java

When high-throughput, low-latency applications (e.g., High-Frequency Trading, API gateways, log parsers) need zero-copy substring mechanics without heap allocation overhead, the following patterns are used:

  1. CharSequence Interface: Algorithms designed around CharSequence.subSequence() can accept custom implementations that slice without copying.
  2. java.nio.CharBuffer:
    CharBuffer buffer = CharBuffer.wrap(largeText);
    CharSequence slice = buffer.subSequence(start, end); // Lightweight view, zero array copying
    
  3. Netty ByteBuf.slice(): In reactive network programming, Netty slices byte buffers by manipulating reader and writer indices without allocating new memory blocks.

4 Tricky Questions

1. What does str.substring(0, str.length()) return in modern Java — does it allocate a new object on the heap?

Answer: No new object is allocated. The very first instruction of the substring() implementation checks: if (beginIndex == 0 && endIndex == length()) return this; Because String is immutable, returning this guarantees semantic safety. The comparison str == str.substring(0, str.length()) evaluates to true, and both memory and CPU allocation overhead are zero.

2. Why did Java 6 developers frequently write new String(str.substring(x, y)), and why is this a severe anti-pattern in Java 17/21?

Answer:

  • In Java 6: Calling str.substring(x, y) did not copy the underlying character array, but rather retained a reference to the parent’s char[]. If the parent string was large (e.g., a 50 MB document), wrapping the call in new String(...) invoked a special copy constructor (String(String original)) that allocated a fresh char[] of exact size, allowing the 50 MB parent array to be reclaimed by GC.
  • In Java 17/21: The substring() method already allocates a fresh, truncated array via Arrays.copyOfRange(). Wrapping it in new String(...) creates a completely redundant second String object and a redundant second array on the heap, doubling memory allocation and GC pressure.

3. Which exceptions can substring(int beginIndex, int endIndex) throw and under what precise conditions?

Answer: The method throws java.lang.StringIndexOutOfBoundsException (a subclass of IndexOutOfBoundsException) under any of the following three conditions:

  1. beginIndex < 0 (negative starting index).
  2. endIndex > length() (ending index exceeds the string’s length).
  3. beginIndex > endIndex (starting index is greater than ending index, e.g., str.substring(8, 3)). Note: Calling str.substring(str.length(), str.length()) is valid; it evaluates without throwing an exception and returns an empty string "".

4. Can substring() in Java 9+ produce a string with a lower byte-per-character storage footprint than the parent string?

Answer: In current OpenJDK HotSpot implementations, no, it inherits the parent’s coder. In Java 9+, substring() invokes: isLatin1() ? StringLatin1.newString(...) : StringUTF16.newString(...) If the parent string contains even a single non-Latin-1 character (e.g., Cyrillic or emoji), its coder is set to UTF16 (2 bytes per character). Even if you extract a slice containing exclusively ASCII characters (e.g., "abc"), the resulting substring is still encoded as UTF-16 (coder = 1) consuming 2 bytes per character. The JVM intentionally does not rescan the substring characters to verify if Latin-1 compression is possible, prioritizing $O(N)$ raw memory copy throughput over re-encoding CPU cycles.


🎯 Interview Cheat Sheet

30-Second Summary

“The substring() method returns a slice from beginIndex (inclusive) to endIndex (exclusive). In Java 6, it operated in $O(1)$ by sharing the parent’s char[] array using offset and count fields. This caused severe memory leaks when small substrings pinned massive parent arrays. In Java 7u6, JVM architects eliminated offset and count, saving 8 bytes per String instance across the entire heap, while making substring() perform a defensive array copy in $O(N)$ time. In Java 9+, it copies byte[] arrays while preserving the parent’s coder flag.”

Key Takeaways for Senior Interviews

  • Evolution: Java 6 (shared char[], $O(1)$, memory leak vulnerability) $\to$ Java 7u6+ (isolated copy, $O(N)$, memory leak safe) $\to$ Java 9+ (Compact Strings byte[]).
  • Heap Footprint: Dropping offset and count reduced String instance size from 32 bytes to 24 bytes (with Compressed OOPs), yielding a 25% size reduction across all strings in the heap.
  • Fast Path: str.substring(0, str.length()) returns this with zero allocation.
  • Obsolete Workaround: new String(str.substring(...)) was a Java 6 necessity to prevent leaks; in modern Java, it is a harmful double-allocation anti-pattern.
  • Zero-Copy Solutions: When zero-copy slicing is critical in modern Java, use CharSequence.subSequence(), java.nio.CharBuffer.wrap(), or Netty ByteBuf.slice().

Red Flags to Avoid

  • ❌ Claiming that substring() in modern Java operates in $O(1)$ constant time.
  • ❌ Recommending new String(str.substring(...)) in modern Java for “memory optimization.”
  • ❌ Forgetting that endIndex is exclusive (not inclusive).
  • ❌ Thinking that substring() modifies the original string (strings are immutable).