Part 9 of 12

Compact Object Headers (JEP 519): 33% Less Heap Overhead

What Is an Object Header?

Every single Java object — every String, every Integer, every record, every array — carries a header that the JVM uses for bookkeeping. Your code never sees this header; it lives alongside the object’s fields in memory.

Before Java 25, on a 64-bit JVM, the header occupied 96 to 128 bits (12–16 bytes):

┌─────────────────────────────────────────────────────────┐
│  Mark Word (64 bits)                                    │
│  ─ identity hash code                                   │
│  ─ lock state (biased lock / thin lock / fat lock)      │
│  ─ GC age bits                                          │
├─────────────────────────────────────────────────────────┤
│  Class Pointer (32 bits compressed / 64 bits full)      │
│  ─ pointer to the object's class (Klass* in HotSpot)    │
└─────────────────────────────────────────────────────────┘

With UseCompressedOops (default), the class pointer is compressed to 32 bits, giving a 96-bit (12-byte) header. Without compression (large heaps > 32GB), it’s 128 bits (16 bytes).


Why This Matters

Twelve bytes per object doesn’t sound like much. But consider:

  • A HashMap with 1,000,000 entries creates ~3–4 million objects (the map itself, each Entry node, each key, each value)
  • A typical microservice processes thousands of requests per second, each allocating hundreds of short-lived objects
  • A data pipeline processing 10 million records allocates 10M+ wrapper objects

For a workload with 100 million live objects, 12 bytes per header = 1.2 GB of header overhead alone. That’s memory your application’s actual data is competing with for heap space.


Java 25: 64-Bit Compact Headers

JEP 519 packs the same information into 64 bits by redesigning the encoding:

┌─────────────────────────────────────────────────────────┐
│  Compact Header (64 bits total)                         │
│  [0–31]  compressed class pointer (32 bits)             │
│  [32–54] identity hash code (22 bits — still enough)    │
│  [55–58] GC age (4 bits)                                │
│  [59–62] lock state (4 bits)                            │
│  [63]    reserved (1 bit)                               │
└─────────────────────────────────────────────────────────┘

Header size drops from 96 bits → 64 bits — a 33% reduction.


How to Enable It

Compact Object Headers are on by default in Java 25 for most workloads. You can verify or force it with a JVM flag:

# Enable explicitly (already the default in Java 25)
java -XX:+UseCompactObjectHeaders -jar myapp.jar

# Disable if you hit compatibility issues
java -XX:-UseCompactObjectHeaders -jar myapp.jar

No code changes required. This is a pure JVM-level optimization.


What Performance Gains to Expect

The gains are workload-dependent:

Workload typeExpected improvement
Object-heavy (collections, graphs)10–20% heap reduction
String-heavy (parsers, serializers)8–15% heap reduction
Compute-heavy (math, no allocations)Negligible
GC pressure reductionReduced GC frequency (less heap = longer between GCs)
Cache efficiencyBetter — objects fit more tightly in CPU cache lines

From the JEP benchmarks on the SPECjbb2015 suite: ~10% throughput improvement on heap-bound workloads.


Demonstrating with a Simple Benchmark

You can measure the impact with a quick allocation benchmark:

import module java.base;

public class HeaderBenchmark {

    record Point(int x, int y) {}

    public static void main(String[] args) {
        int count = 10_000_000;
        long before = Runtime.getRuntime().totalMemory()
                    - Runtime.getRuntime().freeMemory();

        Point[] points = new Point[count];
        for (int i = 0; i < count; i++) {
            points[i] = new Point(i, i * 2);
        }

        System.gc();
        long after = Runtime.getRuntime().totalMemory()
                   - Runtime.getRuntime().freeMemory();

        System.out.printf("Allocated %,d Points%n", count);
        System.out.printf("Heap delta: %,.1f MB%n", (after - before) / 1_048_576.0);
        System.out.printf("Bytes per Point (approx): %.1f%n",
            (double)(after - before) / count);

        // Prevent GC from collecting points before measurement
        if (points[0] == null) System.out.println("unreachable");
    }
}

Run with and without compact headers:

# With compact headers (Java 25 default)
java -XX:+UseCompactObjectHeaders HeaderBenchmark.java

# Without compact headers
java -XX:-UseCompactObjectHeaders HeaderBenchmark.java

A Point record has two int fields (8 bytes of data). With compact headers:

  • Java 24: 8 bytes data + 12 bytes header = 20 bytes → padded to 24 bytes
  • Java 25: 8 bytes data + 8 bytes header = 16 bytes → 16 bytes (no padding needed)

For 10M Points: 24 × 10M = 240 MB vs. 16 × 10M = 160 MB80 MB saved, a 33% reduction exactly.


Impact on Arrays

Arrays also benefit because each array element is an object reference or a primitive value — but array objects themselves have headers:

// An int[] with 1000 elements
// Java 24: 12-byte header + 4 bytes length + (1000 × 4 bytes) = 4016 bytes → padded to 4016
// Java 25: 8-byte header  + 4 bytes length + (1000 × 4 bytes) = 4012 bytes → padded to 4016

For arrays, the impact is smaller because the header overhead is amortized over many elements. The biggest wins come from small objects where the header is a large fraction of total size.


Impact on Records and Small Value Types

Records — one of the best features of modern Java — are especially impacted. A typical record:

record Coordinate(double lat, double lon) {}
  • Field size: 8 + 8 = 16 bytes
  • Java 24 header: 12 bytes → total 28 bytes → padded to 32 bytes
  • Java 25 header: 8 bytes → total 24 bytes → padded to 24 bytes

For an application tracking a million GPS coordinates: 32 MB → 24 MB. Every cache line holds 3 coordinates instead of 2 — better spatial locality, fewer cache misses.


Compatibility Notes

Compact Object Headers required several internal changes to HotSpot:

  1. Identity hash code: The hash code now uses 22 bits instead of 31 bits. For Object.hashCode() this is still sufficient for most use cases (4 million possible values per object).

  2. JVM TI agents: Native agents that read raw object headers need updating. Most production agents (profilers, APM tools) updated for Java 25.

  3. Off-heap access (Unsafe): Code using sun.misc.Unsafe to read object headers directly is fragile regardless — Java 25 doesn’t change this, but compact headers change the offset. Use the official VarHandle API instead.

If you have a native agent that’s incompatible, disable with -XX:-UseCompactObjectHeaders while you wait for an update.


Summary

Java 24Java 25
Header size96 bits (12 bytes)64 bits (8 bytes)
Code changes requiredN/ANone — JVM flag
Enabled by defaultNoYes
Heap saving~10–20% on object-heavy workloads
Identity hash bits3122

Compact Object Headers is one of the most impactful Java 25 improvements for production systems because it requires zero code changes and delivers real memory savings on any object-heavy workload.


Next up: Generational Shenandoah →