Migrating from JDK 17 to JDK 21: An Overview and Practical Guide
Introduction
In the fast-evolving world of software development, staying current isn't just about new features—it's about maintaining a competitive edge. With Java 17 being the previous Long-Term Support (LTS) release, the arrival of JDK 21 presents a pivotal upgrade opportunity for any organization running on the JVM. But migrating an entire ecosystem of microservices is no small feat. This migration stabilized our P99 latency, reduced heap usage by roughly 30% across services, and cut infrastructure costs for I/O-bound services by over 10%.
In this comprehensive guide, we'll walk you through our end-to-end migration from JDK 17 to JDK 21. We'll move beyond a simple version bump and dive into the transformative advantages this new LTS offers, most notably the game-changing Virtual Threads that promise to redefine concurrency and scalability in microservices. We will cover:
- The "Why": A clear breakdown of the business and technical benefits of JDK 21.
- The "How": A step-by-step migration plan, from initial analysis to production rollout.
- The "What If": Our learnings, potential pitfalls, and how to navigate them.
Whether you're building the business case for the migration or are deep in the technical planning, this article will equip you with the knowledge to make your transition to JDK 21 a resounding success.
Why Migrate to JDK 21?
While JDK 17 was a significant Long-Term Support (LTS) release, the upgrade to JDK 21 is arguably one of the most impactful in Java's recent history. It's not just an incremental update; it’s a strategic move that delivers revolutionary performance improvements, massive developer productivity gains, and a secure foundation for the future.
For a quick comparison, here are the key features that finalized in JDK 21, making the upgrade from JDK 17 so compelling:
| Feature | JDK 17 Status | JDK 21 Status | Impact |
|---|---|---|---|
| Virtual Threads | Not Available | Finalized | Massive scalability for I/O-bound apps |
| Sequenced Collections | Not Available | Standard Feature | Unified, predictable collection API |
| Record Patterns | Preview | Finalized | More expressive data decomposition |
| Pattern Matching for switch | Second Preview | Finalized | Safer, more concise conditional logic |
| Generational ZGC | Experimental | Production Ready | Lower application latency |
For our teams at Halodoc, previously on JDK 17, the decision to migrate was driven by these key advantages:
1. The Game Changer: Virtual Threads (Project Loom)
This is the headline feature of JDK 21 and the single most important reason to upgrade for microservice architectures.
- What they are: Virtual threads are extremely lightweight threads managed by the JVM, not the operating system. You can have millions of them running concurrently without the heavy memory and context-switching overhead of traditional "platform" threads.
- Why they matter: Most microservices are I/O-bound (waiting for database queries, API calls, or message queues). You get the performance of complex asynchronous/reactive programming with the simplicity and debuggability of straightforward, blocking code. This leads to:
- Higher Throughput: Services can handle far more concurrent requests.
- Reduced Complexity: No more
CompletableFuturechains or wrestling with reactive frameworks for simple I/O tasks. - Increased Developer Productivity: Code is easier to write, read, and maintain.
2. Enhanced Developer Productivity: Modern Language Features
JDK 21 finalizes several preview features from previous releases, making your code more expressive, concise, and robust.
2.1. Pattern Matching for switch and Record Patterns
Pattern matching has been supercharged. You can now switch over types, use when clauses for conditional logic, and deconstruct records directly within the case label. This eliminates verbose if-else chains and instanceof checks.
Before (Java 17):
After (Java 21):
2.2. Sequenced Collections
This JEP introduces new interfaces (SequencedCollection, SequencedSet, SequencedMap) that provide a unified API for collections with a defined encounter order. You no longer need different methods to get the first or last element of a List vs. a LinkedHashSet.
3. The Strategic Advantage: Long-Term Support (LTS)
JDK 21 is the new LTS release, meaning it will receive security updates and bug fixes for years to come (until at least September 2028, with extended support beyond that). Migrating from one LTS to the next is a standard best practice that ensures your production environment remains:
- Secure: You receive timely security patches.
- Stable: You benefit from years of production hardening and bug fixes.
- Supported: You can rely on a stable platform without being forced into frequent, non-LTS upgrades.
4. Continued Performance and Security Gains
Beyond virtual threads, JDK 21 includes hundreds of smaller performance, stability, and security enhancements. These include improvements to the Garbage Collectors (like G1), JIT compiler optimizations, and updated cryptographic libraries, ensuring your applications run faster and more securely out of the box.
A Strategic Plan for JDK 21 Migration
Migrating a complex microservices ecosystem requires a clear, low-risk strategy. Our approach was centered on a bottom-up, phased rollout designed to prevent downstream conflicts and ensure stability at every stage. This breaks the migration down into manageable, predictable steps.
1. The Core Strategy: Migrate from the Bottom Up
The most effective way to handle a complex dependency web is to start at the foundation and work your way up.
- Prioritize Common Libraries First: We began by migrating our shared libraries to be fully JDK 21 compatible. This ensures that any service migrating later will pull in dependencies that are already certified and stable.
- Migrate Dependent Microservices Next: With the foundational libraries updated, we then proceeded to migrate the services that consume them. This sequential approach drastically reduces compatibility issues and makes debugging far simpler.
This diagram illustrates our rollout strategy, starting from the foundational library layer and moving up to the service layer:
2. The Migration Phases
We structured the entire process into three distinct phases:
- Phase 1: Preparation and Analysis: Before writing any code, we audited all our services and their dependencies to check for JDK 21 compatibility. This involved identifying necessary upgrades for frameworks (like Spring Boot), build tools (Maven/Gradle), and key libraries. We also prepared our CI/CD environment with the new JDK.
- Phase 2: Incremental Execution: We started with a single, low-risk "canary" service to pilot the migration process. After successfully migrating it and its libraries, we expanded the effort in manageable waves across other services, following the dependency graph.
- Phase 3: Validation and Staged Rollout: A successful compilation doesn't equal a successful migration. Each service underwent rigorous performance testing in a staging environment. We then used a canary release strategy to deploy to production, closely monitoring performance and error dashboards before a full rollout.
This methodical plan transforms a potentially disruptive migration into a controlled and safe engineering project. In the next section, we'll dive into the detailed technical steps involved in this process.
A Step-by-Step Technical Guide
Step 1: Local Environment Setup for OpenJDK 21
The first step in our migration is to install OpenJDK 21 on your local development machine. You have several excellent options, each with its own benefits.
1.1. Option 1: Using a Package Manager (Recommended)
The easiest and most common way to install OpenJDK is through your operating system's package manager. This handles the download, installation, and often the basic path configuration for you.
- On macOS with Homebrew:
- On Debian/Ubuntu-based Linux:
- On Windows with Winget:
1.2. Option 2: Manual Installation and Configuration
For more control, you can install OpenJDK manually. This involves downloading the archive and configuring environment variables in your user profile, which avoids modifying system-wide settings.
- Download OpenJDK 21: Go to a trusted OpenJDK provider like Eclipse Temurin and download the JDK 21 archive (
.tar.gzfor Linux/macOS or.zipfor Windows) for your system. - Extract the Archive: Unpack the downloaded file to a permanent location on your machine (e.g.,
~/jdks/on Linux/macOS orC:\Java\on Windows). - Configure Environment Variables: This is the most crucial step. You need to set the
JAVA_HOMEvariable and add it to yourPATH. This should be done in your user-specific shell configuration file (~/.zshrc,~/.bash_profileon macOS/Linux, or through "Environment Variables for your account" on Windows).
Example for~/.zshrcor~/.bash_profile:
After editing the file, you'll need to restart your terminal or run source ~/.zshrc for the changes to take effect.
Why JAVA_HOME Matters
Many development tools, including build tools like Maven and Gradle, and IDEs like IntelliJ IDEA and VS Code, rely on the JAVA_HOME environment variable to locate the correct JDK installation. Setting it explicitly ensures that the entire toolchain uses the same Java version, preventing version conflicts.
1.3. Final Verification
Regardless of the method you choose, always verify that OpenJDK 21 is active in your command line:
The output should confirm you are now running OpenJDK version "21...". With your local machine configured, you are ready to update your IDE and project build files.
1.4. Troubleshooting Tip: Fixing PATH Mix-ups
A common issue is having your PATH point to a different Java installation than JAVA_HOME. This can cause confusing build failures where your terminal uses an old version of Java even though JAVA_HOME is set correctly.
To fix this, check both:
- echo $JAVA_HOME (or %JAVA_HOME% on Windows)
- which java (or where java on Windows)
If they don't align, ensure that $JAVA_HOME/bin is at the beginning of your PATH variable, so it is found first.
Step 2: Update Your Project Configuration and Code
With your local environment ready, the next phase is to update your project's build configuration and source code.
2.1. Audit and Upgrade Dependencies
First, audit your project's dependencies for JDK 21 compatibility. Pay close attention to core frameworks (e.g., Spring Boot requires version 3.2+, Dropwizard) and bytecode-manipulation libraries like Mockito and JaCoCo. Upgrade these to their latest stable versions to prevent runtime issues.
2.2. Configure the Build for JDK 21
Next, update your pom.xml to declare JDK 21. The modern approach is to set the <release> property in the maven-compiler-plugin, which ensures your code is compiled against the correct Java API.
Maven Compiler Plugin for JDK 21
2.3. Configure the Test Environment
To run tests on JDK 21, you often need to grant access for reflection-based tools like Mockito. Update your maven-surefire-plugin and add the --add-opens JVM argument.
Maven Surefire Plugin Configuration
2.4. Compile, Test, and Fix
Finally, run a full build (mvn clean install). Fix any compilation errors that arise, especially those related to illegal reflective access. Run your entire test suite to ensure everything works as expected before proceeding to deployment.
For example, one of our biggest challenges was that Powermock does not work with JDK 21. We had to refactor those tests to use Mockito instead. We also had to update our Guice and Lombok library versions to ensure they were compatible. Checking your core libraries is a critical first step.
Step 3: Containerizing the Application with an OpenJDK 21 Image
After successfully building and testing your application with JDK 21 locally, the next step is to package it into a container for deployment. To ensure consistency between your build and runtime environments, your Dockerfile must be updated to use a base image that includes OpenJDK 21.
For this, we recommend using a JRE (Java Runtime Environment) base image. A JRE image is significantly smaller and more secure than a full JDK image because it only includes the components needed to run a Java application, not to compile it.
We will use a base image from Eclipse Temurin, a trusted, high-quality build of OpenJDK.
To quantify the savings, consider these approximate image sizes:
- Full JDK Image (eclipse-temurin:21-jdk): ~500 MB
- JRE Image (eclipse-temurin:21-jre): ~250 MB
By using a JRE-based image, you can cut your image size in half, leading to faster deployments and a smaller security footprint.
Updating the Dockerfile
Here is a simple, straightforward Dockerfile that packages your application with an OpenJDK 21 JRE.
Dockerfile with an OpenJDK 21 JRE
With your application now packaged in an OpenJDK 21-compatible container, you are ready to move on to the crucial phases of deployment, testing, and validation in a production-like environment.
Step 4: Updating the CI/CD Pipeline
At Halodoc, our pipelines are built on Jenkins and standardized using a shared library. This made the JDK upgrade remarkably simple at the CI/CD level. We didn't need to change Docker images in every service's Jenkinsfile; instead, we only had to update a single parameter in the call to our shared build configuration.
The Jenkinsfile for each service is very clean. The migration was as simple as changing the jdk_version parameter from 'jdk17' to 'jdk21'.
Example from our Jenkinsfile:
Step 5: Optimizing the Runtime with Generational ZGC:
The migration to JDK 21 was more than a compatibility update; it was an opportunity to modernize our runtime performance. The most significant change we made was in our service startup script, which we call the run file. We decided to adopt the new Generational Z Garbage Collector (ZGC), a headline feature of JDK 21 designed for extremely low pause times.
A Word of Caution: When Not to Switch Collectors
ZGC is optimised for extremely low pause times and is excellent for responsive, I/O-bound services. However, it may not be the best choice for all workloads. For CPU-bound services that prioritize raw throughput over latency, the default G1GC might still be a better choice, as ZGC can introduce a slightly higher CPU overhead.
Our run file handles loading secrets from Vault and application configurations before executing the Java process. The key change was updating the JVM arguments to enable ZGC.
A snippet from our run file's exec java command:
By switching to Generational ZGC, we aim to improve our application's latency profile, a critical factor for user experience in our healthcare platform. We also configured detailed GC logging (-Xlog:gc*) to monitor its behavior in production.
ZGC | Using -XX:SoftMaxHeapSize:
As the name implies, this new option sets a soft limit on how large the Java heap can grow. When set, the GC will strive to not grow the heap beyond the soft max heap size. But, the GC is still allowed to grow the heap beyond the specified size if it really needs to, like when the only other alternatives left is to stall an allocation or throw an OutOfMemoryError.
There are different use cases where setting a soft max heap size can be useful. For example:
- When you want to keep the heap footprint down, while maintaining the capability to handle a temporary increase in heap space requirement.
- Or when you want to play it safe, to increase confidence that you will not run into an allocation stall or have an
OutOfMemoryErrorbecause of an unforeseen increase in allocation rate or live-set size.
TL;DR
Using -XX:SoftMaxHeapSize=2G -Xmx5G will tell ZGC to keep the max heap usage at 2G, but it’s allowed to grow to 5G if it otherwise would have resulted in an allocation stall or an OutOfMemoryError. This is useful when you want to keep the memory footprint down, while maintaining the capability to handle a temporary increase in heap space requirement.
Observations:
Key outcomes:
- Reduction in used heap: 50% reduction for Timor Fulfilment
- Reduction in pause time:
Step 6: Test, Deploy, and Monitor
A successful build doesn't guarantee a successful migration. We followed a strict canary release process to deploy to production safely.
First, we ran our full suite of unit and integration tests in the new JDK 21 environment to confirm stability. Once passed, we deployed the new version and routed 10% of live production traffic to it. For several hours, we closely monitored our dashboards, watching three key metrics:
- CPU and Memory Usage: To ensure no performance regressions.
- Error Rates: To catch any new exceptions immediately.
- P99 Latency: To confirm response times were not negatively impacted.
Only after the service ran stable in the canary environment did we proceed with a 100% rollout.
Conclusion:
Migrating from JDK 17 to JDK 21 provides access to new language features, enhanced performance optimizations, improved security, and essential bug fixes. However, careful planning and thorough testing are vital for a seamless transition.
In this guide, we've outlined a step-by-step approach to migrating a microservice from JDK 17 to JDK 21. We've also discussed common challenges encountered during migration and strategies to overcome them.
References:
- JDK 21 Guide: JDK Migration Guide
- Guice: Guice Documentation
- Lombok Issue: Lombok GitHub Issues
- Powermock Issue: Consider using Mockito GitHub Issues
- Surefire Plugin: Surefire Plugin Documentation
Make sure to check the latest documentation and issues pages for the most current information.
Join Us
Scalability, reliability and maintainability are the three pillars that govern what we build at Halodoc Tech. We are actively looking for engineers at all levels, and if solving complex problems with challenging requirements is your forte, please reach out to us with your resumé at careers.india@halodoc.com.
About Halodoc
Halodoc is the number one all-around healthcare application in Indonesia. Our mission is to simplify and deliver quality healthcare across Indonesia, from Sabang to Merauke.
Since 2016, Halodoc has been improving health literacy in Indonesia by providing user-friendly healthcare communication, education, and information (KIE). In parallel, our ecosystem has expanded to offer a range of services that facilitate convenient access to healthcare, starting with Homecare by Halodoc as a preventive care feature that allows users to conduct health tests privately and securely from the comfort of their homes; My Insurance, which allows users to access the benefits of cashless outpatient services in a more seamless way; Chat with Doctor, which allows users to consult with over 20,000 licensed physicians via chat, video or voice call; and Health Store features that allow users to purchase medicines, supplements and various health products from our network of over 4,900 trusted partner pharmacies. To deliver holistic health solutions in a fully digital way, Halodoc offers Digital Clinic services, including Haloskin, a trusted dermatology care platform guided by experienced dermatologists.
We are proud to be trusted by global and regional investors, including the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. With over USD 100 million raised to date, including our recent Series D, our team is committed to building the best personalised healthcare solutions, and we remain steadfast in our journey to simplify healthcare for all Indonesians.