Introduction
After spending the better part of a year migrating multiple Spring Boot services from Java 8 to Java 21 in production, I wanted to share the practical lessons that documentation alone does not cover. Java 8 was an extraordinary release, but the language has evolved dramatically over the last several major versions. If you are still on Java 8 or even Java 11, this guide walks through the features that matter most and the migration pitfalls I encountered firsthand.
This is not an exhaustive changelog. Instead, I focus on the features that had the most tangible impact on code quality, performance, and developer experience in our Spring Boot microservices.
Virtual Threads: The Concurrency Game Changer
Virtual threads (Project Loom), finalized in Java 21, are arguably the single biggest reason to upgrade. If your services are I/O-heavy, which most web applications are, virtual threads can replace reactive programming models entirely while delivering comparable throughput.
Before virtual threads, handling thousands of concurrent HTTP or database calls meant either blocking platform threads (expensive) or adopting reactive frameworks like Project Reactor (complex). Virtual threads give you the simplicity of blocking code with the scalability of non-blocking I/O.
Here is how we migrated an existing ExecutorService-based call to virtual threads:
// Before: Java 8 - Fixed thread pool, limited concurrency
ExecutorService executor = Executors.newFixedThreadPool(200);
List<Future<TranslationResult>> futures = segments.stream()
.map(segment -> executor.submit(() -> translateSegment(segment)))
.collect(Collectors.toList());
List<TranslationResult> results = new ArrayList<>();
for (Future<TranslationResult> future : futures) {
results.add(future.get());
}
// After: Java 21 - Virtual threads, no pool size limit needed
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<TranslationResult>> futures = segments.stream()
.map(segment -> executor.submit(() -> translateSegment(segment)))
.toList();
List<TranslationResult> results = futures.stream()
.map(future -> {
try {
return future.get();
} catch (Exception e) {
throw new TranslationException("Segment translation failed", e);
}
})
.toList();
}
For Spring Boot 3.2+, enabling virtual threads is a single configuration property:
spring:
threads:
virtual:
enabled: true
This switches the embedded Tomcat to use virtual threads for request handling. In our services, this alone reduced p99 latency by roughly 30% under high concurrency because we were no longer queuing requests waiting for platform threads.
Pitfall to watch for: Virtual threads do not play well with synchronized blocks that guard I/O operations. If a virtual thread encounters a synchronized block, it pins to its carrier thread, negating the benefits. Replace synchronized with ReentrantLock in these cases:
// Bad: pins virtual thread to carrier
private synchronized String fetchFromCache(String key) {
return redisTemplate.opsForValue().get(key);
}
// Good: ReentrantLock does not pin
private final ReentrantLock lock = new ReentrantLock();
private String fetchFromCache(String key) {
lock.lock();
try {
return redisTemplate.opsForValue().get(key);
} finally {
lock.unlock();
}
}
Records, Sealed Classes, and Pattern Matching
These three features together fundamentally change how you model domain data in Java. They reduce boilerplate, improve type safety, and make your code more expressive.
Records replace the vast majority of DTOs, value objects, and data carriers. Every DTO class in our codebase that was 50+ lines with constructors, getters, equals, hashCode, and toString became a one-liner:
// Before: Java 8 DTO (imagine equals, hashCode, toString, getters)
public class TranslationRequest {
private final String sourceLanguage;
private final String targetLanguage;
private final List<String> segments;
public TranslationRequest(String sourceLanguage, String targetLanguage, List<String> segments) {
this.sourceLanguage = sourceLanguage;
this.targetLanguage = targetLanguage;
this.segments = List.copyOf(segments);
}
// ... getters, equals, hashCode, toString
}
// After: Java 16+ record
public record TranslationRequest(
String sourceLanguage,
String targetLanguage,
List<String> segments
) {
public TranslationRequest {
segments = List.copyOf(segments);
}
}
Sealed classes let you define closed type hierarchies, making it explicit what subtypes exist:
public sealed interface TranslationEvent permits
TranslationRequested,
TranslationCompleted,
TranslationFailed {
String jobId();
Instant timestamp();
}
public record TranslationRequested(String jobId, Instant timestamp, TranslationRequest request)
implements TranslationEvent {}
public record TranslationCompleted(String jobId, Instant timestamp, List<TranslationResult> results)
implements TranslationEvent {}
public record TranslationFailed(String jobId, Instant timestamp, String errorCode, String message)
implements TranslationEvent {}
Pattern matching with switch expressions ties it all together. The compiler guarantees exhaustiveness on sealed types, so adding a new event type forces you to handle it everywhere:
public void processEvent(TranslationEvent event) {
switch (event) {
case TranslationRequested req -> {
log.info("Job {} requested: {} -> {}", req.jobId(),
req.request().sourceLanguage(), req.request().targetLanguage());
translationService.enqueue(req.request());
}
case TranslationCompleted done -> {
log.info("Job {} completed with {} segments", done.jobId(), done.results().size());
notificationService.notifyCompletion(done);
}
case TranslationFailed failed -> {
log.error("Job {} failed: {} - {}", failed.jobId(), failed.errorCode(), failed.message());
alertService.triggerAlert(failed);
}
}
}
Text Blocks, Stream Enhancements, and Everyday Quality of Life
Beyond the headline features, dozens of smaller improvements compound into a dramatically better developer experience.
Text blocks (Java 13+) eliminate the string concatenation nightmare for SQL, JSON, and HTML:
// Before
String query = "SELECT t.id, t.source_text, t.translated_text " +
"FROM translations t " +
"JOIN jobs j ON t.job_id = j.id " +
"WHERE j.status = ? " +
"AND t.created_at > ? " +
"ORDER BY t.created_at DESC";
// After
String query = """
SELECT t.id, t.source_text, t.translated_text
FROM translations t
JOIN jobs j ON t.job_id = j.id
WHERE j.status = ?
AND t.created_at > ?
ORDER BY t.created_at DESC
""";
Stream API enhancements are subtle but reduce boilerplate. Stream.toList() replaces collect(Collectors.toList()), mapMulti provides a cleaner alternative for flat-mapping complex objects, and Collectors.teeing lets you compute two aggregations in a single pass:
// Compute average and max translation time in one pass
record TranslationStats(double averageMs, long maxMs) {}
TranslationStats stats = timings.stream()
.collect(Collectors.teeing(
Collectors.averagingLong(Duration::toMillis),
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingLong(Duration::toMillis)),
opt -> opt.map(Duration::toMillis).orElse(0L)
),
TranslationStats::new
));
Helpful NullPointerExceptions (Java 14+) are a small feature that saves hours of debugging. Instead of a generic NPE, the runtime tells you exactly which reference was null:
// Before: java.lang.NullPointerException
// After: java.lang.NullPointerException: Cannot invoke "String.length()"
// because the return value of "TranslationResult.getTargetText()" is null
Migration Strategy: Incremental, Not Big-Bang
Based on three separate migration projects, I strongly recommend an incremental approach. Here is the process that worked for us:
Phase 1: Build tooling and dependencies. Update your build tools first. Maven or Gradle, CI/CD pipelines, static analysis tools, and test frameworks all need to support the target Java version. We found that upgrading to Gradle 8.x and Spring Boot 3.x simultaneously with the Java upgrade was the least painful path, because Spring Boot 3 requires Java 17+ anyway.
Phase 2: Compile and fix. Set the source and target to Java 21 and fix compilation errors. The biggest breaking changes are the removal of javax.* packages (moved to jakarta.* in Spring Boot 3), removal of SecurityManager, and changes to internal JDK APIs that some libraries relied on. Use jdeps --jdk-internals to find problematic dependencies before upgrading:
jdeps --jdk-internals --multi-release 21 your-application.jar
Phase 3: Adopt incrementally. Do not rewrite everything at once. Start using records for new DTOs, switch expressions in new code, and virtual threads for new services. Gradually refactor existing code during normal feature development.
Phase 4: Performance validation. Run load tests comparing the old and new versions. Virtual threads can change your concurrency profile significantly. Watch for pinning issues, increased memory usage from larger stack traces, and changes in garbage collection behavior. We switched from G1GC to ZGC during our Java 21 upgrade and saw a meaningful reduction in tail latencies.
# JVM flags we settled on for Java 21 with virtual threads
java -XX:+UseZGC -XX:+ZGenerational \
-Djdk.tracePinnedThreads=short \
-jar application.jar
Key Takeaways
Migrating from Java 8 to Java 21 is not just about keeping dependencies current. The language has genuinely improved in ways that reduce bugs, improve readability, and deliver better runtime performance. Virtual threads alone can eliminate the need for reactive frameworks in most I/O-bound services. Records and sealed classes make domain modeling clearer and more maintainable. And the cumulative effect of dozens of small improvements means you write less code to achieve the same result.
The migration itself is achievable if you approach it incrementally. Upgrade your tooling first, fix the compilation errors, and then adopt new features gradually. The investment pays for itself quickly in developer productivity and runtime performance.