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
| JEP | Feature | Status in Java 21 | Since then (up to JDK 27, added October 2026) |
|---|---|---|---|
| 430 | String Templates | Preview | Withdrawn in JDK 23 |
| 431 | Sequenced Collections | Final | ― |
| 439 | Generational ZGC | Final | Generational by default in JDK 23; non-generational mode removed in JDK 24 |
| 440 | Record Patterns | Final | ― |
| 441 | Pattern Matching for switch | Final | Primitive type patterns in preview |
| 442 | Foreign Function & Memory API | Third preview | Final in JDK 22 |
| 443 | Unnamed Patterns and Variables | Preview | Final in JDK 22 |
| 444 | Virtual Threads | Final | Pinning by synchronized fixed in JDK 24 |
| 445 | Unnamed Classes and Instance Main Methods | Preview | Renamed and final in JDK 25 |
| 446 | Scoped Values | Preview | Final in JDK 25 |
| 448 | Vector API | Sixth incubator | Still incubating in JDK 27 (twelfth) |
| 449 | Deprecate the Windows 32-bit x86 Port | Deprecated | Removed in JDK 24 |
| 451 | Prepare to Disallow the Dynamic Loading of Agents | Prints a warning | ― |
| 452 | Key Encapsulation Mechanism API | Final | ― |
| 453 | Structured Concurrency | Preview | Redesigned 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") = unknownNote 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.0000Since 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.
| Type | First | Last | Reverse |
|---|---|---|---|
List | list.get(0) | list.get(list.size() - 1) | Collections.reverse(list) (modifies the list itself) |
Deque | getFirst() | getLast() | descendingIterator() |
LinkedHashSet | iterator().next() | loop to the end | copy into a List, then reverse |
LinkedHashMap | entrySet().iterator().next() | loop to the end | copy, 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
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 = trueThe 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 donePlotting 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 ○○ msline 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 theirstartline
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-2and, after its I/O, runs onworker-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 end5 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 msTime 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 threads | Virtual threads | |
|---|---|---|
| Threads started | 2,000 | 200,000 |
| Per thread (OS committed virtual memory) | about 2.05 MB | about 90–120 B |
| Per thread (Java heap) | about 370 B | about 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 pool | Virtual threads | |
|---|---|---|---|
| Upper limit on threads | OS threads and memory | Fixed by the pool size | Practically memory only |
| Creation cost | High | Reduced by reuse | Very low |
| Blocking I/O | Blocks the OS thread | Can exhaust the pool | Releases the carrier while waiting |
| Good for | A few long-running or CPU-bound tasks | General use (pool sizing required) | Many I/O-bound tasks |
| Should you pool them? | Usually pooled for reuse | Pooling is the point | No (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 ms1 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
ForkJoinPoolor 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:
InheritableThreadLocalcopies 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("a")"] --> T2["set("b")<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, "a").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, "a").run(...)"]
A1["SV.get() → a"]
subgraph S2["where(SV, "b").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=nullComparing 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 BThe 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 + Future | StructuredTaskScope | |
|---|---|---|
| Style | Imperative | Imperative (try-with-resources) |
| Cancellation on failure | Manual (easy to forget) | Automatic |
| Thread leaks | Possible | Impossible by construction |
| Lifetime of subtasks | Unrelated to the caller | Confined to the scope |
| Thread dumps | No parent–child relationship | Parent–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 msIn 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 16With 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: trueThe 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.
| Category | Features | Recommendation |
|---|---|---|
| Use now | Record Patterns, Pattern Matching for switch, Sequenced Collections | Final features; use them freely in new code |
| Big payoff, verify first | Virtual threads | Big gains for I/O-heavy server code. On JDK 21–23 watch out for pinning, and load-test before adopting |
| Pays off in operations | Generational ZGC | Worth evaluating if your latency requirements are strict |
| Wait and see | Scoped Values, Structured Concurrency, Unnamed and others | Preview in Java 21. Learn the ideas now; use them in production once they are final |
| Don't use | String Templates | Withdrawn |
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
- JDK 21 project page
- JEP 444: Virtual Threads
- JEP 446: Scoped Values (Preview) / JEP 506: Scoped Values
- JEP 453: Structured Concurrency (Preview) / JEP 533: Structured Concurrency (Seventh Preview)
- JEP 431: Sequenced Collections
- JEP 440: Record Patterns / JEP 441: Pattern Matching for switch
- JEP 439: Generational ZGC
- JEP 442: Foreign Function & Memory API (Third Preview)
- JEP 452: Key Encapsulation Mechanism API
- JEP 491: Synchronize Virtual Threads without Pinning
- JEP 512: Compact Source Files and Instance Main Methods
- JDK 23 Release Notes (withdrawal of String Templates)