1. Introduction
Java 11 was released in September 2018. Java 17 was released in September 2021. Both are Long-Term Support (LTS) releases, and migrating from 11 to 17 is one of the most common upgrade paths in the Java ecosystem today. If you are still on Java 11, you are missing three years of language features, performance improvements, and security updates.
Unlike the Java 8 to 11 migration (which broke many applications due to module system changes and API removals), the 11 to 17 migration is significantly smoother. Most of the painful changes happened in Java 9-11. The 12-17 releases are largely additive — new language features, new APIs, and incremental improvements. That said, there are breaking changes you must plan for, particularly around strong encapsulation of JDK internals.
Here is what is at stake:
| Factor | Java 11 | Java 17 |
|---|---|---|
| LTS Support (Oracle) | Extended support until September 2026 | Extended support until September 2029 |
| LTS Support (Adoptium/Eclipse) | Available but winding down | Actively maintained |
| Spring Boot Compatibility | Spring Boot 2.x (maintenance mode) | Spring Boot 3.x (active development, Java 17 required) |
| Language Features | var, HTTP Client | Records, sealed classes, pattern matching, text blocks, switch expressions |
| Performance | Baseline | 15-20% GC improvements, faster startup, smaller footprint |
| Security | Known CVEs accumulating | Latest security patches |
The bottom line: If you are using Spring Boot and plan to stay current, you must migrate to Java 17 because Spring Boot 3.x requires it. Even without Spring, the language features and performance improvements alone justify the upgrade.
2. New Language Features Summary
Here is every language feature added between Java 12 and Java 17. Features marked as “Standard” are finalized and ready for production use:
| Java Version | Feature | Status in 17 | JEP |
|---|---|---|---|
| Java 12 | Switch Expressions (preview) | Standard (Java 14) | JEP 325 |
| Java 12 | Compact Number Formatting | Standard | — |
| Java 13 | Text Blocks (preview) | Standard (Java 15) | JEP 355 |
| Java 14 | Switch Expressions (finalized) | Standard | JEP 361 |
| Java 14 | Helpful NullPointerExceptions | Standard (default on) | JEP 358 |
| Java 14 | Records (preview) | Standard (Java 16) | JEP 359 |
| Java 14 | Pattern Matching instanceof (preview) | Standard (Java 16) | JEP 305 |
| Java 15 | Text Blocks (finalized) | Standard | JEP 378 |
| Java 15 | Sealed Classes (preview) | Standard (Java 17) | JEP 360 |
| Java 16 | Records (finalized) | Standard | JEP 395 |
| Java 16 | Pattern Matching instanceof (finalized) | Standard | JEP 394 |
| Java 16 | Stream.toList() | Standard | — |
| Java 17 | Sealed Classes (finalized) | Standard | JEP 409 |
| Java 17 | RandomGenerator API | Standard | JEP 356 |
| Java 17 | Foreign Function & Memory API | Incubator | JEP 412 |
| Java 17 | Vector API | Incubator (2nd) | JEP 414 |
3. Breaking Changes
While the 11 to 17 migration is smoother than 8 to 11, there are still breaking changes that can cause compilation errors and runtime failures. Here are the ones you must address:
3.1 Strong Encapsulation (Most Impactful)
In Java 11, the --illegal-access=permit flag was the default, which meant reflective access to JDK internals produced warnings but still worked. In Java 17, the flag is removed entirely. All reflective access to JDK internals is blocked by default.
// This code compiled and ran on Java 11 with a warning
// On Java 17, it throws InaccessibleObjectException
var field = String.class.getDeclaredField("value");
field.setAccessible(true); // FAILS on Java 17
// Fix: Use --add-opens as a temporary workaround
// java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar
// Better fix: Stop using internal APIs and use public alternatives3.2 Removed APIs
| Removed API | Removed In | Replacement |
|---|---|---|
| Nashorn JavaScript Engine | Java 15 | GraalJS or standalone V8 |
RMI Activation (java.rmi.activation) |
Java 17 | gRPC, REST APIs, or standard RMI |
| Pack200 API and tools | Java 14 | Standard compression (gzip, bzip2) |
| AOT and JIT compiler (Graal-based) | Java 17 | GraalVM (external) |
| Solaris and SPARC ports | Java 17 | Linux, macOS, or Windows |
3.3 Behavioral Changes
// 1. Stricter JAR signature validation
// Java 17 rejects JARs signed with SHA-1 by default
// Fix: Re-sign JARs with SHA-256 or stronger
// 2. Default charset change (Java 18 heads-up)
// Java 17 still uses platform-specific default charset
// But Java 18 defaults to UTF-8. Start using explicit charsets now:
Files.readString(path, StandardCharsets.UTF_8); // explicit -- always safe
Files.readString(path); // uses platform default -- risky
// 3. DatagramSocket reimplementation (Java 15+)
// The underlying implementation of DatagramSocket was rewritten
// Unlikely to affect most apps, but test network code thoroughly4. Step-by-Step Migration
Follow this checklist in order. Each step builds on the previous one.
Step 1: Audit Your Dependencies
Before changing any JDK version, check that all your dependencies support Java 17. This is the single most important step.
<!-- Run Maven dependency analysis -->
<!-- mvn versions:display-dependency-updates -->
<!-- mvn versions:display-plugin-updates -->
<!-- Check for internal API usage with jdeps -->
<!-- jdeps --jdk-internals -cp 'libs/*' your-app.jar -->
<!-- This shows which internal APIs your code and dependencies use -->Step 2: Update Build Tool Plugins
<!-- Maven: Update compiler plugin -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>17</release>
</configuration>
</plugin>
<!-- Maven: Update surefire for testing -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
</plugin>Step 3: Update Critical Dependencies
| Dependency | Minimum for Java 17 | Recommended |
|---|---|---|
| Spring Boot | 2.5.x | 3.x (requires Java 17) |
| Spring Framework | 5.3.15 | 6.x (requires Java 17) |
| Hibernate | 5.6.x | 6.x |
| Lombok | 1.18.22 | 1.18.30+ |
| Mockito | 4.0 | 5.x |
| Jackson | 2.13 | 2.15+ |
| JUnit 5 | 5.8 | 5.10+ |
| ByteBuddy | 1.12 | 1.14+ |
| ASM | 9.0 | 9.5+ |
| Flyway | 8.0 | 9.x+ |
Step 4: Install Java 17 JDK
# Using SDKMAN (recommended)
sdk install java 17.0.9-tem
sdk use java 17.0.9-tem
# Or download directly:
# - Eclipse Temurin: https://adoptium.net/
# - Amazon Corretto: https://aws.amazon.com/corretto/
# - Oracle JDK: https://www.oracle.com/java/
# Verify installation
java --version
# openjdk 17.0.9 2023-10-17
# OpenJDK Runtime Environment Temurin-17.0.9+9 (build 17.0.9+9)
# Set JAVA_HOME
export JAVA_HOME=$HOME/.sdkman/candidates/java/17.0.9-temStep 5: Compile and Fix Errors
Compile your project with Java 17 and address each compilation error. The most common errors fall into these categories:
// Error 1: Using removed APIs
// javax.script.ScriptEngine with Nashorn
// Fix: Remove Nashorn usage or add GraalJS dependency
// Error 2: Deprecated API warnings (promoted to errors)
// Fix: Replace deprecated APIs with recommended alternatives
// Error 3: Internal API access
// sun.misc.Unsafe, com.sun.*, etc.
// Fix: Use public API alternatives or --add-opens as temporary fix
// Error 4: New reserved keywords
// "sealed", "permits" are now restricted identifiers
// If you have variables named "sealed" or "permits", rename them
String sealed = "value"; // Compile error in Java 17 if used as type nameStep 6: Run jdeps to Find Internal API Usage
# Scan your application for JDK internal API usage
jdeps --jdk-internals --multi-release 17 -cp 'lib/*' your-app.jar
# Sample output:
# your-app.jar -> java.base
# com.example.MyClass -> sun.misc.Unsafe (JDK internal API)
#
# Warning: JDK internal APIs are unsupported and private to JDK implementation
# that are subject to be removed or changed incompatibly and could break your
# application.
#
# JDK Internal API Suggested Replacement
# ---------------- ---------------------
# sun.misc.Unsafe Use VarHandle or MethodHandle APIStep 7: Add –add-opens for Libraries (If Needed)
Some libraries (especially older versions of ORMs, serialization frameworks, and mocking tools) need reflective access to JDK internals. Add --add-opens flags as needed:
<!-- Maven surefire plugin configuration -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<argLine>
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.lang.reflect=ALL-UNNAMED
--add-opens java.base/java.util=ALL-UNNAMED
</argLine>
</configuration>
</plugin>
<!-- For the application itself -->
<!-- Add to JVM startup args in your run script or Dockerfile -->
<!-- java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar -->Step 8: Run All Tests
After compilation succeeds, run your entire test suite. Pay special attention to:
- Serialization/deserialization tests
- Reflection-heavy code (custom annotations, DI containers)
- Network and socket tests (DatagramSocket reimplementation)
- Security-related tests (SecurityManager deprecation)
- Date/time formatting tests (locale data updates)
Step 9: Performance Testing
Java 17 includes significant GC improvements. Run performance benchmarks to validate:
# Compare GC performance between Java 11 and Java 17
# Run your application with GC logging enabled
# Java 17 with G1GC (default)
java -Xlog:gc*:file=gc-java17.log -jar app.jar
# Key improvements in Java 17 GC:
# - G1GC: Reduced pause times, better throughput
# - ZGC: Production-ready, sub-millisecond pauses (Java 15+)
# - Shenandoah: Production-ready, low-pause concurrent GC
# Consider switching GC if applicable:
# java -XX:+UseZGC -jar app.jar # Ultra-low pause times
# java -XX:+UseShenandoahGC -jar app.jar # Low-pause alternativeStep 10: Adopt New Language Features Incrementally
Once your application runs on Java 17, start adopting new language features gradually. You do not need to rewrite everything at once:
// Priority 1: Use text blocks for multi-line strings (immediate wins)
// Before
String sql = "SELECT u.id, u.name, u.email\n" +
"FROM users u\n" +
"WHERE u.active = true\n" +
"ORDER BY u.name";
// After
String sql = """
SELECT u.id, u.name, u.email
FROM users u
WHERE u.active = true
ORDER BY u.name
""";
// Priority 2: Use pattern matching instanceof (reduce casting)
// 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());
}
// Priority 3: Use switch expressions where appropriate
// Before
String label;
switch (status) {
case ACTIVE: label = "Active"; break;
case INACTIVE: label = "Inactive"; break;
case PENDING: label = "Pending"; break;
default: label = "Unknown";
}
// After
String label = switch (status) {
case ACTIVE -> "Active";
case INACTIVE -> "Inactive";
case PENDING -> "Pending";
};
// Priority 4: Use records for new DTOs and value objects
// Priority 5: Use sealed classes for new type hierarchies5. Build Tool Updates
5.1 Maven
<!-- pom.xml updates for Java 17 -->
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<!-- Or use release (preferred, ensures API compatibility) -->
<maven.compiler.release>17</maven.compiler.release>
</properties>
<build>
<plugins>
<!-- Compiler plugin: 3.11+ required for Java 17 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>17</release>
</configuration>
</plugin>
<!-- Surefire: 3.x for proper Java 17 support -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
</plugin>
<!-- Failsafe (integration tests) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.1.2</version>
</plugin>
<!-- JAR plugin -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
</plugin>
</plugins>
</build>5.2 Gradle
// build.gradle updates for Java 17
// Gradle 7.3+ is required for Java 17 support
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0' // if using Spring Boot
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
tasks.withType(JavaCompile).configureEach {
options.release = 17
}
// For Kotlin DSL (build.gradle.kts)
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}6. Framework Compatibility
6.1 Spring Boot 3.x (Requires Java 17)
If you are migrating to Spring Boot 3.x, Java 17 is the minimum requirement. This is the most significant framework change most Java developers will encounter:
// Key Spring Boot 3.x changes:
// 1. javax.* -> jakarta.* namespace migration
// javax.persistence.* -> jakarta.persistence.*
// javax.servlet.* -> jakarta.servlet.*
// javax.validation.* -> jakarta.validation.*
// javax.annotation.* -> jakarta.annotation.*
// Before (Spring Boot 2.x / Java 11)
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.servlet.http.HttpServletRequest;
import javax.validation.constraints.NotNull;
// After (Spring Boot 3.x / Java 17)
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotNull;Spring Boot 2.x to 3.x migration checklist:
- Replace all
javax.*imports withjakarta.* - Update Spring Security configuration (new Lambda DSL)
- Update Spring Data repositories (new method signatures)
- Replace
WebSecurityConfigurerAdapterwithSecurityFilterChainbean - Update Actuator endpoint paths if customized
6.2 Hibernate 6
Hibernate 6 is the default JPA provider in Spring Boot 3.x. Key changes:
javax.persistencetojakarta.persistencenamespace- New type system for custom types
- Some HQL query syntax changes
- Removed deprecated criteria API methods
6.3 Compatibility Matrix
| Framework/Library | Java 11 Compatible | Java 17 Compatible | Java 17 Required |
|---|---|---|---|
| Spring Boot 2.7.x | Yes | Yes | No |
| Spring Boot 3.x | No | Yes | Yes |
| Quarkus 3.x | No | Yes | Yes (11+ for 2.x) |
| Micronaut 4.x | No | Yes | Yes |
| Jakarta EE 10 | No | Yes | Yes (11 minimum) |
| Hibernate 5.6 | Yes | Yes | No |
| Hibernate 6.x | No | Yes | Yes (11 minimum) |
| JUnit 5.9+ | Yes | Yes | No (Java 8+) |
7. Common Migration Issues
Here are the most common problems teams encounter during the 11 to 17 migration, along with their solutions:
| # | Problem | Symptom | Solution |
|---|---|---|---|
| 1 | InaccessibleObjectException | Library uses reflection on JDK internals | Update library or add --add-opens |
| 2 | Lombok compilation failure | java.lang.IllegalAccessError |
Update Lombok to 1.18.22+ |
| 3 | Mockito/ByteBuddy failure | Cannot access internal API for mocking | Update Mockito to 4.0+, ByteBuddy 1.12+ |
| 4 | ASM version incompatibility | UnsupportedClassVersionError |
Update ASM to 9.0+ (transitive dependency) |
| 5 | Nashorn removal | ScriptEngineManager returns null for “nashorn” |
Add GraalJS dependency or rewrite |
| 6 | JAR signature failures | SHA-1 signed JARs rejected | Re-sign with SHA-256 or disable validation |
| 7 | Gradle version too old | Cannot compile Java 17 sources | Update Gradle to 7.3+ |
| 8 | Maven compiler plugin too old | source release 17 requires target release 17 |
Update maven-compiler-plugin to 3.8.1+ |
| 9 | Annotation processor failures | Processors fail on Java 17 bytecode | Update processors (MapStruct, Dagger, etc.) |
| 10 | Javadoc generation fails | Stricter Javadoc linting in newer JDK | Add -Xdoclint:none or fix Javadoc warnings |
| 11 | Date/time formatting differences | Locale-dependent formatting changed | Use explicit locale and format patterns |
| 12 | JaCoCo coverage failures | Cannot instrument Java 17 classes | Update JaCoCo to 0.8.7+ |
8. Testing Strategy
A thorough testing strategy is essential for a safe migration. Here is the approach that works for production systems:
8.1 Unit Tests
// Step 1: Run ALL existing unit tests on Java 17
// If tests fail, categorize the failures:
// - Compilation error -> fix source code
// - Runtime error -> update dependencies or add --add-opens
// - Behavior change -> investigate and update test expectations
// Step 2: Add tests for any workarounds
@Test
void testReflectiveAccessWorkaround() {
// If you added --add-opens, test that the workaround works
assertDoesNotThrow(() -> {
// Code that requires reflective access
});
}
// Step 3: Verify serialization compatibility
@Test
void testSerializationBackwardCompatibility() {
// Deserialize objects that were serialized on Java 11
byte[] java11Serialized = loadFromFile("test-data/user-java11.ser");
User user = deserialize(java11Serialized);
assertEquals("John", user.getName());
}8.2 Integration Tests
Focus on the areas most likely to be affected:
- Database layer — JDBC drivers, connection pools, ORM behavior
- HTTP clients and servers — TLS handshakes, HTTP/2 behavior
- Serialization — JSON, XML, and Java serialization compatibility
- External service integrations — LDAP, SMTP, message queues
8.3 Performance Testing
Java 17 should be faster than 11 in most scenarios, but verify with your workload:
- Run load tests with production-like traffic patterns
- Compare GC pause times (G1GC improved significantly)
- Monitor memory footprint — Java 17 generally uses less memory
- Check startup time — CDS (Class Data Sharing) improvements help
9. Docker and CI/CD
9.1 Dockerfile Updates
# Before (Java 11)
FROM eclipse-temurin:11-jre-alpine
COPY target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
# After (Java 17)
FROM eclipse-temurin:17-jre-alpine
COPY target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
# Multi-stage build for smaller images
FROM eclipse-temurin:17-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
FROM eclipse-temurin:17-jre-alpine
COPY --from=build /app/target/app.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]
# With --add-opens if needed
ENTRYPOINT ["java", \
"--add-opens", "java.base/java.lang=ALL-UNNAMED", \
"-jar", "/app.jar"]9.2 CI/CD Pipeline Updates
# GitHub Actions example
name: Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Build and test
run: mvn clean verify
# Optional: Test on both Java 11 and 17 during migration
compatibility-test:
runs-on: ubuntu-latest
strategy:
matrix:
java-version: [11, 17]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: ${{ matrix.java-version }}
- run: mvn clean verify9.3 Container Image Comparison
| Image | Base OS | Approximate Size | Use Case |
|---|---|---|---|
eclipse-temurin:17-jre-alpine |
Alpine Linux | ~150 MB | Production (smallest) |
eclipse-temurin:17-jre-jammy |
Ubuntu 22.04 | ~260 MB | Production (better compatibility) |
eclipse-temurin:17-jdk-alpine |
Alpine Linux | ~330 MB | CI/CD builds |
amazoncorretto:17-alpine |
Alpine Linux | ~200 MB | AWS deployments |
10. Best Practices
10.1 Incremental Adoption
Do not try to adopt every Java 17 feature at once. Follow this order:
- Week 1: Get the application compiling and all tests passing on Java 17 without using any new features
- Week 2-3: Start using text blocks in new code (lowest risk, highest readability gain)
- Week 3-4: Adopt pattern matching
instanceofduring regular code changes - Month 2: Use switch expressions in new code
- Month 2-3: Create new DTOs as records
- Month 3+: Evaluate sealed classes for new type hierarchies
10.2 Migration Dos and Don’ts
| Do | Don’t |
|---|---|
| Update dependencies before changing JDK | Change JDK and dependencies simultaneously |
| Run jdeps to find internal API usage | Assume your code does not use internal APIs |
| Test on Java 17 in CI before production | Deploy to production without thorough testing |
Use --add-opens as a temporary fix |
Leave --add-opens permanently without a plan to remove |
| Adopt new features incrementally | Rewrite everything in new syntax at once |
| Keep Java 11 builds running during transition | Remove Java 11 CI jobs before migration is complete |
Document all --add-opens flags with reasons |
Add --add-opens flags without understanding why |
| Benchmark before and after migration | Assume Java 17 is faster for your specific workload |
10.3 Feature Flags for New Syntax
When adopting new language features in a team, consider these guidelines:
- Text blocks: Use everywhere immediately — purely cosmetic, zero risk
- Pattern matching instanceof: Use in new code and during refactoring — simple and safe
- Switch expressions: Use in new code — slightly higher learning curve for team members unfamiliar with arrow syntax
- Records: Use for new DTOs and value objects — do not convert existing classes unless you have comprehensive tests
- Sealed classes: Use for new type hierarchies — do not retroactively seal existing class hierarchies without careful analysis
The key principle is: new code uses new features, existing code is migrated only during regular maintenance. Do not create a separate “migration sprint” for syntax changes. Instead, adopt the boy scout rule — leave each file a little better than you found it.