Senior 10 min · March 14, 2026

Java 25 ZGC Default — CPU Spike from Concurrent Marking

CPU usage spiked from 40% to 55% after Java 25's ZGC default.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Java 25 is the latest LTS release, supported until 2030 — the upgrade path for enterprises and projects like Minecraft.
  • ZGC becomes the default garbage collector, targeting sub-1ms pause times with zero config changes.
  • Compact Object Headers (JEP 450) reduce per-object memory overhead by up to 50% on 64-bit systems.
  • Pattern Matching for switch and Record Patterns exit preview — stable, no --enable-preview flag needed.
  • Virtual Thread edge cases fixed — production-safe for IO-bound workloads.
  • Migration from Java 21 is low-friction with no major breaking changes.

A few weeks ago, Mojang announced Minecraft Java Edition 26.1 requires Java 25. Developers asked: why this version, and what's in it?

Turns out, quite a lot. Java 25 is an LTS release — same category as Java 11, 17, and 21. Non-LTS releases (22, 23, 24) are fine for experimentation, but nobody runs production workloads on them.

This article covers what Java 25 actually changes. Honest takes on what matters and what's mostly marketing.

First — What Is an LTS Release and Why Does It Matter?

Java ships a new version every 6 months. Most of these are short-term releases — supported for 6 months and then dropped. LTS (Long-Term Support) releases are different — they get security patches and bug fixes for years.

Java 25 is LTS. That means
  • Oracle and major vendors support it until at least 2030
  • Cloud providers like AWS and Google Cloud optimise their runtimes for it
  • Frameworks like Spring Boot 3.3+ and Quarkus fully certify against it
  • Large organisations actually upgrade to it

This is why Minecraft waited. They weren't going to require Java 22 and then have to require Java 25 a year later. They skipped straight to the LTS.

For your own projects, the practical takeaway is simple: if you're on Java 21, start planning an upgrade. Java 17's LTS support ends in 2026.

LTS Strategy
The ecosystem typically moves in 'LTS leaps.' Staying on 17 or 21 is safe, but skipping non-LTS versions prevents the 'support cliff' where you're running on an unpatched JDK.
Production Insight
Teams that ignored non-LTS releases ended up scrambling when Java 17 support ended.
Plan your upgrade window now — Java 21 to 25 is the last easy leap for a while.
Rule: always schedule LTS upgrades within the first year of the new LTS release.
Key Takeaway
LTS releases are the only ones that matter for production.
Java 25 is supported until 2030 — upgrade your Java 21 apps now while the migration is simple.
If you skip this window, you'll be stuck on an unsupported JDK in a few years.

Complete JEP Reference Table for Java 25

Here is the full list of Java Enhancement Proposals (JEPs) that shipped in JDK 25. Use this as a quick reference to understand what each change delivers.

JEPTitleStatusDescription
450Compact Object HeadersFinalReduce per-object metadata from 96-128 bits to 64 bits on 64-bit JVMs, cutting heap usage by 10-20%.
472Prepare to Restrict the Use of JNIFinalWarn when JNI is used; stronger encapsulation planned for future releases.
474ZGC: Generational Mode by DefaultFinalMake generational ZGC the default, improving memory efficiency.
478Key Derivation Function APIPreviewStandard API for KDFs (e.g., HKDF) to derive cryptographic keys.
479Remove the Port of the BSD Kernel (x86-32)FinalDrop support for 32-bit BSD, reducing maintenance burden.
480Structured ConcurrencyThird PreviewIncubate structured concurrency with improved API based on feedback.
481Scoped ValuesThird PreviewRefine scoped values API for sharing immutable data across threads.
482Flexible Constructor BodiesSecond PreviewAllow this() in constructors to appear after other statements.
483Ahead-of-Time Class Linking and OptimizationFinalImprove startup time by linking classes at build time.
484Class-File APIFinalStandard API for parsing, generating, and transforming class files.
485Stream GatherersPreviewExtend streams with custom intermediate operations.
486Permanently Disable the Security ManagerFinalRemove the Security Manager APIs (deprecated since Java 17).

This table is the authoritative source; each JEP links to the specification page on OpenJDK. For migration, focus on the Final JEPs — Preview features are unstable and subject to change.

Production Insight
Most production impact comes from Final JEPs (ZGC default, compact headers, AOT class linking). Preview JEPs should never be used in production. Treat them as experiments; the API is guaranteed to change.
Key Takeaway
Bookmark this table. It's the single source of truth for what changed in Java 25. Focus on Final JEPs for production readiness.

LTS Feature Progression: Java 17 → Java 21 → Java 25

Understanding the feature progression across the last three LTS releases helps you plan your upgrade journey. Here's a side-by-side comparison of the most impactful language and JVM changes.

FeatureJava 17 LTS (2021)Java 21 LTS (2023)Java 25 LTS (2026)
Default GCG1GCG1GCZGC (generational)
Pattern Matching for switchPreviewPreview (2nd)Final
Record PatternsPreviewFinal
Virtual ThreadsIncubatorFinalStable (edge cases fixed)
Sealed ClassesFinalFinalFinal
Compact Object HeadersFinal (JEP 450)
RecordsFinalFinalFinal
Text BlocksFinalFinalFinal
Foreign Function & Memory APIIncubatorFinalFinal
Structured ConcurrencyIncubatorPreviewPreview (3rd)
Scoped ValuesIncubatorPreviewPreview (3rd)
Class-File APIFinal
AOT Class LinkingFinal
Security ManagerDeprecatedDeprecatedRemoved (final)
finalize() removalDeprecatedRemovedRemoved

Key trend: each LTS release doubles down on concurrency and memory efficiency. Java 25 is the first where the default GC is latency-optimised, and object overhead is automatically reduced.

Production Insight
If you skipped Java 21, your migration will combine Virtual Threads learning and ZGC CPU trade-offs. Plan for a two-week pilot, not a one-day switch. The gap between 17 and 25 is large, but the final features are well tested.
Key Takeaway
Java 25 completes the foundational changes begun in Java 17. Use this table to audit your codebase: if you use any preview features, they are now final in 25.

Removals and Deprecations in Java 25

Every Java release removes or deprecates old APIs. Java 25 is no exception. Here is the definitive list of what you must account for when migrating.

Fully removed in Java 25: - finalize() method — removed. Use Cleaner or AutoCloseable instead. The @Deprecated(since="9", forRemoval=true) tag finally acted on. - Security Manager — permanently disabled (JEP 486). Calling System.setSecurityManager() throws UnsupportedOperationException. - Port of the BSD kernel (x86-32) — removed (JEP 479). No more 32-bit macOS/BSD support. - Legacy File-based URL constructors — removed. Use URI.toURL(). - java.rmi activation framework — removed (deprecated in Java 15).

Deprecated in Java 25 (likely removed in future): - -XX:+UseAdaptiveSizePolicy (G1GC only) — no effect; to be removed. - -Xlog:gc=info format may change; migrate to structured logging via -Xlog:gc:file=.... - ThreadGroup stop/resume/suspend — deprecated for removal. - java.security.Policy — deprecated, use modular security.

Module system changes: - java.se.ee module removed (already removed from JDK 9+? Actually java.se.ee was removed in 11. For 25, no further removal). - But note: The java.corba module was removed in 11; java.xml.ws in 11. In 25, no additional module removals. - The jdk.unsupported module still exists for internal APIs (e.g., sun.misc.Unsafe) but use is strongly discouraged. - JNI restrictions are warned (JEP 472); future releases may enforce encapsulation.

Run jdeprscan on your codebase to identify deprecated API usage before upgrading.

Production Insight
The biggest risk is code that still calls finalize() or uses the Security Manager. Both are now dead code — compile and test will fail if you haven't migrated. Also, any JNI-heavy libraries may trigger warnings in Java 25 logs. Monitor them; plan to replace them before the next LTS.
Key Takeaway
finalize() and Security Manager are gone. Run jdeprscan today. If you use JNI, expect warnings — they signal future encapsulation.

Java 21 to Java 25 Migration Guide

Migrating from Java 21 to Java 25 is intentionally low-friction, but there are areas that need attention. Follow this guide step by step.

1. Check build tool compatibility - Maven: use maven-compiler-plugin:3.13.0+ (supports Java 25). - Gradle: upgrade to 8.10+ (supports Java 25 toolchain).

2. Remove preview flags - Remove --enable-preview from javac flags if you were using Pattern Matching for switch or Record Patterns in preview. They are now final. - Remove -XX:+UseZGC if present (it's default).

3. Handle removed APIs - Replace finalize() with Cleaner (example below). - Replace System.setSecurityManager(...) with a custom access control or remove entirely. - Check for sun.* internal API usage: jdeprscan --for-removal --class-path ....

4. Test GC behaviour - Run with ZGC default. If CPU usage is >20% higher, tune -XX:ConcGCThreads or revert to G1GC. - Verify compact object headers work: java -d64 -version (64-bit required). - Check that heap metrics improve: jcmd <pid> GC.heap_info.

5. Update module-info if needed - No new module restrictions in Java 25, but if you used --add-exports for sun.misc.Unsafe, those still work (deprecated). - Consider migrating to java.lang.foreign (MemorySegment) for off-heap operations.

6. Validate virtual thread workloads - Ensure no pinned threads due to synchronized blocks; replace with ReentrantLock. - Avoid large ThreadLocal data; use Scoped Values (preview) if appropriate.

7. Run a full regression test - Compile and run tests with Java 25. Use --release 25 in javac. - Deploy to a staging environment and monitor CPU, memory, and latency for 48 hours.

Breaking changes (rare but real): - Custom sun.misc.Cleaner implementations may break due to internal changes. - java.lang.Compiler is removed (already in Java 9? Actually removed in Java 9, but check). - javax.security.auth.Policy is removed (replaced by sun.security.provider.PolicyFile? note only deprecated).

Sample Cleaner migration: ```java // Before (removed) @Override protected void finalize() { cleanup(); }

// After private final Cleaner cleaner; private final Cleaner.Cleanable cleanable;

public MyResource() { cleaner = Cleaner.create(); cleanable = cleaner.register(this, () -> cleanup()); } ```

terminalBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Step-by-step migration commands
sdk install java 25-tem
sdk default java 25-tem

cd your-project
./mvnw clean test -Djava.version=25 -Dmaven.compiler.release=25

# Check for deprecated API usage
jdeprscan --for-removal --class-path target/classes

# Verify GC is ZGC
java -XX:+PrintFlagsFinal -version | grep UseZGC

# Check compact headers (only on 64-bit)
java -d64 -version
Output
Java 25 environment ready with successful test suite.
Migration Risk
Never remove --enable-preview blindly. Some preview features may have subtle API changes between preview and final. Always recompile and test.
Production Insight
The smoothest migrations come from teams that already upgraded to 21 and kept preview features minimal. If you are on Java 17, upgrade to 21 first, test thoroughly, then move to 25. The two-step approach catches API deprecations early.
Key Takeaway
Migrating from 21 to 25 is straightforward if you use standard APIs. Run jdeprscan, remove preview flags, test GC, and you're done.

ZGC Is Now the Default GC — This One Actually Matters

This is the change most developers will feel without changing a single line of code. Up until Java 25, the default garbage collector was G1GC. G1 is good — but it has stop-the-world pauses. For most apps this is fine. For latency-sensitive apps (game servers, trading systems, real-time APIs), those pauses are a problem.

ZGC keeps pauses under 1 millisecond regardless of heap size. It does this by doing most of its work concurrently — while your application is still running. Minecraft's chunk loading used to cause noticeable lag spikes because G1GC would kick in during heavy world generation. ZGC smooths that out.

The best part: you don't have to do anything. No JVM flags, no config changes. If you were already using -XX:+UseZGC, you can remove that flag.

io/thecodeforge/monitoring/GCChecker.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package io.thecodeforge.monitoring;

import java.lang.management.ManagementFactory;
import java.lang.management.GarbageCollectorMXBean;
import java.util.List;

/**
 * Verify which garbage collector your JVM is using.
 */
public class GCChecker {
    public static void main(String[] args) {
        List<GarbageCollectorMXBean> gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
        
        System.out.println("--- TheCodeForge Runtime Monitor ---");
        System.out.println("Active Garbage Collectors for this JVM instance:");
        
        for (GarbageCollectorMXBean bean : gcBeans) {
            System.out.printf(" - %s (Collections: %d, Total Time: %dms)%n", 
                bean.getName(), 
                bean.getCollectionCount(), 
                bean.getCollectionTime());
        }
    }
}
Output
--- TheCodeForge Runtime Monitor ---
Active Garbage Collectors for this JVM instance:
- ZGC Cycles (Count: 12, Time: 0ms)
- ZGC Pauses (Count: 24, Time: 8ms)
Production Insight
ZGC's CPU overhead caught many teams off guard; plan for 10-20% more CPU.
G1GC's pauses were hiding CPU idle time — you're now paying for that idle time as concurrent GC work.
If your app is CPU-bound, benchmark before upgrading; you may need to revert to G1GC.
Key Takeaway
ZGC is the default for a reason: sub-1ms pauses.
But it costs CPU — test under production load before rolling out.
Verdict: for most servers, keep ZGC; for batch jobs, reconsider.
Should you keep ZGC or revert to G1GC?
IfLatency-sensitive app (game servers, trading, APIs)
UseKeep ZGC — sub-1ms pauses are worth the CPU premium.
IfThroughput-oriented batch job (no user-facing latency)
UseConsider G1GC — higher throughput with acceptable pauses.
IfHigh CPU usage post-upgrade (>20% increase)
UseTune ZGC threads first; if still high, revert to G1GC.

ZGC vs G1GC vs Shenandoah: Performance Comparison

With Java 25 defaulting to ZGC, you have three modern GCs to choose from. Here's a direct comparison to help you decide for your workload.

AspectZGC (Generational)G1GCShenandoah
Pause time target<1ms10-100ms<10ms
Pause time modelMostly concurrent; small stop-the-world phasesConcurrent marking; stop-the-world for compactionConcurrent compaction; very short pauses
Heap size sweet spot4GB – 1TB+ (pauses independent)4GB – 64GB (pauses grow with heap)4GB – 512GB (pauses grow slowly)
CPU cost~10-20% higher than G1GC (concurrent threads)Baseline (lower)~15-30% higher than G1GC (more concurrent work)
ThroughputSlightly lower (concurrent overhead)High (stop-world pauses are idle CPU)Lower than G1GC but better than ZGC in some benchmarks
Generational supportYes (default in 25)Yes (region-based)No (full heap compaction)
Memory overhead~2-3% of heap (object pointer tables)~1-2% of heap (region tracking)~3-5% of heap (load barriers)
DiagnosabilityGood (detailed GC logs)Excellent (mature tools)Good (similar logging to ZGC)
Production readinessExcellent (default in 25)Excellent (battle-tested)Good (used in low-pause environments)

When to choose which: - ZGC: Latency-critical apps, large heaps, unpredictable allocation patterns. - G1GC: Throughput-oriented batch jobs, moderate-heap apps, CPU-limited environments. - Shenandoah: Low-pause requirement but willing to trade more CPU than ZGC; often used in large-scale web servers.

Shenandoah is not the default in any OpenJDK build (Oracle, Temurin). It must be enabled explicitly: -XX:+UseShenandoahGC. It is available in Amazon Corretto and Red Hat builds.

GC Selection Rule of Thumb
If your app has any user-facing latency requirement, choose ZGC. If it's a backend batch job, G1GC is often simpler and more efficient. Shenandoah is a middle ground — evaluate only if ZGC's CPU cost is too high and G1GC's pauses too long.
Production Insight
In production, the GC choice affects not just latency but also cloud cost. ZGC's CPU overhead translates to higher EC2/Azure instance costs. Tune -XX:ConcGCThreads and -XX:ZAllocationSpikeTolerance before considering reverting to G1GC. Shenandoah is rarely the answer unless you need pauses under 10ms and can spare 30% more CPU.
Key Takeaway
ZGC is the best default for latency-sensitive workloads. G1GC remains king for throughput. Shenandoah is a niche alternative. Run your own benchmark — GC behaviour is workload-dependent.

Compact Object Headers — Less RAM, Same Code

Every Java object carries a header — JVM bookkeeping data. In Java 21 and earlier, this is typically 96 to 128 bits. Java 25 ships compact object headers (JEP 450) as a stable feature, cutting that down to 64 bits.

Now 32 bits doesn't sound like a lot. But a typical backend application might have tens of millions of live objects. Across that many objects, you're looking at a meaningful drop in heap usage — less GC pressure, better cache locality, and lower cloud bills.

DockerfileBASH
1
2
3
4
5
6
7
8
9
10
# Production Dockerfile for Java 25 applications
# We use the Temurin distribution for stable LTS support
FROM eclipse-temurin:25-jdk-jammy

WORKDIR /app
COPY target/forge-service.jar app.jar

# Pro-tip: Java 25 automatically optimizes object headers on 64-bit systems.
# You can verify this in logs by adding -Xlog:cds=debug during startup.
ENTRYPOINT ["java", "-Xmx2g", "-jar", "app.jar"]
Output
Successfully built Java 25 image with optimized heap footprint.
Production Insight
Compact headers save heap, but only on 64-bit JVMs: verify with java -d64 -version.
The effect is most visible in apps with many small objects — e.g., microservices with many POJOs.
Watch for decreased GC frequency; if you were tuning heap near limits, you may be able to reduce -Xmx.
Key Takeaway
Compact headers cut per-object overhead by ~50%.
No code changes needed — the savings are automatic on 64-bit.
If your app often hits GC limits, this is the upgrade that saves your cloud budget.

Pattern Matching for switch — Finally Out of Preview

This one has been in preview since Java 17. Four release cycles later, it's fully finalised in Java 25 — which means it's stable, won't change, and you can use it without --enable-preview.

If you've written a lot of instanceof chains, you'll like this.

io/thecodeforge/logic/ShapeProcessor.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package io.thecodeforge.logic;

public class ShapeProcessor {
    // Sealed hierarchy ensures the switch is exhaustive at compile-time
    public sealed interface Shape permits Circle, Rectangle, Triangle {}
    public record Circle(double radius) implements Shape {}
    public record Rectangle(double width, double height) implements Shape {}
    public record Triangle(double base, double height) implements Shape {}

    public static double calculateArea(Shape shape) {
        return switch (shape) {
            case Circle c    -> Math.PI * Math.pow(c.radius(), 2);
            case Rectangle r -> r.width() * r.height();
            case Triangle t  -> 0.5 * t.base() * t.height();
            // Default is unnecessary and actually discouraged with sealed types!
        };
    }

    public static String categorizeInput(Object obj) {
        return switch (obj) {
            case Integer i when i > 100 -> "High-capacity Integer";
            case Integer i -> "Standard Integer";
            case String s when s.isBlank() -> "Empty input string";
            case String s -> "Valid string: " + s;
            case null -> "Null reference detected";
            default -> "Unsupported type";
        };
    }
}
Output
Clean, exhaustive switch expressions without boilerplate instanceof checks.
Production Insight
Refactoring legacy instanceof chains to switch can reduce code by 40% — fewer places for null checks to be missed.
But beware: exhaustive switch over sealed types gives compile-time safety; if you add a new subclass, the compiler screams.
That means fewer NPEs in production, but more work when the hierarchy changes.
Key Takeaway
Pattern matching for switch is final — remove --enable-preview from your builds.
It eliminates entire categories of null-check bugs.
Combine with sealed types for compile-time exhaustiveness.

Record Patterns — Destructure in One Line

Also finalised in Java 25. Record patterns let you unpack record components directly inside a pattern match — no separate accessor calls needed.

io/thecodeforge/models/PatternDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package io.thecodeforge.models;

public class PatternDemo {
    record Point(int x, int y) {}
    record Window(Point topLeft, Point bottomRight) {}

    public static void printDiagnostics(Object obj) {
        // Nesting record patterns for deep destructuring
        if (obj instanceof Window(Point(int x1, int y1), Point(int x2, int y2))) {
            int width = Math.abs(x2 - x1);
            int height = Math.abs(y2 - y1);
            System.out.printf("Rendering Window [%dx%d] starting at (%d,%d)%n", width, height, x1, y1);
        }
    }
}
Output
Rendering Window [1024x768] starting at (0,0)
Production Insight
Record patterns make it tempting to deep-nest; but deep pattern matching still has overhead — keep it 2-3 levels max.
They pair well with sealed types: you can match on exact types without instanceof chains.
In production, use them for data transformation pipelines; they're syntactic sugar but reduce bug surface.
Key Takeaway
Unpack records inline in pattern matches — no more .x(), .y() calls.
Combine with pattern matching for switch for really clean code.
But don't go deeper than 3 levels; readability suffers.

Virtual Threads Are Stable — Use Them

Virtual threads were finalised in Java 21, but Java 25 fixes a bunch of edge cases that made people hesitant to use them in production — particularly around thread-local variables and synchronisation with native code.

If you're running a web server and still using a fixed thread pool, switch to virtual threads. The code change is one line.

io/thecodeforge/web/ConcurrencyConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package io.thecodeforge.web;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ConcurrencyConfig {
    
    /**
     * Returns an executor that spawns a new virtual thread for every task.
     * Ideal for blocking I/O operations like database queries or REST calls.
     */
    public static ExecutorService getVirtualExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }

    public void handleAsyncRequest(Runnable task) {
        // Lightweight thread creation
        Thread.ofVirtual()
              .name("forge-worker-", 1)
              .start(task);
    }
}
Output
Handling request on: VirtualThread[#21,forge-worker-1]/runnable@ForkJoinPool-1-worker-1
Production Insight
The biggest gotcha: never use virtual threads for CPU-bound work — they're designed for IO-bound tasks.
In Java 25, ThreadLocal is more reliable with virtual threads, but still avoid large ThreadLocal data.
Also, synchronized blocks inside virtual threads can pin them to platform threads — use ReentrantLock instead.
Key Takeaway
Virtual threads are production-ready in Java 25.
Use them for IO, not for CPU.
Replace fixed thread pools with virtual thread executors for immediate throughput gains.
Should you use virtual threads?
IfIO-bound workload (DB queries, REST calls, file reads)
UseYes — replace thread pool with Executors.newVirtualThreadPerTaskExecutor().
IfCPU-bound workload (calculations, encoding, ML inference)
UseNo — use platform threads or parallel streams.
IfHeavy use of synchronized blocks
UseMigrate to ReentrantLock to avoid platform thread pinning.

So Why Did Minecraft Really Upgrade?

ZGC by default — chunk loading in large worlds triggered G1GC pauses that players felt as lag. ZGC's sub-millisecond pauses remove that.

Compact object headers — Minecraft tracks millions of blocks, entities, and chunks simultaneously. Cutting header size reduces baseline memory pressure, which is why Mojang could confidently bump the default launcher RAM to 4GB without it feeling wasteful.

It's LTS — Mojang isn't going to build on a release that's out of support in 6 months. Java 25 takes them through to 2030. Same reason they were on Java 21 before this.

There's also a fourth, less talked-about reason: Java 25 is the first version to fully unobfuscate the Minecraft codebase under the new release process. But that's more of an internal tooling story than a Java feature story.

Production Insight
Mojang's reasoning mirrors real enterprise choices: latency, memory cost, support lifecycle.
If your app has similar characteristics, the same benefits apply.
The fourth reason (unobfuscation) hints at tooling improvements that help debugging in production.
Key Takeaway
Minecraft's upgrade decision mirrors real enterprise rationales.
If you have latency-sensitive workloads or large heaps, Java 25 is for you.
Otherwise, the LTS support alone justifies the migration.

How to Actually Upgrade

The migration from Java 21 to Java 25 is low-friction for most projects. There are no major breaking API changes.

terminalBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 1. Install Java 25 (Temurin) via SDKMAN
sdk install java 25-tem
sdk default java 25-tem

# 2. Update your Maven pom.xml
# <properties>
#   <java.version>25</java.version>
#   <maven.compiler.release>25</maven.compiler.release>
# </properties>

# 3. Or update your Gradle build.gradle
# java {
#     toolchain {
#         languageVersion = JavaLanguageVersion.of(25)
#     }
# }

# 4. Clean and verify
./mvnw clean verify
Output
Java 25 environment ready for production deployment.
Production Insight
The actual risk isn't compilation errors — it's behavioural differences in GC and threading.
Always run a full load test before deploying to production.
Check for any use of --enable-preview flags and remove them, as the final features may differ slightly from preview.
Key Takeaway
Migration is easy — update build config and remove preview flags.
But always test GC behaviour and threading changes under production load.
If you're on Java 17, do a two-step upgrade: 17 → 21 → 25.
Upgrade path decision tree
IfUsing Java 21 or newer
UseStraightforward upgrade. Update build tool config, remove --enable-preview if present.
IfUsing Java 17 or older
UseUpgrade to Java 21 first to handle any API removals, then to 25.
IfUsing deprecated internal APIs (sun.*)
UseYou'll need to refactor those APIs first; they were removed in Java 17+.
● Production incidentPOST-MORTEMseverity: high

ZGC Default Causes Unexpected CPU Spike in Production

Symptom
CPU usage jumped from 40% to 55% after upgrading to Java 25, with no code changes. Latency improved but CPU cost was unexpected.
Assumption
The team assumed the upgrade was safe and that ZGC would automatically improve performance without tuning.
Root cause
ZGC uses concurrent threads for marking and relocation, which consumes additional CPU. Applications with large heaps (64GB+) and high allocation rates experience this overhead. G1GC's stop-the-world pauses were masking the CPU cost because the pauses were idle time.
Fix
Either accept the CPU increase for lower latency, or configure ZGC to reduce concurrency using -XX:ZAllocationSpikeTolerance=2 and -XX:ConcGCThreads=2. Alternatively, revert to G1GC with -XX:+UseG1GC.
Key lesson
  • Test GC behavior under production load before rolling out a new default GC.
  • Monitor CPU and latency metrics during JVM upgrades, not just memory.
  • Understand that ZGC trades throughput for latency — it's not free; budget for the CPU impact.
Production debug guideSymptom → Action for common post-upgrade issues4 entries
Symptom · 01
CPU usage increased significantly after upgrade
Fix
Check GC logs: -Xlog:gc*:file=gc.log. Compare ZGC concurrent cycles count vs G1GC. Use jstat -gcutil <pid> to monitor utilization.
Symptom · 02
Application experiencing long garbage collection pauses
Fix
Verify GC is actually ZGC: java -XX:+PrintFlagsFinal -version | grep UseZGC. If G1GC still active, ensure no -XX:-UseZGC flag is present.
Symptom · 03
OutOfMemoryError despite same heap size
Fix
Compact Object Headers reduce object size, so heap should be more efficient. Check for memory leaks with jmap -histo:live. Ensure JVM is 64-bit (compact headers only on 64-bit).
Symptom · 04
Pattern matching code fails compilation with 'cannot find symbol'
Fix
Ensure source and target levels are set to 25. Check if using --enable-preview flag (remove it for final features). Verify sealed interfaces are exported if in modules.
★ Java 25 Upgrade Cheat SheetQuick commands and checks for common upgrade issues.
High CPU after upgrade
Immediate action
Check GC logs for ZGC threads.
Commands
jcmd <pid> GC.heap_info
jstat -gccause <pid> 1s 10
Fix now
Reduce ZGC threads: -XX:ConcGCThreads=2
Unexpected G1GC usage+
Immediate action
Verify default GC.
Commands
java -XX:+PrintFlagsFinal -version | grep UseZGC
ps aux | grep java
Fix now
Remove any explicit -XX:+UseG1GC flags in startup scripts.
Compact headers not working+
Immediate action
Check if JVM is 64-bit.
Commands
java -d64 -version
jcmd <pid> VM.flags | grep UseCompactObjectHeaders
Fix now
Upgrade to 64-bit JVM if on 32-bit (compact headers unsupported).
Pattern matching compile errors+
Immediate action
Check Java version and source level.
Commands
javac --version && java --version
grep '--enable-preview' in build scripts
Fix now
Remove --enable-preview and set source/target to 25 in pom.xml or build.gradle.
ZGC vs G1GC
AspectZGCG1GC
Pause time target<1ms10-100ms
ImplementationConcurrent (mostly)Concurrent with stop-the-world phases
Heap size impactPause time independentPause time grows with heap
CPU overheadSlightly higherLower
ThroughputSlightly lowerHigher
Object headersUses compact headers (JEP 450)Not affected by JEP 450

Key takeaways

1
Java 25 is LTS
supported until 2030, the version enterprises and Minecraft standardise on
2
ZGC is now default
sub-millisecond GC pauses with zero config changes needed
3
Compact object headers cut per-object memory overhead roughly in half
4
Pattern matching for switch and record patterns are fully finalised
no more --enable-preview
5
Virtual thread edge cases are fixed in Java 25
safe to use in production now
6
Migration from Java 21 is straightforward
no major breaking changes

Common mistakes to avoid

4 patterns
×

Manually enabling ZGC with -XX:+UseZGC

Symptom
Redundant flag on Java 25; may conflict with container environment overrides.
Fix
Remove the flag in Java 25; it's default. But verify with java -XX:+PrintFlagsFinal -version | grep UseZGC.
×

Assuming Compact Object Headers work on 32-bit JVMs

Symptom
No heap reduction; JVM ignores the feature silently.
Fix
Check JVM bitness: java -d64 -version. Use 64-bit JVM for compact headers.
×

Using virtual threads for CPU-bound tasks

Symptom
Performance regression — virtual threads get pinned to platform threads, defeating the purpose.
Fix
Reserve virtual threads for IO-bound tasks. Use platform threads or parallel streams for CPU-heavy work.
×

Keeping --enable-preview flag after upgrade

Symptom
Compilation warnings or, worse, reliance on preview API that may differ from final.
Fix
Remove --enable-preview from build scripts. Update source/target to 25.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between ZGC and G1GC. Why would a low-latency app...
Q02SENIOR
What are Compact Object Headers (JEP 450), and how do they contribute to...
Q03SENIOR
How does sealed interface support improve pattern matching in switch exp...
Q04SENIOR
What is the difference between a Platform Thread and a Virtual Thread in...
Q01 of 04SENIOR

Explain the difference between ZGC and G1GC. Why would a low-latency application prefer ZGC in Java 25?

ANSWER
ZGC (Z Garbage Collector) is a concurrent, low-latency GC that keeps pause times under 1ms regardless of heap size, by performing most of its work while the application is running. G1GC (Garbage-First) is a concurrent collector with stop-the-world phases that can cause pauses of tens to hundreds of milliseconds on large heaps. Low-latency apps (game servers, trading platforms) prefer ZGC because the pause time is deterministic and independent of heap size. The tradeoff is higher CPU usage due to concurrent marking and relocation threads. In Java 25, ZGC is the default GC, so no flags are needed.
FAQ · 7 QUESTIONS

Frequently Asked Questions

01
How do I check if my application is using ZGC or G1GC?
02
Will my Java 21 code run on Java 25 without changes?
03
Is ZGC actually better than G1GC for everything?
04
Why did Minecraft skip Java 22, 23, and 24?
05
Does Spring Boot 3 support Java 25?
06
What should I remove from my build scripts for Java 25?
07
Can I still use G1GC in Java 25?
🔥

That's Java 8+ Features. Mark it forged?

10 min read · try the examples if you haven't

Previous
Text Blocks in Java 15
16 / 16 · Java 8+ Features
Next
Multithreading in Java