Skip to main content
Java 21 (LTS)
  1. Posts/

Java 21 (LTS)

·1974 words·10 mins·
Roman
Author
Roman
Photographer with MSci in Computer Science and a Home Lab obsession
Table of Contents

This covers most of the major language changes from Java 17 (the last LTS) through Java 21.

Runnable
#

java.lang.Runnable is a functional interface with a single method: void run(). It represents a task, the what, separate from the thread that executes it.

In functional programming terms, Runnable is a deferred effect no inputs, no return value, not executed at the point of definition. The lambda body is just captured; execution is deferred until something calls run().

Runnable task = () -> System.out.println("hello");
new Thread(task).start();

A class can implement Runnable to encapsulate task logic alongside state:

class DataProcessor implements Runnable {
    private final List<String> data;

    DataProcessor(List<String> data) {
        this.data = data;
    }

    @Override
    public void run() {
        data.forEach(System.out::println);
    }
}

// Usage
Runnable processor = new DataProcessor(List.of("a", "b", "c"));
Thread t = Thread.startVirtualThread(processor);

t.join();

Runnable and virtual threads

All virtual thread APIs take a Runnable directly:

Thread.startVirtualThread(task);
Thread.ofVirtual().name("worker").start(task);

Virtual Threads
#

JEP 444 - java.lang.Thread

The problem with thread-per-request
#

The natural model for server applications is one thread per request for its full duration, sequential, easy to reason about, easy to debug.

The problem: JDK threads are thin wrappers around OS threads, and OS threads are expensive.

Memory ~1-2 MB stack per thread
Bottleneck OS threads exhausted before CPU or memory

Throughput must grow proportionally with concurrency at a given latency. An app doing 200 req/s at 50ms latency needs ~10 threads. Scaling to 2,000 req/s needs ~100. Scale further and you hit the wall.

The async workaround and its costs
#

The standard fix: asynchronous/reactive programming. Instead of blocking a thread during I/O, register a callback and release the thread back to a pool (CompletableFuture).

It works, but at a cost:

  • Broken control flow – sequential logic must be decomposed into callback chains, losing for loops and try/catch
  • Useless stack traces – a single request is fragmented across many threads, making errors hard to trace
  • Blind profilers – CPU cost can’t be associated back to the original caller
  • Lost debugging – no longer maps to a thread, so tools lose track of “what is this request doing”

Virtual threads: thread-per-request that actually scales
#

Virtual threads use M:N scheduling, many virtual threads (M) mapped onto a small pool of OS “carrier” threads (N). When a virtual thread blocks on I/O or a lock, the JVM suspends it and unmounts it from the carrier, freeing that OS thread to run others. When the operation completes, the virtual thread is rescheduled.

  • Write blocking, sequential code as normal
  • The JVM never wastes an OS thread sitting idle
  • Millions of virtual threads can be live simultaneously, each costing only a few hundred bytes of memory
// Running One Thread
Runnable task = () -> {
    System.out.println("Starting");
    try {
        // sleep for 10 seconds 
        // releases the thread while waiting (non-blocking)
        Thread.sleep(Duration.ofSeconds(10));
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    System.out.println("Hello there");
    System.out.println("Done");

};

Thread virtualThread = Thread.ofVirtual().unstarted(task);

virtualThread.start();
virtualThread.join(); // blocks here until the thread finishes

// Other Thread operations

virtualThread.stop();     
// UNSAFE - deprecated, kills thread at arbitrary point (corrupts state)
virtualThread.interrupt(); 
// SAFE - sets a flag; the thread checks it and stops itself cleanly
// One virtual thread per task - preferred approach
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i ->
            executor.submit(() -> {
                Thread.sleep(Duration.ofSeconds(1));
                return i;
            }));
            // executor.close() is called implicitly, and waits
} // waits for all tasks
// Start a single virtual thread
Thread.startVirtualThread(() ->
        System.out.println("Running: " + Thread.currentThread()));
//Running: VirtualThread[#23]/runnable@ForkJoinPool-1-worker-1

// Or with builder
 Thread t = Thread.ofVirtual()
                .name("worker")
                .start(() ->
        System.out.println("Running: " + Thread.currentThread()));
        
// Running: VirtualThread[#33,worker]/runnable@ForkJoinPool-1-worker-1

A virtual thread is an instance of java.lang.Thread that is not tied to a particular OS thread. A platform thread, by contrast, is an instance of java.lang.Thread implemented in the traditional way, as a thin wrapper around an OS thread.

Aspect Virtual Thread Platform Thread
Creation cost Cheap Expensive
Pool them? No - create freely Often pooled
Always daemon Yes Configurable
Good for I/O-bound tasks CPU-bound tasks
Blocking cost Cheap - unmounts from carrier Expensive - OS thread held

Virtual threads only improve throughput when tasks block on I/O. For CPU-bound work the bottleneck is cores, not threads more threads of any kind won’t help.

As a developer
#

  • Keep tasks focused – each Runnable should do one thing. Virtual threads are cheap, so prefer many small tasks over one large one that mixes concerns
  • Don’t pool virtual threadsnewVirtualThreadPerTaskExecutor() and let the JVM handle scheduling
  • Don’t use for CPU-bound work – virtual threads only outperform platform threads when tasks block on I/O (network, DB, file). Pure in-memory computation never unmounts from the carrier OS thread, so virtual threads offer no benefit, use platform threads instead

Java 21 new feature: Virtual Threads | Youtube


Pattern Matching for switch
#

JEP 441

Pattern Matching for Switch | Youtube

switch can now match on types, not just values. Each case binds a pattern variable that is automatically cast, no explicit cast needed.

// Prior to Java 21 - instanceof chain with manual casting
static String formatter(Object obj) {
    String formatted = "unknown";
    if (obj instanceof Integer i) {
        formatted = String.format("int %d", i);
    } else if (obj instanceof Long l) {
        formatted = String.format("long %d", l);
    } else if (obj instanceof Double d) {
        formatted = String.format("double %f", d);
    } else if (obj instanceof String s) {
        formatted = String.format("String %s", s);
    }
    return formatted;
}
// As of Java 21 - type patterns in switch
static String formatterPatternSwitch(Object obj) {
    return switch (obj) {
        case Integer i -> String.format("int %d", i);  
        // i is already an int
        case Long l    -> String.format("long %d", l);
        case Double d  -> String.format("double %f", d);
        case String s  -> String.format("String %s", s);
        default        -> obj.toString();
    };
}

Switches and null
#

Traditionally switch statements and expressions would throw NullPointerException if the selector evaluated to null, requiring an explicit null check before entering the switch.

// Prior to Java 21 - null must be handled outside the switch
static void testFooBarOld(String s) {
    if (s == null) {
        System.out.println("Oops!");
        return;
    }
    switch (s) {
        case "Foo", "Bar" -> System.out.println("Great");
        default           -> System.out.println("Ok");
    }
}
// As of Java 21 - null handled inline as a case
static void testFooBarNew(String s) {
    switch (s) {
        case null         -> System.out.println("Oops");
        case "Foo", "Bar" -> System.out.println("Great");
        default           -> System.out.println("Ok");
    }
}

Case Guards
#

The when keyword adds a boolean condition to a pattern case. The pattern must match first, then the guard is evaluated, if it fails, the switch continues to the next case.

static void testStringNew(String response) {
    switch (response) {
        case null -> { }
        case "y", "Y" -> {                         
            // exact match
            System.out.println("You got it");
        }
        case "n", "N" -> {
            System.out.println("Shame");
        }
        case String s
        when s.equalsIgnoreCase("YES") -> {         
            // type match + guard
            System.out.println("You got it");
        }
        case String s
        when s.equalsIgnoreCase("NO") -> {
            System.out.println("Shame");
        }
        case String s -> {                          
            // catch-all String case
            System.out.println("Sorry?");
        }
    }
}

Enums
#

With sealed types, the compiler knows all possible subtypes and can verify exhaustiveness, no default case required if all cases are covered.

sealed interface CardClassification permits Suit, Tarot {}
public enum Suit implements CardClassification { CLUBS, DIAMONDS, HEARTS, SPADES }
final class Tarot implements CardClassification {}

static void exhaustiveSwitchWithBetterEnumSupport(CardClassification c) {
    switch (c) {
        case Suit.CLUBS -> {
            System.out.println("It's clubs");
        }
        case Suit.DIAMONDS -> {
            System.out.println("It's diamonds");
        }
        case Suit.HEARTS -> {
            System.out.println("It's hearts");
        }
        case Suit.SPADES -> {
            System.out.println("It's spades");
        }
        case Tarot t -> {
            System.out.println("It's a tarot");
        }
    }
}

Record Patterns
#

JEP 440

Record Patterns in Java 21 | Youtube

Deconstruct record components directly inside a pattern, eliminating accessor calls.

record Point(int x, int y) {}

// Before - match then manually extract via accessors
if (obj instanceof Point p) {
    int x = p.x();
    int y = p.y();
    System.out.println(x + y);
}

// After - deconstruct inline, x and y bound directly
if (obj instanceof Point(int x, int y)) {
    System.out.println(x + y);
}

Type inference
#

var infers the component types, useful when the type is obvious or verbose.

if (obj instanceof Point(var x, var y)) {
    System.out.println(x + y);
}

Nested deconstruction
#

Patterns can nest arbitrarily deep, deconstructing records within records in a single expression.

record ColoredPoint(Point p, String color) {}

if (obj instanceof ColoredPoint(Point(var x, var y), var color)) {
    System.out.println(x + ", " + y + " - " + color);
}

In switch
#

Record patterns compose naturally with switch pattern matching, including guards.

static String describe(Object obj) {
    return switch (obj) {
        case Point(int x, int y) when x == y -> "on diagonal";
        case Point(int x, int y)             -> x + ", " + y;
        default                              -> "not a point";
    };
}

Exhaustiveness with sealed types
#

When record components are sealed types, the compiler can verify that all combinations are covered, no default needed.

sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double w, double h) implements Shape {}

record Pair<T>(T first, T second) {}

// Exhaustive - Circle and Rectangle are the only permitted subtypes,
// so all four combinations are covered
static String describe(Pair<Shape> p) {
    return switch (p) {
        case Pair<Shape>(Circle c,    Circle c2)    -> "two circles";
        case Pair<Shape>(Rectangle r, Rectangle r2) -> "two rectangles";
        case Pair<Shape>(Circle c,    Rectangle r)  -> "circle then rect";
        case Pair<Shape>(Rectangle r, Circle c)     -> "rect then circle";
        // no default required
    };
}

Sequenced Collections
#

JEP 431 - java.util

Sequenced Collections in Java 21 | Youtube

Three new interfaces for collections with a defined encounter order. Retrofitted onto existing types. Improves APIs for accessing first and last elements and processing elements in the reverse order

List and Deque have encounter order but their common supertype Collection does not. SortedSet and LinkedHashSet do too, but Set does not. There was no type that could express “an ordered collection”, Collection is too broad, List too specific.

SequencedCollection hierarchy
Green highlights new additions to the collection interfaces.

SequencedMap hierarchy
Green highlights new additions to the map interfaces.

SequencedCollection
#

SequencedCollection

Uniform first/last access for any ordered collection. Implemented by List and Deque.

Method Description
getFirst() Returns the first element
getLast() Returns the last element
addFirst(E) Inserts element at the front
addLast(E) Inserts element at the end
removeFirst() Removes and returns the first element
removeLast() Removes and returns the last element
reversed() Returns a reversed-order view
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
list.getFirst();    // "a"
list.getLast();     // "c"
list.addFirst("z"); // [z, a, b, c]
list.removeLast();  // removes "c"

list.reversed().forEach(System.out::println); // b, a, z

SequencedSet
#

SequencedSet

Extends SequencedCollection for sets. Implemented by LinkedHashSet and SortedSet.

Method Description
reversed() Returns a reversed-order view as SequencedSet

All other methods are inherited from SequencedCollection.

LinkedHashSet<String> set = new LinkedHashSet<>(List.of("a", "b", "c"));
set.getFirst();   // "a"
set.getLast();    // "c"
set.reversed();   // reversed view as SequencedSet

SequencedMap
#

SequencedMap

First/last access and sequenced views for ordered maps. Implemented by LinkedHashMap and SortedMap.

Method Description
firstEntry() Returns the first key-value entry
lastEntry() Returns the last key-value entry
pollFirstEntry() Removes and returns the first entry
pollLastEntry() Removes and returns the last entry
putFirst(K, V) Inserts or moves entry to the front
putLast(K, V) Inserts or moves entry to the end
reversed() Returns a reversed-order view
sequencedKeySet() Returns keys as a SequencedSet
sequencedValues() Returns values as a SequencedCollection
sequencedEntrySet() Returns entries as a SequencedSet
LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
map.put("a", 1); map.put("b", 2); map.put("c", 3);

map.firstEntry();           // a=1
map.lastEntry();            // c=3
map.reversed();             // {c=3, b=2, a=1} view
map.sequencedKeySet();      // [a, b, c]
map.sequencedValues();      // [1, 2, 3]

Sources
#