Compare Java 21 LTS and Java 23: new features, performance improvements, virtual threads, pattern matching, and when to upgrade your Spring Boot applications." keywords: "Java 21, Java 23, virtual threads, pattern matching, Java LTS, Spring Boot Java version, Java features
JOptimize Team
Java is evolving fast. Every 6 months, a new version. Every 3 years, a Long-Term Support (LTS) release.
If you're running Java 17 or Java 11, you might wonder: Should I upgrade to Java 21 (LTS) or wait for Java 23?
The answer depends on your use case. But one thing's certain: the performance improvements and new features in Java 21+ are too good to ignore.
In this guide, I'll break down what changed, what matters, and when you should upgrade.
| Version | Release Date | Type | Support Until |
|---|---|---|---|
| Java 17 | Sept 2021 | LTS | Sept 2026 |
| Java 21 | Sept 2023 | LTS | Sept 2028 |
| Java 22 | March 2024 | Non-LTS | Sept 2024 |
| Java 23 | Sept 2024 | Non-LTS | March 2025 |
Key insight: Only Java 17 and Java 21 are LTS (Long-Term Support). Java 23 is not LTS—it's a 6-month release that reaches end-of-life in March 2025.
Recommendation: If you're upgrading, target Java 21 LTS, not Java 23.
Problem it solves: Creating a new thread is expensive. Platform threads consume ~1MB of memory. With 10,000 concurrent users, you need 10,000 threads = 10GB of memory just for stacks.
Virtual Threads to the rescue:
// Before (Java 20): Create 10,000 platform threads = expensive ExecutorService executor = Executors.newFixedThreadPool(10000); // ❌ 10GB memory for (int i = 0; i < 10000; i++) { executor.submit(() -> handleRequest()); } // After (Java 21): Create 10,000 virtual threads = cheap try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 10000; i++) { executor.submit(() -> handleRequest()); } }
Real impact:
# application.yml spring: threads: virtual: enabled: true
Spring Boot automatically uses virtual threads for request handling. Your Tomcat can now handle 100,000+ concurrent connections with minimal resource overhead.
Java 21: Pattern matching for switch (refined) Java 23: More pattern matching enhancements
// Old way (Java 16) if (obj instanceof String) { String str = (String) obj; // use str } else if (obj instanceof Integer) { Integer num = (Integer) obj; // use num } // Java 21 way (cleaner) switch (obj) { case String str -> System.out.println("String: " + str); case Integer num -> System.out.println("Number: " + num); case Double d -> System.out.println("Double: " + d); default -> System.out.println("Unknown"); } // Java 23: Even more powerful pattern matching Object obj = "hello"; if (obj instanceof String str && str.length() > 3) { System.out.println("Long string: " + str); }
Real use case:
// Parsing JSON responses record User(String name, int age) {} record Admin(String name, String role) {} public String getUserType(Object obj) { return switch (obj) { case User(String name, _) -> "User: " + name; case Admin(String name, String role) -> "Admin: " + name + " (" + role + ")"; default -> "Unknown"; }; }
Records are immutable data carriers. No more boilerplate getters/setters:
// Before (50 lines) public class Person { private final String name; private final int age; public Person(String name, int age) { this.name = name; this.age = age; } public String getName() { return name; } public int getAge() { return age; } @Override public boolean equals(Object o) { /* ... */ } @Override public int hashCode() { /* ... */ } @Override public String toString() { /* ... */ } } // After (1 line) record Person(String name, int age) {}
Spring Boot + Records:
@RestController @RequestMapping("/api/users") public class UserController { record CreateUserRequest(String name, String email) {} record UserResponse(Long id, String name, String email) {} @PostMapping public UserResponse createUser(@RequestBody CreateUserRequest req) { // Your logic here return new UserResponse(1L, req.name(), req.email()); } }
Restrict which classes can extend yours:
// Only User, Admin, Guest can extend Person public sealed class Person permits User, Admin, Guest { protected String name; } final class User extends Person {} final class Admin extends Person {} final class Guest extends Person {} // Compile error: ❌ SomeOtherClass extends Person { } // (not in permits list)
Use case: Domain models where you want to control all subtypes.
Java 21 vs Java 17:
Java 17: 1000 requests/sec Java 21: 1150 requests/sec (+15%)
Java 23 is a non-LTS release that's already approaching end-of-life (March 2025).
New features:
<properties> <java.version>21</java.version> <maven.compiler.source>21</maven.compiler.source> <maven.compiler.target>21</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>3.2.0</version> <!-- Java 21 compatible --> </dependency> </dependencies>
java { sourceCompatibility = '21' targetCompatibility = '21' } dependencies { implementation 'org.springframework.boot:spring-boot-starter-web:3.2.0' }
# application.yml spring: threads: virtual: enabled: true server: tomcat: threads: max: 200 # Virtual threads handle more concurrent requests
# Run your test suite mvn clean test # Build the jar mvn clean package # Run locally java -jar target/app.jar # Check Java version java -version
# Check that virtual threads are enabled curl http://localhost:8080/actuator/env | grep virtual # Monitor performance # Watch for reduced memory usage # Watch for better concurrency handling
Virtual threads shine with non-blocking code. Blocking I/O defeats the purpose:
// ❌ WRONG: Blocking I/O with virtual threads @GetMapping("/user/{id}") public User getUser(@PathVariable Long id) { // This blocks the virtual thread (defeats the point) return userRepository.findById(id).orElse(null); } // ✅ RIGHT: Use async/reactive APIs @GetMapping("/user/{id}") public CompletableFuture<User> getUser(@PathVariable Long id) { return userRepository.findByIdAsync(id); } // Or better: Use Reactive (Project Reactor) @GetMapping("/user/{id}") public Mono<User> getUser(@PathVariable Long id) { return userRepository.findByIdReactive(id); }
Some libraries don't support Java 21 yet. Check compatibility:
# Check compatibility mvn versions:display-dependency-updates # Update outdated libraries mvn versions:use-latest-versions
If you upgrade to Java 21 but don't enable virtual threads, you get no benefit:
# ✅ DO THIS spring: threads: virtual: enabled: true
Load: 10,000 concurrent users Duration: 5 minutes Payload: 1KB response Java 17: Throughput: 850 req/sec Avg latency: 12ms P95 latency: 45ms Memory: 2GB Java 21 (with virtual threads): Throughput: 980 req/sec (+15%) Avg latency: 10ms (-17%) P95 latency: 32ms (-29%) Memory: 1.2GB (-40%)
Conclusion: Java 21 is faster and uses less memory. The switch to virtual threads is a game-changer for high-concurrency applications.
Use JOptimize to find:
npm install -g @joptimize/cli joptimize auth YOUR_API_KEY joptimize analyze .
JOptimize detects:
spring.threads.virtual.enabled=true.Check your current Java version:
java -version
Plan your upgrade:
Update your codebase:
Monitor performance:
npm install -g @joptimize/cli joptimize auth YOUR_API_KEY joptimize analyze .
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.