Migrating from Java 8 to Java 21-24
Context
The translation platform codebase was running on Java 8, which had reached its extended support window. The team needed to modernize to take advantage of virtual threads, pattern matching, sealed classes, and improved garbage collection available in newer JDK releases.
Decision
Perform a direct migration from Java 8 to Java 21 LTS, then incrementally adopt Java 22-24 preview features behind feature flags
Alternatives Considered
Stay on Java 8 with backported libraries
- Zero migration risk
- No developer retraining needed
- All existing CI/CD pipelines remain unchanged
- Missing virtual threads means continued reliance on thread-pool tuning
- No access to modern language features like records, sealed classes, pattern matching
- Increasingly difficult to find libraries that still support Java 8
- Security patches becoming less frequent
Incremental migration to Java 11 first, then plan Java 21 later
- Smaller migration step reduces immediate risk
- Java 11 is still well-supported LTS
- Gives team time to adapt gradually
- Two migrations instead of one, doubling total effort
- Java 11 misses most of the features we actually wanted (virtual threads, records, pattern matching)
- Delays the real benefits by 6-12 months
Migrate directly to Java 21 LTS
- Single migration effort to latest LTS
- Immediate access to virtual threads, records, sealed classes, pattern matching
- Strong library ecosystem support for Java 21
- Significant GC improvements with ZGC generational mode
- Larger jump requires more thorough testing
- Some legacy dependencies needed replacement
- Team needed upskilling on new language features
Reasoning
The direct jump to Java 21 was chosen because the intermediate stop at Java 11 would have delivered almost none of the features driving the migration. Virtual threads alone justified the move -- our translation processing pipelines were heavily I/O-bound and the thread-per-request model on Java 8 required constant pool tuning. The risk of a larger jump was mitigated by comprehensive integration tests and a phased rollout across services.
Context and Background
The translation platform had been running on Java 8 since its inception. Over the years, the codebase accumulated workarounds for features that modern Java provides natively — custom tuple classes instead of records, complex visitor patterns instead of sealed class hierarchies with pattern matching, and heavily tuned thread pools to manage concurrent translation requests.
The tipping point came when we started hitting scalability walls with our translation processing pipeline. Each translation job involved multiple I/O-bound calls: fetching source documents from S3, calling third-party translation APIs, writing results to PostgreSQL, and publishing events to Kafka. Under Java 8, each of these blocking operations held a platform thread hostage. Our thread pools were at their limits, and adding more threads meant more memory pressure on an already constrained heap.
Java 21’s virtual threads offered a fundamentally different approach — millions of lightweight threads that yield during I/O without consuming platform thread resources. Combined with the developer ergonomics of records, pattern matching, and text blocks, the business case for migration was clear.
Implementation
-
Dependency audit: Cataloged all 147 direct and transitive dependencies, identifying 12 that had no Java 21-compatible version. Replaced javax.* references with jakarta.* equivalents as part of the Spring Boot 3 upgrade that accompanied the migration.
-
Build pipeline update: Updated Gradle to 8.x, switched the toolchain to Temurin JDK 21, and configured the CI pipeline to run tests on both Java 8 (for rollback safety) and Java 21 during the transition period.
-
Code modernization in phases: Rather than rewriting everything at once, we adopted new features incrementally. First pass replaced anonymous inner classes with lambdas and
varwhere appropriate. Second pass converted data carrier classes to records. Third pass introduced sealed interfaces for the translation job state machine. -
Virtual threads rollout: Replaced
Executors.newFixedThreadPool()withExecutors.newVirtualThreadPerTaskExecutor()in the translation processing service first, then expanded to other I/O-heavy services. Kept platform threads for CPU-bound audio transcription workloads. -
GC tuning: Switched from G1GC to ZGC generational mode, which eliminated the long tail latencies we had been seeing during peak translation loads. Reduced GC pause times from ~80ms p99 to under 1ms.
-
Canary deployment: Deployed Java 21 services alongside Java 8 services in ECS, routing 5% of traffic initially and scaling up over two weeks while monitoring error rates, latency, and memory usage.
Results
- Translation pipeline throughput increased by approximately 3x under peak load due to virtual threads eliminating thread pool bottlenecks
- Heap memory usage dropped by roughly 25% after converting large data carrier classes to records and improving GC behavior with ZGC
- Average GC pause times went from ~80ms to sub-millisecond, eliminating timeout-related errors during garbage collection
- Developer productivity improved noticeably — records, pattern matching, and text blocks reduced boilerplate in new code by an estimated 30-40%
- Successfully resolved two long-standing heap leak issues that were easier to diagnose with improved JFR tooling in Java 21
- The migration was completed over 8 weeks with zero production incidents