Skip to content

Modern Java Features: A Practical Guide from Java 8 to 21

BackendBytes Engineering Team
BackendBytes Engineering Team
9 min read
Modern Java Features: A Practical Guide from Java 8 to 21

Key Takeaways

  • Records auto-generate equals, hashCode, toString, and accessors (no getName(), just name()); one @record line replaces 25 lines of POJO boilerplate with no mutation risk
  • Virtual threads (Java 21) apply M:N scheduling: thousands of lightweight threads share a carrier pool, unmounting during I/O — same sequential code, 10x I/O concurrency without rewriting to reactive
  • Pattern matching + sealed classes make switch exhaustive — the compiler forces you to handle every subtype; forgetting one case is a compile error, not a silent bug
  • var (Java 10) infers type from the right-hand side for local variables where the type is obvious from context, eliminating redundant declarations without sacrificing readability

The classic Java 11 → 21 migration shape. An order-processing service migrating from Java 11 to 21 typically loses substantial boilerplate (records replace POJOs by the dozen, pattern matching eliminates explicit casts, virtual threads[JEP 444, 2023] replace hand-tuned thread pools that caused pool-exhaustion incidents). Same functionality, fewer bugs, fewer lines. The features that matter aren't syntactic sugar — they're each targeted at a specific class of bug we've debugged in production.

Bottom Line

Java 8 to 21 gave us records (immutable DTOs), pattern matching (exhaustive type checks), virtual threads (I/O concurrency without pools)[JEP 444, 2023], and switch expressions (no fall-through bugs). Each feature reduces boilerplate and makes bugs harder to write. Adopt them incrementally — start with records and text blocks (zero risk), move to virtual threads (high ROI on I/O workloads).

  • Records — eliminate 14 POJO lines per class; use for DTOs, events, and immutable values
  • Pattern matching + sealed classes — compiler-verified exhaustiveness; use for domain types
  • Virtual threads — replace fixed pools; one Spring Boot[Spring Boot virtual threads] config line for ~10× I/O concurrency
graph LR
    Pain[Production pain point] --> Q{What kind?}
    Q -->|"DTO/POJO boilerplate"| R[Records<br/>Java 14-16]
    Q -->|"instanceof + cast chains"| PM[Pattern matching<br/>Java 16-21]
    Q -->|"thread pool exhaustion<br/>I/O-bound"| VT[Virtual threads<br/>Java 21 / JEP 444]
    Q -->|"missing case in switch"| SE[Switch expressions<br/>+ sealed classes]
    Q -->|"multi-line SQL/JSON"| TB[Text blocks<br/>Java 13]
    Q -->|"local variable types"| Var[var<br/>Java 10]
    style R fill:#efe
    style PM fill:#efe
    style VT fill:#efe
    style SE fill:#efe
    style TB fill:#efe
    style Var fill:#efe

The diagram is the adoption picker: drive each feature adoption from a specific bug class it fixes, not a "let's modernize" instinct. Records aren't there to look modern — they're there to eliminate the equals/hashCode boilerplate that hides bugs. Virtual threads aren't trendy — they're the right answer to thread-pool exhaustion on I/O-bound workloads.

The quick start

The post-Java-8 timeline — features arrive cumulatively; LTS releases (8/11/17/21/25) define the migration jumps. As of 2026 the current LTS is Java 25 (September 2025), and Java 17 is the hard baseline — Spring Boot 3, JUnit 6, Gradle 9, and Maven 4 all refuse to run below JDK 17. Every feature below lands on 21 and 25 identically, so this guide targets 21 as the broadly-deployed floor and notes where 25 changes things:

graph LR
    J8[Java 8 LTS<br/>2014<br/>lambdas, streams,<br/>java.time, Optional] --> J11[Java 11 LTS<br/>2018<br/>var, HTTP/2 client,<br/>String.lines]
    J11 --> J13[Java 13<br/>text blocks preview]
    J13 --> J14[Java 14<br/>records preview,<br/>switch expressions,<br/>pattern matching for instanceof]
    J14 --> J17[Java 17 LTS<br/>2021<br/>records GA,<br/>sealed classes,<br/>switch pattern matching]
    J17 --> J21[Java 21 LTS<br/>2023<br/>virtual threads GA,<br/>structured concurrency<br/>preview, record patterns]
    J21 --> J24[Java 24<br/>2025<br/>JEP 491 — synchronized<br/>no longer pins<br/>virtual threads]
    style J8 fill:#fdd
    style J11 fill:#ffd
    style J17 fill:#dfd
    style J21 fill:#dfd
    style J24 fill:#dfd

These are the modern Java features that reduce boilerplate, eliminate bugs, and actually change how you structure code. Adopt them in the order shown — quick wins first (records, var, switch), then the structural features (pattern matching, virtual threads).

FeatureJavaReplacesImpactMigration
Records14POJO/DTO classes14→1 lines per classZero risk; use for data carriers only
Pattern matching16–21instanceof + cast30+ manual casts per serviceLow risk; compiler verifies exhaustiveness
Virtual threads21Fixed thread pools10x I/O concurrency, one configMedium risk; audit synchronized blocks
Switch expressions14Switch statementsNo fall-through bugsZero risk; arrow syntax
Text blocks13String concatenationMulti-line SQL/JSON readableZero risk; for templated strings
var10Redundant typesCleaner local-variable declarationsZero risk; use for obvious types only

Records (Java 14–16)

[Effective Java]

A record replaces 14+ lines of boilerplate — constructor, getters, equals, hashCode, toString — in a single line.

// Before: 25 lines for a DTO
public class UserDTO {
    private final String name, email;
    private final int age;
    public UserDTO(String name, String email, int age) {
        this.name = name; this.email = email; this.age = age;
    }
    public String name() { return name; }
    public String email() { return email; }
    public int age() { return age; }
    @Override public boolean equals(Object o) { /* boilerplate */ }
    @Override public int hashCode() { return Objects.hash(name, email, age); }
    @Override public String toString() { /* boilerplate */ }
}
 
// After: 1 line
public record UserDTO(String name, String email, int age) {}

Accessor methods use the field name (name(), not getName()). Add validation with a compact constructor:

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
        Objects.requireNonNull(currency, "Currency required");
    }
}

Use records for DTOs, API responses, events, and immutable value objects. Do not use for JPA entities — records have no no-arg constructor and no setters.

Pattern Matching + Sealed Classes (Java 16–21)

Pattern matching eliminates the cast-after-check pattern. Combined with sealed classes (which restrict subtypes), the compiler guarantees exhaustive coverage — no default branch needed.

// Define a sealed type: payment method is exactly one of these
public sealed interface PaymentMethod
    permits CreditCard, BankTransfer, DigitalWallet {}
 
public record CreditCard(String number, String expiry) implements PaymentMethod {}
public record BankTransfer(String iban, String bic) implements PaymentMethod {}
public record DigitalWallet(String provider, String token) implements PaymentMethod {}
// Before: instanceof + cast, incomplete coverage risk
public double calculateFee(PaymentMethod method) {
    if (method instanceof CreditCard card) {
        return card.number().startsWith("4") ? 0.029 : 0.035;
    } else if (method instanceof BankTransfer xfer) {
        return xfer.iban().startsWith("DE") ? 0.001 : 0.005;
    }
    // Forgot DigitalWallet? Compiler says nothing.
    throw new IllegalArgumentException("Unknown payment method");
}
 
// After: pattern matching switch (Java 21), compiler-verified exhaustiveness
public double calculateFee(PaymentMethod method) {
    return switch (method) {
        case CreditCard card when card.number().startsWith("4") -> 0.029;
        case CreditCard _ -> 0.035;
        case BankTransfer xfer when xfer.iban().startsWith("DE") -> 0.001;
        case BankTransfer _ -> 0.005;
        case DigitalWallet _ -> 0.025;
        // Compiler enforces all subtypes are handled — no default needed
    };
}

If you add a fourth payment type to the sealed interface, every switch expression using it fails to compile until you handle the new case. This is exhaustiveness guaranteed at compile time — not a runtime default guard.

Text Blocks (Java 13)

Replace string concatenation with readable multi-line templates:

// Before: escape sequences + concatenation
String query = "SELECT u.name, o.total\n" +
    "FROM users u JOIN orders o\n" +
    "WHERE o.status = 'COMPLETED'";
 
// After: text block (triple quotes)
String query = """
    SELECT u.name, o.total
    FROM users u JOIN orders o
    WHERE o.status = 'COMPLETED'
    """;
 
// Parameterized with String.formatted()
String json = """
    {
        "userId": "%s",
        "timestamp": "%s"
    }
    """.formatted(userId, Instant.now());

Indentation is handled automatically — the compiler strips common leading whitespace. Use for SQL, JSON, HTML templates.

Virtual Threads (Java 21)

[JEP 491, 2025]

Virtual threads are lightweight, managed by the JVM instead of the OS. A platform thread costs 1-2 MB; a virtual thread costs kilobytes. When a virtual thread blocks on I/O, the JVM unmounts it from a carrier thread, which picks up the next task — your code blocks naturally without occupying an OS thread.

// Before: fixed thread pool = concurrency bottleneck
ExecutorService executor = Executors.newFixedThreadPool(200);
List<Future<String>> futures = urls.stream()
    .map(url -> executor.submit(() -> httpGet(url)))
    .toList();
// 201st request queues. Timeout risk. Pool exhaustion incidents.
 
// After: virtual thread per task, no pool limit
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = urls.stream()
        .map(url -> executor.submit(() -> httpGet(url)))
        .toList();
    // 10,000 concurrent requests, no pool tuning.
}

Enable in Spring Boot (3.2+) with a single property:

spring:
  threads:
    virtual:
      enabled: true

This switches Tomcat to use virtual threads for request handling. No code changes — existing blocking service code works as-is, with dramatically higher concurrency headroom.

Avoid synchronized in I/O paths

Virtual threads cannot unmount from a carrier thread while holding a monitor (synchronized block). Replace synchronized with ReentrantLock in code that performs I/O inside a critical section. Use -Djdk.tracePinnedThreads=short to detect pinning at runtime.

Virtual threads don't speed up CPU work — they eliminate thread pool exhaustion for I/O workloads. Size your connection pool for your downstream resource, not your thread count. 10,000 virtual threads will exhaust a 50-connection pool.

Production checklist

  • Records — audit data carrier classes (DTOs, events, responses) and convert to records. Skip JPA entities and classes needing setters.
  • Text blocks — replace multi-line string concatenation in SQL, JSON, HTML templates with triple-quote syntax.
  • var — use in local variables where the type is obvious from the right-hand side (constructor, factory method). Avoid with opaque return types.
  • Switch expressions — replace switch statements with arrow syntax (->) to eliminate fall-through and implicit breaks.
  • Pattern matching — combine sealed classes + records + switch patterns to model closed domain hierarchies (payment types, event types, error categories).
  • Virtual threads — for I/O-heavy services, audit synchronized blocks, replace with ReentrantLock, enable in Spring Boot, monitor connection pool utilization.
  • Pinning audit — run with -Djdk.tracePinnedThreads=short to detect virtual threads blocked in synchronized blocks.

Sealed types + records: closed-domain modelling that the compiler enforces

The two features below compose into the pattern most teams reach for once they understand them — modelling a closed set of payment outcomes as a sealed hierarchy of records, then dispatching with an exhaustive switch the compiler refuses to let go stale:

public sealed interface PaymentResult
    permits PaymentResult.Approved, PaymentResult.Declined, PaymentResult.Pending {
 
    record Approved(String authCode, BigDecimal capturedAmount, Instant capturedAt)
        implements PaymentResult {}
 
    record Declined(String reasonCode, String issuerMessage, boolean retryable)
        implements PaymentResult {}
 
    record Pending(String pollingToken, Duration expectedLatency)
        implements PaymentResult {}
}
 
// Dispatch — the compiler will fail the build if a new variant is added
// to PaymentResult without a matching arm here. That guarantee is the
// entire payoff of the sealed-types feature.
public OrderState reconcile(PaymentResult result, Order order) {
    return switch (result) {
        case PaymentResult.Approved a -> order.markPaid(a.authCode(), a.capturedAmount());
        case PaymentResult.Declined d when d.retryable() -> order.scheduleRetry(d.reasonCode());
        case PaymentResult.Declined d -> order.markFailed(d.reasonCode(), d.issuerMessage());
        case PaymentResult.Pending  p -> order.awaitConfirmation(p.pollingToken(), p.expectedLatency());
    };
}

The single most common mistake — defining the sealed interface in one package and the records in another. Don't. Co-locate them; the JVM's permits-list check is module-aware and a split layout is the kind of thing that compiles fine until module-info.java disagrees with the file layout.

The canonical record customisation — when the compiler-generated constructor isn't enough (validation, normalisation, precision rounding) — is the compact constructor. Use it; do not write a regular constructor that re-assigns every field, because that reintroduces the very boilerplate records exist to remove:

public record Money(BigDecimal amount, Currency currency) {
 
    // Compact constructor: validates and normalises, no field assignments.
    public Money {
        Objects.requireNonNull(amount,   "amount required");
        Objects.requireNonNull(currency, "currency required");
        if (amount.signum() < 0) {
            throw new IllegalArgumentException("Money cannot be negative: " + amount);
        }
        // Normalise to currency-defined fraction digits — prevents subtle
        // ledger drift when 1.00 USD == 1.000 USD compares unequal in tests.
        // HALF_EVEN (banker's rounding) is the right mode for money; RoundingMode.UNNECESSARY
        // would instead THROW ArithmeticException the moment a value has excess precision.
        amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN);
    }
 
    public Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch: " + currency + " vs " + other.currency);
        }
        return new Money(amount.add(other.amount), currency);
    }
}

Scoped Values: replacing ThreadLocal in the virtual-thread era (JEP 506, final in JDK 25)

ThreadLocal has been the standard mechanism for propagating per-request context — tenant id, request id, the current principal — for two decades. With virtual threads it becomes a liability. Each thread-local entry is allocated per thread, and a service that spawns ten thousand virtual threads per second creates ten thousand entries per second, then garbage-collects them. The cost is real, the leak surface is real (any thread-local set inside a request that is not cleared in a finally block leaks for the lifetime of the carrier), and InheritableThreadLocal does not even propagate cleanly through Executors.newVirtualThreadPerTaskExecutor.

ScopedValue is the replacement. After an incubation round (Java 20) and four preview rounds (Java 21–24) it was finalized as a permanent feature in JDK 25 (the September 2025 LTS) via JEP 506 — no --enable-preview flag required. Bindings are immutable, set up with a try-with-resources style API, and the scope is bounded to the dynamic extent of a method call rather than the lifetime of a thread. There is nothing to clear in a finally block because the binding cannot outlive the lambda passed to run (or call).

import java.lang.ScopedValue;
 
public final class RequestContext {
 
    public static final ScopedValue<String>  TENANT_ID  = ScopedValue.newInstance();
    public static final ScopedValue<String>  REQUEST_ID = ScopedValue.newInstance();
    public static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
 
    private RequestContext() {}
}
 
// At the request boundary (servlet filter, gRPC interceptor) — bind once,
// every downstream call inside the lambda sees the same immutable values.
public Response handle(HttpRequest req) {
    var tenant   = req.header("X-Tenant-Id");
    var reqId    = req.header("X-Request-Id");
    var subject  = authenticate(req);
 
    return ScopedValue.where(RequestContext.TENANT_ID,  tenant)
                      .where(RequestContext.REQUEST_ID, reqId)
                      .where(RequestContext.PRINCIPAL,  subject)
                      .call(() -> dispatch(req));
}
 
// Anywhere downstream — service, repository, audit logger — the binding
// is visible without parameter threading. Read-only by construction:
// there is no set() method.
public AuditEntry currentAudit() {
    return new AuditEntry(
        RequestContext.TENANT_ID.get(),
        RequestContext.REQUEST_ID.get(),
        RequestContext.PRINCIPAL.get().name(),
        Instant.now()
    );
}

The mental model: scoped values are dynamic-scope variables, not lexical-scope variables, but unlike thread locals their dynamic scope is a bounded region of the call stack rather than the whole thread. A child task forked inside StructuredTaskScope inherits the binding automatically — the JVM walks the parent scope when resolving get(). There is no inheritance cost per virtual thread because nothing is copied; the lookup is a chain walk that the JIT inlines.

When to migrate: any thread-local that exists purely to propagate request-scoped read-only state. Keep ThreadLocal only for truly mutable per-thread caches (date format pools, byte buffer pools) where the mutability is the point.

Structured Concurrency: scoping fan-out (JEP 505, Preview in JDK 25)

Virtual threads make it cheap to fan out work, but they do not solve the lifecycle problem. A request that spawns three subtasks and forgets to cancel the slow one when the fast one fails leaks resources, mishandles errors, and confuses every observability tool that traces a request as a tree. Structured concurrency treats a group of concurrent subtasks as a single unit of work with a defined lifetime. Unlike scoped values, it is still a preview API in JDK 25 (JEP 505, the fifth preview) — compile with --enable-preview — and the API shape changed materially in this round: scopes are opened with the StructuredTaskScope.open(...) factory and a Joiner policy rather than the old public constructors.

import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;
 
// Fetch user, recommendations, and entitlements in parallel. If the user
// lookup fails, cancel the rest immediately. If everything succeeds, join
// the results. The scope's close() guarantees no thread escapes the method.
public OrderView buildOrderView(long orderId) throws InterruptedException {
    // open(...) + awaitAllSuccessfulOrThrow() is the JDK 25 form; the public
    // ShutdownOnFailure/ShutdownOnSuccess constructors were removed.
    try (var scope = StructuredTaskScope.open(Joiner.<Object>awaitAllSuccessfulOrThrow())) {
 
        var userTask         = scope.fork(() -> userService.find(orderId));
        var recsTask         = scope.fork(() -> recsService.recommend(orderId));
        var entitlementsTask = scope.fork(() -> entitlementsService.lookup(orderId));
 
        // join() blocks until all succeed, then throws FailedException on the
        // first failure (cancelling the rest) — no separate throwIfFailed() call.
        scope.join();
 
        return new OrderView(
            userTask.get(),
            recsTask.get(),
            entitlementsTask.get()
        );
    }
}

The semantics that traditional executors get wrong:

  • Cancellation propagates. When entitlementsTask throws, the awaitAllSuccessfulOrThrow() Joiner cancels the scope and interrupts the other forks. The carrier returns to the pool; the downstream HTTP call is cancelled at the socket level if the client honours interruption.
  • Errors aggregate sensibly. Joiner.anySuccessfulResultOrThrow() returns the first successful result and cancels the rest — exactly the right semantics for fallback chains and racing replicas.
  • Observability stays tree-shaped. Every forked task inherits the parent's scoped values, so distributed tracing libraries can stitch the span tree without any explicit context object being passed around.

The combination of scoped values plus structured concurrency is the actual point of the post-Loom redesign — virtual threads are the runtime mechanism, scoped values give you read-only context propagation that survives forks, and structured task scopes give you cancellation semantics that survive errors.

Pattern matching for switch with sealed types: a real example

The earlier section showed sealed payment results. Here is the pattern at the layer where it pays off most: domain events on a Kafka consumer, where every variant must produce a different downstream effect and a missed case is a silent data-loss bug.

public sealed interface DomainEvent
    permits OrderPlaced, OrderCancelled, PaymentCaptured, RefundIssued {}
 
public record OrderPlaced(long orderId, BigDecimal total, Instant at)        implements DomainEvent {}
public record OrderCancelled(long orderId, String reasonCode, Instant at)    implements DomainEvent {}
public record PaymentCaptured(long orderId, String authCode, Instant at)     implements DomainEvent {}
public record RefundIssued(long orderId, BigDecimal amount, Instant at)      implements DomainEvent {}
 
// Consumer dispatch — record patterns destructure inline, guards filter
// on data, and exhaustiveness is compiler-verified. Add a fifth variant
// to DomainEvent and this method fails to compile until handled.
public ProjectionUpdate apply(DomainEvent event) {
    return switch (event) {
        case OrderPlaced(var id, var total, var at) when total.signum() > 0 ->
            ProjectionUpdate.create(id, total, at);
 
        case OrderPlaced(var id, var total, var at) ->
            ProjectionUpdate.skipZeroTotal(id, at);
 
        case OrderCancelled(var id, var reason, var at) ->
            ProjectionUpdate.cancel(id, reason, at);
 
        case PaymentCaptured(var id, var auth, var at) ->
            ProjectionUpdate.markPaid(id, auth, at);
 
        case RefundIssued(var id, var amount, var at) ->
            ProjectionUpdate.refund(id, amount, at);
    };
}

The thing that traditional instanceof chains cannot guarantee is the compile failure when a new variant lands. With sealed types plus exhaustive switch, adding OrderShipped to the permits clause turns the consumer into a build break — exactly where you want it, before the new event ever reaches production. Pair this with record patterns and you also stop reaching for accessor methods on the variant: the destructuring is in the case label, the body has named locals, and the noise floor of a domain dispatcher drops by half.

Frequently Asked Questions

What are Java records and when should I use them?

Records (Java 14, finalized in 16) are immutable data carriers that auto-generate equals, hashCode, toString, and accessor methods. Use them to replace POJOs and DTOs — they reduce boilerplate while making data classes impossible to mutate accidentally. Add compact constructors for validation.

What is pattern matching for switch in Java 21?

Pattern matching for switch lets you match on type patterns and deconstruct records directly in switch expressions, eliminating explicit instanceof checks and casts. Combined with sealed classes, the compiler guarantees exhaustive coverage of all subtypes.

What Java version should I upgrade to in 2026?

Java 25 (released September 2025) is the current LTS and the recommended target; Java 21 remains a solid LTS if you can't move yet. Treat Java 17 as the hard baseline — Spring Boot 3, JUnit 6, Gradle 9, and Maven 4 all require JDK 17+. Every feature in this guide (virtual threads, pattern matching for switch, record patterns) is available on 21 and 25 alike.

What is the difference between var and explicit types in Java?

var (Java 10) infers the type from the right-hand side for local variables only. Use it when the type is obvious from the initializer (e.g., var users = new ArrayList<User>()). Avoid it when the type is not clear from context, as it reduces readability.

Keep Reading

BackendBytes Engineering Team
BackendBytes Engineering Team

Engineering Team

A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.

Read Next