PCSalt
YouTube GitHub
Back to Java
Java · 3 min read

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:

FeatureStatus in 21
Virtual threadsFinal
Pattern matching for switchFinal
Record patternsFinal
Sequenced collectionsFinal
String templatesPreview
Structured concurrencyPreview
Scoped valuesPreview
Foreign Function & Memory APIThird 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() — throw UnsupportedOperationException
  • Various java.security deprecated methods
  • Legacy Applet API (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:

LibraryMinimum version
Spring Boot3.2+
Spring Framework6.1+
Hibernate6.4+
Jackson2.16+
Lombok1.18.30+
Mockito5.8+
JUnit 55.10+
Gradle8.5+
Maven3.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:

  1. Records — Replace data-only classes as you modify them
  2. Sealed classes — When you add new type hierarchies
  3. Pattern matching — Replace instanceof chains in existing code
  4. Virtual threads — For I/O-heavy services (one config change in Spring Boot)
  5. 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:

  1. Update JDK and build tool to Java 21
  2. Update dependencies — check minimum versions
  3. Build and fix — charset changes, deprecated removals
  4. Run tests — catch runtime issues
  5. 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.