5 New Java 21 Features Every Developer Should Know

When Java 21 was announced as the latest Long Term Support(LTS) release by Oracle official, I found myself genuinely excited about the language’s direction for the first time in years. After spending considerable time with these new features in production environments, I want to share the FIVE key features that I believe will have the most significant impact on how we write Java code.

Let me be upfront about something: not every new feature is a game-changer, and some come with trade-offs you’ll want to understand before diving in. These FIVE features represent a meaningful step forward in Java’s evolution.

Virtual Threads: Finally, Concurrency That Makes Sense

The most talked-about feature in Java 21 is virtual threads, and for good reason. If you’ve ever written server applications that handle thousands of concurrent connections, you know the pain of traditional thread management.

Here’s what changed my perspective: I recently refactored a REST API that was struggling with thread pool exhaustion under load. The old approach looked like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Traditional approach - limited by platform threads
@RestController
public class OrderController {
    // annully setting for fix thread tool
    private final ExecutorService executor = Executors.newFixedThreadPool(200);
    
    @GetMapping("/orders/{id}")
    public CompletableFuture<Order> getOrder(@PathVariable String id) {
        return CompletableFuture.supplyAsync(() -> {
            // Expensive I/O operation
            return orderService.fetchOrder(id);
        }, executor);
    }
}

With virtual threads, the same functionality becomes remarkably simpler:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Virtual threads approach
@RestController
public class OrderController {
    
    @GetMapping("/orders/{id}")
    public Order getOrder(@PathVariable String id) {
        // This now runs on a virtual thread automatically
        return orderService.fetchOrder(id);
    }
}

The key insight is that virtual threads aren’t faster, but they’re more efficient with resources. You can create millions of them without the memory overhead of platform threads. However, there’s a catch: if your code spends significant time doing CPU-intensive work rather than waiting for I/O, virtual threads won’t help much :(.

Pattern Matching for Switch: Beyond Simple Enums

Switch expressions have come quite a distance, and pattern matching turns them into something actually useful. The old approach to handling different types usually led to verbose, error-prone code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Old way - verbose and fragile
public String processShape(Object shape) {
    if (shape instanceof Circle) {
        Circle c = (Circle) shape;
        return "Circle with radius " + c.radius();
    } else if (shape instanceof Rectangle) {
        Rectangle r = (Rectangle) shape;
        return "Rectangle " + r.width() + "x" + r.height();
    } else if (shape instanceof Triangle) {
        Triangle t = (Triangle) shape;
        return "Triangle with base " + t.base();
    }
    return "Unknown shape";
}

Pattern matching transforms this into something much cleaner:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// New way - concise and type-safe
public String processShape(Object shape) {
    return switch (shape) {
        case Circle(var radius) -> "Circle with radius " + radius;
        case Rectangle(var width, var height) -> "Rectangle " + width + "x" + height;
        case Triangle(var base, var height) -> "Triangle with base " + base;
        case null -> "No shape";
        default -> "Unknown shape";
    };
}
AspectTraditional switchPattern Matching switch (Java 21)
Typical Lines of Code12–20 lines (needs multiple case + explicit casting)6–10 lines (concise, direct matching with type patterns)
Example ReadabilityVerbose, repetitive type checks and castingMore compact, expresses intent clearly
BoilerplateHigh — must include multiple break, explicit instanceof, and castsLow — pattern variables are introduced automatically
Error-PronenessHigher — risk of missing break statements, redundant castingLower — compiler enforces exhaustiveness and reduces redundancy
MaintainabilityModerate — adding new cases requires more boilerplateHigh — easier to extend with new patterns
ExpressivenessLimited — cannot directly bind variables in caseRich — allows binding and guarded patterns

What I particularly appreciate is how this handles null values explicitly. No more hidden NullPointerExceptions lurking in your switch statements.

Record Patterns: Destructuring Done Right

Record patterns build on the foundation of pattern matching, focusing specifically on extracting data from records. This is where things get interesting for data processing code.

Consider this scenario where you’re processing nested data structures:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public record Person(String name, Address address) {}
public record Address(String street, String city, String country) {}

// Extract city from nested records
public boolean isInUSA(Person person) {
    return switch (person) {
        case Person(var name, Address(var street, var city, "USA")) -> true;
        case Person(var name, Address(var street, var city, var country)) -> false;
    };
}
flowchart TD
    A[Input Object: Shape] --> B{Is it a Rectangle?}
    B -- Yes --> C[Deconstruct Rectangle: x,y,width,height]
    C --> D{Has Nested Point?}
    D -- Yes --> E[Deconstruct Point: x,y]
    D -- No --> F[Use width/height directly]
    B -- No --> G{Is it a Circle?}
    G -- Yes --> H[Deconstruct Circle: radius]
    G -- No --> I[Fallback: Unknown Shape]

The pattern Address(var street, var city, "USA") destructures the Address record and matches against the specific country value. This eliminates a lot of boilerplate code for accessing nested fields.

String Templates: Security by Design

String templates are still in preview, but they address a real problem with string interpolation. Traditional string formatting is either verbose or potentially unsafe:

1
2
3
4
5
6
7
// Verbose but safe
String query = String.format(
    "SELECT * FROM users WHERE name = '%s' AND age > %d", 
    sanitize(userName), age);

// Concise but dangerous
String query = "SELECT * FROM users WHERE name = '" + userName + "' AND age > " + age;

String templates provide a middle ground:

1
2
// Clean and safe (when using proper processors)
String query = SQL."SELECT * FROM users WHERE name = \{userName} AND age > \{age}";

The key is that different template processors can handle escaping and validation automatically. The SQL processor in the above example would handle SQL injection prevention.

Note: However, I should mention that this feature is still in development! The syntax and available processors will likely change before final release, so use it carefully in production code.

Sequenced Collections: Order Matters

The new SequencedCollection interface fills a gap in Java’s collections hierarchy that has bothered me for years. Previously, there was no common way to access the first and last elements of ordered collections.

flowchart TD
    A[Collection] --> B[List]
    A --> C[Set]
    A --> D[Queue]

    B --> E[ArrayList]
    B --> F[LinkedList]
    C --> G[HashSet]
    C --> H[TreeSet]
    D --> I[PriorityQueue]
    D --> J[Deque]

    %% New Interface:SequencedCollection
    A --> K{{SequencedCollection}}

    %% SequencedCollection 的子接口和实现
    K --> B
    K --> J
    B --> L["ArrayList - implements SequencedCollection"]
    J --> M["LinkedList - implements SequencedCollection"]

    style K fill:#ffcc00,stroke:#333,stroke-width:2px

Blow is the Java Code.

1
2
3
4
5
6
7
8
// Before: different methods for different collection types
List<String> list = Arrays.asList("first", "middle", "last");
String firstFromList = list.get(0);
String lastFromList = list.get(list.size() - 1);

LinkedHashSet<String> set = new LinkedHashSet<>(list);
String firstFromSet = set.iterator().next(); // Awkward
// No direct way to get last element from set

Right now, we can get First and/or Last directly:

1
2
3
4
// Now: consistent interface
SequencedCollection<String> collection = list; // or set
String first = collection.getFirst();
String last = collection.getLast();

This might seem like a small change, but it gets rid of a lot of collection-specific code when you work with ordered data.

Should You Upgrade to Java 21?

The honest answer depends on your situation. If you’re running microservices with heavy I/O, virtual threads alone might justify the upgrade. For applications doing complex data processing, pattern matching could significantly improve code clarity.

There are a couple of things to keep in mind:

How much work migration needs depends a lot on your current Java version. Upgrading from Java 17 to 21 is usually pretty simple, but one thing to note—older versions usually call for more patience and advance planning.

Some features are still in preview, so they might change in future updates. Virtual threads and pattern matching are stable, but you’ll want to use string templates carefully in production.

Your team’s readiness counts too. These features alter how you approach certain problems, and your team will need time to adjust their coding habits.

Here’s my suggestion: if you have I/O-heavy apps, start with virtual threads first, then slowly roll out pattern matching for new code. You can hold off on the other features until your team feels comfortable with the core shifts.

While Java 21 is a big step forward, it’s like any other tool—its value hinges on how much care and work you put into your specific issues.


How have you found Java 21’s features? If you came across any unexpected upsides or challenges in your projects, don’t hesitate to share them in the comments below.