Java / Java 21 latest features Interview questions
Last updated
1. What is Java 21?
Java 21 is the Java SE release that reached general availability in September 2023. It is a Long-Term Support (LTS) release and the successor to Java 17 as the LTS baseline.
It ships 15 JEPs. The headline ones are virtual threads, pattern matching for switch, record patterns, Sequenced Collections and Generational ZGC, all final. Others, such as string templates and structured concurrency, arrive as preview features.
Being LTS is why many teams standardise on it: vendors keep shipping security updates for years. Its class file major version is 65.
Take quiz
a Long-Term Support (LTS) release
a short-lived feature release with six months of support
an incubator-only release
Java 11
Java 17
Java 20
2. What are the final features delivered in Java 21?
Eight JEPs became final (non-preview) in Java 21:
| JEP | Feature |
| 431 | Sequenced Collections |
| 439 | Generational ZGC |
| 440 | Record Patterns |
| 441 | Pattern Matching for switch |
| 444 | Virtual Threads |
| 449 | Deprecate the Windows 32-bit x86 Port for Removal |
| 451 | Prepare to Disallow the Dynamic Loading of Agents |
| 452 | Key Encapsulation Mechanism API |
The other seven are not final: six are preview features and one (the Vector API) is still an incubator module.
Of these, virtual threads, pattern matching for switch and Sequenced Collections change day-to-day coding the most. The agent and Windows entries are mainly housekeeping for tool and platform maintainers.
Take quiz
Scoped values
Virtual threads
String templates
Structured concurrency
None, all were final
Three
Seven (six preview, one incubator)
All fifteen
3. What are Sequenced Collections?
Sequenced Collections (JEP 431) add three interfaces: SequencedCollection, SequencedSet and SequencedMap. They describe collections that have a defined encounter order and give them one common API for first, last and reverse access.
Before Java 21, getting the last element meant list.get(list.size() - 1), deque.getLast() or an iterator loop, depending on the type. Now it is the same call everywhere.
List<String> list = new ArrayList<>(List.of("a", "b", "c")); list.getFirst(); // a list.getLast(); // c list.reversed(); // [c, b, a] - a view, not a copy
Take quiz
HashMap is slow with many keys
No common API for first, last and reverse order across collection types
ArrayList is not thread-safe
SequencedMap
OrderedMap
IndexedMap
LinkedMapView
4. What methods does SequencedCollection add?
SequencedCollection adds seven methods on top of Collection:
| Method | Purpose |
addFirst(e) / addLast(e) |
insert at either end |
getFirst() / getLast() |
read either end |
removeFirst() / removeLast() |
remove and return either end |
reversed() |
reverse-ordered view |
Reading or removing from an empty collection throws NoSuchElementException. Immutable collections, or ones that cannot support the operation, throw UnsupportedOperationException.
SequencedMap adds firstEntry(), lastEntry(), pollFirstEntry(), pollLastEntry(), putFirst(), putLast() and reversed(), plus sequenced views of keys, values and entries.
Take quiz
IndexOutOfBoundsException
NoSuchElementException
NullPointerException
nothing, it returns null
reverse()
toReversed()
reversed()
flip()
5. What is a virtual thread?
A virtual thread (JEP 444) is a lightweight java.lang.Thread managed by the JVM rather than the operating system. Many virtual threads are multiplexed onto a small number of OS threads called carrier threads.
They are cheap enough to create one per task, even millions at a time. When a virtual thread blocks on I/O, it is unmounted from its carrier, so the OS thread can run other work.
They use the normal Thread API, are always daemon threads, and have a fixed normal priority. They target the thread-per-request style for blocking, I/O-heavy servers.
Take quiz
the operating system kernel directly
the garbage collector
the JVM, on top of a small set of carrier threads
high-concurrency workloads that spend time blocking on I/O
long-running CPU-bound number crunching
replacing all JVM tuning
6. How do you create virtual threads in Java 21?
There are three common ways, all in the standard API (no preview flag needed):
// 1. Builder Thread t = Thread.ofVirtual().name("worker-1").start(() -> work()); // 2. Shortcut Thread.startVirtualThread(() -> work()); // 3. Executor, one new virtual thread per task try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() -> work()); }
Use Thread.ofVirtual().unstarted(task) if you want to create the thread first and start it later. Thread.isVirtual() tells you which kind you have.
Virtual threads have no public constructor, so new Thread(...) always gives a platform thread. A ThreadFactory from Thread.ofVirtual().factory() is handy when a framework asks for one.
Take quiz
new Thread(task).setVirtual(true)
Thread.startVirtualThread(task)
Executors.newFixedThreadPool(10_000)
a fixed pool of ten virtual threads
one reusable carrier thread
a new virtual thread for every submitted task
7. What is pattern matching for switch?
Pattern matching for switch (JEP 441, final in Java 21) lets case labels match on an object's type and bind a variable, instead of only matching constants.
static String describe(Object o) { return switch (o) { case Integer i -> "int " + i; case String s -> "string of length " + s.length(); case null -> "null"; default -> "something else"; }; }
It replaces long if (o instanceof ...) chains and works with sealed types, records, guards and null.
Take quiz
test an object's type and bind a variable
match only int constants
run each case in parallel
Java 21
Java 17
Java 19
Java 22
8. What are record patterns?
A record pattern (JEP 440) deconstructs a record into its components directly inside instanceof or switch, so you skip the accessor calls.
record Point(int x, int y) {} if (obj instanceof Point(int x, int y)) { System.out.println(x + y); }
The pattern checks the type and, if it matches, binds x and y in one step. Patterns can also be nested, which is covered in a later question.
Record patterns pair well with sealed interfaces: a switch over the sealed type can deconstruct each record case and the compiler still checks that every case is covered.
Take quiz
serialize a record to JSON
create a new record instance
deconstruct a record into its components
instanceof and switch
only annotation processors
only for-loop headers
9. What is the purpose of the when clause in switch?
The when clause adds a guard, an extra boolean condition, to a pattern label. The case matches only if the pattern matches and the guard is true.
return switch (o) { case Integer i when i > 100 -> "large"; case Integer i -> "small"; default -> "not an int"; };
If the guard is false, evaluation simply moves to the next case. Order matters: the guarded label must come before the unguarded one for the same type.
Before Java 21's final syntax, preview versions used && for guards. The when keyword replaced it, so guards read clearly and cannot be confused with boolean operators inside the pattern.
Take quiz
where
when
if
a MatchException is thrown
the default branch runs immediately
evaluation continues with later cases
10. What is Generational ZGC?
Generational ZGC (JEP 439) extends the Z Garbage Collector with separate young and old generations. Young objects are collected often, old objects rarely.
Most objects die young, so this avoids repeatedly marking long-lived data. The result is fewer allocation stalls, lower CPU and memory overhead, and the same very short (sub-millisecond) pauses ZGC is known for.
It is a good fit for latency-sensitive services with high allocation rates.
You do not change application code to use it. It is a JVM option, so the benefit shows up as lower GC CPU usage and a smaller heap requirement for the same throughput.
Take quiz
collecting young objects separately and more often
adding long stop-the-world compaction
removing concurrent marking
They grow in proportion to heap size
They stay very short, in the sub-millisecond range
Every collection is a full stop-the-world
11. How do you enable Generational ZGC?
In Java 21 you must opt in with two flags:
java -XX:+UseZGC -XX:+ZGenerational -jar app.jar
Using -XX:+UseZGC alone still gives the older non-generational ZGC. Generational mode became the default in a later release (Java 23), and the non-generational mode was removed after that.
To confirm what is running, start the JVM with -Xlog:gc and check the collector name in the log.
If you also set -Xmx, leave some free headroom. ZGC works concurrently with your application, and a heap that is nearly full forces it to stall allocating threads.
Take quiz
-XX:+UseZGC only, generational is default
-XX:+UseGenerationalGC
-XX:+UseG1GC -XX:+ZGenerational
-XX:+UseZGC -XX:+ZGenerational
the non-generational ZGC
Generational ZGC
G1 with extra tuning
12. What are string templates in Java 21?
String templates (JEP 430) embed expressions in a string literal with \{...}. A template processor then turns the template into a result. The built-in STR processor does plain interpolation.
String name = "Ana"; int a = 2, b = 3; String s = STR."Hello \{name}, sum is \{a + b}"; // Hello Ana, sum is 5
In Java 21 this is a preview feature, so it needs --enable-preview. STR is imported automatically in every source file.
Compared with + concatenation or String.format, the template keeps the text and the values visible together, which makes long messages easier to read and review.
Take quiz
${expression}
\{expression}
#{expression}
{{expression}}
a fully final feature
an incubator module
a preview feature that needs --enable-preview
13. What are unnamed patterns and variables?
JEP 443 (preview in Java 21) lets you use the underscore _ for a variable or pattern you must declare but never use. The compiler then knows it is intentionally ignored.
try { parse(text); } catch (NumberFormatException _) { log("bad input"); } if (shape instanceof Point(int x, _)) { use(x); } for (var _ : list) { count++; }
An unnamed variable declares nothing you can read later. An unnamed pattern matches anything without binding it.
Using _ also documents intent: a reader knows the value is deliberately ignored, and static analysis tools stop reporting an unused variable warning.
Take quiz
an unnamed variable or pattern whose value is ignored
a wildcard import
a generic type placeholder
you want to make a field static
a variable must be declared but is never used
a method needs to be overloaded
14. What are unnamed classes and instance main methods?
JEP 445 (preview in Java 21) shortens the first program a beginner writes. A source file may contain just methods and fields, with no class declaration, and main need not be public static or take String[] args.
// Hello.java void main() { System.out.println("Hello, Java 21"); }
The compiler wraps this in an unnamed class. Run it with java --enable-preview --source 21 Hello.java. The aim is a gentler learning curve, not a change to how large applications are structured.
Take quiz
only inside a public final class
only as static void run()
a top-level void main() with no class declaration
lower the entry barrier for beginners
replace packages
speed up garbage collection
15. What are scoped values?
Scoped values (JEP 446, preview in Java 21) let you share immutable data with methods you call, and with child threads, for a bounded stretch of execution. They are a lighter, safer alternative to ThreadLocal, especially with virtual threads.
static final ScopedValue<String> USER = ScopedValue.newInstance(); ScopedValue.where(USER, "alice").run(() -> handle()); void handle() { System.out.println(USER.get()); } // alice
Once run returns, the binding disappears automatically.
They are designed for request context such as the current user or a trace ID. Unlike ThreadLocal, nothing needs to be cleaned up, because the binding ends when the lambda returns.
Take quiz
mutable globals with setters
immutable and bound to a limited execution scope
thread pool settings
returns null
returns the last bound value
throws NoSuchElementException
16. What is structured concurrency?
Structured concurrency (JEP 453, preview in Java 21) treats a group of related tasks running in different threads as one unit of work. The main API is StructuredTaskScope.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Subtask<User> user = scope.fork(() -> findUser()); Subtask<Order> order = scope.fork(() -> findOrder()); scope.join().throwIfFailed(); return new Result(user.get(), order.get()); }
Subtasks cannot outlive the scope, so there are no leaked threads, and failures or cancellation propagate cleanly.
It follows the same idea as structured programming: a block of code that starts concurrent work should also be the block that waits for it, which makes thread dumps and error handling easier to follow.
Take quiz
subtasks finish before the enclosing scope exits
tasks run only on the main thread
subtask exceptions are silently ignored
TaskGroup
StructuredTaskScope
ConcurrentScope
17. What is the Foreign Function and Memory API?
The Foreign Function & Memory (FFM) API (JEP 442, third preview in Java 21) lets Java call native libraries and work with off-heap memory safely, without writing JNI glue code.
Its main pieces are Linker (calls native functions), MemorySegment (a bounds-checked memory region) and Arena (controls when memory is freed).
It aims to replace JNI and much of the risky sun.misc.Unsafe usage. In Java 21 it still needs --enable-preview.
Its safety checks include bounds checking on every segment access and temporal checks, so use-after-free on a closed arena throws an exception instead of crashing the JVM.
Take quiz
JDBC drivers
RMI
JNI and many uses of sun.misc.Unsafe
an Arena
finalizers
System.gc()
18. What is the Vector API in Java 21?
The Vector API (JEP 448, sixth incubator in Java 21) lets you write SIMD computations in Java that the JIT compiler maps to CPU vector instructions, such as AVX or NEON, where the hardware supports them.
var sp = FloatVector.SPECIES_PREFERRED; for (int i = 0; i < sp.loopBound(a.length); i += sp.length()) { FloatVector.fromArray(sp, a, i) .add(FloatVector.fromArray(sp, b, i)) .intoArray(c, i); }
It lives in the jdk.incubator.vector module, so you add --add-modules jdk.incubator.vector. It stays incubating until Project Valhalla features land.
Take quiz
database vector searches
SIMD computations that compile to CPU vector instructions
growable arrays like java.util.Vector
java.vector
jdk.simd
jdk.incubator.vector
19. What is the Key Encapsulation Mechanism API?
The KEM API (JEP 452, final in Java 21) adds javax.crypto.KEM. A KEM uses a public key to securely establish a shared symmetric key. It is a building block for hybrid encryption and for post-quantum schemes.
- The receiver generates a key pair and shares the public key.
- The sender calls
newEncapsulator(publicKey).encapsulate()and gets a secret key plus an encapsulation message. - The receiver passes that message to
newDecapsulator(privateKey).decapsulate(...)and recovers the same secret.
The JDK itself includes a DHKEM implementation, and other providers can plug in more algorithms.
Take quiz
establish a shared symmetric key using public-key cryptography
compress network traffic
hash stored passwords
the sender's private key
the encapsulation message
the hash salt
20. What is a preview feature and how do you enable it?
A preview feature is fully specified and implemented but not yet permanent, so the Java community can try it and give feedback before it is finalised. It may change, or even be dropped.
You must opt in at both compile time and run time, using the same JDK version:
javac --release 21 --enable-preview Main.java java --enable-preview Main
Classes compiled this way are marked with a special minor class file version, so they run only on that exact Java release. That is why preview features are not meant for production libraries.
Take quiz
--allow-preview
-XX:+UsePreview
--enable-preview
only Java 21
any later JDK
any JDK from 8 upwards
21. What new methods were added to String and StringBuilder?
Java 21 added a handful of small but handy text methods:
StringBuilder.repeat(CharSequence, int)andrepeat(int codePoint, int)append text several times.String.indexOf(int ch, int from, int to)andindexOf(String, int, int)search within a range.String.splitWithDelimiters(regex, limit)splits and keeps the matched delimiters.Character.isEmoji()and related checks.
new StringBuilder().repeat("ab", 3); // ababab "a1b2c".splitWithDelimiters("\\d", 0); // [a, 1, b, 2, c]
None of these change existing behaviour, so they are safe to adopt gradually. repeat in particular removes the common loop that appended the same text several times.
Take quiz
ab3
aaabbb
abab
ababab
ignores the regex
returns a single String
also returns the matched delimiters
22. What does Math.clamp do?
Math.clamp (new in Java 21) restricts a value to a range. It returns min if the value is below the range, max if above, and the value itself otherwise.
Math.clamp(150, 0, 100); // 100 Math.clamp(-5, 0, 100); // 0 Math.clamp(42, 0, 100); // 42
There are overloads for long, double and float. If min is greater than max, it throws IllegalArgumentException. It replaces the old Math.max(min, Math.min(max, v)) idiom.
The long, int, int overload returns an int, so no cast is needed when clamping a long value into an int range, such as a page size or percentage.
Take quiz
0
100
150
50
swaps the two bounds
throws IllegalArgumentException
returns max
23. What is the difference between platform threads and virtual threads?
A platform thread wraps one OS thread. A virtual thread is a JVM-managed thread that borrows an OS thread only while it is running.
| Aspect | Platform thread | Virtual thread |
| Mapping | 1:1 with an OS thread | Many virtual threads share a few carrier threads |
| Stack | Fixed OS stack, often around 1 MB reserved | Small stack chunks on the heap that grow as needed |
| Practical count | Thousands | Millions |
| Creation cost | High, so pooled | Very low, so one per task |
| When blocking | Blocks the OS thread | Unmounts and frees the carrier |
| Scheduler | Operating system | JVM (ForkJoinPool) |
| Daemon / priority | Configurable | Always daemon, fixed normal priority |
The API is the same, so existing blocking code usually works unchanged.
Take quiz
its carrier thread blocks as well
the JVM terminates it
it unmounts and frees its carrier thread
fixed at normal priority
adjustable from 1 to 10
inherited from its carrier thread
24. Why shouldn't you pool virtual threads?
Pools exist because platform threads are expensive: you create a few and reuse them. Virtual threads are cheap, so the intended model is one new virtual thread per task.
Pooling them gives no benefit and causes harm:
- It adds queueing and contention for a resource that is not scarce.
- A long-lived pooled thread can leak
ThreadLocalstate between unrelated tasks. - The pool size becomes an accidental concurrency limit.
If you really need to cap load on a database or remote API, limit access to that resource with a Semaphore instead of limiting threads.
A good test is this: if you find yourself writing newFixedThreadPool(n) for virtual threads, you are almost certainly trying to limit something else, and a Semaphore says that more directly.
Take quiz
the JVM cannot reuse them safely
they are cheap, so one per task is the intended model
pools reject Runnable tasks
a fixed pool of virtual threads
Thread.setPriority(1)
a Semaphore around the database call
25. How does the virtual thread scheduler work?
Virtual threads run as continuations on a dedicated ForkJoinPool operating in FIFO mode. Its threads are the carrier threads, and their number defaults to the CPU count.
When code hits a blocking operation such as a socket read, sleep or a lock wait, the continuation yields. Its stack frames are copied to the heap, and the carrier picks up another virtual thread. When the operation completes, the virtual thread is resubmitted and may resume on a different carrier.
flowchart LR
A["Virtual thread starts"] --> B["Mounted on carrier thread"]
B --> C{"Blocking call?"}
C -- No --> D["Runs to completion"]
C -- Yes --> E["Unmount: stack copied to heap"]
E --> F["Carrier runs another virtual thread"]
E --> G["I/O ready: resubmitted to scheduler"]
G --> B
Tune it with -Djdk.virtualThreadScheduler.parallelism and -Djdk.virtualThreadScheduler.maxPoolSize, though the defaults are usually right.
Take quiz
a ForkJoinPool
a cached pool of platform threads
OS-level fibers
on the carrier's native stack
on the heap
in a file on disk
26. What happens when a virtual thread is pinned?
A virtual thread is pinned when it cannot unmount from its carrier. In Java 21 this happens when it blocks while inside a synchronized block or method, or while a native or foreign frame is on its stack.
While pinned, a blocking call holds the OS carrier thread hostage. A few pinned threads reduce throughput. If every carrier is pinned, other virtual threads starve.
synchronized (lock) { socket.read(); // pinned: carrier is blocked too } lock.lock(); // ReentrantLock: can unmount while waiting try { socket.read(); } finally { lock.unlock(); }
Fix it by using ReentrantLock around blocking work and keeping synchronized sections short. Java 24 later removed pinning for synchronized (JEP 491).
Take quiz
is terminated by the JVM
releases the carrier as usual
stays pinned to its carrier thread
use ReentrantLock around blocking sections
raise the thread priority
call System.gc() before blocking
27. Why don't virtual threads speed up CPU-bound work?
Virtual threads improve concurrency, not parallelism. The number of carrier threads is about the number of CPU cores, so only that many virtual threads can execute at the same instant.
A CPU-bound task never blocks, so it never unmounts and never frees its carrier for others. A million such tasks still queue behind a handful of cores, and you add scheduling overhead for no gain.
Virtual threads help when tasks spend most of their time waiting. By Little's law, more requests can be in flight at once if waiting no longer ties up an OS thread. For heavy computation, use a fixed pool sized to the cores, the ForkJoin framework or parallel streams.
Take quiz
virtual threads cannot run computations
parallelism is still capped by the number of CPU cores
they only run after garbage collection
do heavy matrix math
never touch the network
spend most of their time waiting
28. How do you limit concurrency with virtual threads?
Do not limit the thread count. Limit the resource the threads use, with a Semaphore. This keeps the one-thread-per-task model while protecting a database, API or file system.
Semaphore permits = new Semaphore(50); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (var job : jobs) { executor.submit(() -> { permits.acquire(); try { return callDatabase(job); } finally { permits.release(); } }); } }
At most 50 tasks hit the database at once, while the rest wait cheaply as parked virtual threads. Always release in finally.
Pick the permit count from the downstream limit, such as the database connection pool size, not from the CPU count. Waiting on acquire() is cheap because the virtual thread simply parks.
Take quiz
how many tasks use the resource at once
how many virtual threads can exist
the number of carrier threads
ThreadLocal
Semaphore
CountDownLatch
29. How does ExecutorService change in Java 21?
In Java 21, ExecutorService extends AutoCloseable, so you can use it in try-with-resources. Its close() method initiates an orderly shutdown and then waits for submitted tasks to finish.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return i; })); } // close() blocks here until all tasks complete
Before this, you had to call shutdown() and awaitTermination() yourself, and forgetting them was a common source of leaks. If the waiting thread is interrupted, close() calls shutdownNow().
Executors.newVirtualThreadPerTaskExecutor() combined with try-with-resources gives a clear scope: tasks start inside the block, and the block ends only when all of them are done.
Take quiz
kills running tasks immediately
only frees memory
initiates shutdown and waits for submitted tasks to finish
try-with-resources
only static initializers
only with platform threads
30. How do you troubleshoot virtual thread pinning?
Work through these steps:
- Run with
-Djdk.tracePinnedThreads=full. The JVM prints a stack trace whenever a virtual thread blocks while pinned, with the pinning frames marked. - Record with Java Flight Recorder and look at the
jdk.VirtualThreadPinnedevent, which fires when a pinned thread blocks longer than a threshold (20 ms by default). - Find the
synchronizedblock or native call in the trace. - Replace it with
ReentrantLock, or shorten the section so it no longer wraps blocking I/O. - Check third-party libraries and JDBC drivers, which are common culprits, and upgrade them.
Short pinned sections around fast code are harmless. Focus on pinning that wraps long blocking calls.
Take quiz
jdk.virtualThreadDebug
jdk.tracePinnedThreads
java.pinned.trace
jdk.CarrierBlocked
jdk.ThreadPark
jdk.VirtualThreadPinned
31. How do you take a thread dump with virtual threads?
The classic jstack and jcmd <pid> Thread.print output lists only platform threads, because an app may hold millions of virtual threads.
Use the newer command instead:
jcmd <pid> Thread.dump_to_file -format=json threads.json
This writes all threads, including virtual ones, and the JSON format groups threads in a structured-concurrency tree so related tasks are easy to spot. Plain text output is the default if you omit -format.
You can also call HotSpotDiagnosticMXBean.dumpThreads from inside the application.
For a quick look at live problems, a JSON dump can be post-processed with jq or loaded into tools that understand it, which is far easier than scanning a text file with thousands of stacks.
Take quiz
jcmd <pid> Thread.dump_to_file
jstack -v
jmap -histo
virtual threads have no stack
there may be millions, so it lists only platform threads
they are hidden by the security manager
32. Why should you be careful with ThreadLocal on virtual threads?
Virtual threads do support ThreadLocal, but the usual patterns stop paying off.
- Memory multiplies. Each virtual thread gets its own copy. With a million threads, a per-thread cache of a heavy object becomes a million objects.
- Caching assumptions break. Code that stored an expensive
SimpleDateFormatper pooled thread now rebuilds it for every short-lived thread. - Inheritance costs.
InheritableThreadLocalcopies values into every child thread.
Prefer thread-safe shared objects, pass data as parameters, or use ScopedValue for immutable per-request context.
If you keep ThreadLocals for legacy libraries, call remove() when the task finishes. Short-lived virtual threads reduce the leak risk, but a long-lived one can still hold stale data.
Take quiz
virtual threads do not support ThreadLocal
values are shared across all threads
each short-lived thread creates its own copy, multiplying memory use
ScopedValue
a static mutable map
System properties
33. What is the difference between ScopedValue and ThreadLocal?
Both carry per-thread context, but they behave very differently.
| Aspect | ThreadLocal | ScopedValue (preview in 21) |
| Mutability | set() any time |
Immutable within a binding |
| Lifetime | Until remove() or thread end |
Only while run() or call() executes |
| Child threads | InheritableThreadLocal copies values |
Shared with StructuredTaskScope children, no copy |
| Memory cost | A copy per thread | Lightweight, no per-thread map to clean |
| Leak risk | Yes, if not removed | None, the binding ends automatically |
Rebinding is allowed only by nesting another where(...), and the outer value returns when the inner scope ends. That makes data flow easier to reason about.
In short: use ScopedValue for read-only context that flows down a call tree, and keep ThreadLocal only where you truly need a mutable per-thread slot.
Take quiz
ScopedValue allows set() anywhere, ThreadLocal is immutable
ScopedValue is immutable within its binding, while ThreadLocal can be reassigned
there is no difference
the thread terminates
garbage collection runs
the run or call operation returns
34. Explain the execution flow of StructuredTaskScope?
A StructuredTaskScope runs in a fixed order, enforced by try-with-resources:
- The owner thread opens the scope.
- Each
fork()starts a new virtual thread running one subtask. - The owner calls
join(), which waits until all subtasks finish, or until the policy callsshutdown(). - The owner reads results with
Subtask.get(). Calling it beforejoin()throwsIllegalStateException. close()runs at the end of the block and guarantees no subtask thread is still alive.
sequenceDiagram participant M as Owner thread participant S as StructuredTaskScope participant T1 as Subtask 1 participant T2 as Subtask 2 M->>S: open scope M->>S: fork task 1 S->>T1: start virtual thread M->>S: fork task 2 S->>T2: start virtual thread M->>S: join T1-->>S: result T2-->>S: result S-->>M: join returns M->>S: close
If one subtask fails under ShutdownOnFailure, the scope cancels the others, so join() returns early.
Take quiz
shutdown()
join()
fork() a second time
commit()
a task on the common ForkJoinPool
a new virtual thread
a platform thread from a fixed pool
35. What is the difference between ShutdownOnFailure and ShutdownOnSuccess?
Both are StructuredTaskScope policies. They differ in when they cancel the remaining subtasks and what you read afterwards.
| Aspect | ShutdownOnFailure | ShutdownOnSuccess |
| Cancels others when | the first subtask fails | the first subtask succeeds |
| Typical use | Every subtask result is required (aggregate) | Any one result is enough (race, redundancy) |
| Reading the outcome | join().throwIfFailed(), then Subtask.get() |
join().result() returns the first success |
| If it all goes wrong | Rethrows the first exception | result() throws if no subtask succeeded |
Use ShutdownOnFailure to load a user, orders and preferences for one page. Use ShutdownOnSuccess to query three mirror services and take whichever replies first. You can also extend StructuredTaskScope and override handleComplete for a custom policy.
Take quiz
ShutdownOnFailure
a plain invokeAll call
ShutdownOnSuccess
every subtask result is required
only the first result matters
tasks can never fail
36. How does exhaustiveness checking work in a pattern switch?
A switch expression, or a pattern switch statement, must cover every possible input. With a sealed hierarchy the compiler knows all permitted subtypes, so a default branch is unnecessary.
sealed interface Shape permits Circle, Square, Rect {} record Circle(double r) implements Shape {} record Square(double side) implements Shape {} record Rect(double w, double h) implements Shape {} double area(Shape s) { return switch (s) { case Circle c -> Math.PI * c.r() * c.r(); case Square q -> q.side() * q.side(); case Rect r -> r.w() * r.h(); }; }
If you add a fourth permitted type and recompile, this switch fails to compile until you handle it. That is the main benefit: new cases cannot be silently forgotten.
If the hierarchy changes after compilation (separately compiled classes), the switch throws MatchException at run time.
Take quiz
it silently runs the first case
a compile error for a non-exhaustive switch
a ClassCastException at runtime
IllegalStateException
InstantiationError
MatchException
ArrayStoreException
37. How does a pattern switch handle null?
A classic switch throws NullPointerException if the selector is null. Java 21 keeps that default, but lets you handle it explicitly with case null.
String label = switch (obj) { case null -> "nothing"; case String s -> "text: " + s; default -> "other"; }; // or combine with default switch (obj) { case String s -> use(s); case null, default -> fallback(); }
If there is no case null, a null selector still throws NullPointerException, so existing code keeps its behaviour. Handling null in the switch removes a separate guard before it.
This also helps with Optional-free APIs that return null: the null branch lives next to the other cases, so reviewers can see every outcome in one place.
Take quiz
throws NullPointerException
jumps to default
returns null
default null
case null, default
case default null
38. Why does pattern dominance cause compile errors?
A pattern label is dominated if an earlier label already matches every value it could match. That later case could never run, so the compiler rejects it.
switch (o) { case CharSequence cs -> use(cs); case String s -> use(s); // error: dominated by CharSequence default -> other(); }
Every String is a CharSequence, so the first label wins. Swap them and the code compiles.
The same rule covers guards: put case Integer i when i > 0 before a plain case Integer i. Constant labels after a pattern that already covers them are also flagged. The rule turns subtle unreachable-code bugs into build errors.
Take quiz
case CharSequence cs first, then case String s
both orderings compile
case String s first, then case CharSequence cs
prevent unreachable case labels
speed up JIT compilation
enforce variable naming
39. Explain the execution flow of a native call using FFM?
Calling a C function through the FFM API follows a fixed sequence. This example calls strlen (Java 21 preview API):
flowchart TD A["Open an Arena"] --> B["Get native Linker"] B --> C["Find symbol with SymbolLookup"] C --> D["Describe signature with FunctionDescriptor"] D --> E["Create downcall MethodHandle"] E --> F["Allocate MemorySegment in Arena"] F --> G["Invoke handle"] G --> H["Close Arena: memory freed"]
try (Arena arena = Arena.ofConfined()) { Linker linker = Linker.nativeLinker(); MethodHandle strlen = linker.downcallHandle( linker.defaultLookup().find("strlen").orElseThrow(), FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)); MemorySegment str = arena.allocateUtf8String("Hello"); long len = (long) strlen.invokeExact(str); // 5 }
The arena owns the native memory and frees it deterministically on close. Use --enable-preview and --enable-native-access=ALL-UNNAMED to avoid warnings.
Take quiz
an Arena
a SymbolLookup, for example linker.defaultLookup()
a MemoryLayout
at the next garbage collection
never, it must be freed by C code
when the arena is closed
40. How do nested record patterns work?
A record pattern's components can themselves be patterns, including other record patterns. Java matches them recursively, left to right, and binds every variable only if the whole pattern matches.
record Point(int x, int y) {} record Line(Point from, Point to) {} if (obj instanceof Line(Point(var x1, var y1), Point(var x2, var y2))) { double len = Math.hypot(x2 - x1, y2 - y1); }
var lets the compiler infer each component type. This replaces chains of casts and accessor calls with one declarative test.
One detail: a nested type pattern such as Point p matches a null component, but a nested record pattern such as Point(var x, var y) does not. Combined with sealed interfaces, nested patterns make a switch over a data hierarchy compact and exhaustive.
Take quiz
infers the component's type
makes the component mutable
matches only null
mutate immutable records
extract deeply nested values in a single test
skip type checking
41. What is the difference between STR, FMT and RAW processors?
All three are template processors for string templates (preview in Java 21).
| Processor | Result | Notes |
STR |
String |
Plain interpolation; imported automatically in every source file |
FMT |
String |
Supports format specifiers before each expression; needs import static java.util.FormatProcessor.FMT; |
RAW |
StringTemplate |
Not processed yet; you call process() on a processor later |
int x = 7; STR."x = \{x}"; // x = 7 FMT."x = %05d\{x}"; // x = 00007 var raw = RAW."x = \{x}"; // a StringTemplate object String s = STR.process(raw);
Because RAW keeps the fragments and values separate, it is the basis for custom processors.
Most code only needs STR. Reach for FMT when you need aligned numbers or padding, and for RAW only when writing your own processor.
Take quiz
STR
RAW
FMT
a StringTemplate object
a String
a char array
42. Why are string templates safer than concatenation?
With concatenation, values are glued into one string before anything can inspect them. With a template, the processor receives the literal fragments and the embedded values separately, so it can validate, escape or parameterise them.
That is how a custom processor can build a safe SQL PreparedStatement or JSON object from user input, so values become parameters instead of text that could inject extra SQL.
var INSPECT = StringTemplate.Processor.of( st -> st.fragments() + " | " + st.values()); INSPECT."id=\{userId}"; // [id=, ] | [42]
A processor is not limited to returning String. It can return any type. Safety depends on the processor you pick: STR is plain interpolation and offers no extra protection.
Take quiz
template strings are encrypted
a processor sees fragments and values separately, so it can validate or escape them
the compiler deletes user input
only String
only int
any type, not only String
43. What happened to Java 21 preview features in later releases?
Java 21 was a snapshot in time. Here is how its preview, incubator and final features developed (as of Java 25):
| Feature | Later outcome |
| Unnamed patterns and variables | Final in Java 22 (JEP 456) |
| FFM API | Final in Java 22 (JEP 454) |
| Scoped values | Final in Java 25 (JEP 506) |
| Structured concurrency | Still in preview in Java 25 |
| Unnamed classes / instance main | Reworked as implicit classes, then final in Java 25 as compact source files |
| String templates | Second preview in Java 22, then removed in Java 23 for redesign |
| Generational ZGC | Default in Java 23, non-generational mode removed in Java 24 |
| Virtual threads | Pinning for synchronized removed in Java 24 (JEP 491) |
| Vector API | Still incubating |
The practical lesson: avoid building production code on preview APIs, since names and behaviour can change between releases.
Take quiz
were removed in Java 23 so they could be redesigned
became final in Java 22
moved into an incubator module
Java 21
Java 22
Java 24
44. What is the difference between Generational and non-generational ZGC?
Non-generational ZGC treats the whole heap as one region and marks all of it every cycle. Generational ZGC splits it into young and old generations.
| Aspect | Non-generational ZGC | Generational ZGC |
| Collection scope | Whole heap every cycle | Young often, old rarely |
| Allocation stalls | More likely under heavy allocation | Much less likely |
| Memory overhead | Higher headroom needed | Lower |
| CPU cost | Repeatedly scans long-lived objects | Mostly avoids rescanning old data |
| Barriers | Load barriers | Load and store barriers |
Both keep pauses very short. The generational design simply exploits the fact that most objects die young, so the collector does less work per byte allocated. Workloads that allocate many short-lived objects gain most.
Take quiz
programs that never allocate
heaps smaller than 100 MB only
allocation-heavy code creating many short-lived objects
separate young and old generations
stop-the-world full compaction
reference counting instead of marking
45. When would you choose ZGC over G1?
Choose ZGC when tail latency matters more than raw throughput, especially on large heaps. G1, the default collector, balances throughput and pause time with a pause target of about 200 ms.
| Factor | G1 (default) | ZGC |
| Pause times | Tens to hundreds of ms | Sub-millisecond, independent of heap size |
| Heap size | Small to medium | Multi-GB up to terabytes |
| Throughput | Generally higher | Slightly lower, more CPU and memory headroom |
| Best for | General services, batch jobs | Latency-sensitive APIs, trading, large caches |
In Java 21, add -XX:+ZGenerational to get the better allocation behaviour. Measure with your real load before switching, and make sure you have spare memory, since ZGC works best with some headroom.
Take quiz
maximum throughput on a 256 MB batch job
very low pause times on a large heap
a single-core container
ZGC
Epsilon
G1
46. What is the difference between reversed() and Collections.reverse()?
Collections.reverse(list) mutates the list in place and returns nothing. list.reversed() (Java 21) returns a view in reverse order and leaves the original untouched.
List<Integer> nums = new ArrayList<>(List.of(1, 2, 3)); List<Integer> rev = nums.reversed(); // [3, 2, 1], O(1) view nums.add(4); System.out.println(rev); // [4, 3, 2, 1] - view reflects change Collections.reverse(nums); // nums is now [4, 3, 2, 1] in place
Because the view is backed by the original, writes go through both ways. Wrap it in new ArrayList<>(...) if you need an independent copy. Calling reversed() on an immutable list gives an unmodifiable view, while Collections.reverse would throw.
Take quiz
a reverse-ordered view backed by the original list
a new copied list
void, after reversing in place
stays stale
reflects the change
always throws ConcurrentModificationException
47. What is the difference between SequencedMap and SortedMap?
SortedMap orders entries by key comparison (natural order or a comparator). SequencedMap only promises a defined encounter order, such as insertion order. In Java 21, SortedMap extends SequencedMap.
| Aspect | SequencedMap | SortedMap |
| Order comes from | Insertion or access order | Key comparison |
| Implementations | LinkedHashMap, and sorted maps |
TreeMap, ConcurrentSkipListMap |
putFirst / putLast |
Supported by LinkedHashMap |
Throw UnsupportedOperationException |
firstEntry / lastEntry |
Available | Available, plus firstKey, subMap and others |
Sorted maps cannot honour putFirst because position is decided by the key, not by the caller. HashMap is not sequenced at all, as its iteration order is unspecified.
For caches that need least-recently-used ordering, LinkedHashMap in access-order mode is a SequencedMap, and pollFirstEntry() now gives you the eldest entry directly.
Take quiz
TreeMap
HashMap
LinkedHashMap
throws UnsupportedOperationException
appends it regardless of key order
replaces the comparator
48. How do you enable virtual threads in Spring Boot?
With Spring Boot 3.2 or later running on Java 21, set one property:
spring.threads.virtual.enabled=true
Spring Boot then runs embedded Tomcat or Jetty request handling, @Async tasks and scheduling on virtual threads, so each request gets its own cheap thread.
Check these before going live:
- The connection pool size, for example HikariCP, still caps database concurrency, so tune it.
- Libraries that block inside
synchronizedpin carriers in Java 21. Trace with-Djdk.tracePinnedThreads=full. - Drop custom fixed thread pools that were only there to protect blocking calls.
Measure before and after with a load test. Gains are largest for services that wait on databases or HTTP calls, and negligible for CPU-heavy endpoints.
Take quiz
spring.virtual-threads=on
spring.threads.virtual.enabled=true
server.tomcat.virtual=true
the number of carrier threads
the heap size
the connection pool size
49. What can break when upgrading to Java 21?
Most apps upgrade smoothly, but watch these areas:
- New default methods.
getFirst(),getLast()andreversed()were added toListandDeque. A custom class implementing both can fail to compile because the inherited defaults conflict. - Dynamic agent loading. Attaching an agent at run time, as some mocking and profiling tools do, now prints a warning. Use
-javaagentat startup or add-XX:+EnableDynamicAgentLoading. - Class file version 65. Bytecode tools such as ASM, Byte Buddy, JaCoCo and Lombok must be recent enough to read it.
- Encapsulated internals. Reflection into JDK internals needs
--add-opens. - Platform changes. The Windows 32-bit x86 port is deprecated for removal.
Run your full test suite and read the startup warnings before shipping.
Take quiz
both gained conflicting default methods such as reversed()
List became final
Deque was removed from the JDK
-XX:+AllowAgents
-XX:+EnableDynamicAgentLoading
--enable-agents
50. How do you migrate from Java 17 to Java 21?
Treat it as a staged upgrade, not a big-bang rewrite:
- Update tooling. Use recent versions of Maven or Gradle, their compiler plugins and your CI images with JDK 21.
- Update dependencies. Bump bytecode-dependent libraries (ASM, Byte Buddy, Lombok, JaCoCo, Mockito) so they handle class file version 65.
- Build and test on 21 with the same source level first. Read warnings about agents and reflection.
- Review JVM flags. Remove obsolete options and re-test GC choices.
- Adopt features gradually. Start with
getFirst()/getLast(), pattern switch and record patterns, then try virtual threads on I/O-heavy services. - Keep preview features off in production code.
Verify performance and memory in a staging environment before the final rollout.