Java 21 to 25 Features Interview Questions (2026): Virtual Threads, Records, Pattern Matching
Interview questions on Java 21 to 25 features with answers and real output: records, sealed classes, pattern matching, virtual threads, scoped values and what is still preview.
New25 min read

These Java 21 features interview questions cover what interviewers now expect you to know about modern Java: records, sealed classes, pattern matching for switch, virtual threads, and the Java 22 to 25 additions such as scoped values, stream gatherers and flexible constructor bodies. Every answer starts with a short version you can say out loud, then gives the detail, a runnable snippet and its real output.
The guide is for Java developers with one to eight years of experience who learned the language on Java 8, 11 or 17 and now face interviewers who ask "what changed?" Every program was run on JDK 25 (Temurin 25.0.3), and each feature's status (final, preview or experimental) was checked against the official JEP pages, as of October 2026.
Java 25 is the current long-term support (LTS) release, and Java 26 and 27 have shipped since as short-lived feature releases. Interviewers usually ask about the two LTS jumps, 17 to 21 and 21 to 25, so that is how this guide is organized.
In this guide
- Java 17 vs 21 vs 25: what changed
- Records and sealed classes interview questions
- Pattern matching for switch and record patterns
- Virtual threads interview questions
- Scoped values and structured concurrency
- Sequenced collections and stream gatherers
- Java 25 language features
- JVM changes: garbage collection, object headers and startup
- Scenario questions for experienced developers
- FAQ
Java 17 vs 21 vs 25: what changed between the LTS releases
Most companies run one LTS version and skip the six-monthly releases in between. A feature counts as "Java 21" or "Java 25" in an interview when it became final (no preview flag needed) in or before that release. The timeline below shows the headline features that became final in each step; the JDK 25 project page has the full list of JEPs for the latest LTS.
The same information as a list:
- Java 17 (LTS, September 2021): sealed classes, plus records and pattern matching for
instanceof(final in 16), text blocks (15) and switch expressions (14). - Java 21 (LTS, September 2023): virtual threads, pattern matching for switch, record patterns, sequenced collections, and generational ZGC as an option.
- Java 22 to 24 (2024 to March 2025): unnamed variables and patterns (22), the Foreign Function and Memory API (22), stream gatherers (24), the Class-File API (24), and virtual threads no longer pinned by
synchronized(24). - Java 25 (LTS, September 2025): scoped values, flexible constructor bodies, compact source files and instance main methods, module import declarations, compact object headers as a product feature, and a one-step AOT cache.
- Java 26 and 27 (March and September 2026): HTTP/3 in the HTTP client (26), G1 as the default collector in every environment (27), and compact object headers on by default (27). Structured concurrency is still a preview.
Which Java version should you learn for interviews in 2026?
Learn Java 21 thoroughly and know what Java 25 added on top. Java 21 is the LTS most production upgrades from 17 land on, and Java 25 is the natural choice for new projects. Java 17 knowledge (records, sealed classes, text blocks) is assumed.
If an interviewer asks about Java 26 or 27, they want to see that you follow the platform, not that you run it in production. Knowing that structured concurrency is still in preview in Java 27, and that compact object headers became the default there, is enough.
Records and sealed classes interview questions
What is a record in Java and when should you use one?
A record is a class whose job is to carry data. You declare its components once, and the compiler generates a canonical constructor, private final fields, accessor methods, and equals, hashCode and toString based on all components. Use records for DTOs, API request and response bodies, query results, map keys and value objects.
record Money(String currency, long amountInCents) {
Money { // compact constructor: validate before fields are set
Objects.requireNonNull(currency, "currency");
if (amountInCents < 0) throw new IllegalArgumentException("negative amount");
}
Money add(Money other) { // records can have methods
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(currency, amountInCents + other.amountInCents);
}
}
Money a = new Money("USD", 1_250);
Money b = new Money("USD", 1_250);
System.out.println(a);
System.out.println(a.equals(b));
System.out.println(a.add(b).amountInCents());
try {
new Money("USD", -1);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}Money[currency=USD, amountInCents=1250]
true
2500
rejected: negative amount
Notice the accessor is amountInCents(), not getAmountInCents(). Interviewers like to ask that, because it breaks older libraries that look for JavaBean getters.
Can a record extend a class or have mutable fields?
No to both. A record implicitly extends java.lang.Record, so it cannot extend anything else, and it is implicitly final. It cannot declare extra instance fields; every piece of state must be a component. It can implement interfaces and have static fields, static methods, instance methods and nested types.
The immutability is shallow. A record with a List<String> component stores a final reference, but the list itself can still change. Copy it in the compact constructor with List.copyOf(items) if you need a truly immutable value.
What is a compact constructor in a record?
A compact constructor is the canonical constructor written without the parameter list. It runs before the fields are assigned, so you use it to validate or normalize arguments. You may reassign the parameters (for example name = name.strip();), but you do not write this.name = name; the compiler adds those assignments at the end.
What are sealed classes, and why use them with records?
A sealed class or interface lists exactly which classes may extend or implement it, using permits. Every permitted subclass must be final, sealed or non-sealed. Sealed types became final in Java 17.
The point is to model a closed set of alternatives, such as payment results (success, declined, pending) or shapes. Records are a natural fit for the leaves because they are final and carry data. Together they give Java algebraic data types: a sealed interface is the "one of", and each record is the "all of these fields". The payoff comes in the next section, where the compiler can check that a switch handles every case.
What is the difference between a sealed class and a final class?
A final class has no subclasses at all. A sealed class has a fixed, known list of subclasses. Use final when there is one implementation, sealed when there are several but you do not want strangers adding more. non-sealed reopens one branch of a sealed hierarchy for extension when you need it.
Pattern matching for switch and record patterns
How does pattern matching for switch work in Java 21?
Since Java 21, a case label can be a type pattern such as case Circle c. The switch tests the selector against each pattern in order and binds the variable when it matches, so you no longer write an instanceof check followed by a cast. Combined with a sealed type, the compiler knows every possible subtype and checks that the switch is exhaustive.
sealed interface Shape permits Circle, Square, Rectangle {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
static double area(Shape shape) {
return switch (shape) { // no default: the compiler knows every subtype
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Rectangle(double w, double h) -> w * h; // record pattern
};
}
System.out.printf("%.2f%n", area(new Circle(1)));
System.out.println(area(new Square(3)));
System.out.println(area(new Rectangle(2, 5)));3.14
9.0
10.0
What happens if you add a new subtype to a sealed interface?
Every pattern switch over that interface without a default stops compiling until you add a case for the new type. That is the main reason to prefer sealed types plus pattern switches over a default branch: the compiler finds every place that needs updating. We added record Triangle to the permits list and ran the same switch:
Main.java:8: error: the switch expression does not cover all possible input values
return switch (shape) {
^
1 error
If you add default, you lose this check, and a new subtype silently falls into the default branch.
What are record patterns?
A record pattern deconstructs a record into its components inside a pattern, as in case Rectangle(double w, double h). It became final in Java 21. Record patterns nest, so case Line(Point(var x1, var y1), Point(var x2, var y2)) pulls four values out of a nested structure in one line. You can use var for the component types.
What is a guarded pattern (the when clause)?
A guarded pattern adds a boolean condition with when: the case matches only if the type matches and the condition is true. It replaces an if nested inside the case body.
static String describe(Object o) {
return switch (o) {
case null -> "null"; // null is a case now
case Integer i when i > 100 -> "big int " + i; // guarded pattern
case Integer i -> "int " + i;
case String s when s.isBlank() -> "blank string";
case String s -> "string of length " + s.length();
case Rectangle(var w, var h) when w == h -> "a square rectangle";
default -> "something else: " + o.getClass().getSimpleName();
};
}
// called with: null, 7, 500, " ", "Java", new Rectangle(4, 4), 3.5null
int 7
big int 500
blank string
string of length 4
a square rectangle
something else: Double
Does the order of case labels matter in a pattern switch?
Yes. Cases are tested from top to bottom, and the compiler rejects a case that can never match because an earlier case already covers it. This is called dominance. A guarded case must come before the unguarded case for the same type, and a subtype must come before its supertype.
case CharSequence cs -> "char sequence";
case String s -> "string"; // dominated by the case aboveMain.java:5: error: this case label is dominated by a preceding case label
case String s -> "string"; // dominated by the case above
^
1 error
How does switch handle null in Java 21?
A switch still throws NullPointerException on a null selector unless it has a case null. You can write case null on its own or combine it as case null, default ->. This keeps old switches backward compatible while letting new code handle null in one place.
What are unnamed variables and patterns (the underscore)?
Since Java 22, you can write _ for a variable or pattern component you must declare but never use. It works in lambda parameters, catch blocks, enhanced for loops, local variables and record patterns. It tells readers (and linters) that ignoring the value is deliberate.
record Point(int x, int y) {}
Object o = new Point(3, 4);
if (o instanceof Point(int x, _)) { // we only care about x
System.out.println("x = " + x);
}
int count = 0;
for (String _ : List.of("a", "b", "c")) count++; // loop variable not needed
System.out.println("count = " + count);
try {
Integer.parseInt("not a number");
} catch (NumberFormatException _) { // exception not used
System.out.println("invalid number");
}
Map.of("pen", 3).forEach((_, qty) -> System.out.println("qty = " + qty));x = 3
count = 3
invalid number
qty = 3
What are text blocks?
Text blocks (final in Java 15) are multi-line string literals between triple quotes. The compiler strips the common indentation and normalizes line endings, so JSON, SQL and HTML in code stay readable without \n and concatenation. Use \ at the end of a line to join lines and \s to keep a trailing space. Pair them with formatted() for values, never for SQL parameters; use bind parameters there.
Virtual threads interview questions
What is a virtual thread and how is it different from a platform thread?
A virtual thread is a lightweight thread managed by the JVM rather than the operating system. Many virtual threads run on a small pool of platform "carrier" threads. When a virtual thread blocks on I/O, it unmounts from its carrier, its stack is stored on the heap, and the carrier runs another virtual thread. They became final in Java 21.
A platform thread is a thin wrapper around an OS thread, one to one. OS threads are expensive to create and each reserves a large stack, so servers cap them with thread pools of a few hundred. In thread-per-request code, that pool size becomes the limit on how many requests can wait on a database or HTTP call at once. Virtual threads remove that limit for I/O-bound work without forcing you into reactive code.
How do you create virtual threads in Java?
Use Thread.ofVirtual(), Thread.startVirtualThread(runnable), or, most often, Executors.newVirtualThreadPerTaskExecutor(), which starts a new virtual thread for every submitted task. The executor is AutoCloseable, and close() waits for all tasks to finish.
Thread vt = Thread.ofVirtual().name("demo").unstarted(() ->
System.out.println("inside: " + Thread.currentThread()));
vt.start();
vt.join();
System.out.println("isVirtual = " + vt.isVirtual());
long start = System.nanoTime();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // blocking call: the virtual thread unmounts
return i;
}));
} // close() waits for every task
long ms = Duration.ofNanos(System.nanoTime() - start).toMillis();
System.out.println("10,000 sleeping tasks took " + ms + " ms");inside: VirtualThread[#27,demo]/runnable@ForkJoinPool-1-worker-1
isVirtual = true
10,000 sleeping tasks took 1260 ms
The toString shows the carrier: ForkJoinPool-1-worker-1. Ten thousand tasks that each block for one second finish in about a second and a quarter on a laptop. With a fixed pool of 200 platform threads, the same work needs 50 rounds of one second each. The timing on your machine will differ; the shape will not.
What is pinning in virtual threads, and did Java 24 fix it?
A virtual thread is pinned when it cannot unmount from its carrier while it blocks, so it holds the carrier (and its OS thread) hostage. In Java 21, the common cause was blocking inside a synchronized block or method. Java 24 fixed that case with JEP 491, so on Java 24 and later, including Java 25, synchronized no longer pins.
Pinning still happens in rarer cases: when native code (a native method or a Foreign Function call) calls back into Java that then blocks, and when a virtual thread blocks while loading or initializing a class. You can find pinning with the JFR event jdk.VirtualThreadPinned. The old -Djdk.tracePinnedThreads system property no longer has any effect.
What the interviewer is checking: whether your knowledge stops at Java 21. "Replace synchronized with ReentrantLock to avoid pinning" was the right advice in 2023. On Java 24 and later it is unnecessary, and saying so shows you are current.
When should you not use virtual threads?
Virtual threads help when tasks spend most of their time waiting. They do not make code run faster. Avoid them, or expect no gain, in these cases:
- CPU-bound work such as image processing or number crunching. You still have only as many cores as you have; use a fixed pool sized to the cores, or parallel streams.
- As a way to limit concurrency. A pool of 20 platform threads also acted as a limit of 20 calls to a fragile downstream service. With virtual threads, use a
Semaphoreto keep that limit. - Heavy
ThreadLocalcaches. A library that caches an expensive object per thread creates one per virtual thread, which can mean a million copies. Scoped values or an explicit pool are better. - Long-held native calls. A virtual thread stuck in native code pins its carrier.
Should you pool virtual threads?
No. Pooling exists to reuse expensive objects, and virtual threads are cheap to create, so you create one per task and let it die. A pool of virtual threads brings back the concurrency cap you were trying to remove. If you need a limit, limit the resource (for example with a Semaphore or the database connection pool), not the threads.
How do you enable virtual threads in Spring Boot?
Set one property. Spring Boot then runs request handling on Tomcat or Jetty, @Async methods and scheduled tasks on virtual threads, and the thread pool size properties stop having any effect.
spring:
threads:
virtual:
enabled: true
main:
keep-alive: true # virtual threads are daemon threads; keep the JVM alive for @Scheduled-only appsThe Spring Boot reference says virtual threads need Java 21 or later and that Java 24 or later is strongly recommended for the best experience; the synchronized pinning fix above is the practical reason.
Scoped values and structured concurrency
What are scoped values and how are they different from ThreadLocal?
A scoped value shares an immutable value with everything a method calls, for a bounded period, without passing it as a parameter. You bind it with ScopedValue.where(KEY, value).run(...), and the binding disappears when run returns. Scoped values became final in Java 25 (JEP 506).
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public static void main(String[] args) throws Exception {
ScopedValue.where(REQUEST_ID, "req-42").run(() -> handle());
System.out.println("after run, bound = " + REQUEST_ID.isBound());
int length = ScopedValue.where(REQUEST_ID, "req-1001").call(() -> REQUEST_ID.get().length());
System.out.println("length = " + length);
}
static void handle() {
System.out.println("handling " + REQUEST_ID.get());
ScopedValue.where(REQUEST_ID, "req-42-retry").run(() -> // rebind for a nested scope
System.out.println("nested sees " + REQUEST_ID.get()));
System.out.println("back to " + REQUEST_ID.get());
}handling req-42
nested sees req-42-retry
back to req-42
after run, bound = false
length = 8
The differences from ThreadLocal that interviewers look for:
- Immutable: there is no
set(). Code deeper in the call stack can rebind for its own nested scope, but cannot change what its caller sees. - Bounded lifetime: the value is gone when the scope ends, so there is no
remove()to forget and no leak into the next task on a pooled thread. - Cheap to inherit: child threads forked inside a structured concurrency scope see the binding without copying, which matters with millions of virtual threads.
One detail changed when the API became final: orElse(null) is no longer allowed.
What is structured concurrency, and is it final in Java 25?
Structured concurrency treats a group of related subtasks as one unit of work: they start inside a scope, the scope waits for them, and if one fails the others are cancelled. It is not final. It is a preview in Java 21 through 25 (fifth preview in 25, JEP 505), and it is still a preview in Java 26 and 27. You need --enable-preview to use it.
try (var scope = StructuredTaskScope.open()) { // default policy: all must succeed
Subtask<String> user = scope.fork(Main::fetchUser);
Subtask<Integer> items = scope.fork(Main::fetchItems);
scope.join(); // waits for both; throws if one fails
System.out.println(new Order(user.get(), items.get()));
}
try (var scope = StructuredTaskScope.open()) {
scope.fork(Main::fetchUser);
scope.fork(Main::failingCall); // one subtask fails...
scope.join(); // ...so join throws and the other is cancelled
} catch (StructuredTaskScope.FailedException e) {
System.out.println("failed: " + e.getCause().getMessage());
}java --enable-preview --source 25 Main.javaOrder[user=asha, items=3]
failed: inventory service down
The API changed in Java 25: scopes are now created with StructuredTaskScope.open() and a Joiner policy (such as Joiner.anySuccessfulResultOrThrow()), instead of the ShutdownOnFailure subclasses you may see in older articles. Expect it to change again before it is final, which is why production code mostly still uses CompletableFuture or an executor.
Sequenced collections and stream gatherers
What are sequenced collections in Java 21?
Java 21 added three interfaces, SequencedCollection, SequencedSet and SequencedMap, for collections with a defined encounter order. They give one consistent API for the first and last element: getFirst(), getLast(), addFirst(), addLast(), removeFirst(), removeLast() and reversed(). Before this, getting the last element of a LinkedHashSet meant iterating the whole set.
List<String> list = new ArrayList<>(List.of("b", "c"));
list.addFirst("a");
list.addLast("d");
System.out.println(list + " first=" + list.getFirst() + " last=" + list.getLast());
System.out.println("reversed view: " + list.reversed());
LinkedHashSet<Integer> set = new LinkedHashSet<>(List.of(1, 2, 3));
set.addFirst(3); // moves 3 to the front
System.out.println(set);
SequencedMap<String, Integer> map = new LinkedHashMap<>();
map.put("one", 1);
map.put("two", 2);
map.putFirst("zero", 0);
System.out.println(map.firstEntry() + " " + map.lastEntry() + " " + map.reversed());[a, b, c, d] first=a last=d
reversed view: [d, c, b, a]
[3, 1, 2]
zero=0 two=2 {two=2, one=1, zero=0}
reversed() returns a view, not a copy, so changes to the original show through. On an unmodifiable list such as List.of(...), addFirst throws UnsupportedOperationException.
What are stream gatherers in Java 24?
A gatherer is a custom intermediate operation for streams, used with stream.gather(...), in the same way a collector is a custom terminal operation. Gatherers became final in Java 24. The built-in Gatherers class covers the common cases: windowFixed, windowSliding, fold, scan and mapConcurrent.
List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7);
System.out.println(nums.stream().gather(Gatherers.windowFixed(3)).toList());
System.out.println(nums.stream().gather(Gatherers.windowSliding(3)).toList());
System.out.println(nums.stream().gather(Gatherers.scan(() -> 0, Integer::sum)).toList());
// batch IDs for a bulk API call, 3 at a time
Stream.of("u1", "u2", "u3", "u4", "u5")
.gather(Gatherers.windowFixed(3))
.forEach(batch -> System.out.println("POST /users/bulk " + batch));[[1, 2, 3], [4, 5, 6], [7]]
[[1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6], [5, 6, 7]]
[1, 3, 6, 10, 15, 21, 28]
POST /users/bulk [u1, u2, u3]
POST /users/bulk [u4, u5]
Batching and sliding windows used to need an external library or an index-based loop. mapConcurrent(n, fn) runs a function on virtual threads with at most n calls in flight while keeping the stream order, which is useful for calling a slow API for each element. For more Stream API practice, see our Java Stream API coding questions.
Java 25 language features
What are flexible constructor bodies in Java 25?
Since Java 25, a constructor may run statements before calling super(...) or this(...). You can validate arguments, compute the value to pass up, and assign this class's own fields, as long as you do not use the object under construction (no reading fields, no calling instance methods, no passing this).
static class Account {
final String id;
Account(String id) { this.id = id; }
}
static class SavingsAccount extends Account {
private final double rate;
SavingsAccount(String id, double rate) {
if (rate < 0 || rate > 0.2) { // validate BEFORE calling super (Java 25)
throw new IllegalArgumentException("rate out of range: " + rate);
}
this.rate = rate; // allowed: the field has no initializer
super(id.strip().toUpperCase()); // compute the argument first
}
}
SavingsAccount s = new SavingsAccount(" sv-001 ", 0.05);
System.out.println(s.id + " " + s.rate);
new SavingsAccount("sv-002", 0.9); // throws; the caller prints the messageSV-001 0.05
rate out of range: 0.9
Why it matters: the superclass constructor no longer runs before invalid arguments are rejected, and assigning fields before super(...) means a superclass constructor that calls an overridden method sees initialized fields instead of zero or null.
What are compact source files and instance main methods?
Java 25 lets a source file contain methods and fields with no class declaration around them. The compiler wraps them in an implicit class. main no longer has to be public static or take String[] args, and the file automatically imports the whole java.base module. A new java.lang.IO class offers IO.println and IO.readln.
// Hello.java: a compact source file, no class declaration, no imports
String greeting = "Hello from Java 25";
void main() {
List<String> langs = List.of("Java", "Kotlin", "Scala"); // java.base is imported automatically
IO.println(greeting);
IO.println(langs.stream().map(String::toUpperCase).toList());
}Hello from Java 25
[JAVA, KOTLIN, SCALA]
Run it with java Hello.java. It is meant for learning, scripts and quick experiments, not for application code. If an interviewer asks which main runs when a class has several, the launcher prefers main(String[]) over main(), and for an instance main it needs a non-private no-argument constructor.
What are module import declarations?
import module java.sql; imports, in one line, every public top-level type in every package that the module exports, plus the packages of modules it requires transitively (for java.sql, that includes java.xml and java.logging). It became final in Java 25 and works in ordinary classes too; your own code does not need to be a module. If two imported modules export the same simple name, such as List in java.base (java.util.List) and java.desktop (java.awt.List), add a normal single-type import to settle which one you mean.
Which Java features are still in preview in Java 25?
Structured concurrency, primitive types in patterns, stable values (renamed lazy constants in Java 26), PEM encodings and the Vector API (incubator) are not final in Java 25. The grid shows how each feature moved from preview to final across releases.
The same data as a list:
- Final in 21: virtual threads, pattern matching for switch, record patterns, sequenced collections.
- Preview in 21, final in 22: unnamed variables and patterns.
- Preview in 22 and 23, final in 24: stream gatherers.
- Preview from 21 or 22 to 24, final in 25: scoped values, flexible constructor bodies, compact source files and instance main methods.
- Preview in 23 and 24, final in 25: module import declarations.
- Experimental in 24, product in 25, default in 27: compact object headers.
- Still preview in 27: structured concurrency (seventh preview), primitive types in patterns (fifth preview), lazy constants (third preview).
- Withdrawn: string templates.
What happened to string templates in Java?
String templates (STR."Hello \{name}") were a preview in Java 21 and 22 and were removed in Java 23 so the design could be reworked. They are not in Java 25. If you see them in an interview question, the expected answer is that they were withdrawn, and the alternatives today are formatted(), String.format and text blocks.
JVM changes: garbage collection, object headers and startup
What is generational ZGC?
ZGC is HotSpot's low-pause collector. Java 21 added a generational mode that collects young objects more often than old ones, because most objects die young, which cuts CPU and memory overhead. In Java 21 you opted in with -XX:+UseZGC -XX:+ZGenerational; Java 23 made generational the default, and Java 24 removed the old non-generational mode. On Java 25, -XX:+UseZGC alone gives you generational ZGC. G1 remains the default collector.
What are compact object headers in Java 25?
Every Java object starts with a header. On 64-bit HotSpot it takes 96 to 128 bits; compact object headers shrink it to 64 bits. Since many objects are small, that is a large share of heap. They were experimental in Java 24, became a product feature in Java 25, and are on by default in Java 27.
java -XX:+UseCompactObjectHeaders -jar app.jar # Java 25: no unlock flag neededThe JEP reports 22% less heap and 8% less CPU time in one SPECjbb2015 configuration. Your own numbers depend on how many small objects your application allocates, so measure before and after.
What is the AOT cache in Java 25?
The ahead-of-time cache, from Project Leyden, stores classes already loaded and linked, plus method profiles, from a training run, so later starts skip that work and warm up sooner. Java 24 needed two commands to create the cache; Java 25 does it in one.
# training run: run the app with a realistic workload, write the cache on exit
java -XX:AOTCacheOutput=app.aot -jar app.jar
# production runs: start from the cache
java -XX:AOTCache=app.aot -jar app.jarThis is a JVM option, not a code change, which is why it suits Spring Boot services and command-line tools. Every run must use the same JDK release on the same OS and CPU architecture, with the same class path as the training run (extra JARs may only be appended at the end). If those do not match, the JVM prints a warning and starts without the cache.
Scenario questions for experienced developers
You are moving a Spring Boot service from Java 17 to Java 25. What do you check?
Start with the build and the libraries, then run the full test suite and watch the startup log for new warnings. The things that most often surface:
- Bytecode tools: Lombok, Mockito, ByteBuddy, ASM, Jacoco and similar libraries must support the class file version for Java 25. Old versions fail at compile or test time.
- Security Manager: permanently disabled since Java 24. Code that calls
System.setSecurityManagernow fails. - Dynamic agents: since Java 21, loading an agent into a running JVM prints a warning. Mockito's inline mock maker does this in tests; configure it as a
-javaagentinstead. sun.misc.Unsafememory access: Java 24 started warning on first use. Upgrade the library that calls it.- 32-bit platforms: the 32-bit x86 port is gone in Java 25.
Then turn on the new features one at a time: virtual threads with the Spring property, generational ZGC or compact object headers if memory is a problem, and the AOT cache if startup time matters. Measure each change separately.
After enabling virtual threads, a service starts timing out on database calls. What happened?
Most likely the database connection pool is now the bottleneck. Before, 200 Tomcat threads meant at most 200 requests competing for, say, 20 connections. With virtual threads, thousands of requests can reach the pool at once, wait for a connection, and hit the pool's connection timeout.
Virtual threads moved the queue from the thread pool to the connection pool; they did not create more database capacity. Fixes: keep the pool size matched to what the database can handle, add a Semaphore or rate limit in front of expensive calls, and return a fast error (HTTP 503) instead of letting requests pile up. Check for pinning with jdk.VirtualThreadPinned if you are on Java 21, since older JDBC drivers block inside synchronized.
What the interviewer is checking: that you know virtual threads change where waiting happens, not how much work the system can do. Naming the connection pool, and the Java 21 vs 24 pinning difference, is the strong answer.
Would you move a CPU-heavy batch job to virtual threads?
No. A job that keeps every core busy gains nothing, because virtual threads still run on as many carriers as there are cores. Keep a fixed thread pool sized to the cores, or a parallel stream. Virtual threads fit the parts of the job that wait, such as fetching input files or calling APIs, so a mixed job can use both.
How would you replace a ThreadLocal request context in a virtual-thread service?
On Java 25, use a scoped value bound at the request boundary (a servlet filter or interceptor) with ScopedValue.where(CONTEXT, ctx).run(chain). It cannot leak to the next request, cannot be changed halfway through, and costs little per thread. Keep ThreadLocal where a framework still requires it, such as some logging MDC setups, and make sure it is cleared in a finally block.
Practise with the full question bank
This post covers the questions we see most. If you want the full set with answers you can revise from:
- Modern Java 9 to 27: 88 Interview Questions on New Features: 88 questions on every Java release that matters, from modules and records to virtual threads, pattern matching and Java 25.
- Java Multithreading and Concurrency: 100 Most Asked Interview Questions: threads, locks, thread pools,
CompletableFuture, concurrent collections, virtual threads and coding problems. - JVM Internals, GC and Performance Tuning: 95 Interview Questions: for the garbage collector, memory model and JVM flag questions senior interviews go deep on.
Related reading
- Java scenario-based interview questions
- Core Java interview questions
- Java Stream API coding interview questions with output
FAQ
Is Java 21 or Java 25 better for interviews?
Prepare for Java 21 and add Java 25. Most interview questions about modern Java target features final in 21 (virtual threads, pattern matching, records), and Java 25 questions focus on scoped values, flexible constructor bodies, compact source files and the pinning fix.
Are virtual threads production ready?
Yes. They have been final since Java 21. Running them on Java 24 or later is the better choice, because synchronized blocks no longer pin the carrier thread.
Is structured concurrency final in Java 25?
No. It is the fifth preview in Java 25 and still a preview in Java 27, so it needs --enable-preview. Scoped values, which were developed alongside it, are final in Java 25.
What is the difference between a record and a Lombok @Value class?
Both give immutable data classes. A record is a language feature with a fixed shape: no inheritance, no extra instance fields, accessors without the get prefix, and support for record patterns. Lombok generates code at compile time and is more flexible, but it is a dependency that must keep up with each new JDK.
Do I need --enable-preview for pattern matching for switch?
Not on Java 21 or later. Pattern matching for switch and record patterns are final in Java 21. Only primitive types in patterns, such as case int i, still need the preview flag in Java 25.




