Modern Java 9-21: Records, Sealed Types, Pattern Matching
What actually changed after Java 8 and why it matters in an interview: var, records, sealed interfaces, pattern matching for switch, text blocks, the module system and virtual threads.
On this page
Java 8 changed how code is written; Java 9 through 21 changed what it is possible to express. An interviewer asking "what have you used since Java 8?" is really asking whether you have kept up. This is the working answer.
Key Takeaways
- Records (16) are transparent immutable data carriers with generated
equals,hashCode,toStringand accessors. - Sealed types (17) close a hierarchy, which lets
switchbe checked for exhaustiveness. - Pattern matching for
instanceof(16) andswitch(21), plus record deconstruction, replaces the visitor pattern for most cases. - Virtual threads (21) make blocking cheap — a million threads instead of a pool of two hundred.
- Sequenced collections (21) finally give
List,SetandMapa commongetFirst/getLast.
Records
public record Money(BigDecimal amount, Currency currency) implements Comparable<Money> {
// Compact constructor: validation and normalisation, before assignment.
public Money {
Objects.requireNonNull(currency);
if (amount.scale() > currency.getDefaultFractionDigits()) {
amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN);
}
}
// Extra behaviour is allowed; extra *state* is not.
public Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(amount.add(other.amount), currency);
}
@Override public int compareTo(Money o) { return amount.compareTo(o.amount); }
// Static factory alongside the canonical constructor.
public static Money gbp(String amount) { return new Money(new BigDecimal(amount), GBP); }
}The compiler generates the canonical constructor, an accessor per component (amount(), not
getAmount()), and equals, hashCode and toString. A record is implicitly final, cannot extend
another class, and cannot declare additional instance fields.
The interview point beyond the syntax: a record is shallowly immutable. A List component is
still mutable unless the compact constructor copies it — see
Immutable objects and defensive copying.
Sealed types and exhaustive switch
public sealed interface PaymentResult
permits Approved, Declined, RequiresAction { }
public record Approved(String authCode, Money captured) implements PaymentResult { }
public record Declined(String reason, boolean retryable) implements PaymentResult { }
public record RequiresAction(URI redirect) implements PaymentResult { }sealed tells the compiler the complete list of subtypes. That single fact unlocks exhaustiveness:
String describe(PaymentResult result) {
return switch (result) {
case Approved(String code, Money amount) -> "approved " + amount + " (" + code + ")";
case Declined(String reason, boolean retryable) when retryable -> "retry: " + reason;
case Declined(String reason, var ignored) -> "declined: " + reason;
case RequiresAction(URI redirect) -> "redirect to " + redirect;
// No default. Add a fourth subtype and THIS METHOD STOPS COMPILING.
};
}That last comment is the whole value. With a default branch or an if/else chain, adding a case
compiles fine and silently takes the fallback path at runtime. With a sealed hierarchy and an
exhaustive switch, the compiler lists every place that needs updating.
The permitted subtypes must be in the same module, or the same package for an unnamed module, and
each must be declared final, sealed or non-sealed.
Pattern matching
// Java 8
if (obj instanceof String) {
String s = (String) obj;
if (s.length() > 5) { ... }
}
// Java 16 — pattern matching for instanceof
if (obj instanceof String s && s.length() > 5) { ... }
// Java 21 — pattern matching for switch, with record deconstruction and guards
String format(Object obj) {
return switch (obj) {
case null -> "nothing";
case Integer i when i < 0 -> "negative " + i;
case Integer i -> "number " + i;
case String s -> "text of length " + s.length();
case Money(var amount, var currency) -> currency + " " + amount;
case int[] arr -> "int array of " + arr.length;
default -> obj.getClass().getSimpleName();
};
}Three things to note for the interview. case null is now expressible — previously a switch on a
null selector always threw. Guards use when, not &&. And record deconstruction binds
components directly, which is what makes this a genuine replacement for the visitor pattern rather
than a nicer cast.
Switch expressions and text blocks
int days = switch (month) {
case FEB -> isLeap ? 29 : 28;
case APR, JUN, SEP, NOV -> 30;
default -> 31;
};
// yield for a multi-statement branch
int score = switch (grade) {
case "A" -> 100;
case "B" -> { int base = 80; yield base + bonus; }
default -> 0;
};The arrow form has no fall-through, must be exhaustive when used as an expression, and returns a value. The old colon form still works and still falls through.
String query = """
SELECT o.id, o.total, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.placed_at >= ?
""";Incidental leading whitespace is stripped based on the least-indented line, including the closing
delimiter — which is why the closing """ position controls the indentation. \ at end of line
suppresses the newline; \s preserves a trailing space.
Virtual threads
// Platform threads: bounded pool, ~1MB stack each, expensive to create.
ExecutorService old = Executors.newFixedThreadPool(200);
// Virtual threads: one per task, a few hundred bytes, created freely.
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (Order order : tenThousandOrders) {
exec.submit(() -> {
enrich(order); // blocking I/O — the virtual thread unmounts
persist(order); // and the carrier thread runs something else
return null;
});
}
} // close() waits for all tasksA virtual thread is scheduled by the JVM onto a small pool of carrier (platform) threads. When it blocks on I/O it unmounts, freeing the carrier immediately. The result is that ordinary blocking, request-per-thread code scales like asynchronous code, without the callback structure.
Two caveats worth stating, because they are the interesting part of the answer: a virtual thread
pins its carrier inside a synchronized block (largely fixed in Java 24, but still true on 21),
so hot locks should be ReentrantLock; and thread-locals still work but are no longer nearly free
when there are a million threads. Full treatment in
Virtual threads and structured concurrency.
The rest, in one pass
| Version | Feature | Why it matters |
|---|---|---|
| 9 | JPMS modules | Strong encapsulation; the reason --add-opens exists |
| 9 | Collection factories | List.of, Map.of — immutable, null-hostile |
| 9 | Stream.takeWhile / dropWhile / ofNullable | Fills real gaps in the stream API |
| 10 | var | Local inference only; not var fields, not parameters |
| 11 | HttpClient, String.strip, isBlank, lines | Removed the need for Apache Commons in most code |
| 12 | Collectors.teeing | Two collectors, one traversal |
| 14 | Helpful NullPointerExceptions | The message now names the expression that was null |
| 15 | Text blocks | Multi-line SQL and JSON without escaping |
| 16 | Records, instanceof patterns, Stream.toList() | |
| 17 | Sealed classes, LTS | Spring Boot 3's floor |
| 21 | Virtual threads, pattern matching for switch, sequenced collections, LTS |
Sequenced collections (21) are the small one people appreciate most: List, Deque,
LinkedHashSet and LinkedHashMap now share getFirst(), getLast(), addFirst(), addLast()
and reversed(). Before this, getting the last element of a LinkedHashSet required a full
iteration.
Helpful NPEs (14, on by default since 15) changed debugging materially: instead of
NullPointerException at a line with four dereferences, the message reads "Cannot invoke
String.length() because the return value of Order.customer() is null".
How to answer "what have you used since Java 8?"
Pick three, and say what each one removed from your code. For example: records replaced hand-written
value classes and their generated equals; text blocks removed the escaped-newline SQL strings;
var removed the duplicated type on the left of every new. Then mention one you have deliberately
not adopted and why — that answer is far more credible than a feature list.
Frequently Asked Questions
Which Java version should a new project target?
Are records just a shortcut for a POJO?
Do virtual threads make reactive programming unnecessary?
Related tutorials
- The java.time APIChoosing between Instant, LocalDateTime and ZonedDateTime, Period versus Duration, what happens at a daylight-saving gap, and how to store timestamps so they survive a zone change.
- Default & Static Methods in InterfacesWhy default methods were added, the three resolution rules when a class inherits conflicting defaults, calling a specific supertype with X.super.method(), and private interface methods.
- Optional: Correct Use and Common AbuseWhat Optional was designed for and what it was not, the orElse versus orElseGet trap that evaluates the fallback every time, chaining with map and flatMap, and why Optional fields are a mistake.
- Parallel Streams and the Common ForkJoinPoolWhy every parallel stream in your JVM shares one pool, which sources split well, the N times Q rule for deciding, and why a blocking call inside a parallel stream can stall the whole application.