Migrating from Java 17 to 21 — What Breaks, What Improves
A practical migration guide from Java 17 to 21 — breaking changes, deprecated removals, new features to adopt, and step-by-step upgrade process.
Java 21 is the current LTS. If you’re on Java 17, upgrading gives you virtual threads, pattern matching, record patterns, sequenced collections, and significant performance improvements. Here’s what to expect.
What changed between 17 and 21
Java 18, 19, 20, and 21 each added features. Here’s what finalized in 21:
| Feature | Status in 21 |
|---|---|
| Virtual threads | Final |
| Pattern matching for switch | Final |
| Record patterns | Final |
| Sequenced collections | Final |
| String templates | Preview |
| Structured concurrency | Preview |
| Scoped values | Preview |
| Foreign Function & Memory API | Third preview |
Breaking changes
1. UTF-8 is now the default charset
Java 18 changed the default charset to UTF-8 on all platforms. Previously, Windows used the system locale (often Windows-1252).
// Before Java 18: platform-dependent
// After Java 18: always UTF-8
new FileReader("file.txt") // now reads as UTF-8
If your application relied on the platform charset, file reads might produce different results. Fix: explicitly specify the charset:
new FileReader("file.txt", StandardCharsets.UTF_8) // explicit is better
2. Finalization deprecated for removal
finalize() is deprecated for removal. If your code overrides finalize(), migrate to Cleaner or try-with-resources:
// Old way (deprecated)
@Override
protected void finalize() throws Throwable {
resource.close();
}
// New way
private static final Cleaner cleaner = Cleaner.create();
MyClass(Resource resource) {
this.resource = resource;
cleaner.register(this, resource::close);
}
3. Removed APIs
Several long-deprecated APIs were removed:
Thread.stop(),Thread.suspend(),Thread.resume()— throwUnsupportedOperationException- Various
java.securitydeprecated methods - Legacy
AppletAPI (fully removed)
4. Stronger encapsulation of JDK internals
Access to internal JDK APIs (sun.misc.Unsafe, sun.reflect, etc.) is more restricted. If your code or dependencies use --add-opens flags, they might need updates:
# You might need these flags temporarily
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar
Check if your dependencies have newer versions that don’t need internal access.
Step-by-step migration
Step 1: Update your build tool
// build.gradle.kts
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
Or for Maven:
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
Step 2: Update the JDK
Download JDK 21 from Adoptium, Oracle, or your preferred distribution.
Step 3: Build and check for warnings
./gradlew build 2>&1 | grep -i "warning\|error\|deprecated"
Common warnings:
- Deprecated API usage → find the replacement
- Reflection access warnings → update the dependency or add
--add-opens
Step 4: Update dependencies
Major libraries that need specific versions for Java 21:
| Library | Minimum version |
|---|---|
| Spring Boot | 3.2+ |
| Spring Framework | 6.1+ |
| Hibernate | 6.4+ |
| Jackson | 2.16+ |
| Lombok | 1.18.30+ |
| Mockito | 5.8+ |
| JUnit 5 | 5.10+ |
| Gradle | 8.5+ |
| Maven | 3.9+ |
Step 5: Run your test suite
./gradlew test
Pay attention to:
- Charset-related test failures (UTF-8 default change)
- Reflection-related failures (stronger encapsulation)
- Serialization-related failures (class format changes)
Step 6: Adopt new features incrementally
Don’t rewrite everything at once. Adopt features as you touch the code:
- Records — Replace data-only classes as you modify them
- Sealed classes — When you add new type hierarchies
- Pattern matching — Replace instanceof chains in existing code
- Virtual threads — For I/O-heavy services (one config change in Spring Boot)
- Text blocks — Replace multi-line string concatenation
Features to adopt immediately
Records
// Before
public class UserDto {
private final String name;
private final String email;
public UserDto(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() { return name; }
public String getEmail() { return email; }
@Override
public boolean equals(Object o) { /* ... */ }
@Override
public int hashCode() { /* ... */ }
@Override
public String toString() { /* ... */ }
}
// After
public record UserDto(String name, String email) {}
Records give you constructor, getters, equals, hashCode, and toString for free. Use them for DTOs, value objects, and data carriers.
Text blocks (Java 15+, but worth adopting)
// Before
String json = "{\n" +
" \"name\": \"Alice\",\n" +
" \"email\": \"[email protected]\"\n" +
"}";
// After
String json = """
{
"name": "Alice",
"email": "[email protected]"
}
""";
Pattern matching for instanceof
// Before
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
// After
if (obj instanceof String s) {
System.out.println(s.length());
}
Sealed classes
public sealed interface Result<T> permits Success, Failure {
record Success<T>(T data) implements Result<T> {}
record Failure<T>(String error) implements Result<T> {}
}
Sequenced collections
// Before — no standard way to get first/last
List<String> list = List.of("a", "b", "c");
String first = list.get(0);
String last = list.get(list.size() - 1);
// After — Java 21
String first = list.getFirst();
String last = list.getLast();
SequencedCollection<String> reversed = list.reversed();
Virtual threads (Spring Boot)
spring:
threads:
virtual:
enabled: true
One line. Your blocking I/O code now scales to thousands of concurrent requests.
Performance improvements
Java 21 includes cumulative performance improvements from Java 18–21:
- G1 garbage collector improvements — lower pause times, better throughput
- ZGC generational mode — sub-millisecond pauses with better throughput
- JIT compiler optimizations — faster startup, better peak performance
- Virtual threads — dramatically better throughput for I/O-bound workloads
Benchmark your application before and after. Most applications see 5–15% improvement without any code changes.
Common issues during migration
Lombok
Lombok relies on internal compiler APIs. Each Java version can break it. Update to the latest Lombok before upgrading Java:
dependencies {
compileOnly("org.projectlombok:lombok:1.18.34")
annotationProcessor("org.projectlombok:lombok:1.18.34")
}
Consider migrating from Lombok to records for data classes — one less dependency and build-time tool.
Reflection-heavy frameworks
Frameworks that use deep reflection (some ORM configurations, serialization libraries) might need --add-opens flags or dependency updates. Check your framework’s migration guide.
Docker base image
Update your Docker base image:
# Before
FROM eclipse-temurin:17-jre
# After
FROM eclipse-temurin:21-jre
Summary
Migrating from Java 17 to 21:
- Update JDK and build tool to Java 21
- Update dependencies — check minimum versions
- Build and fix — charset changes, deprecated removals
- Run tests — catch runtime issues
- Adopt features — records, pattern matching, virtual threads
The migration is straightforward for most projects. The biggest wins: virtual threads for server applications, pattern matching for cleaner code, and records for less boilerplate. Each alone justifies the upgrade.