🔤 Section 12 · Question #20

How to Find Out How Much Memory a String Occupies

Java lacks a native sizeof operator like C or C++. Consequently, determining the physical memory consumed by a string requires calculating the JVM heap layout of its constituent...


🟢 Junior Level

Java lacks a native sizeof operator like C or C++. Consequently, determining the physical memory consumed by a string requires calculating the JVM heap layout of its constituent components.

In HotSpot JVM, a String is not a single object, but two interconnected objects in the Java Heap:

  1. The java.lang.String object instance (wrapper containing object headers, a reference pointer to the backing array, encoding flags, and the cached hashcode).
  2. The independent backing byte array byte[] value (containing array headers, length metadata, actual character bytes, and 8-byte alignment padding).

In a Nutshell (30 Seconds)

On a standard 64-bit JVM with Compressed OOPs enabled (-XX:+UseCompressedOops, heap size up to 32 GB): \(\text{Total Memory} = \text{String Object (24 bytes)} + \text{byte[] Array (16 bytes header + data + 8-byte padding)}\)

  • For Latin-1 characters (ASCII / Western Europe) in Java 9+: 1 byte per character.
  • For Cyrillic, Asian scripts, and emojis (UTF-16): 2 bytes per character.
// Memory calculation for "Hello" (5 characters Latin-1):
// 1. String wrapper object: 24 bytes
// 2. Backing array byte[5]: 16 bytes (header) + 5 bytes (data) = 21 bytes -> padded to 24 bytes
// TOTAL: 24 + 24 = 48 bytes in Java Heap!

For programmatic measurement, the OpenJDK JOL (Java Object Layout) library provides exact heap layout metrics:

import org.openjdk.jol.info.GraphLayout;

String text = "Hello";
long totalBytes = GraphLayout.parseInstance(text).totalSize(); // 48 bytes

🟡 Middle Level

Shallow Size vs. Retained Size

  1. Shallow Size: The memory consumed solely by the java.lang.String wrapper object itself, excluding any referenced objects. On a 64-bit JVM with Compressed OOPs, the shallow size of a String is strictly 24 bytes, regardless of string length.
  2. Retained Size (Deep Size): The sum of the shallow size plus all objects exclusively reachable through it (i.e., String instance [24 bytes] + byte[] value array [16 bytes header + payload + padding]). This is the exact amount of memory freed when the string is garbage-collected.

HotSpot Memory Layout Breakdown (64-bit, Compressed OOPs)

Component Field / Segment Size Description
String Object Mark Word 8 bytes Locking state, GC age, identity hashcode
  Compressed Klass Pointer 4 bytes Compressed pointer to java.lang.String.class
  value reference 4 bytes Compressed OOP pointer to byte[]
  coder flag 1 byte 0 = LATIN1, 1 = UTF16
  hash 4 bytes Cached 32-bit hash value
  hashIsZero (Java 13+) 1 byte Flag indicating whether hash is explicitly 0
  Alignment Padding 2 bytes Padded to 8-byte boundary
Total Shallow Size   24 bytes Fixed for every String instance
byte[] Array Mark Word 8 bytes GC and locking metadata
  Compressed Klass Pointer 4 bytes Pointer to [B.class (byte array primitive class)
  length 4 bytes Array element count
  Payload Data $N$ bytes $N \times 1$ byte for Latin-1, $N \times 2$ bytes for UTF-16
  Alignment Padding 0 to 7 bytes Padded to nearest multiple of 8 bytes
Total Array Size   align8(16 + N) Variable depending on length and charset

String Memory Matrix in Java 17 / 21

String Example Length Encoding String Shallow byte[] Array Total Retained Size
"" (Empty String) 0 Latin-1 24 bytes 16 bytes (0 data) 40 bytes
"A" 1 Latin-1 24 bytes $16 + 1 \to$ 24 bytes 48 bytes
"Hello" 5 Latin-1 24 bytes $16 + 5 \to$ 24 bytes 48 bytes
"Привет" 6 UTF-16 24 bytes $16 + 12 \to$ 32 bytes 56 bytes
100 characters Latin-1 100 Latin-1 24 bytes $16 + 100 \to$ 120 bytes 144 bytes
100 characters UTF-16 100 UTF-16 24 bytes $16 + 200 \to$ 224 bytes 248 bytes

🔴 Senior Level

The 32 GB Cliff: When Compressed OOPs Are Disabled

The JVM automatically enables Compressed Ordinary Object Pointers (-XX:+UseCompressedOops) as long as -Xmx does not exceed roughly 32 GB:

  • In compressed mode, 64-bit memory addresses are encoded into 32-bit pointers with a 3-bit shift ($2^{32} \times 8 = 32 \text{ GB}$).
  • What happens when setting -Xmx33g? Compressed OOPs are disabled. All object pointers and Klass pointers expand from 4 bytes to full 64-bit 8-byte pointers:
    • String shallow size increases from 24 to 32 bytes.
    • byte[] array header increases from 16 to 24 bytes.
    • In an enterprise service holding 100 million strings, header overhead alone swells by 1.6 gigabytes!
    • The 32 GB Cliff Effect: A JVM configured with -Xmx33g frequently holds less usable application data than one configured with -Xmx31g.

Runtime Profiling: Instrumentation API vs. JOL

The Java platform includes the Java Agent Instrumentation API:

public class ObjectSizeFetcher {
    private static Instrumentation instrumentation;
    public static void premain(String args, Instrumentation inst) {
        instrumentation = inst;
    }
    public static long getShallowSize(Object o) {
        return instrumentation.getObjectSize(o);
    }
}

Critical Gotcha: instrumentation.getObjectSize(str) returns only Shallow Size (24 bytes), whether the string has 5 characters or 5 million characters! To measure Retained Size without external dependencies like JOL, developers must implement recursive reflection graph traversals using an IdentityHashMap to track visited object references and prevent infinite loops.

Future JVM Evolution: Project Lilliput (Java 21+)

OpenJDK’s Project Lilliput aims to compress object headers across the JVM:

  • Under experimental flags (-XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders), object headers are compressed from 12/16 bytes down to a single 64-bit word (8 bytes), combining the Mark Word and a compressed class ID.
  • This shrinks String instance shallow size from 24 to 16 bytes, and the byte[] array header from 16 to 8 bytes, cutting the memory footprint of micro-strings by 30% to 40%.

4 Tricky Questions

1. Why does instrumentation.getObjectSize("Text with 100,000 characters...") return only 24 bytes?

Answer: Instrumentation.getObjectSize(Object obj) measures strictly the Shallow Size of the supplied object reference:

  • It accounts solely for the object’s direct header (Mark Word + Klass Word) and the fields declared directly within that class (primitives and 4-byte reference pointers).
  • It does not traverse object graph references. In java.lang.String, the value field is merely a 4-byte reference pointing to an independent byte[] object in the heap. The String wrapper is always 24 bytes under Compressed OOPs, while the backing array containing the 100,000 characters resides as a separate heap allocation ignored by getObjectSize().

2. How much heap memory does an empty string literal "" consume?

Answer: An empty string "" consumes exactly 40 bytes (on a 64-bit JVM with Compressed OOPs):

  1. The java.lang.String wrapper: 12 bytes (header) + 4 bytes (value reference) + 1 byte (coder) + 4 bytes (hash) + 1 byte (hashIsZero) + 2 bytes (padding) = 24 bytes.
  2. The byte[0] backing array: 12 bytes (header) + 4 bytes (length = 0) + 0 bytes (payload) = 16 bytes. Total: $24 + 16 = \mathbf{40 \text{ bytes}}$.

3. Why can increasing a production server’s heap from -Xmx31g to -Xmx33g cause it to crash with OutOfMemoryError sooner?

Answer: Because passing the 32 GB threshold disables Compressed OOPs (-XX:+UseCompressedOops turns off):

  • Object references and class pointers expand from 32 bits (4 bytes) to 64 bits (8 bytes).
  • All object headers, array metadata, and reference fields across every collection, cache, and string expand by 30% to 50%.
  • For string- and collection-heavy applications, the increased pointer tax consumes 2 to 4 GB of additional heap space just for object overhead. The effective usable memory capacity at 33 GB is therefore lower than at 31 GB, causing Eden and Old Gen to saturate faster and triggering premature OutOfMemoryErrors.

4. If an application creates 100,000 variables declared as String s = "admin", how much heap memory do they consume in aggregate?

Answer: They consume exactly 48 bytes across the entire application (plus stack frame pointer storage on thread stacks). Because "admin" is a string literal, the compiler and JVM canonicalize it into the String Pool. All 100,000 variables reference the exact same physical instance in the heap: one String object (24 bytes) and one shared byte[5] array (24 bytes). In contrast, if created via new String("admin"), 100,000 independent String instances would be allocated, consuming $100\,000 \times 24 \text{ bytes} \approx 2.4 \text{ MB}$ of heap memory.


🎯 Interview Cheat Sheet

30-Second Summary

“A Java string consists of two heap objects: the String wrapper (Shallow Size = 24 bytes with Compressed OOPs) and its backing byte[] value array (16 bytes header + payload + 8-byte alignment padding). Under Java 9+ Compact Strings, Latin-1 chars take 1 byte and UTF-16 chars take 2 bytes. An empty string consumes 40 bytes, and "Hello" consumes 48 bytes. To measure exact retained heap usage, use OpenJDK JOL (GraphLayout.parseInstance().totalSize()), as Instrumentation.getObjectSize() returns shallow size only.”

Key Takeaways for Senior Interviews

  • Shallow vs. Retained: Shallow size is always 24 bytes (with Compressed OOPs); Retained size includes the byte[] array.
  • Calculation Formula: $\text{Retained Size} = 24 + \text{align8}(16 + \text{len} \times (1 \ll \text{coder}))$.
  • Compressed OOPs Cliff: Exceeding 32 GB heap disables pointer compression, increasing string wrapper size to 32 bytes and array headers to 24 bytes.
  • Project Lilliput: Java 21+ compact object headers reduce object headers to 8 bytes, saving up to 40% memory on micro-strings.
  • String Pool Deduplication: A million identical literals consume the memory of only a single 48-byte instance in heap.

Red Flags to Avoid

  • ❌ Stating that Java has a sizeof operator.
  • ❌ Calculating string size as simply “characters $\times$ 2 bytes” (ignoring the 40+ bytes of object and array header overhead).
  • ❌ Assuming Instrumentation.getObjectSize() returns the total memory of the string including its character buffer.
  • ❌ Forgetting the 8-byte memory alignment padding applied to all JVM heap allocations.