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 frombeginIndex(inclusive) to the end of the string.substring(int beginIndex, int endIndex)— returns the substring frombeginIndex(inclusive) toendIndex(exclusive, meaning the character atendIndexis 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 customoffsetandcount. 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 MBchar[]array. Even if the original100 MBstring 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:
- 8-Byte Reduction per
StringInstance:- On a 64-bit JVM with Compressed OOPs (
-XX:+UseCompressedOops), removing the two 4-byteintfields (offsetandcount) shrank the memory footprint of everyStringinstance from 32 bytes to 24 bytes (accounting for 8-byte object alignment padding). - In production Java applications,
Stringinstances 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.
- On a 64-bit JVM with Compressed OOPs (
- 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:
CharSequenceInterface: Algorithms designed aroundCharSequence.subSequence()can accept custom implementations that slice without copying.java.nio.CharBuffer:CharBuffer buffer = CharBuffer.wrap(largeText); CharSequence slice = buffer.subSequence(start, end); // Lightweight view, zero array copying- 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’schar[]. If the parent string was large (e.g., a 50 MB document), wrapping the call innew String(...)invoked a special copy constructor (String(String original)) that allocated a freshchar[]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 viaArrays.copyOfRange(). Wrapping it innew String(...)creates a completely redundant secondStringobject 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:
beginIndex < 0(negative starting index).endIndex > length()(ending index exceeds the string’s length).beginIndex > endIndex(starting index is greater than ending index, e.g.,str.substring(8, 3)). Note: Callingstr.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 frombeginIndex(inclusive) toendIndex(exclusive). In Java 6, it operated in $O(1)$ by sharing the parent’schar[]array usingoffsetandcountfields. This caused severe memory leaks when small substrings pinned massive parent arrays. In Java 7u6, JVM architects eliminatedoffsetandcount, saving 8 bytes perStringinstance across the entire heap, while makingsubstring()perform a defensive array copy in $O(N)$ time. In Java 9+, it copiesbyte[]arrays while preserving the parent’scoderflag.”
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 Stringsbyte[]). - Heap Footprint: Dropping
offsetandcountreducedStringinstance 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())returnsthiswith 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 NettyByteBuf.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
endIndexis exclusive (not inclusive). - ❌ Thinking that
substring()modifies the original string (strings are immutable).
Related Questions
- Why substring() Implementation Was Changed in Java 7 — Architectural rationale and tradeoffs
- Why String is Immutable — Benefits and design of immutability
- What are Compact Strings in Java 9+ — Latin-1 vs UTF-16 representation
- How to Find Out How Much Memory a String Occupies — Deep memory calculation