Java's Virtual Machine Enhancements in Recent Releases

·17 min read·Programming Languagesintermediate

Why performance, portability, and maintainability get a boost when you upgrade your JDK and runtime

A server rack with JVM runtime icon overlay, representing Java Virtual Machine performance and deployment in data centers

I’ve run Java services on cramped virtual machines and on oversized bare-metal hosts. The JVM keeps surprising me—not with magic, but with practical improvements that add up across thousands of hours of GC time, JIT warmup, and deployment friction. In the last few releases, the JVM has quietly evolved to address modern workloads: faster startup for containers and serverless, better observability for GC and JIT, and safer concurrency patterns. It’s not just about raw throughput anymore; it’s about predictable performance, reduced memory footprint, and a smoother developer experience.

If you maintain long-lived services, you probably care about pause times and steady-state memory. If you’re building platform tooling or deploying to Kubernetes, you likely care about startup time and warm C2 thresholds. And if you’re debugging weird performance cliffs, better JVM logging and JFR events matter a lot. This post walks through recent enhancements, how they map to real systems, and where they shine or fall short. I’ll include code and configuration you can run, and I’ll point to upstream sources for anything that feels surprising.

Context: Where the JVM sits in modern systems

The JVM remains a cornerstone of enterprise backends, data pipelines, and high-throughput APIs. You’ll find it powering financial trading systems, large-scale microservices, Android runtime internals, and data engineering tools like Apache Kafka and Flink. Compared to native compilation (GraalVM Native Image, Go) and other runtimes (.NET CLR, V8), the JVM offers mature JIT optimization, a rich tooling ecosystem, and cross-platform portability.

In recent years, two trends shaped JVM priorities:

  • Containerization and cloud-native packaging: Startup time, footprint, and JFR-based observability are now first-class concerns.
  • Maintainability at scale: GC ergonomics, safe concurrency, and better diagnostics reduce operational toil.

Alternatives have strengths. GraalVM Native Image delivers subsecond startup and small footprints for CLI and serverless, but with tradeoffs around dynamic features and reflection-heavy frameworks. Go compiles to single binaries with low latency; however, Java still dominates where JIT can optimize long-running hot loops and where mature frameworks like Spring are already deeply integrated. In practice, many teams run both: traditional JVM services for complex business logic and native images for CLI tools or event-driven functions.

JVM enhancements worth your attention (JDK 17 through JDK 23)

I’ll focus on improvements that affect real deployments: GC refinements, JIT improvements, JVMCI for Graal JIT, JFR enhancements, pattern matching and performance-adjacent language features, and startup/footprint options. Where relevant, I’ll link to JEPs for authoritative detail.

GC ergonomics and low-pause collectors

Garbage collection remains the most visible part of JVM performance. Recent releases improved collector ergonomics and made low-pause goals easier to achieve.

G1: Adaptive region sizing and early old-gen return

G1 has become the default collector for most server workloads. JDK 17+ refined region sizing and allocation pacing so G1 returns memory to the OS earlier after a peak. This helps containerized environments where memory headroom is shared.

Real-world pattern: For microservices with bursty traffic, tune G1 for a soft max heap and allow early memory return.

# Container-friendly G1 flags tuned for a 4 GB cgroup limit
# -Xms is left unset to let G1 adapt startup sizing
java -XX:+UseG1GC \
     -XX:MaxRAMPercentage=75 \
     -XX:G1HeapRegionSize=4m \
     -XX:MaxGCPauseMillis=200 \
     -XX:G1PeriodicGCInterval=60s \
     -XX:G1PeriodicGCSystemLoadThreshold=0 \
     -jar app.jar

Notes:

  • MaxRAMPercentage is helpful inside containers; set it below the cgroup limit to leave room for non-heap memory.
  • G1PeriodicGCInterval and system load threshold allow proactive reclaim when the app is idle; useful for batch services or overnight tasks.

If you need ultra-low pause times and your heap is >4 GB, ZGC is compelling.

ZGC: Generational mode (JDK 21+)

ZGC targets multi-terabyte heaps with sub-millisecond pauses. JDK 21 introduced a generational mode for ZGC, improving throughput by segregating short-lived objects. In practice, if your service has large heaps and is sensitive to tail latency, ZGC can be a strong fit.

Example scenario: An analytics API that processes large in-memory datasets. With generational ZGC, young collections are fast, and the pause floor stays low.

# Enable generational ZGC and set a soft max heap
java -XX:+UseZGC -XX:+ZGenerational \
     -Xms8g -Xmx8g \
     -XX:SoftMaxHeapSize=6g \
     -XX:ZAllocationSpikeTolerance=4 \
     -jar analytics-service.jar

Tradeoffs:

  • ZGC requires more resident memory than G1 for its memory barriers and metadata. In small containers (<2 GB), G1 is usually safer.
  • Native memory tracking (NMT) and JFR are important to understand ZGC overhead.

Shenandoah: Compacting and uncommitting memory

Shenandoah remains a good low-pause alternative, especially in JDK 17+ where it has mature uncommit logic to return memory to the OS. If your service runs under strict memory quotas and you want low-latency GC, Shenandoah is a pragmatic choice.

java -XX:+UseShenandoahGC \
     -XX:ShenandoahGCHeuristics=compact \
     -XX:SoftMaxHeapSize=2g \
     -jar service.jar

JIT improvements and JVMCI

The HotSpot JIT continues to evolve. While JEPs for specific JIT heuristics rarely carry user-facing flags, several developer-visible changes improve performance and debuggability.

JVMCI and Graal JIT (JDK 17+)

JVMCI enables the Graal compiler to run as a JIT inside HotSpot. This is used by GraalVM and by advanced users who need to switch compilers or instrument the JIT pipeline. If you’re experimenting with JIT tuning or deploying GraalVM, this is foundational.

Context: In many production services, you can stay with the default C2 compiler and get excellent results. But if you want to use the Graal JIT or compile Graal itself with JVMCI, JDK 17+ is the baseline.

Example: Running a service with the Graal JIT enabled on GraalVM. Note that using GraalVM typically involves installing the GraalVM JDK and selecting the community or enterprise distribution.

# Check you’re on GraalVM and enable the Graal JIT
java -version
# Should show "GraalVM" or "Oracle GraalVM"

# Run with Graal JIT (default on GraalVM, but you can be explicit)
java -XX:+UnlockExperimentalVMOptions \
     -XX:+UseJVMCICompiler \
     -jar app.jar

If you’re on a standard OpenJDK build (Temurin, Amazon Corretto, Microsoft Build of OpenJDK), JVMCI exists but you’ll need Graal’s jars on the module path. For most teams, sticking with the standard JIT and focusing on JFR-driven tuning is the simpler path.

Reduced warm-up latency

Recent JDKs improved inlining decisions and interpreter-to-JIT thresholds under tiered compilation. In container environments, this can shave seconds off warmup. For microservices that scale up frequently, this matters.

Practical pattern: Combine smaller heap regions (G1 region sizing) with class data sharing (CDS) for faster startup.

JFR and JVM logging: Observability that matters

Java Flight Recorder (JFR) now lands more events and includes more precise metadata, which helps isolate GC stalls, JIT activity, and object allocation hotspots.

Example: Record flight data for a minute and analyze with jfr tooling.

# Start with JFR enabled, dump at the end of the test
java -XX:StartFlightRecording=duration=60s,filename=/tmp/app.jfr \
     -jar app.jar

# Inspect the recording
jfr summary /tmp/app.jfr
jfr print --events gc.phase,jdk.CompilerInlining /tmp/app.jfr

Useful events:

  • gc.phase: Helps see stop-the-world vs concurrent phases.
  • jdk.CompilerInlining: Confirms which methods were inlined.
  • jdk.ObjectAllocationInNewTLAB: Useful for tuning young generation size.

JVM logging has also improved. You can get detailed GC logs without heavy overhead.

# GC log with unified logging
java -Xlog:gc*,gc+stats=debug,gc+heap=info:file=/tmp/gc.log:time,level,tags \
     -jar app.jar

Pattern matching, sealed classes, and performance implications

JDK 17 through JDK 23 introduced several language features that affect performance indirectly by encouraging clearer designs that JIT can optimize.

  • Records (JDK 16): Immutable data carriers that reduce boilerplate and improve readability. JIT can optimize small, predictable object layouts.
  • Pattern matching for instanceof (JDK 16) and switch (JDK 21): Reduces casting and branches; can simplify control flow for parsers, AST traversals, and state machines.
  • Sealed classes (JDK 17): Constrain type hierarchies, which helps JIT reason about call sites.
  • Virtual threads (JDK 21): Lightweight concurrency for blocking I/O. Not a raw performance feature, but a throughput/latency win for certain workloads.

Example: A JSON message handler using records and pattern matching. The compact structure and predictable type switching can help JIT inline and eliminate casts.

// JDK 21+ pattern matching for switch and records
public sealed interface Message permits Ping, Query, Update {}

record Ping(String id, long ts) implements Message {}
record Query(String id, String term) implements Message {}
record Update(String id, Payload payload) implements Message {}

public class Handler {
    public void handle(Message msg) {
        switch (msg) {
            case Ping(String id, long ts) -> {
                // Direct field access, no casting
                System.out.println("ping " + id + " at " + ts);
            }
            case Query(String id, String term) -> {
                System.out.println("query " + id + " for " + term);
            }
            case Update(String id, Payload payload) -> {
                System.out.println("update " + id);
            }
        }
    }
}

While this is about ergonomics, cleaner code tends to produce more predictable hot paths, which JIT compilers like. For event-driven services, it also reduces the mental overhead of maintaining instanceof chains.

Virtual threads: concurrency that changes throughput patterns

Virtual threads (Project Loom) are not a JIT feature, but they’re a JVM runtime feature that changes performance characteristics for I/O-heavy applications. For services that spend time waiting on network calls, virtual threads increase concurrency without the overhead of platform threads.

When to use:

  • REST clients or gRPC backends that call external services.
  • DB access patterns with high concurrency and simple blocking calls.
  • Not recommended for CPU-intensive pipelines where you’ll still want bounded workers.
// JDK 21+ virtual threads for a blocking HTTP client workload
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.Executors;

public class VirtualThreadsDemo {
    public static void main(String[] args) throws Exception {
        var client = HttpClient.newHttpClient();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 1000; i++) {
                final int id = i;
                executor.submit(() -> {
                    try {
                        var req = HttpRequest.newBuilder()
                                .uri(URI.create("https://httpbin.org/delay/1"))
                                .build();
                        var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
                        System.out.println(id + ": " + resp.statusCode());
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                });
            }
        }
    }
}

Observability: Watch JFR events like jdk.VirtualThreadStart and jdk.VirtualThreadEnd to understand concurrency. You can also pin virtual threads to platform threads when using native operations; JFR exposes these pinning events, which help you identify when synchronization is blocking a virtual thread.

Startup time: Class Data Sharing and AppCDS

Class Data Sharing (CDS) reduces startup by sharing class metadata across JVMs. JDK 17+ improved AppCDS usability, allowing application classes to be archived deterministically.

Workflow:

  • Generate a class list during a dry run.
  • Create the archive.
  • Run with the archive.
# 1) Run with -Xlog:class+load to collect loaded classes
java -Xlog:class+load=info:file=/tmp/classload.log \
     -XX:DumpLoadedClassList=/tmp/app.classlist \
     -jar app.jar

# 2) Create a static archive
java -Xshare:dump \
     -XX:SharedArchiveFile=/tmp/app.jsa \
     -XX:SharedClassListFile=/tmp/app.classlist \
     -cp app.jar

# 3) Run with the archive
java -XX:+UseAppCDS \
     -XX:SharedArchiveFile=/tmp/app.jsa \
     -jar app.jar

For container deployments, combine CDS with G1 or ZGC and observe startup using JFR:

java -XX:StartFlightRecording=duration=20s,filename=/tmp/startup.jfr \
     -XX:+UseAppCDS \
     -XX:SharedArchiveFile=/tmp/app.jsa \
     -jar app.jar

Use jfr to analyze jdk.ClassLoad events and see time-to-first-request.

GraalVM Native Image: When to use it

GraalVM Native Image is not the JVM itself, but a companion technology. It compiles Java bytecode ahead-of-time to a native binary. It’s ideal for:

  • CLI tools and build plugins
  • Serverless functions with strict cold-start limits
  • Microservices with small heaps and predictable dependencies

Tradeoffs:

  • Not all features work (dynamic proxies, reflection-heavy libraries may need configuration).
  • JIT optimization is not available at runtime.
  • Build times are longer; you need a separate CI step.

Example: Building a simple native image with Maven and GraalVM. Assumes you have the GraalVM JDK and native-image installed.

<!-- pom.xml snippet for native image profile -->
<profiles>
    <profile>
        <id>native</id>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.graalvm.buildtools</groupId>
                    <artifactId>native-maven-plugin</artifactId>
                    <version>0.9.28</version>
                    <executions>
                        <execution>
                            <id>build-native</id>
                            <goals>
                                <goal>build</goal>
                            </goals>
                            <phase>package</phase>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>
# Build the native image
mvn -Pnative -DskipTests package

# Run the native binary
./target/my-app

If your service relies heavily on dynamic features or deep reflection, consider starting with GraalVM JIT on the JVM instead, or carefully configure reflect-config.json for native image.

Security and CSPRNG updates

JDK 17 introduced a new implementation of SecureRandom that provides better performance and forward-looking algorithm agility. For applications that generate many tokens or keys, this can improve throughput. Additionally, JDK 17+ made TLS and cryptography stack updates standard, which matters for services calling external APIs.

Honest evaluation: strengths, weaknesses, and tradeoffs

Where the JVM (HotSpot) shines:

  • Long-running services: JIT optimizations accumulate over time and produce highly efficient hot loops.
  • Observability: JFR and unified logging provide rich detail with low overhead.
  • Portability: Run the same artifact across OS and cloud providers with predictable performance.
  • Mature ecosystem: Frameworks, libraries, and GC choices cover a wide range of workloads.

Where it might not be the best fit:

  • Extremely short-lived processes (subsecond startup): GraalVM Native Image or Go may be better. Although CDS and AppCDS help, they’re not magic.
  • Very small containers (<1.5 GB heap): Consider G1 over ZGC to minimize native overhead.
  • Highly dynamic workloads with heavy reflection: GraalVM Native Image needs configuration; HotSpot is easier.

GC selection summary:

  • G1: Default, balanced, container-friendly, good for most server workloads.
  • ZGC: Large heaps, low tail latency, but more native memory.
  • Shenandoah: Low pause with strong uncommit behavior; good for tight quotas.

Concurrency:

  • Virtual threads: I/O-bound concurrency with minimal overhead; use with careful synchronization to avoid pinning.
  • Platform threads: Still ideal for CPU-bound tasks; tune with ForkJoinPool or custom pools.

Personal experience: tuning and debugging in the wild

In one service, I faced a classic issue: steady-state throughput was fine, but tail latency spiked every few minutes during GC. We were using G1 with default settings in a 4 GB container. The fix wasn’t exotic; JFR showed long gc.phase.pause events correlated with large humongous allocations. Adjusting G1HeapRegionSize and fixing a few data structures to avoid 8 MB arrays reduced pauses dramatically. The key was JFR’s clarity; without it, we’d be guessing.

Another case was a microservice with slow cold starts in Kubernetes. Adding AppCDS shaved around 200–300 ms off startup (JDK 17 on Linux x64). That’s not revolutionary, but across dozens of pods, deployment windows became tighter. The learning: CDS benefits depend on the class footprint and how much you trim your classpath.

With virtual threads, I’ve seen mixed results. For a gateway that calls multiple HTTP services, virtual threads delivered a 4× throughput increase with no code rewrite beyond replacing ThreadPoolExecutor. In another service, heavy synchronization on shared queues led to virtual thread pinning, and we saw no benefit. Profiling with JFR’s pinning events guided us to switch to lock-free structures.

Common mistakes:

  • Treating -Xmx as the only knob: Native memory (thread stacks, JIT code cache, NMT overhead) matters, especially with ZGC.
  • Ignoring warmup: JIT does its best work over time; don’t judge a service by its first minute.
  • Over-indexing on microbenchmarks: JMH is great, but measure in staging with production-like data.

Getting started: workflow and mental model

If you’re upgrading a service or standing up a new one, follow this workflow:

  1. Pick a baseline JDK
  • Use a recent LTS (JDK 21 or JDK 17) or JDK 23 for latest features. Temurin, Amazon Corretto, and Microsoft Build of OpenJDK are reliable.
  1. Choose your GC based on heap and latency goals
  • 1–4 GB containers → G1
  • 4 GB with low tail latency → ZGC generational

  • Tight memory quotas with low pause → Shenandoah
  1. Enable observability early
  • JFR for 1–5 minute captures under load.
  • Unified GC logging to confirm behavior.
  1. Add startup optimizations if you run in autoscaling
  • CDS or AppCDS for class metadata sharing.
  1. Consider virtual threads for I/O-bound code
  • Start with task executors; measure throughput and pinning.
  1. Validate with staged rollouts
  • Compare GC logs and JFR events before and after changes.

Example project layout for a typical microservice with GC tuning and JFR scripts:

my-service/
├─ src/
│  └─ main/
│     ├─ java/
│     │  └─ com/example/ServiceMain.java
│     └─ resources/
├─ scripts/
│  ├─ run-g1.sh
│  ├─ run-zgc.sh
│  ├─ jfr-start.sh
│  └─ create-appcds.sh
├─ Dockerfile
├─ pom.xml
└─ README.md

Example run scripts:

# scripts/run-g1.sh
#!/usr/bin/env bash
java -XX:+UseG1GC \
     -XX:MaxRAMPercentage=75 \
     -XX:G1HeapRegionSize=4m \
     -XX:MaxGCPauseMillis=200 \
     -Xlog:gc*:file=/tmp/gc.log:time,level,tags \
     -jar app.jar
# scripts/jfr-start.sh
#!/usr/bin/env bash
java -XX:StartFlightRecording=duration=5m,filename=/tmp/app.jfr \
     -jar app.jar

For local testing with Docker, a simple Dockerfile:

FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY target/app.jar /app/app.jar
CMD ["java", "-XX:+UseG1GC", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]

Mental model:

  • Measure first; tune second. JFR and GC logs are your compass.
  • Startup vs steady state: Optimize for steady state first, then address startup if autoscaling is frequent.
  • Containers are not VMs: Respect cgroup limits and leave headroom for native memory.

What makes the JVM stand out today

  • Predictable long-run performance: JIT plus mature GC provides stable throughput with manageable tail latency.
  • Developer experience: From JFR to pattern matching, the tooling and language features reduce friction and bugs.
  • Portability with observability: Run anywhere, then profile with the same tools.
  • Ecosystem strength: You can swap GCs without rewriting code; change concurrency models without a full rewrite.

These strengths map to outcomes:

  • Fewer production surprises: Better GC ergonomics and JFR visibility.
  • Faster iteration: Pattern matching and records simplify domain modeling.
  • Cost control: AppCDS and memory-aware GC reduce resource waste.

Free learning resources

Summary: Who should use the JVM and who might skip

Use the JVM (HotSpot) if:

  • You run long-lived services where JIT can optimize hot paths.
  • You need mature, tunable GC options and deep observability with JFR.
  • Your team values portability and an ecosystem that supports a wide range of workloads.

Consider GraalVM Native Image or other runtimes if:

  • Startup must be subsecond in serverless or CLI contexts and you can live with AOT tradeoffs.
  • Your application relies heavily on dynamic features and cannot be adapted easily.
  • You have very small containers and can’t afford JVM native memory overhead.

Ultimately, modern JVM enhancements make it easier to get predictable performance without exotic tuning. JFR and GC ergonomics reduce guesswork. AppCDS and virtual threads address startup and I/O-bound concurrency in practical ways. For many teams, that’s the difference between fragile performance and steady, maintainable systems.

If you’re upgrading, start with a recent LTS (JDK 21), enable JFR, add CDS, and pick a GC that matches your heap and latency profile. Measure, iterate, and let the JVM’s visibility tools guide you. That’s where recent releases deliver real-world value.