Skip to content
6 min read

Modern Java (17-21) Features

Why This Matters for Interviews

FAANG companies now run Java 17+ in production (Netflix, Amazon, Google all mandate it). Interviewers expect you to write idiomatic modern Java — using records instead of POJOs, pattern matching instead of instanceof chains, and virtual threads instead of thread pools. This page covers features from Java 14 through 21 that are finalized and production-ready.


Feature Timeline

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
timeline
    title Modern Java Feature Releases
    section LTS Releases
        Java 17 (2021) : Records
                       : Sealed Classes
                       : Pattern Matching instanceof
                       : Text Blocks
                       : Switch Expressions
    section Non-LTS
        Java 18 (2022) : Simple Web Server
        Java 19 (2022) : Virtual Threads (Preview)
        Java 20 (2023) : Scoped Values (Incubator)
    section LTS
        Java 21 (2023) : Virtual Threads (Final)
                       : Pattern Matching for Switch
                       : Record Patterns
                       : Sequenced Collections
                       : Structured Concurrency (Preview)

Records (Java 16 — Final)

Records are transparent carriers for immutable data. The compiler generates the canonical constructor, accessor methods, equals(), hashCode(), and toString().

Syntax & Generated Methods

Java
public record Point(int x, int y) {}

This single line generates:

Generated Member Equivalent Code
Canonical constructor Point(int x, int y)
Accessor x() public int x() { return this.x; }
Accessor y() public int y() { return this.y; }
equals() Component-wise equality
hashCode() Based on all components
toString() Point[x=1, y=2]

Compact Constructors (Validation)

A compact constructor omits the parameter list — parameters are implicitly in scope. Assignment to fields happens automatically at the end.

Java
public record Employee(String name, int age, String department) {
    // Compact constructor — no parameter list, no explicit assignment
    public Employee {
        Objects.requireNonNull(name, "name must not be null");
        if (age < 0 || age > 150) throw new IllegalArgumentException("Invalid age: " + age);
        name = name.strip();  // normalize before implicit assignment
    }
}

Custom Constructors

You can add non-canonical constructors, but they must delegate to the canonical constructor:

Java
public record Range(int low, int high) {
    public Range {
        if (low > high) throw new IllegalArgumentException("low > high");
    }

    // Custom constructor delegates to canonical
    public Range(int single) {
        this(single, single);
    }
}

Records Implementing Interfaces

Java
public sealed interface Shape permits Circle, Rectangle {}

public record Circle(double radius) implements Shape {
    public double area() { return Math.PI * radius * radius; }
}

public record Rectangle(double width, double height) implements Shape {
    public double area() { return width * height; }
}

When to Use vs When NOT to Use

Use Records For Do NOT Use Records For
DTOs / API request-response objects Entities with mutable state (JPA entities)
Value objects (Money, Coordinate) Classes that need inheritance
Event payloads (Kafka, domain events) Objects requiring private fields with getters only
Map keys (auto equals/hashCode) Builders with many optional fields
Tuple-like groupings Classes needing lazy initialization

Records & JPA

Records cannot be JPA entities because JPA requires a no-arg constructor, mutable fields, and proxying via inheritance. Use records for projections/DTOs, not for @Entity classes.


Sealed Classes & Interfaces (Java 17 — Final)

Sealed types restrict which classes or interfaces can extend or implement them. This gives you closed type hierarchies with compiler-enforced exhaustiveness.

permits Clause

Java
public sealed interface Payment permits CreditCard, BankTransfer, UPI {
    BigDecimal amount();
}

public record CreditCard(String number, BigDecimal amount) implements Payment {}
public record BankTransfer(String iban, BigDecimal amount) implements Payment {}
public record UPI(String vpa, BigDecimal amount) implements Payment {}
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
    Payment{{"sealed interface Payment"}}
    Payment -->|permits| CC(["record CreditCard"])
    Payment -->|permits| BT(["record BankTransfer"])
    Payment -->|permits| UPI(["record UPI"])

    style Payment fill:#DBEAFE,color:#1E40AF,stroke:#93C5FD,stroke-width:2px
    style CC fill:#DBEAFE,color:#1E40AF,stroke:#6EE7B7
    style BT fill:#FEF3C7,color:#1E40AF,stroke:#FCD34D
    style UPI fill:#D1FAE5,color:#1E40AF,stroke:#6EE7B7

Subclass modifiers:

Modifier Effect
final No further extension
sealed Must declare its own permits
non-sealed Open for extension by anyone
record Implicitly final

Exhaustive Pattern Matching

When you switch over a sealed type, the compiler knows all possible subtypes — no default branch needed:

Java
public static String describe(Payment payment) {
    return switch (payment) {
        case CreditCard cc  -> "Card ending " + cc.number().substring(12);
        case BankTransfer bt -> "Transfer to " + bt.iban();
        case UPI upi        -> "UPI to " + upi.vpa();
        // No default needed — compiler knows this is exhaustive
    };
}

Algebraic Data Types (ADTs) with Sealed + Records

This is the killer combination for domain modelling — equivalent to Scala/Kotlin sealed traits:

Java
public sealed interface Result<T> {
    record Success<T>(T value) implements Result<T> {}
    record Failure<T>(String error, Exception cause) implements Result<T> {}
}

// Usage
Result<User> result = fetchUser(id);
switch (result) {
    case Result.Success<User> s -> renderUser(s.value());
    case Result.Failure<User> f -> showError(f.error());
}
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
    subgraph "Algebraic Data Type"
        direction LR
        Result{{"sealed Result&lt;T&gt;"}}
        Result --> Success(["Success(T value)"])
        Result --> Failure(["Failure(String error)"])
    end

    subgraph "Pattern Match"
        direction LR
        Switch{"switch(result)"}
        Switch -->|"case Success s"| Happy(["use s.value()"])
        Switch -->|"case Failure f"| Error(["handle f.error()"])
    end

    Result -.->|"exhaustive"| Switch

    style Result fill:#93C5FD,color:#1E40AF
    style Success fill:#6EE7B7,color:#1E40AF
    style Failure fill:#FCA5A5,color:#1E40AF

Pattern Matching

instanceof Pattern Matching (Java 16)

Eliminates the cast-after-check boilerplate:

Java
// Before
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

// After — binding variable 's' is in scope when the pattern matches
if (obj instanceof String s && s.length() > 5) {
    System.out.println(s.toUpperCase());
}

The binding variable respects flow scoping — it is in scope wherever the compiler can prove the pattern matched:

Java
if (!(obj instanceof String s)) {
    return;  // early exit
}
// s is in scope here — compiler knows obj IS a String
System.out.println(s.strip());

Switch Expressions (Java 14)

Switch becomes an expression that returns a value, uses arrow syntax, and eliminates fall-through:

Java
int numLetters = switch (day) {
    case MONDAY, FRIDAY, SUNDAY -> 6;
    case TUESDAY                -> 7;
    case THURSDAY, SATURDAY     -> 8;
    case WEDNESDAY              -> 9;
};

Use yield for multi-statement branches:

Java
String description = switch (statusCode) {
    case 200 -> "OK";
    case 404 -> "Not Found";
    default -> {
        log.warn("Unexpected status: {}", statusCode);
        yield "Unknown (" + statusCode + ")";
    }
};

Pattern Matching for Switch (Java 21 — Final)

Combines type patterns, guarded patterns, and null handling in a single switch:

Java
public String format(Object obj) {
    return switch (obj) {
        case null            -> "null";
        case Integer i       -> "int: " + i;
        case Long l          -> "long: " + l;
        case String s
            when s.isBlank() -> "blank string";
        case String s        -> "string: \"" + s + "\"";
        case int[] arr       -> "array of length " + arr.length;
        default              -> "other: " + obj.getClass().getSimpleName();
    };
}

Key features:

Feature Syntax Purpose
Type patterns case String s Match + bind
Guarded patterns case String s when s.length() > 5 Additional condition
Null handling case null No more NPE on switch
Dominance order Specific before general Compiler enforces ordering

Dominance Ordering

The compiler rejects switches where a general pattern appears before a more specific one. case Object o must come after case String s, otherwise the String case is unreachable.

Record Patterns — Destructuring (Java 21)

Deconstruct records directly in patterns, even nested ones:

Java
record Point(int x, int y) {}
record Line(Point start, Point end) {}

// Destructure a record
if (obj instanceof Point(int x, int y)) {
    System.out.println("Point at (" + x + ", " + y + ")");
}

// Nested destructuring in switch
static String describeShape(Object shape) {
    return switch (shape) {
        case Line(Point(var x1, var y1), Point(var x2, var y2))
            -> "Line from (%d,%d) to (%d,%d)".formatted(x1, y1, x2, y2);
        case Point(var x, var y)
            -> "Point at (%d,%d)".formatted(x, y);
        default -> "Unknown shape";
    };
}
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
    A(("Object obj")) --> B{"Pattern Match"}
    B -->|"instanceof Point(int x, int y)"| C(["Destructure into x, y"])
    B -->|"instanceof Line(Point s, Point e)"| D(["Destructure nested records"])
    B -->|"no match"| E(["default branch"])

    style A fill:#DBEAFE,color:#1E40AF
    style B fill:#FFFBEB,color:#1E40AF
    style C fill:#ECFDF5,color:#1E40AF
    style D fill:#EFF6FF,color:#1E40AF
    style E fill:#EFF6FF,color:#1E40AF

Text Blocks (Java 15 — Final)

Multi-line string literals enclosed in """. The compiler strips common leading whitespace (incidental indentation).

Syntax & Formatting

Java
String json = """
        {
            "name": "Vamsi",
            "role": "SDE",
            "level": 5
        }
        """;
// Indentation relative to the closing """ determines stripped whitespace

Escape Sequences

Escape Purpose
\s Preserve trailing space (normally stripped)
\ at end of line Suppress newline (line continuation)
\"\"\" Include triple quotes inside text block
Java
String html = """
        <html>
            <body>
                <p>Hello, %s</p>\s\
            </body>
        </html>
        """.formatted(userName);

Useful Methods

Java
// formatted() — like String.format but called on the text block
String sql = """
        SELECT * FROM users
        WHERE age > %d
        AND city = '%s'
        """.formatted(18, "Bangalore");

// stripIndent() — programmatically remove incidental whitespace
// translateEscapes() — process escape sequences in a literal string
String raw = "Hello\\nWorld";
String processed = raw.translateEscapes();  // "Hello\nWorld"

Virtual Threads (Java 21 — Final)

The Blocking Problem

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
    subgraph "Platform Threads (Old Model)"
        direction LR
        PT1(("Thread-1<br/>blocked on DB")) --> OS1[["OS Thread"]]
        PT2(("Thread-2<br/>blocked on HTTP")) --> OS2[["OS Thread"]]
        PT3(("Thread-3<br/>blocked on I/O")) --> OS3[["OS Thread"]]
    end

    subgraph "Virtual Threads (Java 21)"
        direction LR
        VT1(("VThread-1")) -.->|"mounted"| C1[["Carrier-1"]]
        VT2(("VThread-2")) -.->|"parked"| Park[/"Heap<br/>(costs ~1KB)"/]
        VT3(("VThread-3")) -.->|"mounted"| C1
        VT4(("VThread-4")) -.->|"parked"| Park
        VT5(("VThread-1M")) -.->|"mounted"| C2[["Carrier-2"]]
    end

    style OS1 fill:#FCA5A5,color:#1E40AF
    style OS2 fill:#FCA5A5,color:#1E40AF
    style OS3 fill:#FCA5A5,color:#1E40AF
    style C1 fill:#6EE7B7,color:#1E40AF
    style C2 fill:#6EE7B7,color:#1E40AF
    style Park fill:#FEF3C7,color:#1E40AF

Platform threads are mapped 1:1 to OS threads. A server with 200 threads hitting a 50ms database call can only handle ~4000 req/s. Virtual threads unmount from the carrier thread when blocked, allowing millions of concurrent tasks on a handful of OS threads.

How to Use

Java
// Option 1: Create a single virtual thread
Thread.ofVirtual().name("worker").start(() -> {
    var data = blockingHttpCall();  // thread is unmounted while blocked
    process(data);
});

// Option 2: Virtual thread executor (most common in servers)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = urls.stream()
        .map(url -> executor.submit(() -> fetch(url)))
        .toList();

    for (var future : futures) {
        System.out.println(future.get());
    }
}

// Option 3: Direct factory
Thread vt = Thread.ofVirtual()
    .name("processor-", 0)  // names: processor-0, processor-1, ...
    .factory()
    .newThread(() -> doWork());

Structured Concurrency (Preview in Java 21)

Structured concurrency ensures child tasks are bounded by the parent scope — no leaked threads:

Java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<User> userTask   = scope.fork(() -> findUser(userId));
    Subtask<Order> orderTask = scope.fork(() -> fetchOrder(orderId));

    scope.join();            // wait for both
    scope.throwIfFailed();   // propagate any exception

    // Both completed successfully
    return new UserOrder(userTask.get(), orderTask.get());
}
// If one fails, the other is cancelled automatically
Strategy Behavior
ShutdownOnFailure Cancel all if any subtask fails
ShutdownOnSuccess Cancel remaining once first succeeds (racing)

When NOT to Use Virtual Threads

Scenario Why Not
CPU-bound work (math, encryption) Virtual threads optimize for blocking I/O, not CPU — use platform thread pools
synchronized blocks with I/O inside Pins the carrier thread (use ReentrantLock instead)
Thread-local heavy code Virtual threads are cheap; millions of ThreadLocals waste memory
Already using reactive (WebFlux) Reactive already avoids blocking — mixing adds complexity

Pinning

When a virtual thread executes inside a synchronized block or calls a native method, it pins to its carrier thread, negating the benefit. Replace synchronized with ReentrantLock in I/O-heavy code to avoid this.


Sequenced Collections (Java 21)

Java finally has interfaces that guarantee encounter order with first/last access:

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
    SC(("SequencedCollection"))
    SC --> SM{{"SequencedMap"}}
    SC --> SS{{"SequencedSet"}}

    List(["List"]) --> SC
    LinkedHashSet(["LinkedHashSet"]) --> SS
    SortedSet(["SortedSet / TreeSet"]) --> SS
    LinkedHashMap(["LinkedHashMap"]) --> SM
    SortedMap(["SortedMap / TreeMap"]) --> SM

    style SC fill:#DBEAFE,color:#1E40AF
    style SM fill:#93C5FD,color:#1E40AF
    style SS fill:#6EE7B7,color:#1E40AF

Key Methods

Java
SequencedCollection<String> names = new LinkedHashSet<>(List.of("A", "B", "C"));

names.getFirst();      // "A"
names.getLast();       // "C"
names.addFirst("Z");   // [Z, A, B, C]
names.addLast("D");    // [Z, A, B, C, D]
names.removeFirst();   // removes "Z"
names.reversed();      // reversed view: [D, C, B, A]

Before vs After

Java
// Before Java 21 — getting the last element of a List
var last = list.get(list.size() - 1);

// Java 21
var last = list.getLast();

// Before — getting first key of a LinkedHashMap
var firstKey = map.entrySet().iterator().next().getKey();

// Java 21
var firstKey = map.firstEntry().getKey();

String Enhancements

String.formatted() (Java 15)

Instance method replacement for String.format():

Java
// Before
String msg = String.format("Hello %s, you have %d items", name, count);

// After — reads more fluently
String msg = "Hello %s, you have %d items".formatted(name, count);

stripIndent() (Java 15)

Removes incidental whitespace (same algorithm as text blocks):

Java
String code = "    public void hello() {\n        System.out.println(\"hi\");\n    }";
String stripped = code.stripIndent();
// "public void hello() {\n    System.out.println(\"hi\");\n}"

translateEscapes() (Java 15)

Interprets escape sequences in strings read from external sources:

Java
String fromFile = "Hello\\tWorld\\n";        // literal backslash-t, backslash-n
String processed = fromFile.translateEscapes(); // "Hello\tWorld\n" (actual tab and newline)

Other Additions

Method Version Purpose
repeat(int) Java 11 "ha".repeat(3) -> "hahaha"
isBlank() Java 11 True for whitespace-only strings
indent(int) Java 12 Adjusts indentation of each line
transform(Function) Java 12 Apply a function: str.transform(String::toUpperCase)

Putting It All Together — Real-World Example

A complete domain model using sealed interfaces, records, and pattern matching:

Java
// Domain model
public sealed interface HttpResponse permits Success, ClientError, ServerError {
    int statusCode();
}

public record Success(int statusCode, String body) implements HttpResponse {}
public record ClientError(int statusCode, String message) implements HttpResponse {}
public record ServerError(int statusCode, Exception cause) implements HttpResponse {}

// Handler using pattern matching for switch + record patterns
public String handleResponse(HttpResponse response) {
    return switch (response) {
        case Success(var code, var body)
            when body.isBlank()           -> "Empty response (%d)".formatted(code);
        case Success(var code, var body)   -> "OK: " + body;
        case ClientError(var code, var msg)
            when code == 404              -> "Not found: " + msg;
        case ClientError(var code, var msg) -> "Client error %d: %s".formatted(code, msg);
        case ServerError(_, var cause)     -> "Server error: " + cause.getMessage();
    };
}

Interview Questions

1. Why are records not suitable for JPA entities?

JPA requires: (a) a no-arg constructor, (b) mutable fields for lazy loading and dirty checking, © ability to proxy via inheritance. Records are final, have no no-arg constructor, and all fields are final. Use records as DTOs or projections, not entities.

2. How does exhaustive pattern matching with sealed classes prevent bugs?

When you switch over a sealed type, the compiler knows all permitted subtypes. If a developer adds a new subtype to the permits clause, every switch expression that doesn't handle it fails to compile. This catches bugs at compile time instead of runtime. Without sealed classes, you rely on a default branch that silently swallows unknown types.

3. Explain the difference between guarded patterns and nested if-else inside a case block.

case String s when s.length() > 5 is a guarded pattern — the when clause is part of the pattern. If the guard fails, the switch tries the next case. In contrast, an if-else inside a case block means the case already matched — the switch won't try other cases. Guarded patterns enable fallthrough-style logic without actual fallthrough.

4. A microservice handles 10K concurrent requests, each making a 200ms DB call. Explain why virtual threads help and what the thread count looks like vs platform threads.

With platform threads (200-thread pool): throughput = 200 / 0.2s = 1000 req/s. To handle 10K concurrent, you need 10K platform threads (each using ~1MB stack = 10GB RAM). With virtual threads: you create 10K virtual threads (each ~1KB on heap = 10MB total). They share a small carrier pool (typically CPU-count threads). When blocked on DB I/O, the virtual thread unmounts, freeing the carrier for others. Result: 10K concurrency with minimal memory.

5. What is carrier thread pinning and how do you avoid it?

Pinning occurs when a virtual thread executes inside a synchronized block or a native method — it cannot unmount from its carrier thread, blocking that carrier. Fix: replace synchronized with ReentrantLock. Detect pinning by running with -Djdk.tracePinnedThreads=short. Never hold a monitor while performing blocking I/O.

6. How do record patterns enable nested destructuring? Give an example with 2 levels.

Record patterns deconstruct a record's components inline. For nested records like record Line(Point start, Point end) and record Point(int x, int y), you can write: case Line(Point(var x1, var y1), Point(var x2, var y2)) — this extracts all four coordinates in one pattern. The compiler calls the record's accessor methods and recursively matches inner patterns.

7. What is Structured Concurrency and how does it prevent thread leaks?

Structured concurrency (StructuredTaskScope) ties the lifetime of child tasks to the parent scope. When the scope closes, all forked tasks are guaranteed to be complete or cancelled. This prevents the common bug where a spawned thread outlives the request. ShutdownOnFailure cancels siblings if one fails; ShutdownOnSuccess cancels remaining once one succeeds (useful for racing).

8. Compare SequencedCollection.reversed() with Collections.reverse(). Which is destructive?

Collections.reverse(list) mutates the original list in place. sequencedCollection.reversed() returns a reversed view — the original collection is unchanged, and the view is backed by the original. Modifications through the view reflect in the original in reverse order. This is non-destructive and consistent with the existing Collections.unmodifiableList() view pattern.