Skip to content

Java 21

Neelu edited this page Jul 26, 2026 · 1 revision

☕ Java 21: Virtual Threads Standard — The Concurrency LTS

Release Date: September 19, 2023 | Type: LTS | Status: ✅ Active — the current recommended production LTS


📋 Table of Contents

  1. Overview
  2. Core Features
  3. Best Practices
  4. Migration Notes

🎯 Overview

Java 21 is the most consequential LTS since Java 8. Virtual Threads, Pattern Matching for switch, and Record Patterns all go standard simultaneously — giving Java both a modern concurrency model and a modern data-oriented programming style in one release.

mindmap
  root((Java 21 LTS))
    Virtual Threads
      STANDARD - JEP 444
    Pattern Matching switch
      STANDARD - JEP 441
    Record Patterns
      STANDARD - JEP 440
    Sequenced Collections
      JEP 431
    String Templates
      Preview - JEP 430
    Structured Concurrency
      Preview - JEP 453
    Scoped Values
      Preview - JEP 446
    Generational ZGC
      JEP 439
Loading

🚀 Core Features

1️⃣ Virtual Threads (Standard) 🎉

// Simple: one virtual thread per task
Thread.ofVirtual().name("worker-1").start(() -> {
    System.out.println("Running on: " + Thread.currentThread());
});

// The production pattern — virtual-thread-per-task executor
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> results = IntStream.range(0, 10_000)
        .mapToObj(i -> executor.submit(() -> callDownstreamService(i)))
        .toList();

    for (Future<String> future : results) {
        System.out.println(future.get());  // blocking .get() is fine — cheap threads!
    }
}

Spring Boot 3.2+ integration (real-world usage):

# application.yml
spring:
  threads:
    virtual:
      enabled: true   # Tomcat/servlet container uses virtual threads per request
@RestController
public class OrderController {

    @GetMapping("/orders/{id}")
    public Order getOrder(@PathVariable String id) {
        // This blocking JDBC call is now FINE — the virtual thread
        // unmounts from its carrier while waiting on the DB,
        // freeing that carrier to serve other requests
        return orderRepository.findById(id);
    }
}
flowchart TD
    subgraph "Before: Platform Threads"
        A1[Request 1] --> T1[Thread 1 - 1MB stack]
        A2[Request 2] --> T2[Thread 2 - 1MB stack]
        A3[Request N] --> T3["Thread N...\nOS thread limit ~thousands"]
    end
    subgraph "After: Virtual Threads"
        B1[Request 1] --> V1[Virtual Thread 1]
        B2[Request 2] --> V2[Virtual Thread 2]
        B3[Request N] --> V3["Virtual Thread N...\nmillions possible"]
        V1 & V2 & V3 -.->|multiplexed onto| C["Small pool of\ncarrier threads"]
    end
Loading

Pinning gotchasynchronized blocks pin a virtual thread to its carrier during blocking calls (fixed later in Java 24's JEP 491, but relevant here):

// ⚠️ Avoid: synchronized + blocking I/O pins the carrier thread, reducing scalability
synchronized (lock) {
    String result = blockingHttpCall(); // pins carrier thread until this returns
}

// ✅ Prefer: ReentrantLock, which doesn't pin
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    String result = blockingHttpCall(); // carrier thread is freed normally
} finally {
    lock.unlock();
}

2️⃣ Record Patterns (Standard)

sealed interface Shape permits Circle, Rectangle {}
record Circle(Point center, double radius) implements Shape {}
record Rectangle(Point topLeft, Point bottomRight) implements Shape {}
record Point(int x, int y) {}

static double area(Shape shape) {
    return switch (shape) {
        case Circle(Point(var cx, var cy), var r) -> Math.PI * r * r;
        case Rectangle(Point(var x1, var y1), Point(var x2, var y2)) ->
            Math.abs((x2 - x1) * (y2 - y1));
    };
    // No default needed — Shape is sealed, compiler verifies exhaustiveness!
}

3️⃣ Pattern Matching for switch (Standard) — with Exhaustiveness

sealed interface PaymentEvent permits Authorized, Captured, Refunded, Failed {}
record Authorized(String txId, double amount) implements PaymentEvent {}
record Captured(String txId) implements PaymentEvent {}
record Refunded(String txId, double amount) implements PaymentEvent {}
record Failed(String txId, String reason) implements PaymentEvent {}

static String handle(PaymentEvent event) {
    return switch (event) {
        case Authorized(var id, var amt) when amt > 10_000 -> "Large auth: " + id + " needs review";
        case Authorized(var id, var amt) -> "Authorized " + id + " for $" + amt;
        case Captured(var id) -> "Captured: " + id;
        case Refunded(var id, var amt) -> "Refunded $" + amt + " for " + id;
        case Failed(var id, var reason) -> "Failed " + id + ": " + reason;
        // Compiler ERROR if any sealed permit is missing — true exhaustiveness checking
    };
}
flowchart TD
    A["sealed interface PaymentEvent"] --> B[Authorized]
    A --> C[Captured]
    A --> D[Refunded]
    A --> E[Failed]
    F["switch (event) { ... }"] -->|"Compiler verifies\nALL 4 cases handled"| G["✅ Compile-time\nexhaustiveness guarantee"]
Loading

4️⃣ Sequenced Collections

What: A new collection interface hierarchy fixing a decades-old gap — no consistent way to get the "first"/"last" element or reversed view across List, Deque, LinkedHashSet, etc.

List<String> list = new ArrayList<>(List.of("a", "b", "c"));

list.getFirst();     // "a"  — no more list.get(0)
list.getLast();      // "c"  — no more list.get(list.size() - 1)
list.addFirst("z");  // [z, a, b, c]
list.addLast("d");   // [z, a, b, c, d]

List<String> reversed = list.reversed();  // [d, c, b, a, z] — reversed view

// Works uniformly across LinkedHashSet, LinkedHashMap too
LinkedHashSet<String> set = new LinkedHashSet<>(List.of("x", "y", "z"));
set.getFirst();  // "x"
set.reversed();  // view: [z, y, x]

LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
map.put("a", 1);
map.put("b", 2);
map.sequencedKeySet().getFirst();  // "a"
classDiagram
    class SequencedCollection {
        <<interface>>
        +getFirst()
        +getLast()
        +addFirst(e)
        +addLast(e)
        +reversed()
    }
    class List
    class Deque
    class LinkedHashSet
    SequencedCollection <|-- List
    SequencedCollection <|-- Deque
    SequencedCollection <|-- LinkedHashSet
Loading

5️⃣ String Templates (Preview)

String name = "Neelu";
int age = 34;

// Before: String.format or concatenation
String s1 = String.format("Hello %s, you are %d years old", name, age);
String s2 = "Hello " + name + ", you are " + age + " years old";

// String Templates (Preview) — embed expressions directly
String s3 = STR."Hello \{name}, you are \{age} years old";

// Works with any expression, not just variables
String s4 = STR."Total: \{price * quantity}";

// Note: withdrawn after Java 21 preview and redesigned —
// verify current syntax against the JDK version you're targeting

⚠️ Note: String Templates were later withdrawn from the JDK roadmap for redesign after the Java 21/22 previews — check current status before relying on this in new code.


6️⃣ Structured Concurrency & Scoped Values (Preview)

See the Java 19 and Java 20 guides for the concepts — both continue as previews in 21, standardizing in later releases.


7️⃣ Generational ZGC

What: ZGC gains generational collection (young/old generations), dramatically improving throughput for typical allocation patterns (most objects die young) while keeping ZGC's signature sub-millisecond pause times.

java -XX:+UseZGC -XX:+ZGenerational -Xmx8g -jar app.jar
# (Generational mode became the default in Java 23 — see that guide)

💡 Best Practices

Adopt Java 21 as your production LTS baseline today — it's the version with the longest runway and richest feature set
✅ Turn on spring.threads.virtual.enabled: true for I/O-heavy Spring Boot services and load test — often a big throughput win with zero code changes
✅ Model business events/results as sealed hierarchies of records, then handle them with exhaustive switch pattern matching — eliminates a whole class of "forgot to handle a case" bugs
✅ Replace list.get(0) / list.get(list.size()-1) with getFirst()/getLast() for readability
❌ Don't use synchronized blocks around blocking I/O on virtual threads — use ReentrantLock to avoid carrier-thread pinning
❌ Don't treat virtual threads as a magic performance fix for CPU-bound work — they only help I/O-bound concurrency


🔄 Migration Notes (Java 17 → 21)

Change Action
Virtual threads available Opt-in per executor/framework config — not automatic
Record patterns / switch pattern matching standard Refactor instanceof chains and old-style switches where it improves clarity
Sequenced Collections New default methods — no breaking changes, purely additive
String Templates preview → later withdrawn Don't build production code on preview String Templates from 21
SecurityManager deprecated further Continue migration off it (fully removed in 24)
flowchart LR
    A[Java 17 App] --> B{I/O-bound service\nwith thread-pool bottlenecks?}
    B -->|Yes| C[Enable virtual threads\nload test throughput]
    B -->|No, CPU-bound| D[Virtual threads won't help—skip]
    C --> E[Adopt sealed+record+switch\nfor domain modeling]
    D --> E
    E --> F[Deploy on Java 21 LTS]
Loading

Clone this wiki locally