Java 21 Interview Questions (Free Preview)
Free sample of 15 from 53 questions available
Scoped Values (Preview)
What is the performance advantage of scoped values?
The 30-Second Answer: Scoped values offer faster read performance (constant-time access), lower memory overhead, and automatic cleanup compared to ThreadLocal. They're optimized for immutability and can be up to 30% faster for reads while using significantly less memory, especially in applications with many virtual threads.
The 2-Minute Answer (If They Want More):
The performance advantages of scoped values come from several design decisions that optimize for the common case: many reads, few writes, and bounded lifetimes. The JVM can apply aggressive optimizations because of the immutability guarantee.
Performance Characteristics:
Read Performance: Scoped values use a cache-friendly design that allows the JVM to optimize reads to near-constant access. In many cases, the JVM can inline the access completely. Benchmarks show reads can be 20-30% faster than ThreadLocal.
Memory Efficiency: ThreadLocal maintains a separate HashMap-like structure per thread, which scales poorly with virtual threads. With one million virtual threads and 10 ThreadLocal variables, you could have 10 million storage slots. Scoped values share bindings immutably, using memory proportional to the nesting depth, not thread count.
No Cleanup Overhead: ThreadLocal requires explicit
remove()calls, and forgetting them causes memory leaks. Scoped values are automatically cleaned up when the scope exits, eliminating both the cognitive burden and runtime overhead.Write Performance: While binding a scoped value is slightly more expensive than setting a ThreadLocal, this happens far less frequently than reads, making it a good trade-off.
Here's a performance comparison example:
import java.util.concurrent.*;
public class PerformanceBenchmark {
private static final ThreadLocal<String> THREAD_LOCAL =
ThreadLocal.withInitial(() -> "default");
private static final ScopedValue<String> SCOPED_VALUE =
ScopedValue.newInstance();
// ThreadLocal approach - slower reads, manual cleanup
public static void threadLocalApproach() {
try {
THREAD_LOCAL.set("value");
// Read 1000 times
for (int i = 0; i < 1000; i++) {
String val = THREAD_LOCAL.get(); // HashMap lookup
process(val);
}
} finally {
THREAD_LOCAL.remove(); // Required cleanup
}
}
// ScopedValue approach - faster reads, auto cleanup
public static void scopedValueApproach() {
ScopedValue.where(SCOPED_VALUE, "value").run(() -> {
// Read 1000 times
for (int i = 0; i < 1000; i++) {
String val = SCOPED_VALUE.get(); // Optimized access
process(val);
}
}); // Automatic cleanup
}
// Benchmark with virtual threads
public static void benchmarkWithVirtualThreads() throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Bad: ThreadLocal with 100k virtual threads
// Uses ~100k storage slots
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> threadLocalApproach());
}
// Good: ScopedValue with 100k virtual threads
// Shares single binding efficiently
ScopedValue.where(SCOPED_VALUE, "shared").run(() -> {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
String val = SCOPED_VALUE.get();
process(val);
});
}
});
}
}
static void process(String val) { /* processing */ }
}
Memory Comparison:
// Memory usage with 1 million virtual threads:
// ThreadLocal:
// - 1M threads Ă— 1 ThreadLocal = 1M storage slots
// - Each slot: ~48 bytes = ~48 MB minimum
// - With 10 ThreadLocals: ~480 MB
// ScopedValue:
// - Shared immutable binding
// - Memory: ~few KB regardless of thread count
// - Scales with nesting depth, not thread count
Optimization Example:
The JVM can optimize scoped value reads aggressively:
public class OptimizationExample {
private static final ScopedValue<Config> CONFIG =
ScopedValue.newInstance();
public void processLoop() {
ScopedValue.where(CONFIG, new Config()).run(() -> {
// JVM can hoist this out of the loop
Config cfg = CONFIG.get();
for (int i = 0; i < 1_000_000; i++) {
// No repeated lookups needed
if (cfg.isEnabled()) {
doWork(i);
}
}
});
}
}
The combination of immutability, bounded lifetime, and optimized implementation makes scoped values the clear choice for sharing context in modern Java applications, especially those using virtual threads.
References:
- https://openjdk.org/jeps/446
- https://www.infoq.com/news/2023/06/java-scoped-values/
- https://inside.java/2023/05/26/sip070/
Java 21 LTS Overview
What is the difference between Java 21 and Java 17 LTS?
The 30-Second Answer: Java 21 adds Virtual Threads for massively scalable concurrency, finalizes Pattern Matching for switch and Record Patterns, introduces Sequenced Collections for consistent ordering APIs, and includes preview features like String Templates. Java 17 focused on sealed classes, pattern matching basics, and removing deprecated features. Java 21 represents 2.5 years of evolution with 4 intermediate releases (18-20).
The 2-Minute Answer (If They Want More):
The gap between Java 17 LTS (September 2021) and Java 21 LTS (September 2023) spans 2.5 years and includes innovations from Java 18, 19, and 20. The most transformative difference is Virtual Threads, which fundamentally changes Java's concurrency model. While Java 17 requires careful thread pool management and async programming patterns, Java 21 allows simple, synchronous-looking code that scales to millions of concurrent operations.
Pattern Matching evolution is another major differentiator. Java 17 introduced basic pattern matching for instanceof (JEP 394), but Java 21 takes this much further with Record Patterns (JEP 440) and Pattern Matching for switch (JEP 441), enabling destructuring and complex pattern combinations. This makes data-oriented programming significantly more concise and type-safe.
// Java 17 - Basic pattern matching
if (obj instanceof String s) {
System.out.println(s.toUpperCase());
}
// Java 21 - Advanced pattern matching with switch and records
record Employee(String name, int age, Department dept) {}
record Department(String name, String location) {}
// Java 21 enables nested destructuring
String getLocation(Object obj) {
return switch (obj) {
case Employee(var name, var age, Department(var dName, var loc))
when age > 50 -> "Senior at " + loc;
case Employee(var name, var age, Department(var dName, var loc))
-> "Works at " + loc;
case null -> "No location";
default -> "Unknown";
};
}
Sequenced Collections (JEP 431) in Java 21 addresses API inconsistencies that existed in Java 17 and earlier. Previously, getting the first or last element, or reversing a collection, required different approaches for List, Deque, and SortedSet. Java 21 unifies these operations through new interfaces.
// Java 17 - Inconsistent APIs
List<String> list = List.of("a", "b", "c");
String first = list.get(0);
String last = list.get(list.size() - 1);
Deque<String> deque = new ArrayDeque<>(list);
String dequeFirst = deque.getFirst();
String dequeLast = deque.getLast();
// Java 21 - Unified Sequenced Collections API
SequencedCollection<String> seq = List.of("a", "b", "c");
String first = seq.getFirst();
String last = seq.getLast();
SequencedCollection<String> reversed = seq.reversed();
Performance and tooling improvements in Java 21 include generational ZGC (JEP 439) for better garbage collection, the Vector API for SIMD operations, and enhanced foreign function & memory API (JEP 442). Java 17 had earlier versions of these features but Java 21 brings them to production-ready maturity. The removal of deprecated features continues, with Java 21 preparing to remove Applet API (deprecated since Java 9) and finalizing the removal of legacy features.
graph LR
A[Java 17 LTS] -->|2.5 years evolution| B[Java 21 LTS]
A --> A1[Basic Pattern Matching]
A --> A2[Sealed Classes]
A --> A3[Platform Threads]
B --> B1[Advanced Pattern Matching]
B --> B2[Record Patterns]
B --> B3[Virtual Threads]
B --> B4[Sequenced Collections]
B --> B5[String Templates Preview]
style A fill:#e1f5ff
style B fill:#c3f0c3
References:
- https://blogs.oracle.com/java/post/the-arrival-of-java-21
- https://openjdk.org/projects/jdk/21/jeps-since-jdk-17
- https://www.happycoders.eu/java/java-21-features/
Sequenced Collections
What is the reversed() method and how does it work?
The 30-Second Answer:
The reversed() method returns a reverse-ordered view of a sequenced collection, where the first element becomes the last and vice versa. Critically, it returns a live view, not a copy—any modifications to the reversed view are reflected in the original collection and vice versa, and iteration over the reversed view occurs in reverse encounter order.
The 2-Minute Answer (If They Want More):
The reversed() method is one of the most powerful features of sequenced collections. It provides a bidirectional view of any sequenced collection without creating a new copy, making it both memory-efficient and performant. When you call reversed() on a collection, you get a SequencedCollection that presents the elements in the opposite order of the original.
The key characteristic of reversed() is that it returns a view, not a snapshot. This means that:
- Changes made through the reversed view affect the original collection
- Changes made to the original collection are visible through the reversed view
- No additional memory is allocated for the elements themselves
- The reversed view's
reversed()method returns the original collection
This bidirectional relationship is maintained through the entire sequenced collection hierarchy. For example, calling getFirst() on a reversed view returns the same element as calling getLast() on the original collection. Similarly, addFirst() on the reversed view is equivalent to addLast() on the original.
The reversed view is particularly useful for iteration. You can iterate over a collection in reverse order simply by calling reversed() and then iterating normally. This is more efficient and cleaner than manually iterating backward or creating a reversed copy.
For immutable collections or specific implementations like TreeSet (which are sorted), the reversed view maintains the immutability or sorted characteristics. You cannot modify an immutable collection through its reversed view, and a reversed TreeSet view still maintains proper tree ordering.
Example usage:
// Basic reversed view
List<String> original = new ArrayList<>(List.of("A", "B", "C", "D"));
SequencedCollection<String> reversed = original.reversed();
System.out.println(reversed.getFirst()); // "D"
System.out.println(reversed.getLast()); // "A"
// Modifications through reversed view affect original
reversed.addFirst("E"); // Adds to end of original
System.out.println(original); // [A, B, C, D, E]
System.out.println(original.getLast()); // "E"
// Modifications to original affect reversed view
original.addLast("F");
System.out.println(reversed.getFirst()); // "F"
// Reverse iteration
List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4, 5));
for (Integer num : numbers.reversed()) {
System.out.print(num + " "); // Prints: 5 4 3 2 1
}
// Double reversal returns original
SequencedCollection<String> doubleReversed = reversed.reversed();
System.out.println(doubleReversed == original); // true
// With LinkedHashSet
SequencedSet<String> set = new LinkedHashSet<>(List.of("X", "Y", "Z"));
SequencedSet<String> reversedSet = set.reversed();
reversedSet.removeFirst(); // Removes "Z" from original set
System.out.println(set); // [X, Y]
// With SequencedMap
SequencedMap<Integer, String> map = new LinkedHashMap<>();
map.put(1, "one");
map.put(2, "two");
map.put(3, "three");
SequencedMap<Integer, String> reversedMap = map.reversed();
reversedMap.forEach((k, v) -> System.out.println(k + "=" + v));
// Prints: 3=three, 2=two, 1=one
References:
- https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/SequencedCollection.html
- https://openjdk.org/jeps/431
- https://www.baeldung.com/java-sequenced-collections
What is the SequencedCollection interface?
The 30-Second Answer:
SequencedCollection<E> is a new interface in Java 21 that extends Collection<E> and represents collections with a defined encounter order. It provides methods to access, add, and remove elements at both ends (getFirst(), getLast(), addFirst(), addLast(), removeFirst(), removeLast()) and to obtain a reversed view of the collection with the reversed() method.
The 2-Minute Answer (If They Want More):
The SequencedCollection interface is the cornerstone of the sequenced collections feature in Java 21. It establishes a common API for all collections that have a well-defined encounter order, which includes Lists, Deques, and LinkedHashSets. This interface sits in the collection hierarchy between Collection and the more specific collection types.
The interface defines eight key methods:
getFirst()andgetLast()- retrieve the first and last elements without removaladdFirst(E)andaddLast(E)- add elements at the beginning or endremoveFirst()andremoveLast()- remove and return elements from either endreversed()- returns a reversed-order view of the collection
One important characteristic is that reversed() returns a view, not a copy. Any modifications made to the reversed view are reflected in the original collection and vice versa. This is memory-efficient and maintains consistency.
All methods that would modify the collection (add/remove operations) throw UnsupportedOperationException if the collection is immutable or doesn't support the operation. For example, calling addFirst() on an unmodifiable list will throw an exception.
public interface SequencedCollection<E> extends Collection<E> {
// Access operations
E getFirst();
E getLast();
// Modification operations
void addFirst(E e);
void addLast(E e);
E removeFirst();
E removeLast();
// View operation
SequencedCollection<E> reversed();
}
Example usage:
SequencedCollection<String> collection = new ArrayList<>();
collection.addFirst("first");
collection.addLast("last");
collection.addLast("end");
System.out.println(collection.getFirst()); // "first"
System.out.println(collection.getLast()); // "end"
// Working with reversed view
SequencedCollection<String> reversed = collection.reversed();
System.out.println(reversed.getFirst()); // "end"
reversed.addFirst("new"); // Adds to end of original
System.out.println(collection.getLast()); // "new"
References:
- https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/SequencedCollection.html
- https://openjdk.org/jeps/431
- https://inside.java/2023/05/12/quality-heads-up/
String Templates (Preview)
What is the difference between string templates and string concatenation?
The 30-Second Answer: String concatenation creates a new string by joining pieces together as opaque text, losing information about what's literal vs. computed. String templates maintain this distinction by keeping literal fragments separate from embedded expressions, allowing template processors to validate, transform, and safely compose the result with full knowledge of the template's structure.
The 2-Minute Answer (If They Want More):
The fundamental difference between string templates and concatenation lies in how they represent and process string composition. String concatenation, whether using the + operator, StringBuilder, or String.format(), produces an opaque string where the original structure and intent are lost. Once concatenated, you can't tell which parts were literals and which were computed values.
// String concatenation - loses structure
String name = "Alice";
int count = 5;
String message1 = "User " + name + " has " + count + " items";
// Result is just a String - no memory of how it was constructed
// String template - preserves structure during processing
String message2 = STR."User \{name} has \{count} items";
// The template processor receives:
// - Fragments: ["User ", " has ", " items"]
// - Values: [name, count]
// And then composes them
This structural difference enables several advantages:
1. Readability and Intent: Templates make the output structure immediately visible. With concatenation, you have to mentally parse the concatenation operators and quote boundaries. With templates, what you see is close to what you get.
2. Performance: The compiler can optimize string templates differently than concatenation. Templates provide clear boundaries that can be optimized at compile time, while concatenation chains may require runtime analysis.
3. Safety and Validation: Template processors receive structured input and can validate it. A SQL template processor knows exactly which parts are SQL syntax (literals) and which are values (expressions), enabling proper escaping or parameterization. With concatenation, everything is just string parts—you can't reliably tell SQL syntax from user data.
4. Type Safety: Template processors can enforce type requirements. For example, a JSON processor could require that embedded expressions produce JSON-compatible types, rejecting templates that would produce invalid JSON at compile time.
5. Domain-Specific Logic: Custom processors can implement domain-specific rules. An HTML processor could auto-escape HTML entities, a regex processor could escape regex special characters, etc. With concatenation, you must remember to manually escape values everywhere they're used.
References:
- https://openjdk.org/jeps/430#Motivation
- https://www.oracle.com/technical-resources/articles/java/string-templates.html
- https://blogs.oracle.com/javamagazine/post/java-string-templates-safe-secure
Virtual Threads (Project Loom)
What is a carrier thread in the context of virtual threads?
The 30-Second Answer: Carrier threads are platform (OS) threads that execute virtual threads. The JVM maintains a pool of carrier threads (by default, one per CPU core) and schedules virtual threads onto them. When a virtual thread blocks, it unmounts from its carrier, allowing other virtual threads to use that carrier.
The 2-Minute Answer (If They Want More): Carrier threads are the bridge between virtual threads and the operating system. Think of them as the "workers" that actually execute virtual thread code on real CPU cores. The JVM creates a ForkJoinPool of carrier threads (typically matching the number of CPU cores via Runtime.getRuntime().availableProcessors()) to run all virtual threads in the application.
The relationship is many-to-few: millions of virtual threads share a small pool of carrier threads. When a virtual thread is ready to execute, the JVM scheduler mounts it onto an available carrier thread. The virtual thread's continuation (its execution state) is loaded onto the carrier thread's stack, and execution proceeds. When the virtual thread blocks on I/O, locks, or other operations, the JVM unmounts it - its continuation is saved to the heap, and the carrier thread becomes available for another virtual thread.
This mounting/unmounting mechanism is transparent to application code. From the virtual thread's perspective, it just runs normally. The carrier thread handles the actual CPU execution, but the virtual thread maintains its own identity, thread-local variables, stack traces, and lifecycle.
You can configure the carrier thread pool size using the system property jdk.virtualThreadScheduler.parallelism, though this is rarely necessary. The default (number of cores) is optimal for most workloads. Increasing it won't help I/O-bound applications since virtual threads already handle I/O concurrency efficiently. Decreasing it limits CPU utilization.
import java.util.concurrent.Executors;
import java.time.Duration;
public class CarrierThreadDemo {
public static void main(String[] args) throws InterruptedException {
System.out.println("Available processors: " +
Runtime.getRuntime().availableProcessors());
// Default carrier thread pool size = CPU cores
// Can be configured with: -Djdk.virtualThreadScheduler.parallelism=N
// Create virtual threads and observe carrier threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
final int taskId = i;
executor.submit(() -> {
// Get current thread info
Thread vThread = Thread.currentThread();
// Virtual thread name
System.out.println("Virtual thread: " + vThread.getName() +
" (isVirtual: " + vThread.isVirtual() + ")");
// Note: You can't directly access the carrier thread from code,
// but the JVM manages the mounting/unmounting
try {
// When this blocks, vThread unmounts from carrier
Thread.sleep(Duration.ofMillis(100));
// After sleep, vThread remounts (possibly different carrier!)
System.out.println("Task " + taskId + " completed");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
// Demonstrating mount/unmount cycle
demonstrateMountUnmount();
}
static void demonstrateMountUnmount() {
Thread.ofVirtual().start(() -> {
System.out.println("\n=== Mount/Unmount Cycle ===");
// Point A: Virtual thread mounted on a carrier
System.out.println("Point A: Executing (mounted on carrier)");
try {
// Point B: Blocking operation - virtual thread unmounts
System.out.println("Point B: About to block (will unmount)");
Thread.sleep(Duration.ofMillis(50));
// Point C: After blocking - remounted (possibly different carrier)
System.out.println("Point C: Resumed (remounted on carrier)");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Point D: Computation completes - virtual thread terminated
System.out.println("Point D: Done (unmounts permanently)");
});
}
}
// Monitoring carrier threads with JFR
class CarrierThreadMonitoring {
/*
* JFR events related to carrier threads:
* - jdk.VirtualThreadSubmitFailed: Carrier pool saturation
* - jdk.VirtualThreadPinned: Virtual thread couldn't unmount
*
* View with:
* jcmd <pid> JFR.start
* jcmd <pid> JFR.dump filename=recording.jfr
*
* Analyze in JDK Mission Control to see:
* - Carrier thread utilization
* - Virtual thread mount/unmount patterns
* - Pinning events
*/
}
graph TB
subgraph Virtual Threads
VT1[VThread 1<br/>RUNNABLE]
VT2[VThread 2<br/>BLOCKED - I/O]
VT3[VThread 3<br/>RUNNABLE]
VT4[VThread 4<br/>BLOCKED - Sleep]
VT5[VThread 5<br/>RUNNABLE]
end
subgraph Carrier Thread Pool
CT1[Carrier Thread 1<br/>Platform Thread]
CT2[Carrier Thread 2<br/>Platform Thread]
CT3[Carrier Thread 3<br/>Platform Thread]
CT4[Carrier Thread 4<br/>Platform Thread]
end
subgraph CPU Cores
CPU1[Core 1]
CPU2[Core 2]
CPU3[Core 3]
CPU4[Core 4]
end
VT1 -.Mounted.-> CT1
VT3 -.Mounted.-> CT2
VT5 -.Mounted.-> CT3
VT2 -.Unmounted<br/>Continuation in heap.-> Heap[(Heap Memory)]
VT4 -.Unmounted<br/>Continuation in heap.-> Heap
CT1 --> CPU1
CT2 --> CPU2
CT3 --> CPU3
CT4 --> CPU4
style VT1 fill:#90EE90
style VT3 fill:#90EE90
style VT5 fill:#90EE90
style VT2 fill:#FFB6C6
style VT4 fill:#FFB6C6
References:
- https://openjdk.org/jeps/444#Virtual-threads
- https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-704A716D-0662-4BC7-8C7F-66EE74B1EDAD
- https://www.infoq.com/articles/java-virtual-threads/
What is Executors.newVirtualThreadPerTaskExecutor() and when would you use it?
The 30-Second Answer: Executors.newVirtualThreadPerTaskExecutor() creates an ExecutorService that spawns a new virtual thread for each submitted task. It's ideal for migrating existing ExecutorService-based code to virtual threads and for I/O-bound workloads where you want simple thread-per-task semantics without thread pool limits.
The 2-Minute Answer (If They Want More): This executor represents a paradigm shift from traditional thread pool executors. Unlike fixed or cached thread pools that reuse threads to manage resource constraints, newVirtualThreadPerTaskExecutor() creates a fresh virtual thread for every task submitted. This seems wasteful with platform threads, but with virtual threads' low overhead, it's actually the recommended pattern.
The executor is unbounded - it never blocks task submission waiting for an available thread, and it can run millions of concurrent tasks. Each task gets its own virtual thread that exists only for that task's lifetime, then is discarded. This simplicity eliminates tuning concerns about pool sizes, queue lengths, and rejection policies that plague traditional executors.
You should use this executor when migrating existing code that uses ExecutorService, when you need cancellation/shutdown capabilities that ExecutorService provides, or when integrating with frameworks expecting an ExecutorService. It's particularly well-suited for I/O-bound microservices, web servers handling many concurrent requests, and applications making numerous external API calls or database queries.
The executor automatically shuts down cleanly - calling shutdown() will reject new tasks but allow running tasks to complete, while shutdownNow() attempts to interrupt all running virtual threads. This makes it safer than manually managing thread lifecycle.
import java.util.concurrent.*;
import java.time.Duration;
import java.util.stream.IntStream;
public class VirtualThreadExecutorExample {
public static void main(String[] args) throws InterruptedException {
// Traditional fixed thread pool - limited scalability
ExecutorService traditionalExecutor = Executors.newFixedThreadPool(10);
// Virtual thread executor - unlimited scalability
ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
try {
// Submit 10,000 I/O-bound tasks
// Traditional executor: only 10 concurrent, rest queued
// Virtual executor: all 10,000 run concurrently
IntStream.range(0, 10_000).forEach(i -> {
virtualExecutor.submit(() -> {
try {
// Simulate I/O operation
Thread.sleep(Duration.ofSeconds(1));
System.out.println("Task " + i + " on " +
Thread.currentThread().getName());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
});
// Using with CompletableFuture
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return "Result from virtual thread";
}, virtualExecutor);
System.out.println(future.get());
} catch (ExecutionException e) {
e.printStackTrace();
} finally {
// Proper shutdown
virtualExecutor.shutdown();
traditionalExecutor.shutdown();
if (!virtualExecutor.awaitTermination(10, TimeUnit.SECONDS)) {
virtualExecutor.shutdownNow();
}
}
}
// Example: HTTP server handling concurrent requests
public static class SimpleHttpHandler {
private final ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor();
public void handleRequest(Runnable requestHandler) {
executor.submit(() -> {
try {
requestHandler.run();
} catch (Exception e) {
// Handle exception
}
});
}
public void shutdown() {
executor.shutdown();
}
}
}
sequenceDiagram
participant App as Application
participant Exec as VirtualThreadPerTaskExecutor
participant VT as Virtual Threads
participant CT as Carrier Threads
App->>Exec: submit(task1)
Exec->>VT: Create VThread1
VT->>CT: Mount on carrier
App->>Exec: submit(task2)
Exec->>VT: Create VThread2
VT->>CT: Mount on carrier
App->>Exec: submit(task3...10000)
Exec->>VT: Create VThread3...10000
Note over VT,CT: All virtual threads share<br/>small carrier thread pool
VT->>VT: task1 blocks on I/O
VT->>CT: Unmount VThread1
VT->>CT: Mount VThread3
App->>Exec: shutdown()
Exec->>VT: Complete all tasks
VT->>App: Done
References:
- https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/Executors.html#newVirtualThreadPerTaskExecutor()
- https://www.baeldung.com/java-executors-virtual-threads
- https://inside.java/2021/11/30/on-parallelism-and-concurrency/
What is thread pinning and how do you identify it?
The 30-Second Answer: Thread pinning occurs when a virtual thread cannot be unmounted from its carrier thread during blocking operations, forcing the carrier thread to block and reducing concurrency. It happens primarily with synchronized blocks and native methods. You identify it using JDK Flight Recorder events or the -Djdk.tracePinnedThreads=full JVM flag.
The 2-Minute Answer (If They Want More): Thread pinning is the primary performance pitfall with virtual threads. Normally, when a virtual thread blocks (on I/O, Thread.sleep(), etc.), the JVM unmounts it from its carrier thread, allowing other virtual threads to use that carrier. However, in certain situations, the JVM cannot unmount the virtual thread, "pinning" it to the carrier thread. This blocks the carrier thread, reducing the available carrier thread pool size and degrading concurrency.
The two main causes are synchronized blocks/methods and native method calls. When a virtual thread enters a synchronized block and then blocks, it pins to its carrier. This is because synchronized uses OS-level monitors that are tied to the carrier thread. Similarly, native methods (JNI calls) pin virtual threads because the native code executes on the carrier thread stack.
To identify pinning, use the JVM flag -Djdk.tracePinnedThreads=full, which prints a stack trace whenever a virtual thread parks while pinned. The stack trace shows where in your code the pinning occurred. For production monitoring, use JDK Flight Recorder (JFR) which emits jdk.VirtualThreadPinned events with detailed information about pinning occurrences, duration, and frequency.
Pinning isn't always catastrophic - if pinning is infrequent and short-duration, the impact is minimal. However, if many virtual threads frequently pin for extended periods, you'll lose virtual threads' scalability benefits. The solution is to replace synchronized with ReentrantLock or other java.util.concurrent locks, which support virtual thread unmounting.
import java.util.concurrent.locks.ReentrantLock;
import java.time.Duration;
public class ThreadPinningExample {
private final Object syncLock = new Object();
private final ReentrantLock reentrantLock = new ReentrantLock();
// BAD: This will pin the virtual thread
public void synchronizedMethod() {
synchronized (syncLock) {
try {
// This blocks, but virtual thread cannot unmount
// Carrier thread is blocked!
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// GOOD: This allows virtual thread to unmount
public void reentrantLockMethod() {
reentrantLock.lock();
try {
// Virtual thread can unmount during sleep
// Carrier thread is freed for other virtual threads
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
reentrantLock.unlock();
}
}
public static void main(String[] args) {
// Run with: -Djdk.tracePinnedThreads=full
ThreadPinningExample example = new ThreadPinningExample();
// This will show pinning warning
Thread.ofVirtual().start(() -> {
example.synchronizedMethod();
});
// This won't pin
Thread.ofVirtual().start(() -> {
example.reentrantLockMethod();
});
}
}
// Example JFR monitoring code
class PinningMonitor {
/*
* Enable JFR recording:
* jcmd <pid> JFR.start name=pinning settings=default
*
* After running:
* jcmd <pid> JFR.dump name=pinning filename=pinning.jfr
*
* Analyze with JFR events:
* - jdk.VirtualThreadPinned
* Shows: stack trace, duration, frequency
*/
}
sequenceDiagram
participant VT as Virtual Thread
participant CT as Carrier Thread
participant Sync as Synchronized Block
participant Lock as ReentrantLock
Note over VT,CT: Scenario 1: Pinning with synchronized
VT->>CT: Mount on carrier
VT->>Sync: Enter synchronized
VT->>VT: Thread.sleep()
Note over VT,CT: PINNED!<br/>Carrier blocked
CT->>CT: Blocked waiting
VT->>Sync: Exit synchronized
VT->>CT: Unmount
Note over VT,CT: Scenario 2: No pinning with ReentrantLock
VT->>CT: Mount on carrier
VT->>Lock: lock()
VT->>VT: Thread.sleep()
VT->>CT: Unmount (no pinning!)
CT->>CT: Available for other VThreads
Note over VT: Parked in heap
VT->>CT: Remount when awake
VT->>Lock: unlock()
References:
- https://openjdk.org/jeps/444#Pinning
- https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-DC4306FC-D6C1-4BCC-AECE-48C32C1A8DAA
- https://blog.rockthejvm.com/ultimate-guide-to-java-virtual-threads/
Structured Concurrency (Preview)
What is the relationship between structured concurrency and virtual threads?
The 30-Second Answer: Structured concurrency and virtual threads are complementary features that work together: virtual threads provide lightweight, efficient concurrency primitives, while structured concurrency provides the programming model to manage them safely. Structured concurrency enables you to spawn thousands of virtual threads within a scope without worrying about resource leaks or orphaned tasks.
The 2-Minute Answer (If They Want More):
Virtual threads and structured concurrency were designed to work together as part of Project Loom. Virtual threads make it practical to create large numbers of concurrent tasks (millions if needed) because they're lightweight and don't tie up OS threads. However, this abundance of cheap threads creates new challenges: how do you manage thousands of concurrent operations, ensure they all complete properly, and prevent resource leaks?
Structured concurrency solves these management challenges by providing a clear ownership model and lifecycle for concurrent tasks. When you fork a task within a StructuredTaskScope, it automatically uses a virtual thread. The scope ensures that all forked virtual threads complete before the scope exits, preventing thread leaks. This is crucial when dealing with large numbers of threads—without structured concurrency, it would be easy to lose track of threads and create resource leaks.
The combination enables a new programming style: instead of carefully managing a small pool of expensive platform threads, you can freely create virtual threads for each concurrent operation (like handling each database query or API call separately) and use structured concurrency to ensure they're properly coordinated and cleaned up. The scope's try-with-resources pattern guarantees cleanup, while virtual threads make this approach performant.
This relationship mirrors the one between garbage collection and object allocation: garbage collection made it safe to freely allocate objects without manual memory management, and structured concurrency with virtual threads makes it safe to freely create concurrent tasks without manual thread lifecycle management. Both features promote writing more maintainable, correct concurrent code by reducing the cognitive burden on developers.
import java.util.concurrent.StructuredTaskScope;
import java.util.List;
import java.util.ArrayList;
import java.util.stream.IntStream;
public class VirtualThreadsWithStructuredConcurrency {
// Before virtual threads + structured concurrency - limited parallelism
public List<String> oldApproach(List<String> userIds) throws Exception {
// Limited by thread pool size
var executor = java.util.concurrent.Executors.newFixedThreadPool(10);
var results = new ArrayList<String>();
try {
var futures = userIds.stream()
.map(id -> executor.submit(() -> fetchUserData(id)))
.toList();
// Manual collection and error handling
for (var future : futures) {
results.add(future.get());
}
} finally {
executor.shutdown(); // Manual cleanup
}
return results;
}
// With virtual threads + structured concurrency - scales effortlessly
public List<String> newApproach(List<String> userIds) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork one virtual thread per user - could be thousands or millions
var subtasks = userIds.stream()
.map(id -> scope.fork(() -> fetchUserData(id)))
.toList();
// Wait for all virtual threads
scope.join();
scope.throwIfFailed();
// Collect results - guaranteed all succeeded
return subtasks.stream()
.map(task -> task.get())
.toList();
} // All virtual threads guaranteed to be cleaned up
}
// Demonstrating massive parallelism with virtual threads
public record ProcessingResult(int processed, int failed, long durationMs) {}
public ProcessingResult processLargeDataset(int itemCount) throws Exception {
long start = System.currentTimeMillis();
int failureCount = 0;
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Create thousands of virtual threads - one per item
var tasks = IntStream.range(0, itemCount)
.mapToObj(i -> scope.fork(() -> processItem(i)))
.toList();
scope.join();
// Count successes and failures
long successful = tasks.stream()
.filter(t -> t.state() == StructuredTaskScope.Subtask.State.SUCCESS)
.count();
failureCount = (int) (itemCount - successful);
long duration = System.currentTimeMillis() - start;
return new ProcessingResult((int) successful, failureCount, duration);
}
}
// Nested structured scopes with virtual threads
public record OrderDetails(
String orderInfo,
List<String> items,
String shippingInfo
) {}
public List<OrderDetails> fetchMultipleOrders(List<String> orderIds) throws Exception {
// Outer scope - one virtual thread per order
try (var outerScope = new StructuredTaskScope.ShutdownOnFailure()) {
var orderTasks = orderIds.stream()
.map(orderId -> outerScope.fork(() -> fetchOrderDetails(orderId)))
.toList();
outerScope.join();
outerScope.throwIfFailed();
return orderTasks.stream()
.map(task -> task.get())
.toList();
}
}
private OrderDetails fetchOrderDetails(String orderId) throws Exception {
// Inner scope - multiple virtual threads per order
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var infoTask = scope.fork(() -> fetchOrderInfo(orderId));
var itemsTask = scope.fork(() -> fetchOrderItems(orderId));
var shippingTask = scope.fork(() -> fetchShippingInfo(orderId));
scope.join();
scope.throwIfFailed();
return new OrderDetails(
infoTask.get(),
itemsTask.get(),
shippingTask.get()
);
}
}
// Demonstrating virtual thread efficiency
public void demonstrateVirtualThreadEfficiency() throws Exception {
System.out.println("Platform threads available: " +
Runtime.getRuntime().availableProcessors());
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Create 10,000 virtual threads - impossible with platform threads
var tasks = IntStream.range(0, 10_000)
.mapToObj(i -> scope.fork(() -> {
Thread.sleep(1000); // Simulate I/O
return "Task " + i + " on " + Thread.currentThread();
}))
.toList();
scope.join();
scope.throwIfFailed();
// All 10,000 tasks completed using only a few platform threads
System.out.println("Completed " + tasks.size() + " virtual thread tasks");
}
}
// Comparing thread creation overhead
public void compareThreadCreation() throws Exception {
// Virtual threads with structured concurrency
long vtStart = System.nanoTime();
try (var scope = new StructuredTaskScope<Void>()) {
for (int i = 0; i < 1000; i++) {
scope.fork(() -> {
// Minimal work
return null;
});
}
scope.join();
}
long vtDuration = System.nanoTime() - vtStart;
System.out.println("1000 virtual threads: " + vtDuration / 1_000_000 + "ms");
// Platform threads (for comparison - don't actually do this)
// Would be much slower and consume more resources
}
private String fetchUserData(String userId) {
return "User data for " + userId;
}
private String processItem(int index) {
return "Processed item " + index;
}
private String fetchOrderInfo(String orderId) {
return "Order info for " + orderId;
}
private List<String> fetchOrderItems(String orderId) {
return List.of("Item1", "Item2");
}
private String fetchShippingInfo(String orderId) {
return "Shipping info for " + orderId;
}
}
graph LR
subgraph "Traditional Approach"
A1[Platform Thread Pool<br/>Limited Size 10-100] --> B1[Tasks Queue]
B1 --> C1[Wait for<br/>Available Thread]
C1 --> D1[Execute Task]
D1 --> E1[Return Thread<br/>to Pool]
end
subgraph "Virtual Threads + Structured Concurrency"
A2[StructuredTaskScope] --> B2[Fork Task 1<br/>Creates Virtual Thread]
A2 --> C2[Fork Task 2<br/>Creates Virtual Thread]
A2 --> D2[Fork Task N<br/>Creates Virtual Thread]
A2 --> E2[Fork Task 10000<br/>Creates Virtual Thread]
B2 --> F2[Platform Thread<br/>Carrier 1]
C2 --> F2
D2 --> G2[Platform Thread<br/>Carrier 2]
E2 --> G2
F2 --> H2[Multiplexing<br/>Virtual Threads]
G2 --> H2
H2 --> I2[Scope Guarantees<br/>All Complete]
end
style A1 fill:#ffcdd2
style A2 fill:#c8e6c9
style I2 fill:#4caf50
sequenceDiagram
participant App as Application
participant Scope as StructuredTaskScope
participant VT1 as Virtual Thread 1
participant VT2 as Virtual Thread 2
participant VTN as Virtual Thread N
participant Carrier as Platform Thread Pool
App->>Scope: Create scope
App->>Scope: fork(task1)
Scope->>VT1: Create virtual thread
VT1->>Carrier: Mount on available carrier
App->>Scope: fork(task2)
Scope->>VT2: Create virtual thread
VT2->>Carrier: Mount on available carrier
App->>Scope: fork(taskN) ... x 10000
Scope->>VTN: Create virtual threads
VTN->>Carrier: Mount on available carriers
Note over VT1,Carrier: Virtual threads unmount during I/O<br/>freeing carriers for other virtual threads
VT1-->>Scope: Complete
VT2-->>Scope: Complete
VTN-->>Scope: Complete
App->>Scope: join()
Scope-->>App: All 10000 tasks complete
App->>Scope: close()
Note over Scope: Guaranteed cleanup of<br/>all virtual threads
References:
- https://openjdk.org/jeps/444 (Virtual Threads)
- https://openjdk.org/jeps/453 (Structured Concurrency)
- https://inside.java/2021/05/10/networking-io-with-virtual-threads/
- https://www.infoq.com/articles/java-virtual-threads/
Best Practices and Migration
What is the recommended approach for concurrent programming in Java 21?
The 30-Second Answer:
Java 21's recommended approach is virtual threads combined with structured concurrency. Replace thread pools with virtual thread executors, use StructuredTaskScope to manage related tasks as a unit, and replace ThreadLocal with scoped values for better performance. Virtual threads enable simple thread-per-request style code that scales efficiently without the complexity of reactive programming.
The 2-Minute Answer (If They Want More):
The fundamental shift in Java 21 concurrency is embracing virtual threads for scalable I/O-bound operations. Virtual threads are lightweight (millions can run concurrently) and managed by the JVM, eliminating the need for complex thread pool tuning and reactive programming patterns for most applications.
For web applications and services, adopt the thread-per-request model using virtual threads:
// Old approach - thread pool with limited threads
ExecutorService executor = Executors.newFixedThreadPool(200);
executor.submit(() -> handleRequest(request));
// Java 21 approach - virtual threads, unlimited scalability
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> handleRequest(request));
// Even better - one virtual thread per request
Thread.startVirtualThread(() -> handleRequest(request));
Use Structured Concurrency for managing related concurrent operations. StructuredTaskScope ensures that all subtasks complete (successfully or not) before the scope exits, preventing thread leaks and ensuring proper error handling:
// Structured concurrency for fan-out operations
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> userTask = scope.fork(() -> fetchUser(userId));
Subtask<Orders> ordersTask = scope.fork(() -> fetchOrders(userId));
Subtask<Preferences> prefsTask = scope.fork(() -> fetchPreferences(userId));
scope.join(); // Wait for all tasks
scope.throwIfFailed(); // Propagate failures
return new UserProfile(
userTask.get(),
ordersTask.get(),
prefsTask.get()
);
}
Replace ThreadLocal with scoped values when sharing data across virtual threads. ThreadLocal has high memory overhead with millions of virtual threads, while scoped values are immutable and much more efficient:
// Old approach - ThreadLocal
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
// Java 21 approach - ScopedValue
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
// Usage
ScopedValue.where(CURRENT_USER, user)
.run(() -> processRequest());
For CPU-bound tasks, continue using platform threads and the Fork/Join framework. Virtual threads excel at I/O-bound workloads (network calls, database queries, file operations) but offer no advantage for CPU-intensive computation. Don't use synchronized blocks in hot paths with virtual threads; prefer ReentrantLock or other non-pinning synchronization mechanisms.
References:
- https://openjdk.org/jeps/444
- https://openjdk.org/jeps/462
- https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html
Record Patterns
What is nested record pattern matching?
The 30-Second Answer: Nested record pattern matching allows you to deconstruct records that contain other records as components in a single pattern expression. You can nest patterns arbitrarily deep, matching the structure of complex hierarchical data and extracting values at any level of the hierarchy.
The 2-Minute Answer (If They Want More): Nested record patterns enable you to work with complex, hierarchical data structures by matching and deconstructing multiple levels of records in one operation. Instead of performing multiple instanceof checks and extractions, you can write a single pattern that mirrors the structure of your data and extracts all the values you need.
This feature is particularly powerful when working with domain models that use composition, such as geometric shapes containing points, tree structures, or any nested data representation. The syntax is intuitive: wherever you would write a variable to capture a component, you can instead write another record pattern to destructure that component further.
Nested patterns can be combined with other pattern matching features like type patterns, null patterns, and guards. The compiler can verify exhaustiveness when used with sealed types, ensuring that all possible data structures are handled. This makes nested record patterns especially valuable for processing abstract syntax trees, configuration hierarchies, or any domain where data is naturally represented as nested records.
The depth of nesting is not limited by the language, though deeply nested patterns can become hard to read. In practice, nesting 2-3 levels deep is common and maintains good readability. You can also mix nested patterns with traditional component access when only certain parts of the hierarchy need deconstruction.
Code Example:
// Define nested records
record Point(int x, int y) {}
record Circle(Point center, double radius) {}
record Rectangle(Point topLeft, Point bottomRight) {}
sealed interface Shape permits Circle, Rectangle {}
// Nested pattern matching
public void describeShape(Shape shape) {
switch (shape) {
case Circle(Point(var x, var y), var radius) ->
System.out.println("Circle centered at (" + x + ", " + y +
") with radius " + radius);
case Rectangle(Point(var x1, var y1), Point(var x2, var y2)) ->
System.out.println("Rectangle from (" + x1 + "," + y1 +
") to (" + x2 + "," + y2 + ")");
}
}
// Deeper nesting example
record Address(String street, String city, String zipCode) {}
record Contact(String email, String phone) {}
record Employee(String name, Address address, Contact contact) {}
record Department(String name, Employee manager) {}
public void printManagerContact(Department dept) {
// Three levels deep!
if (dept instanceof Department(var deptName,
Employee(var mgrName,
_, // ignore address
Contact(var email, var phone)))) {
System.out.println("Department: " + deptName);
System.out.println("Manager: " + mgrName);
System.out.println("Contact: " + email + ", " + phone);
}
}
// Partial nesting - mix patterns with accessor calls
public void processCircle(Object obj) {
if (obj instanceof Circle(Point(var x, var y), var radius)) {
// Extracted x, y, and radius directly
double area = Math.PI * radius * radius;
System.out.println("Area: " + area);
}
// Alternatively, partial deconstruction
if (obj instanceof Circle(var center, var radius)) {
// center is still a Point object, use accessors if needed
System.out.println("Center: " + center.x() + "," + center.y());
}
}
References:
- https://openjdk.org/jeps/440
- https://www.baeldung.com/java-record-patterns
- https://dev.java/learn/pattern-matching/
Pattern Matching for switch
What is pattern matching for switch in Java 21?
The 30-Second Answer: Pattern matching for switch (finalized in Java 21) extends switch statements and expressions to allow patterns instead of just constants in case labels. This enables type testing, deconstruction, and more expressive conditional logic directly within switch constructs, eliminating the need for cascading if-else statements with instanceof checks.
The 2-Minute Answer (If They Want More): Pattern matching for switch represents a major evolution of Java's switch construct, building on the pattern matching foundation introduced with instanceof in Java 16. Before this feature, switch statements could only work with a limited set of types (primitives, enums, String, and wrapper classes) and only supported constant values. This forced developers to use verbose if-else chains with instanceof checks when dealing with polymorphic types.
With pattern matching, switch can now accept any reference type and use type patterns in case labels. The compiler automatically performs type checking and casting, making the code more concise and readable. For example, instead of writing multiple if-else blocks to handle different object types, you can use a single switch expression that clearly expresses the intent.
This feature integrates seamlessly with other modern Java features like records and sealed classes. When used with sealed classes, the compiler can verify exhaustiveness, ensuring all possible subtypes are handled. The feature also supports null handling directly in the switch, guarded patterns with when clauses for additional conditions, and pattern dominance checking to prevent unreachable code.
Pattern matching for switch improves code maintainability by centralizing type-based dispatch logic and leveraging compiler verification to catch errors at compile time rather than runtime.
// Before: verbose if-else chain
String describe(Object obj) {
if (obj instanceof Integer i) {
return "Integer: " + i;
} else if (obj instanceof String s) {
return "String: " + s;
} else if (obj instanceof Long l) {
return "Long: " + l;
} else {
return "Unknown";
}
}
// After: pattern matching switch
String describe(Object obj) {
return switch (obj) {
case Integer i -> "Integer: " + i;
case String s -> "String: " + s;
case Long l -> "Long: " + l;
default -> "Unknown";
};
}
References:
- https://openjdk.org/jeps/441
- https://docs.oracle.com/en/java/javase/21/language/pattern-matching-switch-expressions-and-statements.html
- https://www.baeldung.com/java-switch-pattern-matching
How does exhaustiveness checking work with switch patterns?
The 30-Second Answer: Exhaustiveness checking ensures that a switch expression covers all possible input values. For switch expressions (not statements), the compiler verifies that every possible value has a matching case, requiring either complete case coverage or a default clause to prevent compilation errors and ensure the expression always produces a value.
The 2-Minute Answer (If They Want More): Exhaustiveness checking is a compile-time verification mechanism that guarantees switch expressions will always produce a result without throwing an exception for unhandled cases. This is critical for switch expressions because they must evaluate to a value, unlike switch statements which can simply fall through or do nothing for unmatched cases.
For primitive types and their wrappers, exhaustiveness is straightforward—you either need to cover all possible constant values (feasible for enums and boolean) or provide a default case. For reference types, exhaustiveness checking becomes more sophisticated, especially when combined with sealed classes and pattern matching.
When switching on sealed classes, the compiler can determine the complete set of permitted subtypes and verify that all are explicitly handled. If you provide case labels for every permitted subtype, no default clause is needed—the compiler knows the switch is exhaustive. This creates a powerful synergy between sealed hierarchies and pattern matching: you get both type-safe polymorphism and compile-time completeness checking.
Exhaustiveness checking also accounts for null handling. In modern Java switch, null must be explicitly handled if it's a possibility, either with a dedicated null case or as part of a pattern. The compiler considers a switch exhaustive only if it handles both all type possibilities and the null case.
Pattern dominance is another aspect of exhaustiveness checking. The compiler ensures that more specific patterns appear before more general ones. For example, you can't place a default or generic Object pattern before more specific type patterns, as that would make subsequent cases unreachable.
// Exhaustive switch with sealed classes (no default needed)
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}
double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
// No default needed - all permitted types covered
};
}
// Non-exhaustive example (compilation error)
double areaIncomplete(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
// ERROR: Triangle not covered and no default
};
}
// Exhaustive with null handling
String describe(Shape shape) {
return switch (shape) {
case null -> "No shape";
case Circle c -> "Circle with radius " + c.radius();
case Rectangle r -> "Rectangle " + r.width() + "x" + r.height();
case Triangle t -> "Triangle";
};
}
// Pattern dominance - order matters
String classify(Object obj) {
return switch (obj) {
case String s when s.isEmpty() -> "Empty string";
case String s -> "Non-empty string";
case Integer i -> "Integer";
// 'default' or 'Object o' must come last
default -> "Other";
};
}
References:
- https://openjdk.org/jeps/441
- https://docs.oracle.com/en/java/javase/21/language/pattern-matching-switch-expressions-and-statements.html#exhaustiveness-and-compatibility
- https://www.oracle.com/technical-resources/articles/java/pattern-matching.html
Generational ZGC
What is Generational ZGC and how does it differ from classic ZGC?
The 30-Second Answer: Generational ZGC is an enhancement to the Z Garbage Collector that separates the heap into young and old generations, similar to G1GC, while maintaining ZGC's low-latency characteristics. Unlike classic ZGC which treats all objects equally, Generational ZGC focuses garbage collection efforts on young objects that typically die quickly, resulting in improved throughput and reduced memory overhead.
The 2-Minute Answer (If They Want More): Classic ZGC, introduced in Java 11 and production-ready in Java 15, revolutionized garbage collection with its sub-millisecond pause times and ability to handle multi-terabyte heaps. However, it treated the entire heap uniformly, collecting all objects regardless of age. This approach, while providing consistent low latency, didn't leverage the weak generational hypothesis—the observation that most objects die young.
Generational ZGC, available as a preview in Java 21 and production-ready in later versions, addresses this limitation by introducing a generational model. The heap is divided into young and old generations, with the young generation being collected more frequently. This design allows Generational ZGC to reclaim memory from short-lived objects more efficiently without scanning the entire heap every time.
The key differences include improved throughput (up to 2x in some workloads), reduced memory footprint, and better CPU utilization while preserving ZGC's hallmark sub-millisecond pause times. The generational approach also reduces the amount of memory that needs to be scanned during each collection cycle, as the collector can focus on the young generation most of the time.
Under the hood, Generational ZGC uses colored pointers and load barriers like classic ZGC, but adds write barriers to track references from old generation to young generation objects. This enables efficient young generation collections without full heap scans.
graph TB
subgraph "Classic ZGC"
A1[Entire Heap] --> B1[Uniform Collection]
B1 --> C1[All Objects Treated Equally]
end
subgraph "Generational ZGC"
A2[Heap] --> B2[Young Generation]
A2 --> C2[Old Generation]
B2 --> D2[Frequent Collections]
C2 --> E2[Infrequent Collections]
D2 --> F2[Better Throughput]
E2 --> F2
end
style A1 fill:#ffcccc
style A2 fill:#ccffcc
style F2 fill:#ffffcc
References:
- https://openjdk.org/jeps/439
- https://docs.oracle.com/en/java/javase/21/gctuning/z-garbage-collector.html
- https://inside.java/2023/09/13/gen-zgc-explainer/
Foreign Function and Memory API
What is the Foreign Function and Memory API?
The 30-Second Answer: The Foreign Function and Memory API (FFM API) is a Java feature that provides safe and efficient access to native code and memory outside the JVM. It replaces the older JNI (Java Native Interface) with a pure Java API that allows developers to call native libraries and manage off-heap memory without the complexity, unsafety, and performance overhead of JNI. The FFM API became a preview feature in Java 19, refined in Java 20 and 21, and finalized in Java 22.
The 2-Minute Answer (If They Want More):
The FFM API consists of two main components: the Foreign Function part (via the Linker interface) for calling native functions, and the Memory part (via MemorySegment and Arena) for managing native memory. This API was developed as part of Project Panama to improve Java's interoperability with native code and data.
Traditional JNI required writing C/C++ glue code, compiling it for each platform, and dealing with manual memory management prone to memory leaks and crashes. The FFM API eliminates these issues by providing a pure Java solution where you describe the native function signatures and memory layouts using Java code, and the JVM handles the low-level details.
The API provides strong safety guarantees through spatial safety (preventing out-of-bounds access), temporal safety (preventing use-after-free errors via Arena lifecycle management), and type safety (through method handles and memory layouts). It also offers better performance than JNI because it eliminates the JNI overhead and allows the JIT compiler to optimize foreign calls.
Key classes include Arena for managing memory lifecycle, MemorySegment for representing memory regions, MemoryLayout for describing memory structure, Linker for linking to native functions, and FunctionDescriptor for describing native function signatures. Together, these enable safe, efficient native interop directly from Java.
Code Example:
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
public class FFMExample {
public static void main(String[] args) throws Throwable {
// Get the native linker for the current platform
Linker linker = Linker.nativeLinker();
// Find the C standard library strlen function
SymbolLookup stdlib = linker.defaultLookup();
MemorySegment strlenAddr = stdlib.find("strlen").orElseThrow();
// Describe the function signature: size_t strlen(const char *s)
FunctionDescriptor strlenDescriptor = FunctionDescriptor.of(
ValueLayout.JAVA_LONG, // return type (size_t)
ValueLayout.ADDRESS // parameter (const char*)
);
// Create a method handle for the native function
MethodHandle strlen = linker.downcallHandle(
strlenAddr,
strlenDescriptor
);
// Allocate native memory and call the function
try (Arena arena = Arena.ofConfined()) {
MemorySegment str = arena.allocateUtf8String("Hello, Native World!");
long length = (long) strlen.invoke(str);
System.out.println("String length: " + length); // 20
} // Memory automatically freed when arena closes
}
}
References:
- https://openjdk.org/jeps/442 (JEP 442: Foreign Function & Memory API - Third Preview)
- https://openjdk.org/jeps/454 (JEP 454: Foreign Function & Memory API - Finalized in Java 22)
- https://docs.oracle.com/en/java/javase/21/core/foreign-function-and-memory-api.html