What's New in Java 21

Published 2025-12-01 21:00 11500 words 58 min read

issyuu avatar

issyuu

Quiet waters, shimmering Lakeheart Retreat; thoughts and daily musings woven by the peaceful waterside.

All 15 JEPs of Java 21 (LTS), explained with runnable samples and their real output. Virtual threads, scoped values and structured concurrency are examined with measurements, and each feature's later changes up to JDK 27 are summarized.

Introduction

Java 21, released in September 2023, is a long-term support (LTS) release. Virtual threads became final, and record patterns and pattern matching for switch arrived along with them: features that change the way Java code is written.

This article walks through all 15 JEPs of Java 21 by running them. Every sample is a single file with no external libraries, so you can copy it and run it with the java command as is.

Some features that were in preview in Java 21 have since changed or been withdrawn. Each feature therefore ends with a "Since then" note covering what happened up to JDK 27 (GA in September 2026). These notes were added in October 2026.

Environment and how to run the samples

  • JDK: OpenJDK 21 (21.0.12)
  • OS / CPU: macOS (Apple Silicon, 8 cores)
  • The samples are launched directly from source (JEP 330); no compilation step is needed.
java RecordPatternsDemo.java                              # final feature
java --enable-preview --source 21 ScopedValueDemo.java    # preview feature
  • The samples are also on GitHub at smile-lxy/java-new-features. Their comments are written in Japanese, Chinese and English; this article shows only the English ones.
  • The comment at the top of each sample (// Run:) gives its command line.
  • Times, thread IDs (such as #23) and memory figures in the output vary with the environment and from run to run.

Overview: the 15 JEPs of Java 21

JEPFeatureStatus in Java 21Since then (up to JDK 27, added October 2026)
430String TemplatesPreviewWithdrawn in JDK 23
431Sequenced CollectionsFinal―
439Generational ZGCFinalGenerational by default in JDK 23; non-generational mode removed in JDK 24
440Record PatternsFinal―
441Pattern Matching for switchFinalPrimitive type patterns in preview
442Foreign Function & Memory APIThird previewFinal in JDK 22
443Unnamed Patterns and VariablesPreviewFinal in JDK 22
444Virtual ThreadsFinalPinning by synchronized fixed in JDK 24
445Unnamed Classes and Instance Main MethodsPreviewRenamed and final in JDK 25
446Scoped ValuesPreviewFinal in JDK 25
448Vector APISixth incubatorStill incubating in JDK 27 (twelfth)
449Deprecate the Windows 32-bit x86 PortDeprecatedRemoved in JDK 24
451Prepare to Disallow the Dynamic Loading of AgentsPrints a warning―
452Key Encapsulation Mechanism APIFinal―
453Structured ConcurrencyPreviewRedesigned API, still in preview in JDK 27 (seventh)

They fall roughly into four groups.

flowchart LR
    J21["Java 21"] --> L["Language"]
    J21 --> C["Collections"]
    J21 --> P["Concurrency"]
    J21 --> R["Runtime & APIs"]
    L --> L1["Record Patterns (440)"]
    L --> L2["Pattern Matching for switch (441)"]
    L --> L3["Unnamed Patterns (443)"]
    L --> L4["String Templates (430)"]
    L --> L5["Instance Main Methods (445)"]
    C --> C1["Sequenced Collections (431)"]
    P --> P1["Virtual Threads (444)"]
    P --> P2["Scoped Values (446)"]
    P --> P3["Structured Concurrency (453)"]
    R --> R1["Generational ZGC (439)"]
    R --> R2["FFM API (442)"]
    R --> R3["Vector API (448)"]
    R --> R4["KEM API (452) and more"]
    classDef star fill:#fde68a,stroke:#b45309,color:#78350f
    class P1,P2,P3 star

We go through them in the order language → collections → concurrency → runtime & APIs. The three concurrency features (in yellow) are closely related, so they get the most detailed treatment, with measurements.

Language features: taking data apart

The theme shared by the language features in Java 21 is data-oriented programming: records declare the shape of the data, patterns take that shape apart, and switch handles each shape separately. Java 21 is where all three come together.

Record Patterns (JEP 440)

With instanceof and switch, a record can now be type-checked and taken apart into its components in one step.

if (obj instanceof Point) {
    Point p = (Point) obj;   // cast...
    return p.x() + p.y();    // ...then call the accessors
}
if (obj instanceof Point(int x, int y)) {  // type check + deconstruction
    return x + y;
}

Record patterns really shine with nested data. A pattern follows the shape of the data, and you bind only the parts you need.

flowchart TB
    R["Rectangle(…, …)"] --> UL["upperLeft: ColoredPoint(…, …)"]
    R --> LR["lowerRight: ColoredPoint ➜ variable lr"]
    UL --> P["p: Point ➜ variable p"]
    UL --> C["c: Color ➜ variable c"]
    style C fill:#bbdefb,stroke:#1565c0,color:#0d47a1

Written as code, the diagram above becomes this pattern:

if (obj instanceof Rectangle(ColoredPoint(Point p, Color c), ColoredPoint lr)) {
    return c;   // just the color of the upper-left point
}

Add a when guard, and a condition such as "a rectangle whose upper-left corner is blue" can be written declaratively inside a switch.

// JEP 440: Record Patterns
// Run: java RecordPatternsDemo.java

public class RecordPatternsDemo {

    enum Color { RED, GREEN, BLUE }

    record Point(int x, int y) {}
    record ColoredPoint(Point p, Color c) {}
    record Rectangle(ColoredPoint upperLeft, ColoredPoint lowerRight) {}

    // Before: type check -> cast -> accessor
    static int sumOld(Object obj) {
        if (obj instanceof Point) {
            Point p = (Point) obj;
            return p.x() + p.y();
        }
        return 0;
    }

    // Java 21: type check and deconstruction in one step
    static int sumNew(Object obj) {
        if (obj instanceof Point(int x, int y)) {
            return x + y;
        }
        return 0;
    }

    // Deconstruct nested records at once
    static Color upperLeftColor(Object obj) {
        if (obj instanceof Rectangle(ColoredPoint(Point p, Color c), ColoredPoint lr)) {
            return c;
        }
        throw new IllegalArgumentException("not a rectangle");
    }

    // switch + record patterns + when guards
    static String describe(Object obj) {
        return switch (obj) {
            case Point(int x, int y) when x == y -> "point on the diagonal (" + x + ", " + y + ")";
            case Point(int x, int y) -> "point (" + x + ", " + y + ")";
            case Rectangle(ColoredPoint(Point p, Color c), ColoredPoint lr) when c == Color.BLUE ->
                    "rectangle with a blue upper-left corner (x=" + p.x() + ")";
            case Rectangle r -> "rectangle";
            default -> "unknown";
        };
    }

    public static void main(String[] args) {
        Point point = new Point(1, 2);
        Rectangle blue = new Rectangle(
                new ColoredPoint(new Point(0, 0), Color.BLUE),
                new ColoredPoint(new Point(10, 10), Color.RED));
        Rectangle green = new Rectangle(
                new ColoredPoint(new Point(5, 5), Color.GREEN),
                new ColoredPoint(new Point(10, 10), Color.RED));

        System.out.println("sumOld(point)        = " + sumOld(point));
        System.out.println("sumNew(point)        = " + sumNew(point));
        System.out.println("upperLeftColor(blue) = " + upperLeftColor(blue));
        System.out.println("describe(Point(3,3)) = " + describe(new Point(3, 3)));
        System.out.println("describe(point)      = " + describe(point));
        System.out.println("describe(blue)       = " + describe(blue));
        System.out.println("describe(green)      = " + describe(green));
        System.out.println("describe(\"text\")     = " + describe("text"));
    }
}
sumOld(point)        = 3
sumNew(point)        = 3
upperLeftColor(blue) = BLUE
describe(Point(3,3)) = point on the diagonal (3, 3)
describe(point)      = point (1, 2)
describe(blue)       = rectangle with a blue upper-left corner (x=0)
describe(green)      = rectangle
describe("text")     = unknown

Note that case Point(int x, int y) when x == y comes before case Point(int x, int y). Patterns in a switch are tried from top to bottom, so the stricter one goes first. In the opposite order, the later case could never match (it is "dominated"), and the code does not compile.

Pattern Matching for switch (JEP 441)

The old switch could only compare against constants. In Java 21, a case can hold a pattern of any type.

flowchart LR
    O["switch (o)"] --> A{"case Integer i<br/>when i < 0"}
    A -- match --> A1["negative integer"]
    A -- no match --> B{"case Integer i<br/>when i == 0"}
    B -- match --> B1["zero"]
    B -- no match --> C{"case Integer i"}
    C -- match --> C1["positive integer"]
    C -- no match --> D{"case String s ..."}
    D -- no match --> E["default"]

Another improvement is how null is handled. The old switch threw NullPointerException immediately on null, so a check beforehand was mandatory. In Java 21 you can handle it with case null.

Combined with a sealed interface, the compiler also performs an exhaustiveness check: when every implementing type has a case, no default is needed. If you add an implementation later, every switch that lacks a case for it fails to compile, so nothing gets missed.

// JEP 441: Pattern Matching for switch
// Run: java SwitchPatternDemo.java

public class SwitchPatternDemo {

    // Before: a chain of instanceof checks
    static String formatOld(Object o) {
        if (o instanceof Integer) {
            Integer i = (Integer) o;
            return "int " + i;
        } else if (o instanceof Long) {
            Long l = (Long) o;
            return "long " + l;
        } else if (o instanceof String) {
            String s = (String) o;
            return "String " + s;
        }
        return "unknown";
    }

    // Java 21: type patterns in case labels
    static String formatNew(Object o) {
        return switch (o) {
            case Integer i -> "int " + i;
            case Long l -> "long " + l;
            case String s -> "String " + s;
            default -> "unknown";
        };
    }

    // Before: without a null check first, switch throws NullPointerException
    static String answerOld(String s) {
        if (s == null) {
            return "Oops";
        }
        return switch (s) {
            case "YES", "NO" -> "Known";
            default -> "Other";
        };
    }

    // Java 21: handle it with case null
    static String answerNew(String s) {
        return switch (s) {
            case null -> "Oops";
            case "YES", "NO" -> "Known";
            default -> "Other";
        };
    }

    // when guards
    static String classify(Object o) {
        return switch (o) {
            case Integer i when i < 0 -> "negative integer";
            case Integer i when i == 0 -> "zero";
            case Integer i -> "positive integer";
            case String s when s.isBlank() -> "blank string";
            case String s -> "string (" + s.length() + " chars)";
            default -> "other";
        };
    }

    // With sealed types no default is needed (the compiler checks exhaustiveness)
    sealed interface Shape permits Circle, Square {}
    record Circle(double r) implements Shape {}
    record Square(double side) implements Shape {}

    static double area(Shape s) {
        return switch (s) {
            case Circle c -> Math.PI * c.r() * c.r();
            case Square q -> q.side() * q.side();
        };
    }

    public static void main(String[] args) {
        for (Object o : new Object[] {1, 2L, "abc", 3.14}) {
            System.out.printf("%-6s old=%-12s new=%s%n", o, formatOld(o), formatNew(o));
        }
        System.out.println("---");
        for (String s : new String[] {"YES", "yes", null}) {
            System.out.printf("%-5s old=%-6s new=%s%n", s, answerOld(s), answerNew(s));
        }
        System.out.println("---");
        for (Object o : new Object[] {-5, 0, 42, "  ", "Java", 1.0}) {
            System.out.printf("%-6s -> %s%n", "\"" + o + "\"", classify(o));
        }
        System.out.println("---");
        System.out.printf("area(Circle(1)) = %.4f%n", area(new Circle(1)));
        System.out.printf("area(Square(2)) = %.4f%n", area(new Square(2)));
    }
}
1      old=int 1        new=int 1
2      old=long 2       new=long 2
abc    old=String abc   new=String abc
3.14   old=unknown      new=unknown
---
YES   old=Known  new=Known
yes   old=Other  new=Other
null  old=Oops   new=Oops
---
"-5"   -> negative integer
"0"    -> zero
"42"   -> positive integer
"  "   -> blank string
"Java" -> string (4 chars)
"1.0"  -> other
---
area(Circle(1)) = 3.1416
area(Square(2)) = 4.0000

Since then (added October 2026): from JDK 23, "Primitive Types in Patterns, instanceof, and switch", which lets primitive types such as int be used as patterns, has been in preview (fifth preview in JDK 27).

Unnamed Patterns and Variables (JEP 443, preview)

Variables and pattern components you don't use can be written as _. The code then states that a value is intentionally unused, and unused-variable warnings go away.

// JEP 443: Unnamed Patterns and Variables (preview in Java 21)
// Run: java --enable-preview --source 21 UnnamedVariablesDemo.java

import java.util.List;

public class UnnamedVariablesDemo {

    record Point(int x, int y) {}
    record Line(Point from, Point to) {}

    public static void main(String[] args) {
        // 1. Unused parts of a pattern
        Object obj = new Line(new Point(1, 2), new Point(3, 4));
        if (obj instanceof Line(Point(int x, _), _)) {
            System.out.println("x of the start point = " + x);
        }

        // 2. An unused loop variable
        int count = 0;
        for (var _ : List.of("a", "b", "c")) {
            count++;
        }
        System.out.println("number of elements = " + count);

        // 3. An unused exception variable
        try {
            Integer.parseInt("abc");
        } catch (NumberFormatException _) {
            System.out.println("not a number");
        }

        // 4. An unused lambda parameter
        List.of(1, 2, 3).forEach(_ -> System.out.print("*"));
        System.out.println();
    }
}
x of the start point = 1
number of elements = 3
not a number
***

Since then (added October 2026): final in JDK 22 (JEP 456); it no longer needs --enable-preview.

String Templates (JEP 430, preview)

String interpolation: embed \{expression} inside STR."...". You can see where each expression goes, so there is no counting of arguments as with String.format, and no traps like MessageFormat's '' (escaped single quote).

// JEP 430: String Templates (preview in Java 21, withdrawn in JDK 23)
// Run: java --enable-preview --source 21 StringTemplatesDemo.java

import java.text.MessageFormat;

public class StringTemplatesDemo {

    public static void main(String[] args) {
        String name = "xyz";
        int age = 18;

        String a = "Hello, " + name + "! Next year you'll be " + (age + 1) + ".";
        String b = new StringBuilder().append("Hello, ").append(name)
                .append("! Next year you'll be ").append(age + 1).append(".").toString();
        String c = String.format("Hello, %s! Next year you'll be %d.", name, age + 1);
        String d = MessageFormat.format("Hello, {0}! Next year you''ll be {1}.", name, age + 1);
        String e = STR."Hello, \{name}! Next year you'll be \{age + 1}.";

        System.out.println("+ operator    : " + a);
        System.out.println("StringBuilder : " + b);
        System.out.println("String.format : " + c);
        System.out.println("MessageFormat : " + d);
        System.out.println("STR           : " + e);

        // Works with text blocks too (any expression can be embedded)
        int x = 3, y = 4;
        String report = STR."""
                x = \{x}
                y = \{y}
                x * y = \{x * y}
                """;
        System.out.print(report);

        // Note: STR just concatenates the values (no escaping)
        String user = "' OR '1'='1";
        System.out.println(STR."SELECT * FROM users WHERE name = '\{user}'");
    }
}
+ operator    : Hello, xyz! Next year you'll be 19.
StringBuilder : Hello, xyz! Next year you'll be 19.
String.format : Hello, xyz! Next year you'll be 19.
MessageFormat : Hello, xyz! Next year you'll be 19.
STR           : Hello, xyz! Next year you'll be 19.
x = 3
y = 4
x * y = 12
SELECT * FROM users WHERE name = '' OR '1'='1'

Do not build SQL with STR. As the last line of the output shows, STR simply concatenates the values without any escaping, so this code is wide open to SQL injection. Keep building SQL with PreparedStatement placeholders.

The original idea of JEP 430 was to embed values safely into SQL, JSON and so on through "template processors" other than STR.

Since then (added October 2026): it was previewed again in JDK 22, but the discussion about its syntax and design did not converge, and the whole feature was withdrawn in JDK 23. Current JDKs cannot compile STR."...". For now, String.format, "...".formatted(...) and text blocks are the practical choice. Where a redesign will go has not been decided.

Unnamed Classes and Instance Main Methods (JEP 445, preview)

No class declaration and no public static void main(String[] args): write methods and fields directly in a file, and it runs.

// JEP 445: Unnamed Classes and Instance Main Methods (preview in Java 21)
// Run: java --enable-preview --source 21 InstanceMainDemo.java

// Fields and methods without a class declaration
String greeting = "Hello";

String greet(String name) {
    return greeting + ", " + name + "!";
}

void main() {
    System.out.println(greet("World"));
}
Hello, World!

It looks like a feature for beginners, but being able to write a small script or a quick experiment in one file and run it directly is handy in daily work too.

Since then (added October 2026): renamed to "Compact Source Files and Instance Main Methods" and final in JDK 25 (JEP 512). It adds a java.lang.IO class for console input and output, and classes in the java.base module can be used without imports.

void main() {
    IO.println("Hello, World!");
}

Sequenced Collections (JEP 431)

One way to reach "first" and "last"

Before Java 21, every collection type had its own way of getting the first and last elements.

TypeFirstLastReverse
Listlist.get(0)list.get(list.size() - 1)Collections.reverse(list) (modifies the list itself)
DequegetFirst()getLast()descendingIterator()
LinkedHashSetiterator().next()loop to the endcopy into a List, then reverse
LinkedHashMapentrySet().iterator().next()loop to the endcopy, then reverse

They all share the property of having an order, yet there was no type for it. Java 21 adds three interfaces for this property between the existing types (in green).

flowchart TB
    Collection --> Set
    Collection --> SC["SequencedCollection"]
    Collection --> Queue
    Set --> SS["SequencedSet"]
    SC --> SS
    SC --> List
    SC --> Deque
    Queue --> Deque
    SS --> SortedSet
    Set --> SortedSet
    SortedSet --> NavigableSet
    SS --> LHS["LinkedHashSet"]
    Map --> SM["SequencedMap"]
    SM --> SortedMap
    SortedMap --> NavigableMap
    SM --> LHM["LinkedHashMap"]
    classDef added fill:#c8f7c5,stroke:#2e7d32,color:#1b5e20
    class SC,SS,SM added

These are the new methods. Most of them are default methods, so existing implementations stay compatible.

interface SequencedCollection<E> extends Collection<E> {
    SequencedCollection<E> reversed();
    default void addFirst(E e);
    default void addLast(E e);
    default E getFirst();
    default E getLast();
    default E removeFirst();
    default E removeLast();
}

interface SequencedSet<E> extends SequencedCollection<E>, Set<E> {
    SequencedSet<E> reversed();
}

interface SequencedMap<K, V> extends Map<K, V> {
    SequencedMap<K, V> reversed();
    default Map.Entry<K, V> firstEntry();
    default Map.Entry<K, V> lastEntry();
    default Map.Entry<K, V> pollFirstEntry();
    default Map.Entry<K, V> pollLastEntry();
    default V putFirst(K k, V v);
    default V putLast(K k, V v);
    default SequencedSet<K> sequencedKeySet();
    default SequencedCollection<V> sequencedValues();
    default SequencedSet<Map.Entry<K, V>> sequencedEntrySet();
}

Before and after

// JEP 431: Sequenced Collections
// Run: java SequencedCollectionsDemo.java

import java.util.*;

public class SequencedCollectionsDemo {

    public static void main(String[] args) {
        list();
        deque();
        set();
        map();
        reversedIsView();
    }

    static void list() {
        System.out.println("=== List ===");
        List<Integer> list = new ArrayList<>(List.of(0, 1, 2));
        // Before
        System.out.println("get(0) / get(size()-1) : " + list.get(0) + " / " + list.get(list.size() - 1));
        // Java 21
        System.out.println("getFirst() / getLast() : " + list.getFirst() + " / " + list.getLast());
        System.out.println("reversed()             : " + list.reversed() + "  original: " + list);
        // The old Collections.reverse modifies the list itself
        List<Integer> copy = new ArrayList<>(list);
        Collections.reverse(copy);
        System.out.println("Collections.reverse    : " + copy + "  (the copy itself was modified)");
    }

    static void deque() {
        System.out.println("=== Deque ===");
        Deque<Integer> deque = new ArrayDeque<>(List.of(0, 1, 2));
        // Before: reverse order through descendingIterator
        List<Integer> rev = new ArrayList<>();
        deque.descendingIterator().forEachRemaining(rev::add);
        System.out.println("descendingIterator     : " + rev);
        // Java 21
        System.out.println("reversed()             : " + deque.reversed() + "  original: " + deque);
    }

    static void set() {
        System.out.println("=== LinkedHashSet ===");
        LinkedHashSet<Integer> set = new LinkedHashSet<>(List.of(0, 1, 2));
        // Before: the only way to the last element is to loop to the end
        Integer last = null;
        for (Integer x : set) last = x;
        System.out.println("iterator().next() / loop : " + set.iterator().next() + " / " + last);
        // Java 21
        System.out.println("getFirst() / getLast()   : " + set.getFirst() + " / " + set.getLast());
        System.out.println("reversed()               : " + set.reversed());
        // Add at the front (an existing element moves to the front)
        set.addFirst(9);
        System.out.println("addFirst(9)              : " + set);
    }

    static void map() {
        System.out.println("=== LinkedHashMap ===");
        LinkedHashMap<Integer, String> map = new LinkedHashMap<>();
        map.putLast(0, "zero");
        map.putLast(1, "one");
        map.putLast(2, "two");
        System.out.println("map                    : " + map);
        System.out.println("firstEntry / lastEntry : " + map.firstEntry() + " / " + map.lastEntry());
        System.out.println("reversed()             : " + map.reversed());
        // An existing key moves to the front
        map.putFirst(1, "ONE");
        System.out.println("putFirst(1, \"ONE\")     : " + map);
        System.out.println("pollLastEntry()        : " + map.pollLastEntry() + "  remaining: " + map);
    }

    static void reversedIsView() {
        System.out.println("=== reversed() is a view ===");
        Deque<Integer> deque = new ArrayDeque<>(List.of(0, 1, 2));
        SequencedCollection<Integer> view = deque.reversed();
        // The front of the reversed view = the end of the original
        view.addFirst(9);
        System.out.println("after view.addFirst(9) : view=" + view + "  original=" + deque);
    }
}
=== List ===
get(0) / get(size()-1) : 0 / 2
getFirst() / getLast() : 0 / 2
reversed()             : [2, 1, 0]  original: [0, 1, 2]
Collections.reverse    : [2, 1, 0]  (the copy itself was modified)
=== Deque ===
descendingIterator     : [2, 1, 0]
reversed()             : [2, 1, 0]  original: [0, 1, 2]
=== LinkedHashSet ===
iterator().next() / loop : 0 / 2
getFirst() / getLast()   : 0 / 2
reversed()               : [2, 1, 0]
addFirst(9)              : [9, 0, 1, 2]
=== LinkedHashMap ===
map                    : {0=zero, 1=one, 2=two}
firstEntry / lastEntry : 0=zero / 2=two
reversed()             : {2=two, 1=one, 0=zero}
putFirst(1, "ONE")     : {1=ONE, 0=zero, 2=two}
pollLastEntry()        : 2=two  remaining: {1=ONE, 0=zero}
=== reversed() is a view ===
after view.addFirst(9) : view=[9, 2, 1, 0]  original=[0, 1, 2, 9]

addFirst on a LinkedHashSet and putFirst on a LinkedHashMap move an element that already exists to the front, which only makes sense for collections that keep insertion order.

Caution: reversed() is a view, not a copy

reversed() does not create a new collection. It returns a view that shows the original collection backwards. There is only one copy of the data, so changes made through the view show up in the original.

flowchart LR
    subgraph DATA["The data (only one copy)"]
        direction LR
        D0["0"] --- D1["1"] --- D2["2"] --- D9["9"]
    end
    ORIG["deque<br/>first → last"] -.-> DATA
    VIEW["deque.reversed()<br/>shown last → first"] -.-> DATA
    ADD["view.addFirst(9)<br/>= added at the end of the data"] --> D9
    style D9 fill:#fde68a,stroke:#b45309,color:#78350f

That saves memory, but when you want a reversed copy you have to make one explicitly, as in new ArrayList<>(list.reversed()). Despite the similar names, Collections.reverse modifies the list, while reversed() returns a non-destructive view. Keep the difference in mind when migrating.

Virtual Threads (JEP 444)

This is the feature in Java 21 with the biggest impact.

Why we need them

Many web applications are written thread-per-request: one request, one thread. The model is easy to read and easy to debug. But a Java thread maps one-to-one to an OS thread, so the number of requests you can handle at once is capped by the number of OS threads.

On top of that, a typical request spends most of its time waiting for a database or an external API.

Where the time of one request goes (illustration, unit: ms)

%%{init: {"gantt": {"leftPadding": 110}}}%%
gantt
    %% gantt:request
    dateFormat x
    axisFormat %L
    section Request
    Parse parameters :active, 0, 5
    DB query (wait) :crit, 5, 60
    API request (wait) :crit, 60, 140
    Build the response :active, 140, 150

Only "parse parameters" and "build the response" use the CPU; during the "waiting" parts the thread does nothing. Even so, it keeps a stack measured in megabytes and keeps occupying an OS thread.

The traditional answer has been reactive programming with CompletableFuture, Reactor and the like. The code gets more complicated, though, and stack traces get harder to follow. Virtual threads solve the problem while you keep writing plain synchronous code.

How they work: M
scheduling

A virtual thread is a lightweight thread managed by the JVM; underneath, it is an object on the heap. Only when it actually needs the CPU is it placed on a carrier thread (by default, a platform thread of a ForkJoinPool). Many (M) virtual threads run on a few (N) carrier threads, hence the name M

scheduling. You can create hundreds of thousands or even millions of virtual threads on the heap, while the number of carrier threads defaults to the number of CPU cores.

flowchart TB
    subgraph JVM["JVM"]
        subgraph HEAP["Heap"]
            V1((VT1)):::vt
            V2((VT2)):::vt
            V3((VT3)):::vt
            V4((VT4)):::vt
            V5((VT5)):::vt
            V6((...)):::vt
        end
        subgraph POOL["ForkJoinPool"]
            C1[Carrier 1]
            C2[Carrier 2]
        end
        V1 -- mount --> C1
        V3 -- mount --> C2
    end
    C1 --- O1[OS thread]
    C2 --- O2[OS thread]
    classDef vt fill:#e53935,color:#fff,stroke:#b71c1c

The key is what happens when a virtual thread enters blocking I/O (Thread.sleep, socket reads and writes, BlockingQueue.take, and so on).

sequenceDiagram
    participant VT as Virtual thread
    participant C as Carrier thread
    participant IO as I/O
    VT->>C: Mount (load its stack)
    C->>C: Compute
    C->>IO: I/O request (blocking)
    Note over VT,C: Unmount: the stack is saved to the heap,<br/>and the carrier runs other virtual threads
    IO-->>VT: I/O done
    VT->>C: Remount (on any free carrier)
    C->>C: Continue

The blocking operations inside the JDK have been rewritten to cooperate with virtual threads, so application code just stays synchronous, and the carrier thread is released while it waits.

Creating virtual threads

A virtual thread is still a Thread. There are four ways to create one.

// JEP 444: Virtual Threads - four ways to create a virtual thread
// Run: java VirtualThreadCreateDemo.java

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;

public class VirtualThreadCreateDemo {

    public static void main(String[] args) throws Exception {
        Runnable task = () -> System.out.println("  " + Thread.currentThread());

        System.out.println("1. Thread.startVirtualThread");
        Thread.startVirtualThread(task).join();

        System.out.println("2. Thread.ofVirtual().name(...).start");
        Thread.ofVirtual().name("my-virtual").start(task).join();

        System.out.println("3. ThreadFactory");
        ThreadFactory factory = Thread.ofVirtual().name("worker-", 0).factory();
        Thread t = factory.newThread(task);
        t.start();
        t.join();

        System.out.println("4. Executors.newVirtualThreadPerTaskExecutor");
        // Leaving try-with-resources, close() waits for all tasks to finish
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            executor.submit(task);
            executor.submit(task);
        }

        System.out.println("For comparison: a platform thread");
        Thread.ofPlatform().start(task).join();
        System.out.println("isVirtual = " + Thread.ofVirtual().unstarted(task).isVirtual());
    }
}
1. Thread.startVirtualThread
  VirtualThread[#23]/runnable@ForkJoinPool-1-worker-1
2. Thread.ofVirtual().name(...).start
  VirtualThread[#26,my-virtual]/runnable@ForkJoinPool-1-worker-1
3. ThreadFactory
  VirtualThread[#27,worker-0]/runnable@ForkJoinPool-1-worker-1
4. Executors.newVirtualThreadPerTaskExecutor
  VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
  VirtualThread[#29]/runnable@ForkJoinPool-1-worker-2
For comparison: a platform thread
  Thread[#31,Thread-0,5,main]
isVirtual = true

The toString() of a virtual thread has the form VirtualThread[#ID]/state@carrier-name, which tells you which carrier it is currently mounted on. In practice, option 4, the ExecutorService, is the usual choice. ExecutorService has also been AutoCloseable since Java 19: in a try-with-resources block, leaving the block waits for all tasks to finish.

Measurement 1: watching the scheduler

Let's run 4 virtual threads on 2 carrier threads. The number of carriers can be limited with the system properties jdk.virtualThreadScheduler.parallelism / maxPoolSize. Each virtual thread alternates between computing (using the CPU) and I/O (stood in for by Thread.sleep).

// JEP 444: Virtual Threads - run 4 virtual threads on 2 carrier threads and watch them mount / unmount
// Run: java VirtualThreadScheduleDemo.java

import java.time.Duration;
import java.util.concurrent.CountDownLatch;

public class VirtualThreadScheduleDemo {

    static final long START = System.nanoTime();

    // Executed in the order [compute ms, I/O ms, compute ms, ...]
    static final long[][] PLANS = {
            {2500, 500, 1000, 1500, 1300},
            {1800, 1000, 3800, 1500, 600},
            {2000, 500, 2500},
            {800, 500, 2800},
    };

    public static void main(String[] args) throws InterruptedException {
        // Limit the carrier threads to 2 (set before the first virtual thread is created)
        System.setProperty("jdk.virtualThreadScheduler.parallelism", "2");
        System.setProperty("jdk.virtualThreadScheduler.maxPoolSize", "2");

        CountDownLatch done = new CountDownLatch(PLANS.length);
        for (int i = 0; i < PLANS.length; i++) {
            int id = i + 1;
            long[] plan = PLANS[i];
            Thread.startVirtualThread(() -> {
                log(id, "start");
                for (int step = 0; step < plan.length; step++) {
                    if (step % 2 == 0) {
                        log(id, "compute " + plan[step] + " ms");
                        compute(plan[step]);
                    } else {
                        log(id, "I/O " + plan[step] + " ms (unmount)");
                        sleep(plan[step]);
                        log(id, "I/O done (remount)");
                    }
                }
                log(id, "done");
                done.countDown();
            });
        }
        done.await();
    }

    // Keep the CPU busy (the virtual thread stays mounted)
    static void compute(long ms) {
        long end = System.nanoTime() + ms * 1_000_000;
        double sink = 0;
        while (System.nanoTime() < end) {
            sink += Math.sqrt(sink + 1);
        }
    }

    // Stands in for blocking I/O (the virtual thread unmounts)
    static void sleep(long ms) {
        try {
            Thread.sleep(Duration.ofMillis(ms));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    static synchronized void log(int id, String message) {
        long elapsed = (System.nanoTime() - START) / 1_000_000;
        // toString() looks like "VirtualThread[#id]/state@carrier-thread-name"
        String carrier = Thread.currentThread().toString().replaceAll(".*@", "");
        System.out.printf("%5d ms  %-23s  VT-%d  %s%n", elapsed, carrier, id, message);
    }
}
    4 ms  ForkJoinPool-1-worker-2  VT-2  start
   12 ms  ForkJoinPool-1-worker-1  VT-1  start
   14 ms  ForkJoinPool-1-worker-1  VT-1  compute 2500 ms
   14 ms  ForkJoinPool-1-worker-2  VT-2  compute 1800 ms
 1817 ms  ForkJoinPool-1-worker-2  VT-2  I/O 1000 ms (unmount)
 1821 ms  ForkJoinPool-1-worker-2  VT-3  start
 1821 ms  ForkJoinPool-1-worker-2  VT-3  compute 2000 ms
 2515 ms  ForkJoinPool-1-worker-1  VT-1  I/O 500 ms (unmount)
 2516 ms  ForkJoinPool-1-worker-1  VT-4  start
 2517 ms  ForkJoinPool-1-worker-1  VT-4  compute 800 ms
 3317 ms  ForkJoinPool-1-worker-1  VT-4  I/O 500 ms (unmount)
 3318 ms  ForkJoinPool-1-worker-1  VT-2  I/O done (remount)
 3318 ms  ForkJoinPool-1-worker-1  VT-2  compute 3800 ms
 3821 ms  ForkJoinPool-1-worker-2  VT-3  I/O 500 ms (unmount)
 3822 ms  ForkJoinPool-1-worker-2  VT-1  I/O done (remount)
 3822 ms  ForkJoinPool-1-worker-2  VT-1  compute 1000 ms
 4823 ms  ForkJoinPool-1-worker-2  VT-1  I/O 1500 ms (unmount)
 4826 ms  ForkJoinPool-1-worker-2  VT-4  I/O done (remount)
 4826 ms  ForkJoinPool-1-worker-2  VT-4  compute 2800 ms
 7118 ms  ForkJoinPool-1-worker-1  VT-2  I/O 1500 ms (unmount)
 7119 ms  ForkJoinPool-1-worker-1  VT-3  I/O done (remount)
 7119 ms  ForkJoinPool-1-worker-1  VT-3  compute 2500 ms
 7627 ms  ForkJoinPool-1-worker-2  VT-4  done
 7627 ms  ForkJoinPool-1-worker-2  VT-1  I/O done (remount)
 7627 ms  ForkJoinPool-1-worker-2  VT-1  compute 1300 ms
 8928 ms  ForkJoinPool-1-worker-2  VT-1  done
 8928 ms  ForkJoinPool-1-worker-2  VT-2  I/O done (remount)
 8928 ms  ForkJoinPool-1-worker-2  VT-2  compute 600 ms
 9528 ms  ForkJoinPool-1-worker-2  VT-2  done
 9619 ms  ForkJoinPool-1-worker-1  VT-3  done

Plotting the timestamps of this log gives the following timeline.

4 virtual threads on 2 carrier threads (measured, unit: s)

%%{init: {"gantt": {"leftPadding": 110}}}%%
gantt
    %% gantt:schedule
    dateFormat x
    axisFormat %S
    tickInterval 1second
    section VT-1
    compute :active, 14, 2515
    I/O :2515, 3015
    wait :crit, 3015, 3822
    compute :active, 3822, 4823
    I/O :4823, 6323
    wait :crit, 6323, 7627
    compute :active, 7627, 8928
    section VT-2
    compute :active, 14, 1817
    I/O :1817, 2817
    wait :crit, 2817, 3318
    compute :active, 3318, 7118
    I/O :7118, 8618
    wait :crit, 8618, 8928
    compute :active, 8928, 9528
    section VT-3
    wait :crit, 0, 1821
    compute :active, 1821, 3821
    I/O :3821, 4321
    wait :crit, 4321, 7119
    compute :active, 7119, 9619
    section VT-4
    wait :crit, 0, 2516
    compute :active, 2517, 3317
    I/O :3317, 3817
    wait :crit, 3817, 4826
    compute :active, 4826, 7627

The chart is drawn from the log above as follows:

  • compute: from the time of a compute ○○ ms line to the time of that thread's next line (mounted on a carrier)
  • I/O: from the time of an I/O ○○ ms (unmount) line, for the given sleep time (not on a carrier)
  • wait: from the end of the I/O until the I/O done (remount) line. The I/O is over, but the thread is waiting for a free carrier. The initial wait of VT-3 and VT-4 runs from the start of the program until their start line

From it we can see that:

  • Only 2 carriers are ever in use: at any moment, at most 2 virtual threads are in "compute".
  • A thread gives up its carrier during I/O: the moment VT-2 enters I/O at 1.8 s, the waiting VT-3 starts on that very carrier (worker-2).
  • The carrier can change on remount: VT-2 starts on worker-2 and, after its I/O, runs on worker-1. Virtual threads are not tied to a particular OS thread.
  • The whole run takes about 9.6 s: the four threads compute for 19.1 s in total, which on 2 carriers works out to about 9.55 s in theory. The run finishes almost exactly on schedule.

Turned around, the last point means CPU-bound work does not get faster with virtual threads. Virtual threads help where waiting (I/O) dominates.

Measurement 2: compared with a thread pool

Now run the same "5 tasks that each wait 1 s" on a thread pool and on virtual threads.

// JEP 444: Virtual Threads - limits of a thread pool vs virtual threads on a single carrier
// Run: java ThreadPoolVsVirtualDemo.java

import java.time.Duration;
import java.util.concurrent.*;

public class ThreadPoolVsVirtualDemo {

    static final long START = System.nanoTime();

    static void log(String message) {
        String thread = Thread.currentThread().isVirtual()
                ? "virtual@" + Thread.currentThread().toString().replaceAll(".*@", "")
                : Thread.currentThread().getName();
        System.out.printf("%5d ms  %-32s  %s%n", (System.nanoTime() - START) / 1_000_000, thread, message);
    }

    static Runnable task(int n) {
        return new Runnable() {
            public void run() {
                log("task " + n + " start");
                try {
                    Thread.sleep(Duration.ofSeconds(1));
                } catch (InterruptedException ignored) {
                }
                log("task " + n + " end");
            }

            public String toString() {
                return "task " + n;
            }
        };
    }

    public static void main(String[] args) throws Exception {
        System.out.println("=== 1. Fixed pool (1 thread), 5 tasks of 1 s ===");
        try (ExecutorService pool = Executors.newFixedThreadPool(1)) {
            for (int i = 0; i < 5; i++) pool.execute(task(i));
        }

        System.out.println("=== 2. Bounded queue (1 thread, queue of 2), 5 tasks ===");
        ThreadPoolExecutor bounded = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(2),
                (r, executor) -> log("[REJECTED] " + r + " (queue is full)"));
        for (int i = 0; i < 5; i++) bounded.execute(task(i));
        bounded.shutdown();
        bounded.awaitTermination(10, TimeUnit.SECONDS);

        System.out.println("=== 3. Virtual threads (1 carrier), 5 tasks of 1 s ===");
        // Must be set before the first virtual thread is created
        System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");
        System.setProperty("jdk.virtualThreadScheduler.maxPoolSize", "1");
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 5; i++) executor.execute(task(i));
        }
    }
}
=== 1. Fixed pool (1 thread), 5 tasks of 1 s ===
    1 ms  pool-1-thread-1                   task 0 start
 1012 ms  pool-1-thread-1                   task 0 end
 1013 ms  pool-1-thread-1                   task 1 start
 2016 ms  pool-1-thread-1                   task 1 end
 2017 ms  pool-1-thread-1                   task 2 start
 3023 ms  pool-1-thread-1                   task 2 end
 3023 ms  pool-1-thread-1                   task 3 start
 4025 ms  pool-1-thread-1                   task 3 end
 4026 ms  pool-1-thread-1                   task 4 start
 5027 ms  pool-1-thread-1                   task 4 end
=== 2. Bounded queue (1 thread, queue of 2), 5 tasks ===
 5031 ms  pool-2-thread-1                   task 0 start
 5034 ms  main                              [REJECTED] task 3 (queue is full)
 5035 ms  main                              [REJECTED] task 4 (queue is full)
 6037 ms  pool-2-thread-1                   task 0 end
 6038 ms  pool-2-thread-1                   task 1 start
 7044 ms  pool-2-thread-1                   task 1 end
 7044 ms  pool-2-thread-1                   task 2 start
 8047 ms  pool-2-thread-1                   task 2 end
=== 3. Virtual threads (1 carrier), 5 tasks of 1 s ===
 8053 ms  virtual@ForkJoinPool-1-worker-1   task 0 start
 8055 ms  virtual@ForkJoinPool-1-worker-1   task 1 start
 8056 ms  virtual@ForkJoinPool-1-worker-1   task 2 start
 8057 ms  virtual@ForkJoinPool-1-worker-1   task 3 start
 8057 ms  virtual@ForkJoinPool-1-worker-1   task 4 start
 9060 ms  virtual@ForkJoinPool-1-worker-1   task 0 end
 9060 ms  virtual@ForkJoinPool-1-worker-1   task 1 end
 9061 ms  virtual@ForkJoinPool-1-worker-1   task 2 end
 9061 ms  virtual@ForkJoinPool-1-worker-1   task 3 end
 9062 ms  virtual@ForkJoinPool-1-worker-1   task 4 end

5 tasks that each wait 1 s (measured, unit: s)

%%{init: {"gantt": {"leftPadding": 110}}}%%
gantt
    %% gantt:pool
    dateFormat x
    axisFormat %S
    tickInterval 1second
    section Fixed pool
    task 0 :active, 1, 1012
    task 1 :active, 1013, 2016
    task 2 :active, 2017, 3023
    task 3 :active, 3023, 4025
    task 4 :active, 4026, 5027
    section Virtual
    task 0 :active, 0, 1007
    task 1 :active, 2, 1007
    task 2 :active, 3, 1008
    task 3 :active, 4, 1008
    task 4 :active, 4, 1009
  • Fixed pool (1 thread): with only one thread, the 5 tasks run 1 s at a time, one after another, 5 s in total.
  • Bounded queue: once the queue (length 2) is full, tasks 3 and 4 are rejected. With thread pools you always have to tune the pool size, the queue length and the rejection policy.
  • Virtual threads (1 carrier): all of them run on the same worker-1, yet the five waits overlap and everything finishes in about 1 s. A waiting thread gives up its carrier, so even one carrier can serve many waiting threads.

Measurement 3: throughput

What happens with many more tasks? Let's compare 10,000 tasks that each block for 1 s.

// JEP 444: Virtual Threads - 10,000 tasks that block for 1 s: fixed thread pool vs virtual threads
// Run: java ThroughputDemo.java

import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

public class ThroughputDemo {

    static void run(String label, ExecutorService executor) {
        long start = System.nanoTime();
        try (executor) {
            IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> {
                Thread.sleep(Duration.ofSeconds(1));
                return i;
            }));
        }
        // close() waits for every task before we take the time
        System.out.printf("%-28s %6d ms%n", label, (System.nanoTime() - start) / 1_000_000);
    }

    public static void main(String[] args) {
        run("newFixedThreadPool(200)", Executors.newFixedThreadPool(200));
        run("newVirtualThreadPerTask", Executors.newVirtualThreadPerTaskExecutor());
    }
}
newFixedThreadPool(200)       50306 ms
newVirtualThreadPerTask        1071 ms

Time to run 10,000 tasks that block for 1 s (ms)

xychart-beta
    x-axis ["Fixed pool (200 threads)", "Virtual threads"]
    y-axis "ms" 0 --> 55000
    bar [50306, 1071]

The fixed pool can only process 200 tasks at a time, so it waits 10,000 ÷ 200 = 50 times in a row. With virtual threads, all 10,000 tasks wait at once, and the run ends in about 1 s: roughly 47 times faster.

Measurement 4: memory per thread

Start 2,000 platform threads and 200,000 virtual threads, leave them waiting, and measure how much memory grows.

// JEP 444: Virtual Threads - memory per platform thread vs per virtual thread
// Run: java MemoryCostDemo.java

import com.sun.management.OperatingSystemMXBean;

import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;

public class MemoryCostDemo {

    static final int PLATFORM_COUNT = 2_000;
    static final int VIRTUAL_COUNT = 200_000;

    public static void main(String[] args) throws Exception {
        System.out.println("Java " + System.getProperty("java.version")
                + ", " + Runtime.getRuntime().availableProcessors() + " CPU cores");
        measure("Platform", PLATFORM_COUNT, Thread.ofPlatform());
        measure("Virtual ", VIRTUAL_COUNT, Thread.ofVirtual());
    }

    static void measure(String label, int count, Thread.Builder builder) throws Exception {
        gc();
        long heapBefore = usedHeap();
        long osBefore = committedVirtualMemory();

        CountDownLatch ready = new CountDownLatch(count);
        CountDownLatch release = new CountDownLatch(1);
        List<Thread> threads = new ArrayList<>(count);
        for (int i = 0; i < count; i++) {
            threads.add(builder.start(() -> {
                ready.countDown();
                try {
                    // Keep every thread alive and waiting
                    release.await();
                } catch (InterruptedException ignored) {
                }
            }));
        }
        ready.await();
        gc();

        long heapDelta = usedHeap() - heapBefore;
        long osDelta = committedVirtualMemory() - osBefore;
        System.out.printf("[%s] %,d threads: heap +%s (%s each), OS committed virtual memory +%s (%s each)%n",
                label, count, format(heapDelta), format(heapDelta / count),
                format(osDelta), format(osDelta / count));

        release.countDown();
        for (Thread t : threads) t.join();
    }

    static long usedHeap() {
        return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed();
    }

    static long committedVirtualMemory() {
        var os = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
        return os.getCommittedVirtualMemorySize();
    }

    static void gc() throws InterruptedException {
        for (int i = 0; i < 3; i++) {
            System.gc();
            Thread.sleep(300);
        }
    }

    static String format(long bytes) {
        if (Math.abs(bytes) < 1024) return bytes + " B";
        if (Math.abs(bytes) < 1024 * 1024) return String.format("%.2f KB", bytes / 1024.0);
        if (Math.abs(bytes) < 1024L * 1024 * 1024) return String.format("%.2f MB", bytes / 1024.0 / 1024);
        return String.format("%.2f GB", bytes / 1024.0 / 1024 / 1024);
    }
}
Java 21.0.12.1, 8 CPU cores
[Platform] 2,000 threads: heap +719.57 KB (368 B each), OS committed virtual memory +4.00 GB (2.05 MB each)
[Virtual ] 200,000 threads: heap +336.66 MB (1.72 KB each), OS committed virtual memory +17.88 MB (93 B each)
Platform threadsVirtual threads
Threads started2,000200,000
Per thread (OS committed virtual memory)about 2.05 MBabout 90–120 B
Per thread (Java heap)about 370 Babout 1.0–1.7 KB

The table shows the range over 7 runs. The heap usage of virtual threads varies from run to run (1.0–1.7 KB), because the measurement includes not only the Thread objects and the stacks of the waiting threads (stack chunks) but also objects the GC has not collected yet. The platform-thread figures barely changed.

A platform thread reserves a stack for its OS thread (around 1–2 MB by default on macOS and Linux). A virtual thread keeps only as much stack as it needs, on the heap, so it is orders of magnitude smaller.

"Committed virtual memory" is the amount of address space reserved from the OS, which is not the same as the physical memory actually used (RSS). Treat it as an indication of how much the process asks of the OS.

Small, however, is not unlimited. Let's limit the heap to 128 MB and keep creating virtual threads that sleep for 10 s.

// JEP 444: Virtual Threads - what happens if you keep creating them without a limit
// Run: java -Xmx128m VirtualThreadOomDemo.java

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

public class VirtualThreadOomDemo {

    static volatile boolean outOfMemory = false;
    // Spare memory so that the result can still be printed
    static byte[] reserve = new byte[8 * 1024 * 1024];

    public static void main(String[] args) {
        // The OOM may happen inside a virtual thread, so detect it with a handler
        Thread.Builder builder = Thread.ofVirtual().uncaughtExceptionHandler((t, e) -> {
            if (e instanceof OutOfMemoryError) outOfMemory = true;
        });
        Runnable sleep10s = () -> {
            try {
                Thread.sleep(Duration.ofSeconds(10));
            } catch (InterruptedException ignored) {
            }
        };

        List<Thread> hold = new ArrayList<>();
        long start = System.nanoTime();
        try {
            while (!outOfMemory) {
                hold.add(builder.start(sleep10s));
            }
        } catch (OutOfMemoryError e) {
            outOfMemory = true;
        }
        long elapsed = (System.nanoTime() - start) / 1_000_000;
        // Release the spare memory before printing
        reserve = null;
        System.out.printf("OutOfMemoryError after creating %,d threads (%,d ms)%n", hold.size(), elapsed);
    }
}
OutOfMemoryError after creating 173,940 threads (882 ms)

With every thread still alive (sleeping), the heap ran out after about 170,000 threads (170,000–200,000 over several runs). Virtual threads are cheap, but a design that puts no limit on the number of threads alive at the same time will eventually run out of memory.

Threads vs thread pools vs virtual threads

Thread (platform)Thread poolVirtual threads
Upper limit on threadsOS threads and memoryFixed by the pool sizePractically memory only
Creation costHighReduced by reuseVery low
Blocking I/OBlocks the OS threadCan exhaust the poolReleases the carrier while waiting
Good forA few long-running or CPU-bound tasksGeneral use (pool sizing required)Many I/O-bound tasks
Should you pool them?Usually pooled for reusePooling is the pointNo (create one per task)
Compatibility with existing APIs――Same Thread API

Things to watch out for

1. Don't pool them; limit concurrency with a Semaphore

Virtual threads are cheap to create, so the right way to use them is one per task, then throw it away. If you need to limit how many things run at once (say, connections to an external API), use a Semaphore rather than a thread pool.

private static final Semaphore PERMITS = new Semaphore(50);

String callExternalApi() throws InterruptedException {
    PERMITS.acquire();
    try {
        return client.call();
    } finally {
        PERMITS.release();
    }
}

2. Beware of pinning (JDK 21–23)

In JDK 21, a virtual thread that blocks inside a synchronized block cannot leave its carrier. This is called pinning. With a single carrier, let's run 2 tasks that sleep for 1 s while holding a lock.

// JEP 444: Virtual Threads - pinning caused by synchronized (JDK 21-23)
// Run: java -Djdk.tracePinnedThreads=short PinningDemo.java

import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;

public class PinningDemo {

    static void sleep1s() {
        try {
            Thread.sleep(Duration.ofSeconds(1));
        } catch (InterruptedException ignored) {
        }
    }

    // Run 2 tasks that block for 1 s while holding a lock (each task has its own lock, so there is no contention)
    static void run(String label, Runnable body) {
        long start = System.nanoTime();
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            executor.execute(body);
            executor.execute(body);
        }
        System.out.printf("%-14s %5d ms%n", label, (System.nanoTime() - start) / 1_000_000);
    }

    public static void main(String[] args) {
        // Use a single carrier thread to make the difference obvious
        System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");
        System.setProperty("jdk.virtualThreadScheduler.maxPoolSize", "1");

        run("synchronized", () -> {
            Object lock = new Object();
            synchronized (lock) {
                // JDK 21: the carrier is pinned as well
                sleep1s();
            }
        });

        run("ReentrantLock", () -> {
            ReentrantLock lock = new ReentrantLock();
            lock.lock();
            try {
                // Can unmount
                sleep1s();
            } finally {
                lock.unlock();
            }
        });
    }
}
VirtualThread[#23]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
    PinningDemo.lambda$main$0(PinningDemo.java:43) <== monitors:1
synchronized    2018 ms
ReentrantLock   1020 ms

1 carrier, 2 tasks that wait 1 s while holding a lock (drawn from PinningDemo, unit: s)

%%{init: {"gantt": {"leftPadding": 110}}}%%
gantt
    %% gantt:pinning
    dateFormat x
    axisFormat %S.%L
    tickInterval 500millisecond
    section synchronized
    task 1 :crit, 0, 1009
    task 2 :crit, 1009, 2018
    section ReentrantLock
    task 1 :active, 0, 1020
    task 2 :active, 0, 1020

(The split between the two tasks in the chart is drawn by halving the total time of the synchronized run.)

Each task has its own lock, so there is no lock contention at all. Yet in the synchronized version the only carrier gets pinned, the two tasks run one after the other, and the run takes 2 s. With -Djdk.tracePinnedThreads=short, the JVM prints where the pinning happened (reason:MONITOR).

On JDK 21–23, the standard fix is to replace locks around sections that do I/O with ReentrantLock.

Since then (added October 2026): JEP 491 in JDK 24 removed pinning by synchronized. From JDK 24 on, a virtual thread that blocks inside synchronized unmounts as usual (a few cases remain, such as while calling a native method).

3. Other points

  • CPU-bound work does not get faster: as Measurement 1 showed. For parallel computation, keep using ForkJoinPool or parallel streams.
  • Don't put heavy objects in ThreadLocal: virtual threads are meant to be created in large numbers, so the old pattern of caching something per thread eats memory. Scoped values, covered next, are the alternative.

Scoped Values (JEP 446, preview)

What's wrong with ThreadLocal

To share request-wide context such as a request ID or the logged-in user without passing it through every parameter, Java code has traditionally used ThreadLocal. But ThreadLocal has problems:

  • It can be changed at any time: it is hard to track down where a value changed
  • It lives too long: forget remove(), and in a thread pool the value leaks into the next request (which can also leak information)
  • Passing it to child threads is expensive: InheritableThreadLocal copies the values, which does not suit huge numbers of virtual threads

A ThreadLocal value lives as long as the thread; a ScopedValue value lives as long as the scope, a block of code.

flowchart TB
    subgraph TL["ThreadLocal"]
        direction LR
        T1["set(&quot;a&quot;)"] --> T2["set(&quot;b&quot;)<br/>can be overwritten anywhere"] --> T3["forget remove()<br/>and it leaks into the next task"]
    end
    subgraph SV["ScopedValue"]
        direction LR
        S1["where(SV, &quot;a&quot;).run(..."] --> S2["a is visible only inside"] --> S3["...) and it disappears<br/>on leaving"]
    end
    TL ~~~ SV
    style T2 fill:#ffcdd2,stroke:#c62828,color:#7f1d1d
    style T3 fill:#ffcdd2,stroke:#c62828,color:#7f1d1d
    style S3 fill:#c8e6c9,stroke:#2e7d32,color:#1b5e20

Using scoped values

A ScopedValue gets its range of validity, the scope, from the structure of the code. There is no set(); to change the value you open a nested scope. When the scope ends, the previous value is back, so where a value is valid is exactly the indentation of the code.

flowchart LR
    subgraph S1["where(SV, &quot;a&quot;).run(...)"]
        A1["SV.get() → a"]
        subgraph S2["where(SV, &quot;b&quot;).run(...)"]
            B1["SV.get() → b"]
        end
        A2["SV.get() → a (restored)"]
        A1 --> S2 --> A2
    end
    OUT["Outside the scope: isBound() = false"]
    S1 --> OUT

Let's change the value in the same way as with ThreadLocal and compare.

// JEP 446: Scoped Values (preview in Java 21)
// Run: java --enable-preview --source 21 ScopedValueDemo.java

import java.util.concurrent.StructuredTaskScope;

public class ScopedValueDemo {

    static final ThreadLocal<String> TL = new ThreadLocal<>();
    static final ScopedValue<String> SV = ScopedValue.newInstance();

    public static void main(String[] args) throws Exception {
        System.out.println("=== 1. ThreadLocal: a value you set stays from then on ===");
        TL.set("a");
        threadLocalFlow();
        TL.remove();

        System.out.println("=== 2. ScopedValue: the value exists only inside the scope ===");
        ScopedValue.where(SV, "a").run(ScopedValueDemo::scopedValueFlow);
        System.out.println("  outside the scope: isBound = " + SV.isBound());

        System.out.println("=== 3. The binding is immutable, but the object's contents are not ===");
        mutableObject();

        System.out.println("=== 4. Inherited automatically by StructuredTaskScope subtasks ===");
        inheritance();
    }

    static void threadLocalFlow() {
        System.out.println("  " + TL.get()); // a
        // If we overwrite it here...
        TL.set("b");
        System.out.println("  " + TL.get()); // b
        // ...callees keep seeing b too
        print();
    }

    static void print() {
        System.out.println("  " + TL.get());
    }

    static void scopedValueFlow() {
        System.out.println("  " + SV.get()); // a
        // b only inside the inner scope
        ScopedValue.where(SV, "b").run(() -> System.out.println("  " + SV.get()));
        // back to a after leaving it
        System.out.println("  " + SV.get());
    }

    // A deliberately mutable class
    static class Context {
        String name;
        Context(String name) { this.name = name; }
        public String toString() { return "Context{name=" + name + "}"; }
    }

    static final ScopedValue<Context> CTX = ScopedValue.newInstance();

    static void mutableObject() {
        ScopedValue.where(CTX, new Context("a")).run(() -> {
            System.out.println("  before: " + CTX.get());
            // Change the object's contents
            CTX.get().name = "b";
            System.out.println("  after : " + CTX.get() + "  <- visible to everyone in the same scope");
        });
    }

    static void inheritance() throws Exception {
        TL.set("REQ-123");
        ScopedValue.where(SV, "REQ-123").run(() -> {
            try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
                var a = scope.fork(() -> describe("fork-A"));
                var b = scope.fork(() -> describe("fork-B"));
                scope.join().throwIfFailed();
                System.out.println("  " + a.get());
                System.out.println("  " + b.get());
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        });
        TL.remove();
    }

    static String describe(String name) {
        return name + ": ScopedValue=" + SV.get() + ", ThreadLocal=" + TL.get();
    }
}
=== 1. ThreadLocal: a value you set stays from then on ===
  a
  b
  b
=== 2. ScopedValue: the value exists only inside the scope ===
  a
  b
  a
  outside the scope: isBound = false
=== 3. The binding is immutable, but the object's contents are not ===
  before: Context{name=a}
  after : Context{name=b}  <- visible to everyone in the same scope
=== 4. Inherited automatically by StructuredTaskScope subtasks ===
  fork-A: ScopedValue=REQ-123, ThreadLocal=null
  fork-B: ScopedValue=REQ-123, ThreadLocal=null

Comparing parts 1 and 2 makes the difference clear. With ThreadLocal, the value set by set("b") halfway through stays from then on, and callees see b as well. With ScopedValue, b exists only inside the inner scope, and the value goes back to a afterwards.

As part 3 shows, the binding is immutable, but the object's contents are not. If you bind a mutable object, changes to it are visible to everyone sharing the scope. As a rule, only immutable objects such as records should go into a ScopedValue.

Passing values to child threads

As part 4 shows, ScopedValue bindings are inherited automatically and without copying by the child threads that StructuredTaskScope.fork() creates (structured concurrency is covered next). ThreadLocal is not inherited (null).

Threads created on their own, for example with Thread.startVirtualThread, do not inherit bindings automatically. In that case, bind the value again in the child thread with ScopedValue.where(...).

Since then (added October 2026): final in JDK 25 (JEP 506). Before that, runWhere / callWhere were removed (JDK 24), leaving just the ScopedValue.where(key, value).run(...) / .call(...) style. Writing where(...).run(...) already in Java 21 makes migrating easy.

Structured Concurrency (JEP 453, preview)

Giving concurrency a structure

When you call a function, the callee always finishes before the caller. Thanks to this nesting, exceptions propagate to the caller, and a stack trace shows how the code got there.

Write concurrent code with an ExecutorService, and that structure is gone. Subtasks run independently of the caller, and if one fails, nobody stops the other: it keeps running. Structured concurrency groups several subtasks into a single unit of work, a scope, bringing the structure of function calls to concurrent code.

flowchart TB
    H["handle() (owner of the scope)"] --> U["fork: findUser()"]
    H --> O["fork: fetchOrder()"]
    U -. fails .-> H
    H -. cancels the rest automatically .-> O
    style H fill:#e3f2fd,stroke:#1565c0,color:#0d47a1
    style U fill:#ffcdd2,stroke:#c62828,color:#7f1d1d

This is the life of a scope:

sequenceDiagram
    participant Owner as Caller (owner of the scope)
    participant Scope as StructuredTaskScope
    participant A as Subtask A (virtual thread)
    participant B as Subtask B (virtual thread)
    Owner->>Scope: try (var scope = new ShutdownOnFailure())
    Owner->>Scope: fork(A)
    Scope->>A: Start in a new virtual thread
    Owner->>Scope: fork(B)
    Scope->>B: Start in a new virtual thread
    Owner->>Scope: join() (wait)
    A-->>Scope: Fails!
    Scope->>B: shutdown → interrupt
    Scope-->>Owner: Return from join()
    Owner->>Scope: throwIfFailed() → exception
    Owner->>Scope: close() (leaving try): all subtasks are guaranteed to have ended

Each fork() runs the subtask in a new virtual thread. Virtual threads are cheap, so one thread per subtask is fine; the design depends on virtual threads.

Compared with ExecutorService

Run findUser (fails after 200 ms) and fetchOrder (takes 3 s) concurrently, and compare the ExecutorService version with the StructuredTaskScope version. Part 4 also tries ShutdownOnSuccess, which asks several servers at once and uses only the first successful result.

// JEP 453: Structured Concurrency (preview in Java 21)
// Run: java --enable-preview --source 21 StructuredConcurrencyDemo.java

import java.util.concurrent.*;
import java.util.function.Supplier;

public class StructuredConcurrencyDemo {

    static long t0;

    static void log(String message) {
        System.out.printf("%5d ms  %s%n", (System.nanoTime() - t0) / 1_000_000, message);
    }

    record User(String id, String name) {}
    record Order(String userId, int count) {}

    static User findUser(String id) throws InterruptedException {
        Thread.sleep(200);
        if (id.equals("bad")) throw new IllegalStateException("user not found");
        return new User(id, "Alice");
    }

    static Order fetchOrder(String id) throws InterruptedException {
        try {
            Thread.sleep(3_000);
            log("fetchOrder: finished");
            return new Order(id, 3);
        } catch (InterruptedException e) {
            log("fetchOrder: interrupted");
            throw e;
        }
    }

    // ExecutorService + Future (cancelling is the caller's job)
    static String withFuture(ExecutorService pool, String id) throws Exception {
        Future<Order> order = pool.submit(() -> fetchOrder(id));
        Future<User> user = pool.submit(() -> findUser(id));
        // Even if user fails, order keeps running
        return user.get().name() + " / " + order.get().count() + " orders";
    }

    // StructuredTaskScope.ShutdownOnFailure
    static String withScope(String id) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Supplier<Order> order = scope.fork(() -> fetchOrder(id));
            Supplier<User> user = scope.fork(() -> findUser(id));
            // If any subtask fails, interrupt the rest and throw
            scope.join().throwIfFailed();
            return user.get().name() + " / " + order.get().count() + " orders";
        }
    }

    // StructuredTaskScope.ShutdownOnSuccess: use only the first successful result
    static String fastest() throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
            scope.fork(() -> { Thread.sleep(300); return "server A"; });
            scope.fork(() -> { Thread.sleep(100); return "server B"; });
            scope.fork(() -> { Thread.sleep(200); throw new IllegalStateException("server C is down"); });
            scope.join();
            return scope.result();
        }
    }

    public static void main(String[] args) throws Exception {
        System.out.println("=== 1. Success ===");
        t0 = System.nanoTime();
        log("StructuredTaskScope: " + withScope("u1"));

        System.out.println("=== 2. findUser fails: ExecutorService + Future ===");
        t0 = System.nanoTime();
        try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
            try {
                withFuture(pool, "bad");
            } catch (ExecutionException e) {
                log("caller: failure detected -> " + e.getCause().getMessage());
            }
        }
        // close() waits for the remaining tasks
        log("pool closed");

        System.out.println("=== 3. findUser fails: StructuredTaskScope ===");
        t0 = System.nanoTime();
        try {
            withScope("bad");
        } catch (ExecutionException e) {
            log("caller: failure detected -> " + e.getCause().getMessage());
        }
        log("scope closed");

        System.out.println("=== 4. Use the first success ===");
        t0 = System.nanoTime();
        log("result: " + fastest());
    }
}
=== 1. Success ===
 3011 ms  fetchOrder: finished
 3032 ms  StructuredTaskScope: Alice / 3 orders
=== 2. findUser fails: ExecutorService + Future ===
  207 ms  caller: failure detected -> user not found
 3006 ms  fetchOrder: finished
 3008 ms  pool closed
=== 3. findUser fails: StructuredTaskScope ===
  205 ms  fetchOrder: interrupted
  206 ms  caller: failure detected -> user not found
  206 ms  scope closed
=== 4. Use the first success ===
  107 ms  result: server B

The difference is clearest when something fails (parts 2 and 3).

When findUser fails (measured, unit: s)

%%{init: {"gantt": {"leftPadding": 110}}}%%
gantt
    %% gantt:cancel
    dateFormat x
    axisFormat %S.%L
    tickInterval 500millisecond
    section ExecutorService
    findUser (fails) :crit, 0, 207
    fetchOrder (keeps running) :active, 0, 3006
    section Structured
    findUser (fails) :crit, 0, 206
    fetchOrder (interrupted) :done, 0, 205

In the ExecutorService version, fetchOrder kept running for 3 s for a result nobody would use, even after the caller had seen the failure. In a real system it would hold on to resources such as a database connection the whole time. A call to Future.cancel() would stop it, but nobody notices when it is missing.

In the StructuredTaskScope version, the remaining subtask is interrupted automatically the moment the other one fails, and the scope ends after 206 ms.

In part 4, ShutdownOnSuccess ignored the broken server C, returned the result of server B, the first to succeed (100 ms), and cancelled the slower server A automatically. Doing the same with an ExecutorService means taking the first completion from an ExecutorCompletionService and calling cancel() on the rest by hand.

ExecutorService + FutureStructuredTaskScope
StyleImperativeImperative (try-with-resources)
Cancellation on failureManual (easy to forget)Automatic
Thread leaksPossibleImpossible by construction
Lifetime of subtasksUnrelated to the callerConfined to the scope
Thread dumpsNo parent–child relationshipParent–child relationship visible

Since then: a redesigned API

Since then (added October 2026): the structured concurrency API keeps being revised and is still in preview in JDK 27 (seventh preview, JEP 533). Since JDK 25, instead of the ShutdownOnFailure / ShutdownOnSuccess subclasses, you combine StructuredTaskScope.open() with a "Joiner" (a completion policy).

// Preview API of JDK 26 and later (does not run on JDK 21; method names as of JDK 26)
try (var scope = StructuredTaskScope.open()) {        // default policy: fail as soon as any subtask fails
    Subtask<User> user = scope.fork(() -> findUser(id));
    Subtask<Order> order = scope.fork(() -> fetchOrder(id));
    scope.join();                                     // throws if a subtask failed
    return new Response(user.get(), order.get());
}

// "The first one to succeed" is chosen with a Joiner
try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulOrThrow())) {
    scope.fork(() -> callServerA());
    scope.fork(() -> callServerB());
    return scope.join();
}

During the preview, Joiner method names and exception types have changed from release to release. For production code, waiting for the final version is the safe choice. The idea itself has not changed: fork inside a scope, wait with join, and when you leave the scope everything has ended.

Runtime and APIs

Generational ZGC (JEP 439)

ZGC is a low-latency garbage collector that keeps pauses under 1 ms. Java 21 adds generations (young and old) to it.

Most objects become garbage soon after they are created (the generational hypothesis). So new objects go into the young generation, which is collected frequently on its own (minor collections). Objects that survive move to the old generation, which is collected occasionally (major collections). Not having to scan the whole heap every time makes better use of CPU and memory.

flowchart LR
    NEW["Newly created objects"] --> Y["Young generation<br/>collected often: minor"]
    Y -- "most die here" --> GONE["Freed"]
    Y -- "survivors" --> O["Old generation<br/>collected occasionally: major"]
    style Y fill:#c8e6c9,stroke:#2e7d32,color:#1b5e20
    style O fill:#ffe0b2,stroke:#e65100,color:#7c2d12

Let's compare the generational and non-generational modes with a program that creates huge numbers of short-lived objects.

// JEP 439: Generational ZGC
// Run (generational)    : java -XX:+UseZGC -XX:+ZGenerational -Xmx1g GenerationalZgcDemo.java
// Run (non-generational): java -XX:+UseZGC -Xmx1g GenerationalZgcDemo.java

import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;

public class GenerationalZgcDemo {

    public static void main(String[] args) {
        // Keep only the latest 50,000 objects (short-lived ones that soon become garbage)
        byte[][] recent = new byte[50_000][];
        // A small fraction lives until the end
        List<byte[]> longLived = new ArrayList<>();
        long start = System.nanoTime();

        for (int i = 0; i < 30_000_000; i++) {
            recent[i % recent.length] = new byte[1024];
            if (i % 500 == 0) longLived.add(new byte[1024]);
        }

        System.out.printf("allocated ~%d GB, long-lived %,d, elapsed %,d ms%n",
                30_000_000L * 1024 / 1024 / 1024 / 1024, longLived.size(), (System.nanoTime() - start) / 1_000_000);
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.printf("  %-20s count %4d, total %,6d ms%n", gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
        }
    }
}
java -XX:+UseZGC -XX:+ZGenerational -Xmx1g GenerationalZgcDemo.java
allocated ~28 GB, long-lived 60,000, elapsed 6,156 ms
  ZGC Minor Cycles     count   69, total  1,350 ms
  ZGC Minor Pauses     count  210, total      1 ms
  ZGC Major Cycles     count    9, total    587 ms
  ZGC Major Pauses     count   42, total      0 ms
java -XX:+UseZGC -Xmx1g GenerationalZgcDemo.java
allocated ~28 GB, long-lived 60,000, elapsed 7,203 ms
  ZGC Cycles           count   68, total  2,236 ms
  ZGC Pauses           count  204, total      2 ms

In generational mode, most collections (69) were minor collections of the young generation alone, and only 9 were major collections of the whole heap. Pauses stayed well under 1 ms each: 210 pauses totalling 1 ms in generational mode, 204 totalling 2 ms in non-generational mode. Over several runs, generational mode tended to finish sooner (about 6.2 s versus 7.2 s this time, though the numbers vary a lot between runs).

Since then (added October 2026): generational mode became the default in JDK 23 (JEP 474), and non-generational mode was removed in JDK 24 (JEP 490). Today, -XX:+UseZGC alone gives you generational ZGC.

Foreign Function & Memory API (JEP 442, third preview)

An API for calling functions in native libraries and working with off-heap memory safely, in Java code alone, without JNI. There is no C glue code to write as with JNI.

flowchart LR
    subgraph JAVA["Java"]
        MH["MethodHandle<br/>strlen"]
        AR["Arena<br/>(manages memory lifetime)"]
    end
    L["Linker<br/>(converts calling conventions)"]
    subgraph NATIVE["Native"]
        LIB["strlen() in libc"]
        MEM["Off-heap memory<br/>MemorySegment"]
    end
    MH --> L --> LIB
    AR -- "allocate / free" --> MEM
    LIB -. "reads" .-> MEM
// JEP 442: Foreign Function & Memory API (third preview in Java 21)
// Run: java --enable-preview --source 21 --enable-native-access=ALL-UNNAMED ForeignFunctionDemo.java

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;

public class ForeignFunctionDemo {

    public static void main(String[] args) throws Throwable {
        Linker linker = Linker.nativeLinker();

        // Look up size_t strlen(const char *s) in the C library and turn it into a Java method handle
        MethodHandle strlen = linker.downcallHandle(
                linker.defaultLookup().find("strlen").orElseThrow(),
                FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));

        // The memory is freed when the scope ends
        try (Arena arena = Arena.ofConfined()) {
            // Allocate a C string off-heap
            MemorySegment str = arena.allocateUtf8String("Hello, FFM!");
            long len = (long) strlen.invokeExact(str);
            System.out.println("strlen(\"Hello, FFM!\") = " + len);

            // Write and read an int array in off-heap memory
            MemorySegment ints = arena.allocateArray(ValueLayout.JAVA_INT, 5);
            for (int i = 0; i < 5; i++) ints.setAtIndex(ValueLayout.JAVA_INT, i, i * i);
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < 5; i++) sb.append(ints.getAtIndex(ValueLayout.JAVA_INT, i)).append(' ');
            System.out.println("off-heap int[] = " + sb.toString().trim());
        }
    }
}
strlen("Hello, FFM!") = 11
off-heap int[] = 0 1 4 9 16

With an Arena in try-with-resources, the off-heap memory is reliably freed when the scope ends. Accessing memory that has been freed throws an exception, so there are no dangling pointers as in C.

Since then (added October 2026): final in JDK 22 (JEP 454). Some method names changed in the final version: allocateUtf8String became allocateFrom, and allocateArray became allocate. Native calls are "restricted methods", so add --enable-native-access to avoid the warning.

Vector API (JEP 448, sixth incubator)

An API for writing vector computations that use the CPU's SIMD instructions explicitly from Java. One instruction computes several elements (lanes) at once.

// JEP 448: Vector API (sixth incubator in Java 21)
// Run: java --add-modules jdk.incubator.vector VectorApiDemo.java

import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorSpecies;

public class VectorApiDemo {

    static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;

    // Compute c[i] = a[i] * a[i] + b[i] in bulk with SIMD
    static void vectorized(float[] a, float[] b, float[] c) {
        int i = 0;
        int upper = SPECIES.loopBound(a.length);
        for (; i < upper; i += SPECIES.length()) {
            var va = FloatVector.fromArray(SPECIES, a, i);
            var vb = FloatVector.fromArray(SPECIES, b, i);
            va.mul(va).add(vb).intoArray(c, i);
        }
        // Handle the remaining elements one by one (scalar)
        for (; i < a.length; i++) {
            c[i] = a[i] * a[i] + b[i];
        }
    }

    public static void main(String[] args) {
        float[] a = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
        float[] b = {10, 10, 10, 10, 10, 10, 10, 10, 10, 10};
        float[] c = new float[a.length];
        vectorized(a, b, c);
        System.out.println("lanes per operation = " + SPECIES.length());
        System.out.println(java.util.Arrays.toString(c));
    }
}
WARNING: Using incubator modules: jdk.incubator.vector
lanes per operation = 4
[11.0, 14.0, 19.0, 26.0, 35.0, 46.0, 59.0, 74.0, 91.0, 110.0]

On this machine (Apple Silicon), four floats are computed at a time. The number of lanes depends on the SIMD instructions the CPU supports (16 with AVX-512).

Since then (added October 2026): because it is waiting for Project Valhalla (value objects), it is still incubating in JDK 27 (twelfth incubator).

Key Encapsulation Mechanism API (JEP 452)

A standard API for using public-key cryptography to safely share the same key (a shared key) with the other party. It is also the foundation for bringing post-quantum cryptography, such as ML-KEM, to Java.

sequenceDiagram
    participant R as Receiver
    participant S as Sender
    R->>R: Generate a key pair (public, private)
    R->>S: Send the public key
    S->>S: encapsulate(): create the shared key and an encapsulation
    S->>R: Send only the encapsulation
    R->>R: decapsulate(encapsulation): recover the same shared key with the private key
    Note over R,S: From here on, communicate encrypted with the shared key (e.g. AES)
// JEP 452: Key Encapsulation Mechanism API
// Run: java KemDemo.java

import javax.crypto.KEM;
import javax.crypto.SecretKey;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.HexFormat;

public class KemDemo {

    public static void main(String[] args) throws Exception {
        // Receiver: create a key pair and hand the public key to the sender
        KeyPair receiverKeys = KeyPairGenerator.getInstance("X25519").generateKeyPair();

        // Sender: from the public key, create the shared key and the encapsulation (the encrypted key material)
        KEM kem = KEM.getInstance("DHKEM");
        KEM.Encapsulated enc = kem.newEncapsulator(receiverKeys.getPublic()).encapsulate();
        SecretKey senderKey = enc.key();
        // Only the encapsulation is sent to the receiver
        byte[] capsule = enc.encapsulation();

        // Receiver: open the encapsulation with the private key and recover the same shared key
        SecretKey receiverKey = kem.newDecapsulator(receiverKeys.getPrivate()).decapsulate(capsule);

        HexFormat hex = HexFormat.of();
        System.out.println("encapsulation (" + capsule.length + " bytes): " + hex.formatHex(capsule).substring(0, 32) + "...");
        System.out.println("sender's shared key  : " + hex.formatHex(senderKey.getEncoded()));
        System.out.println("receiver's shared key: " + hex.formatHex(receiverKey.getEncoded()));
        System.out.println("equal: " + java.util.Arrays.equals(senderKey.getEncoded(), receiverKey.getEncoded()));
    }
}
encapsulation (32 bytes): a114f4e68a0dd6fa1b062c9d8ae291eb...
sender's shared key  : 52a6bb4ad3e477af9c59f5f37b7fdee934fe5f26dec2abc0da8749c347854389
receiver's shared key: 52a6bb4ad3e477af9c59f5f37b7fdee934fe5f26dec2abc0da8749c347854389
equal: true

The sender and the receiver end up with the same key, without the shared key itself ever crossing the network.

Deprecate the Windows 32-bit x86 Port (JEP 449)

Builds for 32-bit Windows were deprecated, to be removed later. Java 21 can still be built for it, with a warning.

Since then (added October 2026): removed in JDK 24 (JEP 479), and JDK 25 removed the 32-bit x86 port altogether (JEP 503).

Prepare to Disallow the Dynamic Loading of Agents (JEP 451)

Attaching an agent (a tool that rewrites bytecode) to a running JVM now prints a warning. The plan is to disallow it by default in the future. If a library such as Mockito or a profiler triggers the warning, specify the agent at startup with -javaagent, or allow it explicitly with -XX:+EnableDynamicAgentLoading.

Summary: what to use in a Java 21 project

Finally, here is how the features apply to existing projects.

CategoryFeaturesRecommendation
Use nowRecord Patterns, Pattern Matching for switch, Sequenced CollectionsFinal features; use them freely in new code
Big payoff, verify firstVirtual threadsBig gains for I/O-heavy server code. On JDK 21–23 watch out for pinning, and load-test before adopting
Pays off in operationsGenerational ZGCWorth evaluating if your latency requirements are strict
Wait and seeScoped Values, Structured Concurrency, Unnamed and othersPreview in Java 21. Learn the ideas now; use them in production once they are final
Don't useString TemplatesWithdrawn

Virtual threads in particular can be turned on in Spring Boot (3.2 or later) with a single setting, spring.threads.virtual.enabled=true. Their biggest attraction is that existing synchronous code scales almost unchanged. On the other hand, they only help where waiting on I/O dominates, and they bring new concerns such as pinning and limiting concurrency. Rather than "flip the switch and be done", adopt them while measuring.

References

© 2020 - 2026 issyuu @Lakeheart Retreat
Powered by theme astro-koharu · Inspired by Shoka
Loading...