I have migrated enterprise codebases for fifteen years. I have seen every failure mode: the team that tried to jump from Java 8 to Java 17 in a single sprint and spent three weeks untangling NoClassDefFoundError from the javax to jakarta rename. The architect who enabled virtual threads on Java 21 without auditing ThreadLocal usage and watched heap climb until the pods OOM-killed themselves under load. The platform team that ran jdeps for the first time on the day of deployment and discovered six internal API dependencies that had been removed four versions ago.
This guide is the one I wish had existed for all of those projects.
It is not a feature changelog. It is a production-tested blueprint. You will find the exact migration path for each starting version — Java 8, Java 17, or Java 21 — the architectural trade-offs behind each transformation, real code before and after, and the production JVM flags that actually move the metrics.
Jump to your starting point using the section labels, or read through in order for the complete picture.
| Metric | Legacy Baseline (Java 8 / 17) | Modern Target (Java 25) | Business Impact |
|---|---|---|---|
| Cloud Compute (CPU) | High OS-thread scheduling overhead under heavy I/O | Lightweight Virtual Threads (JEP 444) | 20–40% reduction in compute costs from pod consolidation |
| Heap Memory | 96–128-bit object headers inflating footprint | 64-bit headers via Compact Object Headers (JEP 519) | 10–20% heap reduction; higher deployment density |
| GC Latency | Stop-The-World pauses causing tail-latency spikes | Generational ZGC / Shenandoah (JEP 521) | Sub-millisecond P99.9 tail latency; no circuit-breaker trips |
| Context Propagation | Memory-heavy, mutable ThreadLocal stores | Immutable ScopedValue (JEP 506) | Eliminates thread-pool contamination and OOM under load |
| Developer Velocity | Verbose boilerplate, heavy Lombok dependency | Records, Switch Patterns, Module Imports | Faster reviews, native immutability, less toolchain risk |
Choosing Your Path
The most expensive mistake in a Java version migration is treating it as a single event. It is not. It is a staged progression, and each stage has a distinct failure surface.
Here is the architecture of a safe upgrade. Find your starting version and follow the chain:
Starting from Java 17? Skip directly to Section IV. Starting from Java 21? Jump to Section V. The guide is structured so each section is self-contained for its target upgrade range.
If your microservice fleet is heterogeneous — some services on Java 8, some on Java 17, some on Java 21 — resist the urge to standardize everything in a single migration sprint. Run the phases independently per service, validate in staging, and promote. The goal is to never have a partial migration in a production environment; every service should be stable at a supported LTS version before moving to the next phase.
Static Analysis Before You Change a Line of Code
The fastest migration failures happen when a team modifies source code before understanding what the JVM is actually using at runtime. Run this analysis pipeline first, on every service. It takes less than an hour per artifact and will surface the issues that would otherwise appear at three in the morning on release day.
Step 1: Scan Internal API Dependencies with jdeps
The JDK Dependency Analysis Tool reveals exactly which internal platform APIs your compiled code uses — the ones that are progressively removed or encapsulated in newer JDK versions.
# Point at your fat JAR or exploded classes directory
jdeps --jdk-internals \
--multi-release 25 \
-cp "lib/*" \
target/your-application.jarPay close attention to any output referencing sun.*, com.sun.*, or jdk.internal.*. These are your highest-risk dependencies. They may have worked on Java 8 or 11 through unofficial access, but Java 17's strong encapsulation (JEP 403) or subsequent removals will break them at startup.
Step 2: Run an Automated Recipe Pass with OpenRewrite
OpenRewrite executes source-level code transformations via composable recipes. For a Java 8 to Java 25 migration, chain these recipes in your pom.xml or build.gradle:
<!-- Maven: add to pom.xml -->
<plugin>
<groupId>org.openrewrite.maven</groupId>
<artifactId>rewrite-maven-plugin</artifactId>
<version>5.x.x</version>
<configuration>
<activeRecipes>
<recipe>org.openrewrite.java.migrate.UpgradeToJava17</recipe>
<recipe>org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3</recipe>
<recipe>org.openrewrite.java.migrate.jakarta.JavaxMigrationToJakarta</recipe>
</activeRecipes>
</configuration>
</plugin>Run it, review the diff, and commit the machine-generated changes separately from your manual work. This separation is critical: it lets you bisect regressions without wondering which automated transformation caused a specific failure.
Step 3: Audit Dependency Compatibility
Check every third-party dependency against its declared Java compatibility. The libraries most likely to cause failures across this migration window are:
- Lombok — versions before 1.18.26 use reflection paths blocked by JDK 17 strong encapsulation
- Hazelcast — pre-4.x versions attempt to access
sun.misc.Unsafevia reflection - Byte Buddy — heavily used by Mockito and Spring AOP; requires version alignment with the target JDK
- ASM — the bytecode manipulation library used by Spring, Hibernate, and many others; must match target class file version
- Jackson Databind — Hibernate 6.x (required by Spring Boot 3.x) changed mapping behaviors; mismatches produce silent data errors
The three hours spent on static analysis before migration starts saves three weeks of emergency debugging after it ends.
The Core Platform Hurdle: Java 8 to Java 17
This is the highest-friction phase of the entire migration, and it carries the most risk. Two changes — the javax.* to jakarta.* namespace rename and strong module encapsulation — are binary compatibility breaks. There is no gradual path through them; you pass or you don't.
The javax.* to jakarta.* Namespace Revolution
Spring Boot 3.x requires Java 17 and has completely dropped the old Java EE ecosystem, including every javax.* import. Every entity, validation constraint, servlet interface, and persistence annotation must switch namespaces.
This is not a minor search-and-replace. It is a full binary compatibility break. If any third-party library in your dependency tree was compiled against javax.* and has not published a Jakarta EE-compatible release, it will throw NoClassDefFoundError at runtime — not at compile time.
Before — Java 8 / Spring Boot 2.x:
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.servlet.http.HttpServletRequest;
import javax.validation.constraints.NotNull;
import javax.transaction.Transactional;
@Entity
@Table(name = "merchant_ledger")
public class Transaction {
@Id
private String transactionId;
@NotNull
private java.math.BigDecimal amount;
@Transactional
public void settle(HttpServletRequest request) {
// Legacy servlet-based settlement logic
}
}After — Java 17 / Spring Boot 3.x:
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotNull;
import jakarta.transaction.Transactional;
@Entity
@Table(name = "merchant_ledger")
public class Transaction {
@Id
private String transactionId;
@NotNull
private java.math.BigDecimal amount;
@Transactional
public void settle(HttpServletRequest request) {
// Jakarta-based settlement logic
}
}The OpenRewrite JavaxMigrationToJakarta recipe handles the automated portion. After running it, grep your dependency tree for unmigrated libraries:
# Find any remaining javax references in compiled class files
find . -name "*.jar" | xargs -I{} jar tf {} | grep "javax/"For any library that still ships javax.* classes and has no Jakarta-compatible release, your options are: replace it, fork it, or use the Apache Tomcat Migration Tool for Jakarta EE to patch the bytecode at build time.
Defeating Strong Encapsulation (JEP 403)
Java 17 enforces strict encapsulation of JDK internals. Reflection-heavy frameworks attempting to access sun.misc.Unsafe, java.lang private fields, or other platform internals will throw InaccessibleObjectException at runtime.
The right fix is to upgrade the offending dependency. If you cannot — because the vendor has not released a compatible version and the migration is time-boxed — use --add-opens as a temporary production escape hatch:
java \
--add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.lang.reflect=ALL-UNNAMED \
-jar app.jarTreat every --add-opens flag as technical debt with a due date. Log each one in your architecture decision record, assign it a dependency ticket, and set a hard deadline to remove it. In my experience, teams that add these flags without tracking them still have the same flags running in production three years later against a long-patched library.
Text Blocks (Java 15, Available by Java 17)
Any multi-line string construction using concatenation or StringBuilder can be replaced with text blocks. This is a low-risk, high-readability improvement that pays off immediately in test fixtures, SQL queries, and JSON templates.
Before:
String query = "SELECT t.id, t.amount, t.currency\n" +
"FROM transactions t\n" +
"WHERE t.merchant_id = ?\n" +
" AND t.created_at > ?\n" +
"ORDER BY t.created_at DESC";After:
String query = """
SELECT t.id, t.amount, t.currency
FROM transactions t
WHERE t.merchant_id = ?
AND t.created_at > ?
ORDER BY t.created_at DESC
""";Spring Security 6.x Functional Configuration
Spring Boot 3.x ships Spring Security 6.x, which removed the WebSecurityConfigurerAdapter class entirely. The old inheritance-based configuration model no longer compiles.
Before — Java 8 / Spring Boot 2.x:
@Configuration
@EnableWebSecurity
public class LegacySecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/v1/public/**").permitAll()
.antMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}After — Java 17 / Spring Boot 3.x:
@Configuration
@EnableWebSecurity
public class ModernSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
}The key architectural change: security configuration is now a @Bean-producing method rather than a class hierarchy. This makes it far more composable — you can declare multiple SecurityFilterChain beans with different securityMatcher predicates to apply different security rules to different URL namespaces.
Concurrency and Data Modernization: Java 17 to Java 21
If Java 8 to Java 17 was the necessary cost of modernity, Java 17 to Java 21 is where the returns begin. This phase introduces the concurrency model that directly reduces your cloud compute bill and the data modeling primitives that remove entire categories of boilerplate.
Activating Virtual Threads (JEP 444)
For thread-per-request applications running on Spring MVC and Tomcat, virtual thread adoption is a single configuration change:
# application.yml
spring:
threads:
virtual:
enabled: trueThat is it for the standard case. Tomcat automatically routes request handling to a virtual thread pool executor. Under this model, each incoming request runs on a lightweight JVM-managed virtual thread rather than an expensive OS-backed platform thread. (For the full mount/unmount mechanism behind this, see the virtual threads deep dive.)
The operational impact is concrete. Before virtual threads, a service handling 5,000 concurrent I/O-bound requests needed a thread pool sized to 5,000 — each platform thread carrying a ~1 MB stack reservation. That is 5 GB of stack memory doing nothing but waiting. Virtual threads unmount from their carrier when they block, so those 5,000 logical threads are multiplexed across a handful of carrier threads. The stack memory drops to megabytes, not gigabytes, and the OS scheduler stops thrashing between thousands of parked threads.
For reactive-style code or explicit executor usage, switch the executor factory:
// Before — fixed platform-thread pool
ExecutorService executor = Executors.newFixedThreadPool(200);
// After — virtual thread per task, no pooling needed
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();Do not pool virtual threads. Pooling was necessary only because platform threads were scarce and expensive to create. Virtual threads cost kilobytes, not megabytes, and take microseconds to create. Pooling them re-introduces the fixed-size constraint that virtual threads were designed to eliminate. Spawn one per task, every time.
One important caveat at this stage: if your code uses ThreadLocal variables to propagate context across requests — a common pattern for tenant IDs, correlation IDs, or security principals — audit them carefully before enabling virtual threads. With millions of potential virtual threads, unbounded ThreadLocal usage creates memory pressure because the JVM allocates a per-thread copy for every entry. This is addressed directly in the Java 25 phase with ScopedValue.
Records: Replacing Lombok @Data with Native Immutability
Lombok's @Data annotation was a necessary workaround for a verbose language. Java 21's records eliminate the need for it in data-carrying classes and offer something @Data never could: guaranteed immutability enforced by the compiler, not a code-generation tool.
Before — Java 17 with Lombok:
import lombok.Data;
import java.math.BigDecimal;
import java.time.Instant;
@Data
public class PaymentPayload {
private final String merchantId;
private final BigDecimal amount;
private final String currency;
private final Instant timestamp;
}After — Java 21 Native Record:
import java.math.BigDecimal;
import java.time.Instant;
public record PaymentPayload(
String merchantId,
BigDecimal amount,
String currency,
Instant timestamp
) {
// Compact constructor validates at construction time
public PaymentPayload {
Objects.requireNonNull(merchantId, "merchantId required");
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
}
}Records are not a drop-in replacement for every @Data class — they cannot be extended, and their fields are always final. But for data transfer objects, command/event objects, and value types — the vast majority of @Data usage — they are strictly superior. Jackson 2.12+ deserializes them natively without any additional annotation.
Pattern Matching for switch (JEP 441)
The switch pattern matching that lands as a standard feature in Java 21 replaces the multi-branch instanceof chains that bloat domain dispatch code.
Before — Java 17 procedural dispatch:
public String routePaymentEvent(Object event) {
if (event instanceof CardPayment) {
CardPayment cp = (CardPayment) event;
return "Processing card token: " + cp.getCardToken();
} else if (event instanceof CryptoPayment) {
CryptoPayment crypto = (CryptoPayment) event;
return "Executing wallet tx: " + crypto.getWalletAddress();
} else if (event instanceof BankTransfer) {
BankTransfer bt = (BankTransfer) event;
return "Initiating ACH: " + bt.getRoutingNumber();
}
return "Unknown payment channel";
}After — Java 21 switch pattern matching:
public String routePaymentEvent(Object event) {
return switch (event) {
case CardPayment cp -> "Processing card token: " + cp.getCardToken();
case CryptoPayment c -> "Executing wallet tx: " + c.getWalletAddress();
case BankTransfer bt -> "Initiating ACH: " + bt.getRoutingNumber();
case null -> throw new IllegalArgumentException("Event cannot be null");
default -> "Unknown payment channel";
};
}Combined with sealed classes — which restrict which classes can implement an interface — this pattern also gives you exhaustiveness checking at compile time. The compiler tells you if you have forgotten to handle a case, eliminating the entire category of runtime default fallthrough bugs.
Sequenced Collections (JEP 431)
Java 21 introduces SequencedCollection, SequencedSet, and SequencedMap interfaces that provide a uniform API for collections with a defined encounter order — getFirst(), getLast(), addFirst(), addLast(), reversed(). Before this, getting the last element of a LinkedHashMap required casting to LinkedHashMap and using its iterator in reverse. Now:
SequencedMap<String, Transaction> recentTransactions = new LinkedHashMap<>();
// ...populate...
Transaction latest = recentTransactions.lastEntry().getValue();
Transaction earliest = recentTransactions.firstEntry().getValue();
// Reversed view — no copy made
recentTransactions.reversed().forEach((k, v) -> auditLog.record(k, v));Production Scaling: Java 21 to Java 25
This phase is where the platform investment becomes directly visible in infrastructure metrics. Java 25 finalizes a set of JEPs that reduce heap pressure, eliminate a class of virtual-thread failure modes, and give you a safer, more expressive construction model.
Eliminating ThreadLocal Risks with Scoped Values (JEP 506)
ThreadLocal was designed for a world with a bounded number of platform threads. It stores one value per thread, which means one value per carrier thread in a virtual thread context — not per virtual thread. Worse, forgetting to call remove() contaminates subsequent requests when threads are reused from a pool.
ScopedValue, finalized in Java 25, solves this precisely. It is immutable, stack-confined, and automatically cleaned up when execution leaves the scope — no remove() call, no risk of leaked context.
Before — Java 21 ThreadLocal for tenant context:
public class TenantContextHolder {
private static final ThreadLocal<String> TENANT_ID = new ThreadLocal<>();
public static void set(String tenantId) {
TENANT_ID.set(tenantId);
}
public static String get() {
return TENANT_ID.get();
}
public static void clear() {
TENANT_ID.remove(); // Forgetting this line causes cross-request contamination
}
}After — Java 25 ScopedValue:
public class TenantContextHolder {
public static final ScopedValue<String> TENANT_ID = ScopedValue.newInstance();
}
// At the request entry point — filter, interceptor, or controller:
String tenantId = request.getHeader("X-Tenant-Id");
ScopedValue
.where(TenantContextHolder.TENANT_ID, tenantId)
.run(() -> {
// TENANT_ID is immutable and visible to all code in this call tree,
// including child virtual threads forked inside this scope.
// It is automatically cleaned up when run() returns — no manual clear().
ledgerService.processRequest();
});The immutability guarantee is load-bearing in distributed tracing and multi-tenant architectures: no code inside the scope can overwrite the value and corrupt the context for another thread. Under ThreadLocal, this was a class of bug that surfaced only under high concurrency and was notoriously hard to reproduce in staging.
Fail-Fast Construction with Flexible Constructor Bodies (JEP 513)
Before Java 25, super() or this() was required to be the absolute first statement in a constructor. This forced you to trust the parent class constructor with potentially invalid state — if your validation failed after super(), the parent had already initialized its fields, potentially registered listeners, or published the object reference.
Java 25 removes this restriction. You can validate and compute values before invoking the parent constructor.
Before — Java 21: forced super() first:
public class PremiumRouter extends BaseValidationRouter {
private final String routingToken;
public PremiumRouter(String config) {
super(); // Parent initializes even if config is corrupt
if (config == null || !config.contains("ACTIVE")) {
throw new IllegalArgumentException("Configuration corrupted.");
}
this.routingToken = decryptToken(config);
}
}After — Java 25: validate before super():
public class PremiumRouter extends BaseValidationRouter {
private final String routingToken;
public PremiumRouter(String config) {
// Validate input and compute derived values before the parent sees anything
if (config == null || !config.contains("ACTIVE")) {
throw new IllegalArgumentException("Configuration corrupted.");
}
String parsed = decryptToken(config);
super(); // Parent constructor runs only on valid input
this.routingToken = parsed;
}
}This is particularly valuable in hierarchies where the parent constructor registers the object with an event bus, publishes a reference, or acquires a lock. With the old constraint, a partially-initialized child could be exposed externally before validation ran. With JEP 513, the parent constructor only runs when you are ready for it.
Virtual Thread Pinning Eliminated (JEP 491)
In Java 21, virtual threads had a significant failure mode: entering a synchronized block while performing I/O would "pin" the virtual thread to its carrier OS thread, blocking the carrier for the duration of the I/O operation. Under high concurrency, if enough virtual threads pinned simultaneously, the carrier thread pool would exhaust and the application would effectively deadlock.
The Java 21 workaround was to replace synchronized blocks around blocking operations with ReentrantLock, which is cooperative with the virtual thread scheduler. This was invasive, particularly in heavily-synchronized legacy code.
Java 21 workaround — replacing synchronized with ReentrantLock:
// Before: blocked carrier threads when I/O happened inside synchronized
public synchronized PaymentResult processPayment(Payment payment) {
return repository.save(payment); // Database I/O here would pin the carrier
}
// Java 21 workaround
private final ReentrantLock lock = new ReentrantLock();
public PaymentResult processPayment(Payment payment) {
lock.lock();
try {
return repository.save(payment);
} finally {
lock.unlock();
}
}Java 25: no workaround needed. JEP 491 rearchitected the JVM so that virtual threads can park and unmount smoothly inside synchronized blocks. The virtual thread suspends, releases the carrier, and remounts when both the lock is available and a carrier is free.
// Java 25: synchronized works correctly with virtual threads — no ReentrantLock needed
public synchronized PaymentResult processPayment(Payment payment) {
return repository.save(payment);
}One important caveat remains: native method calls and foreign function calls (via JNI or the Foreign Function & Memory API) can still pin. If your codebase calls native libraries inside blocking operations, those specific paths still benefit from the ReentrantLock pattern. But the vast majority of Java application code — synchronized blocks around database calls, cache operations, and I/O — is now safe without modification.
Compact Object Headers (JEP 519): Free Heap Reduction
Every Java object carries a header that stores the object's class pointer and identity hash code. On a standard 64-bit JVM, this header consumes 96 to 128 bits. For an application with millions of live objects — a realistic figure for a microservice with a warm in-memory cache or a large domain model — this overhead accumulates.
Java 25 reduces all object headers to a consistent 64 bits. Enable it with a single JVM flag:
java -XX:+UseCompactObjectHeaders -XX:+UseZGC -XX:+ZGenerational -jar service.jarIn heap-heavy workloads — services with deep object graphs, large domain models, or high-volume in-memory caches — expect 10–20% heap reduction immediately. This is not an optimization you tune or an algorithm you rewrite. It is a flag that makes every existing object smaller.
The trade-off to be aware of: compact headers change internal assumptions about object layout. Run your full integration test suite and your production load tests before enabling this in production. In practice I have not seen failures, but the JVM is making a different layout promise and existing test coverage is the right safety net.
Generational ZGC (JEP 521): Predictable Tail Latency
ZGC has been production-grade since Java 15 but operated as a non-generational collector — it treated young and old objects identically. Generational ZGC, finalized in Java 25, applies the generational hypothesis (most objects die young) to dramatically reduce the work the collector does per cycle.
The result: GC cycles complete faster, pause times stay consistently sub-millisecond, and overall throughput improves because the collector spends less time scanning long-lived objects on every cycle.
# Full recommended production GC configuration for Java 25
java \
-XX:+UseZGC \
-XX:+ZGenerational \
-XX:+UseCompactObjectHeaders \
-Xlog:gc*,gc+age=trace,safepoint:file=/var/log/app/gc.log:time,uptime,pid:filecount=5,filesize=100M \
-jar service.jarFor batch-processing or throughput-sensitive workloads where latency variance matters less than raw throughput, G1GC remains a strong alternative. But for latency-sensitive API services — anything with SLA commitments at P99 or P99.9 — Generational ZGC is the correct default on Java 25.
Native Cryptography via the Key Derivation Function API (JEP 510)
Java 25 introduces a standard API for Key Derivation Functions, eliminating the need for BouncyCastle or other third-party cryptographic libraries for HKDF-based key derivation — a common requirement in token generation, session key rotation, and JWT signing flows.
import javax.crypto.KDF;
import javax.crypto.SecretKey;
import java.security.spec.HKDFParameterSpec;
public class SecurityEngine {
public SecretKey deriveSessionKey(SecretKey masterKey, byte[] salt, byte[] info)
throws Exception {
KDF kdf = KDF.getInstance("HKDF-SHA256");
HKDFParameterSpec params = HKDFParameterSpec
.expandOnly(masterKey, info, 32);
return kdf.deriveKey("AES", params);
}
}Removing BouncyCastle as a direct dependency reduces your attack surface, eliminates a FIPS compliance conversation, and removes a transitive dependency that has historically been a source of CVEs in enterprise audits.
Cleaner Source Files with Module Import Declarations (JEP 511)
Module import declarations let you import an entire platform module with a single directive, replacing cascading individual package imports in import-heavy files.
// Before — verbose per-package imports
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.function.Function;
import java.math.BigDecimal;
import java.time.Instant;
// After — single module import
import module java.base; // Covers collections, streams, math, I/O, time, functions
import module java.sql; // Covers JDBC drivers, connection, statement interfacesThis does not affect compiled output or runtime behavior. It is purely a readability and maintenance improvement, particularly valuable in service and repository classes that use a broad range of platform APIs.
Build Infrastructure and JVM Tuning
Maven Configuration
<properties>
<java.version>25</java.version>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>25</release>
<compilerArgs>
<arg>-Xlint:unchecked</arg>
<arg>-Xlint:deprecation</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>Gradle Configuration
// build.gradle.kts
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:unchecked", "-Xlint:deprecation"))
}Unified GC Logging
The old -XX:+PrintGCDetails and -Xloggc flags are removed in Java 17+. Use the unified logging framework:
-Xlog:gc*,gc+age=trace,safepoint:file=/var/log/app/gc.log:time,uptime,pid:filecount=5,filesize=100MThis produces structured, timestamped GC events that feed directly into log-analysis pipelines (Datadog, Grafana Loki, Splunk) without requiring a separate GC log parser.
Complete Recommended Production JVM Flags
java \
# GC — Generational ZGC for sub-millisecond tail latency
-XX:+UseZGC \
-XX:+ZGenerational \
# Memory — compact object headers for 10–20% heap reduction
-XX:+UseCompactObjectHeaders \
# Heap sizing — tune to your service; start conservative and profile
-Xms2g \
-Xmx4g \
# Unified GC logging
-Xlog:gc*,gc+age=trace,safepoint:file=/var/log/app/gc.log:time,uptime,pid:filecount=5,filesize=100M \
# Container awareness — let the JVM respect cgroup limits
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
# Virtual thread diagnostics — shows when/if pinning occurs
-Djdk.tracePinnedThreads=full \
-jar service.jar-Djdk.tracePinnedThreads=full writes a stack trace to stdout whenever a virtual thread pins. Run it in your first production deployment of each service to audit any remaining pinning paths, then remove it once you are satisfied the carrier pool is not being held hostage.
Spring Boot Application Configuration
# application.yml — full Java 25 / Spring Boot 3.x configuration
spring:
threads:
virtual:
enabled: true # Enable virtual threads for Tomcat
datasource:
hikari:
maximum-pool-size: 20 # Virtual threads make large pools unnecessary;
# JDBC connections are still limited by the database
jpa:
open-in-view: false # Always disable with virtual threads; OSIV holds connections
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
generate_statistics: falseProduction Readiness Verification
Work through this checklist before promoting to production. Each item maps to a known failure mode from real migrations.
Build and Compilation
- Compile with
-Xlint:uncheckedand-Xlint:deprecation— clean compiler output with zero warnings - Run
jdeps --jdk-internalson the final fat JAR and confirm zero internal API references - Confirm no
--add-opensflags remain in your JVM args that were added as temporary workarounds; each one still present should have a named ticket
Framework and Data Mapping
- Run the full integration test suite against Hibernate 6.x — verify entity mapping, query behavior, and schema validation
- Confirm Jackson deserialization works correctly for record types under all serialization variants (JSON, CBOR, MessagePack if used)
- Verify Spring Security filter chains produce the same authorization decisions as the legacy
WebSecurityConfigurerAdapterconfiguration
Concurrency and Memory
- Enable
-Djdk.tracePinnedThreads=fullin staging and drive load through every code path; confirm zero pinning events in the log for synchronized blocks - Run load tests with
ScopedValuescopes active and profile heap using JFR — confirm no scoped value context is retained outside therun()boundary - Run a 30-minute sustained load test with
-XX:+UseCompactObjectHeadersenabled and compare heap usage, GC frequency, and pause times against the Java 21 baseline
Security
- If migrating away from BouncyCastle to the Java 25 KDF API, validate key derivation output against known test vectors before deploying to production
- Confirm TLS configuration is aligned with Java 25 defaults — Java 25 disables TLS 1.0 and 1.1 by default
Monitoring and Observability
- Replace any
-XX:+PrintGCDetailsor-Xloggcflags in all deployment manifests, Helm charts, and CI scripts - Confirm your APM agent (Datadog, New Relic, Dynatrace) has a Java 25-compatible version deployed
- Verify distributed tracing propagation still works correctly after migrating from
ThreadLocaltoScopedValuecontext holders - Set up a dashboard alert on virtual thread carrier pool exhaustion: monitor
jvm.threads.carrier.countand alert if it reaches the configured maximum
The migration is not complete when the service starts. It is complete when you have validated memory, GC, and concurrency behavior under real production load.
A Java 25 migration done in phases is not a disruption — it is a systematic retirement of operational risk. The compute savings from virtual threads and compact object headers are not theoretical. The sub-millisecond GC latency from Generational ZGC is not a benchmark number. These show up in your cloud bill, your P99 dashboard, and your on-call rotation frequency.
Start where you are, validate each layer, and give the JVM the flags it needs to do its job. The runtime will take care of the rest.
References and Further Reading
- JEP 444 — Virtual Threads (Java 21)
- JEP 491 — Synchronize Virtual Threads without Pinning (Java 25)
- JEP 506 — Scoped Values (Java 25)
- JEP 510 — Key Derivation Function API (Java 25)
- JEP 511 — Module Import Declarations (Java 25)
- JEP 513 — Flexible Constructor Bodies (Java 25)
- JEP 519 — Compact Object Headers (Java 25)
- JEP 521 — Generational ZGC (Java 25)
- OpenRewrite Java Migration Recipes
- Spring Boot 3.x Migration Guide
- Jakarta EE 10 Namespace Migration