Skip to content
Back
JVM internals

JVM: The Silent Revolution

How Java 25 quietly changed everything you thought you knew about performance with zero code changes required.

Michał Artur Marciniak

10 min read

On this page
  1. Prologue: The City Beneath Your Code
  2. Part I: Anatomy - What Actually Happens When You Run java -jar
  3. Part II: Beyond the Headlines - Incremental Improvements
  4. Part III: The 12-Byte Tax
  5. Part IV: The Compression That Changed Everything
  6. Part V: The Warmup Problem - Solved
  7. Part VI: Generational Shenandoah - The Other GC Gets an Upgrade
  8. Part VII: Scoped Values - ThreadLocal’s Successor
  9. Part VIII: What This Means for You
  10. Part IX: When to Exercise Caution
  11. Epilogue: The Future is Already Here
  12. References

How Java 25 Quietly Changed Everything You Thought You Knew About Performance


“The JVM is not a virtual machine. It’s a time machine - taking code written yesterday and making it run faster tomorrow, without you touching a single line.”


Prologue: The City Beneath Your Code

Imagine your Java application as a bustling metropolis. The Heap is the residential district - sprawling, crowded, constantly being cleaned by sanitation trucks (the Garbage Collector). The Stack is the business district - efficient, vertical, each building demolished the moment its purpose is served. And somewhere in between, unseen engineers work day and night to make everything run smoother, faster, cheaper.

For thirty years, this city has been evolving. While Oracle announced Java 25 as the latest Long-Term Support release with the usual fanfare, the quiet elegance lies in how these improvements arrive: no code changes, no migration pain, just add a flag and watch performance metrics improve. The revolution isn’t in the announcement-it’s in the effortless adoption. In September 2025, Java 25 arrived carrying a feature that would make CTOs do double-takes at their monitoring dashboards: 22% less heap and 8% less CPU time in SPECjbb2015, zero code changes required.

How? Let’s open the hood.


Part I: Anatomy - What Actually Happens When You Run java -jar

The Assembly Line

Your Java code doesn’t “run.” It transforms:

Build

  1. YourCode.javasource
  2. javaccompiler
  3. Bytecode.class

Load

  1. Class loaderloads classes
  2. Verifierchecks bytecode

Execute

  1. Interpreterslow, at once
  2. C1quick, profiles
  3. C2hot paths
  4. Native codefast
Tiered compilation: code starts in the interpreter, C1 compiles it quickly and collects a profile, and C2 uses that profile to optimize the hot paths. The violet deepens as the code gets hotter.

This is tiered compilation, and it’s been Java’s secret sauce since version 8. The JVM watches how your code behaves - which methods get called frequently, which branches are taken, which loops matter. Then it recompiles the hot paths into highly optimized native code.

But here’s the thing: for all its sophistication, the JVM has been carrying historical baggage. Every object in Java carries a header - metadata about the object itself. And for thirty years, that header has been… chunky.


Part II: Beyond the Headlines - Incremental Improvements

While Compact Object Headers steal the spotlight, Java 25 continues the C2 compiler’s ongoing refinement. These aren’t revolutionary changes, but they add up:

  • Merge store optimization2-4xBetter handling of sequential byte stores, including reverse-order ones; the figure is for network byte-order conversions.
  • Math.max/min recognition1.5-2xImproved auto-vectorization of common math operations on numerical workloads.
  • Pattern recognitionVaries by workloadMore loops become eligible for SIMD auto-vectorization.
Java
// This pattern benefits from merge store optimization:
byte[] networkBuffer = new byte[1024];
for (int i = 0; i < data.length; i++) {
    networkBuffer[i] = (byte) ((data[i] >> 8) & 0xFF);  // Big-endian conversion
}

These improvements demonstrate the JVM’s continuous evolution: your unchanged code gets faster because the compiler keeps getting smarter, release by release.


Part III: The 12-Byte Tax

Meet Your Object’s Overhead

Create a simple object in Java:

Java
class Point {
    int x;
    int y;
}

How much memory does it consume? Not the 8 bytes of its two ints, but 24 bytes.

In the traditional HotSpot JVM, every object carries:

  • Mark Word (64 bits): GC age, identity hash code, lock information
  • Class Pointer (32 bits compressed): Reference to class metadata

Total: 12 bytes of header + 8 bytes of data = 20 bytes, rounded up to 24 by 4 bytes of padding, because objects are aligned to 8 bytes.

For an object with just two integers, two thirds of the memory is overhead.

This isn’t just academic. In microservices architectures, in-memory caches, and data processing pipelines, applications create millions of small objects per second. That 12-byte tax compounds into gigabytes of wasted RAM and countless CPU cycles spent managing the bloat.

In many workloads, object headers alone take more than 20% of live data (JEP 450).

Something had to change.

When benefits are minimal: Applications with large object-to-header ratios (CAD tools, video processing, applications using mostly Direct ByteBuffers) won’t see dramatic improvements from Compact Object Headers-the header overhead was already negligible compared to the data payload.


Part IV: The Compression That Changed Everything

JEP 519: Compact Object Headers

Java 25 introduces Compact Object Headers (JEP 519), and the math is elegant in its simplicity:

Traditional96 bits = 12 bytes

Mark word64 bitsClass pointer32 bits

Compact (Java 25)64 bits = 8 bytes

Hash, age, lock42 bitsClass pointer22 bits
The compact header is a single 64-bit word: the class pointer, shrunk to 22 bits, moves inside the mark word. A Point with two ints drops from 24 to 16 bytes.

The trick? The JVM engineers realized they could overlay the class pointer onto unused bits in the mark word. Most objects don’t need all 64 bits of metadata simultaneously. By carefully encoding the class pointer into just 22 bits - enough to address 4 million unique classes - they freed up 4 bytes per object.

33% header reduction, measured gains in heap, CPU and GC.

JEP 519 reports these measurements, and the feature has been tested at Amazon by hundreds of services in production:

  • 22% less heap and 8% less CPU time (SPECjbb2015)
  • 15% fewer garbage collections (SPECjbb2015, G1 and Parallel)
  • 10% less time for a highly parallel JSON parser benchmark

Hardware context: Most benchmarks and production results cited are from x86_64 deployments. ARM64 (Graviton) results may vary. Applications spending most time in I/O operations see proportionally smaller CPU improvements since the gains come from reduced memory management overhead.

When Compact Object Headers Shine

The numbers make headlines, but context matters. Compact Object Headers provide the most benefit for:

  • Microservices architectures - Spring Boot creates thousands of small objects per request
  • In-memory caches - Caffeine, Ehcache, custom caches with small entries
  • Data processing pipelines - JSON parsing, message deserialization
  • Applications with high object churn - frequent allocation of short-lived objects

Conversely, applications with large object-to-header ratios (e.g., processing massive byte arrays or image buffers) won’t see dramatic improvements-the header overhead was already negligible compared to the data.

The Magic Flag

Enabling this revolution requires exactly one JVM argument:

Shell
# Java 24 (experimental)
java -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders MyApp

# Java 25 (production-ready)
java -XX:+UseCompactObjectHeaders MyApp

That’s it. No code changes. No recompilation. No regression testing. Just add the flag and watch your metrics.

What to monitor:

Shell
# Before and after comparison with JFR
java -XX:+UseCompactObjectHeaders -XX:StartFlightRecording=duration=60s,filename=compact.jfr -jar app.jar
java -XX:StartFlightRecording=duration=60s,filename=baseline.jfr -jar app.jar

# Compare heap usage
target:~$ jfr print compact.jfr | grep -A5 "Heap Summary"

More: JVM Object Headers and Java 25 Compact Headers takes the header apart bit by bit.


Part V: The Warmup Problem - Solved

AOT Profiling and Project Leyden

Compact Headers solve memory efficiency. But Java 25 has another trick for speed.

Every JVM developer knows the warmup problem. When your application starts, it runs interpreted bytecode - slow. It takes time for the JIT compilers to identify hot paths and optimize them. In microservices environments where instances start and stop constantly, you’re always in warmup mode.

Java 25 introduces AOT (Ahead-of-Time) Profiling, part of Project Leyden:

  1. Training Run: Start your application with a special flag. Let it exercise real workloads.
  2. Profile Capture: The JVM saves optimization data - which methods matter, which branches are hot, which types are common.
  3. Production Deployment: Subsequent starts load the profile and begin optimized compilation immediately.

Result: 20-40% faster startup in production-ready Java 25, with early access builds of Project Leyden showing potential for even greater gains (approaching 70% in optimal scenarios) as the technology matures.

It’s worth noting that JEP 514 and JEP 515 in Java 25 represent the production-ready foundation of Project Leyden, but not its full vision. The most dramatic improvements will come as the project continues to evolve. Still, Quarkus framework benchmarks show measurable improvements in both startup times and warmup efficiency even with Java 25’s current capabilities.

AOT vs. CDS: Project Leyden builds upon Java’s existing Class Data Sharing (CDS) mechanism, which has been available since Java 5. While CDS focused on sharing class metadata between JVMs, Project Leyden’s AOT capabilities go further by capturing method profiles, resolving constant pool entries, and even pre-compiling hot methods. Think of CDS as the foundation and Leyden as the next evolution.

Shell
# Step 1: Training run - exercise the app; on exit the JVM writes the AOT cache
java -XX:AOTCacheOutput=app.aot -jar app.jar

# Step 2: Production runs load the cache
java -XX:AOTCache=app.aot -jar app.jar

For serverless and Kubernetes environments where cold starts directly impact user experience, this is transformative.


Part VI: Generational Shenandoah - The Other GC Gets an Upgrade

While ZGC grabbed headlines with its generational mode in Java 23, Java 25 brings the same treatment to Shenandoah GC (JEP 521).

Shenandoah has always been the low-pause alternative to G1, promising sub-millisecond pauses regardless of heap size. But like ZGC before it, Shenandoah treated all objects equally-missing optimization opportunities from generational hypothesis (most objects die young).

Generational Shenandoah changes the game:

  • Separates young and old generations for more efficient collection
  • Reduces pause times for workloads with typical generational behavior
  • Improves throughput without sacrificing Shenandoah’s ultra-low latency promise
  • Makes Shenandoah more competitive for mainstream applications
Shell
# Enable Generational Shenandoah
java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -jar app.jar

ZGC vs. Shenandoah in Java 25: Both now offer generational modes. ZGC is optimized for massive heaps (up to 16TB), while Shenandoah targets a broader range of workloads. The choice depends on your specific latency and throughput requirements.

More: ZGC and Shenandoah: Low-Latency Garbage Collection compares the two collectors in depth.


Part VII: Scoped Values - ThreadLocal’s Successor

Java 21 introduced Virtual Threads (millions of lightweight threads). Java 25 finalizes Scoped Values - a modern replacement for ThreadLocal that works seamlessly with virtual threads.

Java
// OLD: ThreadLocal (expensive with millions of virtual threads)
private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();

void handleRequest(Request req) {
    REQUEST_ID.set(req.getId());  // Duplicated per thread
    try {
        processRequest();
    } finally {
        REQUEST_ID.remove();  // Memory leak if forgotten!
    }
}

// NEW: ScopedValue (efficient with virtual threads)
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

void handleRequest(Request req) {
    ScopedValue.where(REQUEST_ID, req.getId()).run(() -> {
        // Automatically available to all callees
        // Automatically cleaned up when scope exits
        processRequest();
    });
}

Unlike ThreadLocal, which duplicates state per thread (expensive with millions of virtual threads), Scoped Values allow shared access with less memory overhead and lower synchronization cost.

Migration Strategy

Scoped Values aren’t a drop-in replacement-you’ll need to refactor ThreadLocal code. But they can coexist during migration:

Java
// Migration pattern: Both can coexist during transition
private static final ThreadLocal<String> OLD_REQUEST_ID = new ThreadLocal<>();
private static final ScopedValue<String> NEW_REQUEST_ID = ScopedValue.newInstance();

void handleRequest(Request req) {
    // Set both during migration period
    OLD_REQUEST_ID.set(req.getId());

    ScopedValue.where(NEW_REQUEST_ID, req.getId()).run(() -> {
        // New code uses NEW_REQUEST_ID
        // Legacy code still uses OLD_REQUEST_ID
        processRequest();
    });

    OLD_REQUEST_ID.remove();  // Still need cleanup for ThreadLocal!
}

More: Modern Java Error Handling uses Scoped Values to carry request context into error reports.


Part VIII: What This Means for You

The Practical Checklist

If you’re on Java 21 or earlier:

  • Upgrade to Java 25. The performance gains are real and require no code changes.
  • Add -XX:+UseCompactObjectHeaders to your JVM flags.
  • Monitor heap usage and GC frequency - you should see improvements immediately.

If you’re running microservices or serverless:

  • Experiment with AOT profiling for faster cold starts.
  • Measure startup time before and after - the difference might surprise you.

If you’re building high-throughput applications:

  • Consider ZGC for sub-millisecond pauses. Generational ZGC arrived in Java 21 (JEP 439) and became the default in Java 23 (JEP 474); since Java 24 it is the only mode, and by Java 25 it’s a well-tested, production-hardened feature.
  • Alternatively, explore Generational Shenandoah (JEP 521), new in Java 25, which brings similar generational improvements to the Shenandoah GC for reduced pause times.
  • Combine with Compact Headers for the ultimate memory-efficient setup.
Shell
java \
  -XX:+UseCompactObjectHeaders \
  -XX:+UseZGC \
  -XX:+AlwaysPreTouch \
  -Xms4g \
  -Xmx4g \
  -jar your-application.jar

Note: Generational ZGC is the default since Java 23 (JEP 474) and the only mode since Java 24, so -XX:+ZGenerational is no longer needed: Java 25 ignores it with a warning. ZGC does not use -XX:MaxGCPauseMillis either, which is why the configuration leaves it out.


Part IX: When to Exercise Caution

Not All Improvements Fit All Workloads

While Java 25’s improvements are impressive, they’re not universal solutions. Here’s when to think twice:

Compact Object Headers:

  • Best for: applications with many small objects (microservices, caches, data pipelines, Spring Boot)
  • Don’t enable if you’re using native libraries that directly access object memory or assume fixed header layout
  • Cannot be combined with -XX:-UseCompressedClassPointers (deprecated in Java 25 anyway)
  • Test thoroughly before production if you use JNI or custom native code

AOT Profiling (Project Leyden):

  • Best for: microservices, serverless functions, containers with consistent workloads
  • Only beneficial if your application has predictable, repeatable startup patterns
  • Highly dynamic applications that load different classes per execution won’t benefit as much

Scoped Values vs ThreadLocal:

  • Best for: new code using virtual threads and structured concurrency
  • Not a drop-in replacement-ThreadLocal code needs careful refactoring
  • Designed for structured concurrency patterns

Generational GC (ZGC & Shenandoah):

  • Best for: typical enterprise workloads with clear young/old generation separation
  • May not benefit applications with atypical object lifetime patterns

Epilogue: The Future is Already Here

We’ve reached an inflection point. For decades, Java performance improvements required developer effort - better algorithms, cleaner code, careful tuning. Java 25 flips the script: the platform itself has become the optimization engine.

Your code from 2010 runs faster today on Java 25 than it did when written. Not because you changed it, but because the JVM evolved underneath it.

This is the silent revolution. No press releases. No breaking changes. Just a flag (-XX:+UseCompactObjectHeaders) that cuts the header of every object by a third. Just a runtime that learns from itself and optimizes without human intervention.

The JVM isn’t just a virtual machine anymore. It’s a self-improving system - one that carries the weight of thirty years of Java code and makes it lighter, faster, and more efficient with every release.

The future of Java performance isn’t about writing better code. It’s about running it on a better JVM.

That future arrived in September 2025, and by early 2026, production deployments are already showing the promised results. The silent revolution is well underway.


References


Written for developers who remember when double-digit performance gains required months of refactoring. Today, they require a single JVM flag. Welcome to Java 25.