Abstract Algorithms

Java 8 to Java 25: How Java Evolved from Boilerplate to a Modern Language

Every major Java feature from 8 to 25 โ€” lambdas, records, sealed classes, virtual threads, pattern matching, and what they replace

Abstract AlgorithmsAbstract Algorithms//Software Engineering Principles

On this page

Reader feedback

Was this article useful?

Rate it if it helped, then continue with the next deep dive when you are ready.

Executive TLDR

  • TLDR: Java went from the most verbose mainstream language to one of the most expressive.
  • Lambdas killed anonymous inner classes.
  • Virtual threads killed thread pools for I/O work.
  • Sealed classes killed unchecked inheritance.

Core mental model

Read this as a system of state, constraints, and failure boundaries.

Every major Java feature from 8 to 25 โ€” lambdas, records, sealed classes, virtual threads, pattern matching, and what they replace

Key systems visualization

The articleโ€™s conceptual path

01

๐Ÿ“– 50 Lines vs. 5 Lines โ€” The Before and After That Started Everything

->

02

๐Ÿ” Java's Six-Month Release Cadence โ€” Understanding the Version Landscape

->

03

โš™๏ธ Java 8 โ€” The Features That Changed Everything (What We're Migrating From)

->

04

๐Ÿ”ง Java 9โ€“10 โ€” Modules, Inference, and Small Wins

->

05

๐Ÿ›๏ธ Java 11 โ€” The First Modern LTS (The New Java 8 Baseline)

TLDR: Java went from the most verbose mainstream language to one of the most expressive. Lambdas killed anonymous inner classes. Records killed POJOs. Virtual threads killed thread pools for I/O work. Sealed classes killed unchecked inheritance. Each feature in this guide exists to eliminate a specific category of pain โ€” and understanding why makes you a better engineer on any version of Java.


๐Ÿ“– 50 Lines vs. 5 Lines โ€” The Before and After That Started Everything

Before Java 8, creating a background task meant writing this:

// Java 7 โ€” anonymous inner class just to run one line
Runnable r = new Runnable() {
    @Override
    public void run() {
        System.out.println("Running in background");
    }
};
new Thread(r).start();

Five lines. Four of them ceremony. The actual logic โ€” System.out.println โ€” is buried in boilerplate that exists purely because Java required a named type to represent a function.

After Java 8, the same thing became:

// Java 8+ โ€” lambda
Runnable r = () -> System.out.println("Running in background");
new Thread(r).start();

One line for the logic. This is the moment Java changed โ€” not just in syntax, but in philosophy. The language started acknowledging that a function should be a first-class value, not a class that happens to contain one method.

Java went from the most verbose mainstream language to one of the most expressive. The journey from Java 8 to Java 25 is the story of how a language designed for enterprise verbosity became clean, concise, and genuinely modern โ€” without breaking a single line of the billions of lines of Java code already in production. Every feature in this guide removes a specific category of pain. Understanding which pain and why is what separates engineers who adopt features because they exist from engineers who adopt them because they solve a real problem.


๐Ÿ” Java's Six-Month Release Cadence โ€” Understanding the Version Landscape

For thirteen years, Java had slow, multi-year release cycles. Java 7 launched in 2011; Java 8 in 2014; Java 9 in 2017. Each release bundled years of work and introduced massive migration risk.

In 2017, Oracle switched to a six-month release cadence: a new Java version every March and September. Features that aren't ready ship as "preview" (opt-in, subject to change) and graduate to stable over one or two versions. This separated release of the JVM from release of individual language features โ€” a massive improvement for the ecosystem.

To handle upgrade conservatism, Oracle designated certain versions as Long-Term Support (LTS): Java 11, 17, 21, and 25. Most production teams track LTS releases only.

The timeline below shows the major milestones and which releases earned LTS status.

flowchart LR
    A[Java 8 - 2014 - LTS] --> B[Java 9 - 2017 - Modules]
    B --> C[Java 10 - 2018 - var]
    C --> D[Java 11 - 2018 - LTS]
    D --> E[Java 14-15 - 2020 - Records Preview]
    E --> F[Java 16 - 2021 - Records Stable]
    F --> G[Java 17 - 2021 - LTS]
    G --> H[Java 21 - 2023 - LTS]
    H --> I[Java 25 - 2025 - LTS]

This diagram traces the LTS milestones and the preview-to-stable graduation path for key features. Notice that records debuted as a preview in Java 14, iterated through Java 15, and stabilised in Java 16 โ€” all in about 12 months. The six-month cadence enabled this rapid iteration without destabilising production deployments on Java 11 or 17.

The practical implication: if your team is on Java 11, you have skipped two full LTS generations (17 and 21) and are now on the cusp of missing a third (25). The cost of staying on Java 11 is no longer just "missing features" โ€” it is paying the performance, security, and developer experience tax every day.


โš™๏ธ The Three Eras of Modern Java โ€” What Each One Solved

Java 8 to Java 25 covers eleven years and three distinct architectural shifts. Each era solves a specific category of pain that was previously handled by boilerplate, convention, or workarounds. The dedicated posts below go deep on each era with full before/after code pairs, internals, and migration guides.

Era 1 (Java 8โ€“11): Lambdas, Streams, and the LTS Baseline

Java 8 ended the anonymous-inner-class era. Lambdas made functions first-class values. The Streams API replaced imperative for-loops with declarative pipelines. Optional<T> made null-safety explicit at the return type. Java 9's module system made dependency boundaries compiler-enforceable โ€” though most teams skip it unless building minimal runtime images with jlink. Java 10 added var for local type inference. Java 11 rounded out the baseline with strip(), isBlank(), HttpClient, and Files.readString() โ€” it became the new Java 8.

// Java 7 โ€” 6-line Comparator just to sort a list
Collections.sort(names, new Comparator<String>() {
    @Override public int compare(String a, String b) { return a.compareTo(b); }
});

// Java 8+ โ€” method reference; one token
names.sort(String::compareTo);

โ†’ Full deep-dive: Java 8 to 11: Lambdas, Streams, and the Module System

Era 2 (Java 14โ€“17): Records, Sealed Classes, and Pattern Matching

The Java 14โ€“17 era is defined by language ergonomics. Records replaced 40โ€“60-line POJOs with a single-line declaration โ€” the compiler generates the constructor, accessors, equals(), hashCode(), and toString(). Text blocks made embedded SQL, JSON, and HTML readable without escape sequences. Sealed classes brought algebraic data types to Java's type system โ€” no more comment-only hierarchy restrictions. Pattern matching for instanceof eliminated the redundant cast ceremony. Java 17 LTS made all of these stable and production-ready.

// Java 15 and below โ€” 50+ lines of POJO ceremony
public final class Point { /* constructor, getX(), getY(), equals(), hashCode(), toString() */ }

// Java 16+ โ€” compiler generates everything
public record Point(int x, int y) {}

โ†’ Full deep-dive: Java 14 to 17: Records, Sealed Classes, and Pattern Matching

Era 3 (Java 21โ€“25): Virtual Threads and Modern Concurrency

Java 21 is the most impactful release since Java 8. Virtual threads replace the bounded-thread-pool model for I/O-bound work: every task gets its own virtual thread, the JVM mounts and unmounts them on a small pool of carrier OS threads, and blocking I/O never occupies an OS thread. Pattern matching for switch with sealed classes gives compiler-checked exhaustive dispatch โ€” no more default branches to paper over unhandled cases. Sequenced collections add getFirst() and getLast() to every ordered collection. Java 25 promotes structured concurrency to GA, adds scoped values, and introduces unnamed variables (_).

// Java 8 โ€” pool hard limit; request 51 waits in queue
ExecutorService pool = Executors.newFixedThreadPool(50);

// Java 21+ โ€” one virtual thread per task; JVM schedules thousands concurrently
ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();

โ†’ Full deep-dive: Java 21 to 25: Virtual Threads, Pattern Matching, and Modern Concurrency


๐Ÿง  How the JVM Implements Virtual Threads: A Deep Dive

Virtual threads are the most architecturally significant addition to the JVM since the introduction of garbage collection. Understanding how they work under the hood lets you reason about when they help, when they hurt, and why the synchronized caveat exists. This section goes deeper than "they're lightweight threads" โ€” it explains the actual scheduling model and the performance characteristics you should internalise before adopting them in production.

The Internals of Virtual Thread Scheduling and Carrier Thread Architecture

A virtual thread is a Thread instance in the Java heap โ€” not a native OS thread. The JVM maintains a small pool of carrier threads (OS-level threads, one per CPU core by default) called the ForkJoinPool. When a virtual thread is scheduled to run, the JVM mounts it onto an available carrier thread. When the virtual thread encounters a blocking operation (a socket read, a LockSupport.park(), a database call via JDBC), the JVM unmounts it โ€” saving its stack frame to the heap โ€” and mounts a different virtual thread onto the freed carrier thread.

flowchart TD
    VT1[Virtual Thread 1 - running] --> CT1[Carrier Thread - OS Thread 1]
    VT2[Virtual Thread 2 - parked on IO] --> Heap[Heap - stack saved]
    VT3[Virtual Thread 3 - runnable] --> CT2[Carrier Thread - OS Thread 2]
    CT1 -->|IO blocks VT1| Heap
    CT1 -->|unmounts VT1 mounts VT3| VT3

This diagram shows the core scheduling model: carrier threads are the real OS threads; virtual threads are heap objects that get mounted and unmounted. When Virtual Thread 1 blocks on I/O, it is saved to the heap and Virtual Thread 3 is mounted in its place โ€” the carrier OS thread never sits idle waiting for I/O to complete.

The critical constraint is synchronized. When a virtual thread enters a synchronized block or synchronized method, the JVM pins it to its carrier thread for the duration of the block. The carrier OS thread cannot serve other virtual threads while pinned โ€” defeating the scheduling model. This is why ReentrantLock is preferred in code that will run on virtual threads: it uses LockSupport.park() internally, which the JVM knows how to unpark correctly.

// Problematic โ€” synchronized pins the virtual thread to its carrier OS thread
synchronized (this) {
    result = jdbcConnection.executeQuery(sql);  // blocks carrier thread during DB I/O
}

// Correct โ€” ReentrantLock allows the virtual thread to be unmounted during blocking
lock.lock();
try {
    result = jdbcConnection.executeQuery(sql);  // carrier thread is free during DB I/O
} finally {
    lock.unlock();
}

Starting with Java 24, the JVM began relaxing the pinning behaviour for synchronized blocks that do not hold a monitor across a blocking operation. Java 25 continues this work. For now, the safest production pattern is to audit hot paths for synchronized-over-I/O and replace with ReentrantLock.

Performance Analysis: When Virtual Threads Win and When They Don't

The throughput benefit of virtual threads is directly proportional to the blocking ratio of your workload โ€” the fraction of time a request spends waiting for I/O vs. computing.

Workload typeBlocking ratioVirtual thread benefit
HTTP proxy / API gateway90%+ blockingVery high โ€” 5โ€“10ร— more concurrent requests at same OS thread count
CRUD service with DB queries70โ€“90% blockingHigh โ€” 2โ€“4ร— throughput improvement
REST service with some computation40โ€“70% blockingModerate โ€” depends on computation share
Image processing / hashing0โ€“10% blockingNone โ€” CPU never parks; virtual threads behave like platform threads

The JVM profiler diagnostic flag to identify pinned threads is -Djdk.tracePinnedThreads=full, which logs a stack trace whenever a virtual thread is pinned to a carrier. Run this in staging to find synchronized-over-I/O before deploying to production.


๐ŸŒ Modern Java Features Deployed at Scale โ€” Netflix, Stripe, and Spring

Java's evolution did not happen in a vacuum. The language features released from Java 14 onward directly address patterns that large-scale engineering teams hit repeatedly in production systems.

Netflix and Project Loom: Netflix operates thousands of JVM-based microservices. Their primary latency bottleneck has historically been thread pool saturation during intra-service HTTP calls โ€” each service calling two or three downstream services per request. After migrating high-throughput services to Java 21 virtual threads, Netflix reported that they could remove thread pool tuning from their configuration entirely for I/O-bound services and rely on JVM-managed scheduling instead. The removal of maxThreads as a first-class operational concern reduces the class of incidents caused by misconfigured thread pools.

Stripe and Records + Sealed Classes: Stripe's Java API client library generates typed response models for every API endpoint. Before records, every response type was a 50โ€“80-line POJO. The migration to records reduced generated code by roughly 70%, making the library easier to audit for correctness. The sealed interface pattern (sealed interface StripeResponse permits SuccessResponse, ErrorResponse) replaced the previous convention of checking response fields at runtime โ€” errors that were previously surfaced at runtime (checking response.isSuccess()) are now surfaced at compile time when callers forget to handle the failure case in a switch expression.

Spring Boot and the Virtual Thread Revolution: Spring Boot 3.2 (released November 2023) is the most widely deployed framework integration for Java 21 virtual threads. The configuration is a single property (spring.threads.virtual.enabled=true). Under the hood, Spring registers a TomcatVirtualThreadsWebServerCustomizer that replaces Tomcat's executor with Executors.newVirtualThreadPerTaskExecutor(). Every incoming HTTP request, @Async method, and @Scheduled task runs on a virtual thread. The impact on Spring Data JPA is especially notable: JDBC calls, which previously pinned threads when using connection pools with synchronized internals, are now safe because HikariCP 5.1+ replaced all synchronized blocks with ReentrantLock.


โš–๏ธ The Real Migration Costs โ€” Thread Pinning, Framework Lag, and Immutability Constraints

Modernising a Java codebase is not cost-free. Understanding the real friction points prevents premature optimism in migration planning.

Thread pinning is a hidden tax. Legacy JDBC drivers (Oracle thin driver before 23c, some MySQL drivers before 8.2), older connection pools (DBCP2, c3p0), and hand-written synchronized cache implementations can all pin virtual threads. The symptom is that virtual thread adoption produces no throughput improvement in production while tests show large gains โ€” because test environments often skip the persistence layer. Audit production JDBC driver and connection pool versions before relying on virtual thread throughput figures.

Record immutability is a constraint, not just a feature. Records are ideal for value objects and DTOs but cannot replace entities with mutable lifecycle state. A JPA entity that accumulates state changes between findById() and save() cannot be a record โ€” records have no setters and cannot be modified after construction. Teams that convert entity classes to records break JPA's dirty-checking mechanism. The right scope for records is data transfer, not persistence.

Java module adoption requires upfront investment. Project Jigsaw (Java 9) is still widely skipped because migrating an existing multi-module Maven or Gradle project to JPMS modules requires creating module-info.java files, resolving all split packages, and ensuring every transitive dependency exposes the right exports. The payoff โ€” smaller runtime images with jlink, stronger encapsulation โ€” is real but deferred. Most teams only benefit when building containerised microservices where image size matters.

The "synchronized pinning" cliff. An application that appears to run fine under moderate load can degrade severely under peak load if it has pinned virtual threads. Each pinned virtual thread holds one of the few carrier OS threads โ€” under 200 pinned virtual threads (all waiting for I/O while holding a synchronized monitor), all carrier threads are occupied and new virtual threads cannot run. This failure mode looks exactly like old-fashioned thread pool exhaustion and is diagnosed the same way: add -Djdk.tracePinnedThreads=full and look for repeated stack traces in your logs.

Migration RiskAffected VersionsMitigation
Synchronized-over-I/O pinningJava 21+ (virtual threads)Replace with ReentrantLock; run -Djdk.tracePinnedThreads=full
JPA entities as recordsJava 16+ (records)Keep records for DTOs only; entities remain plain classes
Module system framework failuresJava 9+ (JPMS)Add opens declarations for reflection; validate with jdeps --check
Strong encapsulation JDK internalsJava 17+Update Hibernate to 6+, Jackson to 2.14+, Spring to 6+
parallelStream() on request pathJava 8+Replace with sequential streams or virtual threads for I/O

๐Ÿงช Migrating a Java 8 Service to Java 21 Virtual Threads Step by Step

This section walks through a realistic migration โ€” a payment verification service that fetches a user record, checks fraud scores, and validates order history before confirming a payment. In Java 8, this used a fixed thread pool. In Java 21, the same service uses virtual threads without changing any business logic.

This scenario is instructive because it combines two common patterns: parallel fan-out to multiple services and sequential validation steps. Understanding why each change is made (not just what is changed) is the point of this walkthrough.

Step 1 โ€” Baseline Java 8 Implementation

// Java 8 โ€” PaymentVerificationService with fixed thread pool
public class PaymentVerificationService {

    // Pool sized conservatively to avoid OOM; too small = queuing; too large = memory pressure
    private final ExecutorService executor = Executors.newFixedThreadPool(50);

    public PaymentResult verify(String userId, String orderId) throws Exception {
        // Sequential calls โ€” each blocks the calling thread
        User user       = userClient.fetchUser(userId);         // 20-50ms I/O
        FraudScore score = fraudClient.checkFraud(userId);      // 30-80ms I/O
        Order order     = orderClient.fetchOrder(orderId);      // 20-40ms I/O

        if (score.getRisk() > 0.8) {
            return PaymentResult.denied("High fraud risk");
        }
        if (!order.getUserId().equals(userId)) {
            return PaymentResult.denied("Order mismatch");
        }
        return PaymentResult.approved();
    }
}

Total latency per request: 70โ€“170ms from sequential I/O. Under 50 concurrent requests, the thread pool saturates. Request 51 waits in the queue until one of the 50 threads finishes its I/O.

Step 2 โ€” Java 21 Migration with Virtual Threads and Structured Concurrency

// Java 21 โ€” same service with virtual threads + structured concurrency
public class PaymentVerificationService {

    // No pool sizing needed โ€” JVM manages virtual thread scheduling
    public PaymentResult verify(String userId, String orderId) throws Exception {
        User user;
        FraudScore score;
        Order order;

        // Fan-out: all three I/O calls run concurrently; fail fast if any throws
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Future<User>       userFuture  = scope.fork(() -> userClient.fetchUser(userId));
            Future<FraudScore> scoreFuture = scope.fork(() -> fraudClient.checkFraud(userId));
            Future<Order>      orderFuture = scope.fork(() -> orderClient.fetchOrder(orderId));

            scope.join();           // wait for all three
            scope.throwIfFailed();  // propagate exception if any subtask failed

            user  = userFuture.get();
            score = scoreFuture.get();
            order = orderFuture.get();
        }
        // Total latency: max(fetchUser, checkFraud, fetchOrder) โ‰ˆ 30โ€“80ms (parallel)

        if (score.getRisk() > 0.8) {
            return PaymentResult.denied("High fraud risk");
        }
        if (!order.getUserId().equals(userId)) {
            return PaymentResult.denied("Order mismatch");
        }
        return PaymentResult.approved();
    }
}

What changed and why: The three I/O calls now run in parallel inside a StructuredTaskScope. If the fraud service throws a ServiceUnavailableException, ShutdownOnFailure cancels the other two forks immediately and throwIfFailed() propagates the exception โ€” no orphaned threads, no partial results. Total latency drops from the sum of three calls (~70โ€“170ms) to the maximum of the three calls (~30โ€“80ms). The service handles thousands of concurrent requests without a pool cap because virtual threads scale to the heap, not to a bounded OS thread count.

Before/After metrics for this pattern:

MetricJava 8 (fixed pool 50)Java 21 (virtual threads)
Max concurrent requests50 (pool limit)Bounded by heap memory only
Latency per request70โ€“170ms (sequential)30โ€“80ms (parallel)
Thread pool tuning requiredYes (critical path)No
Partial failure handlingManual CompletableFutureAutomatic via ShutdownOnFailure

๐Ÿ“Š Feature Impact Summary โ€” Java 8 to Java 25

FeatureStable InWhat It ReplacesWhen to Use
LambdaJava 8Anonymous inner classAny functional interface โ€” callbacks, comparators, event handlers
Stream APIJava 8Manual for-loops with temp listsDeclarative collection processing; avoid parallelStream for small datasets
OptionalJava 8Unchecked null returnsReturn types only; never as parameter or field type
varJava 10Verbose generic type declarationsRHS makes type obvious; avoid when method name obscures type
Text BlockJava 15String concatenation for multiline stringsSQL, JSON, HTML in test fixtures and templates
RecordJava 1640-line POJO boilerplateDTOs, value objects, response models, event payloads
Pattern instanceofJava 16Explicit cast after instanceof checkAny instanceof check that uses the variable
Sealed ClassJava 17Documentation-only hierarchy restrictionsAlgebraic data types, finite domain models, Result/Option
Pattern switchJava 21if-instanceof chainsMulti-type dispatch; exhaustiveness checked by compiler with sealed types
Virtual ThreadsJava 21Bounded thread pools for I/O workAll I/O-bound blocking code: HTTP, DB, file reads
Sequenced CollectionsJava 21list.get(list.size()-1) and iterator tricksFirst/last access on any ordered collection
Structured ConcurrencyJava 25CompletableFuture.allOf() with manual cancellationParallel I/O fan-out where all tasks must succeed or all must cancel
Unnamed Variables _Java 25Named but unused variables (IDE warnings)Catch blocks, switch cases where the bound variable is irrelevant

๐Ÿงญ Migration Strategy: Moving from Java 8 to Java 25

Many teams are still on Java 11 or 17, even though Java 21 and 25 are LTS releases. The reluctance is understandable: framework compatibility, security team sign-off, and testing effort all create friction. Here is a phase-by-phase approach.

flowchart TD
    A[Java 8] -->|Step 1| B[Java 11]
    B -->|Step 2| C[Java 17]
    C -->|Step 3| D[Java 21]
    D -->|Step 4| E[Java 25]

    A1[Enable --illegal-access=warn. Update Hibernate and Jackson. Migrate HttpURLConnection to HttpClient.] --> B
    B1[Replace POJOs with records. Use text blocks. Adopt sealed classes for domain models.] --> C
    C1[Enable virtual threads for I/O services. Replace if-instanceof chains with pattern switch.] --> D
    D1[Enable structured concurrency. Run jdeprscan. Remove deprecated APIs. Use module imports in scripts.] --> E

This diagram shows the four-phase migration path. Each phase has a focused set of changes โ€” you don't need to adopt every feature at once. The key insight is that each LTS-to-LTS step has a clearly bounded scope of breaking changes.

Step 1 (8 โ†’ 11): Run --illegal-access=warn to discover framework reflection issues before they become errors. Update Spring to 5.3+, Hibernate to 5.6+, Jackson to 2.14+. Replace HttpURLConnection with HttpClient. Use Files.readString and Files.writeString.

Step 2 (11 โ†’ 17): Adopt records for all new DTOs. Introduce text blocks in test fixtures with SQL or JSON. Model finite domain hierarchies with sealed classes. The --illegal-access removal is now a deny โ€” any unresolved framework issues surface here.

Step 3 (17 โ†’ 21): This is the high-value step. Enable virtual threads in your HTTP server (Tomcat/Jetty/Undertow all support virtual-thread-executor). Replace if-instanceof chains in domain logic with pattern switch. Adopt SequencedCollection methods to clean up first/last access code.

Step 4 (21 โ†’ 25): Enable structured concurrency for fan-out I/O patterns. Run jdeprscan to find deprecated API usage. Clean up unused catch variable warnings with _. Use jlink to produce a minimal custom runtime image.

Useful tools: jdeprscan --class-path <your-jar> <your-jar> scans for deprecated API usage. jlink produces a minimal JRE containing only the modules your application uses, reducing container image size.


๐Ÿšง What Java 25 Still Doesn't Have

Java has moved fast but several long-awaited features are still in preview or on the roadmap.

Value types (Project Valhalla): The full vision is primitive-like classes โ€” objects with no heap identity, inlineable into arrays and fields like int. Java 25 has early previews of value classes, but the full reification that eliminates the int/Integer split is still years away.

Reified generics: Java's generics are still erased at runtime. You still cannot write new T[] or instanceof List<String>. Reification requires changes to both the language and the JVM bytecode format and is not on a near-term schedule.

Metaprogramming / macro system: Java has no compile-time code generation equivalent to Rust macros or C++ templates with concepts. Annotation processors (@Generated, Lombok, MapStruct) fill part of this gap but operate outside the language proper. There are no current plans for a first-class macro system.


๐Ÿ› ๏ธ Spring Boot + Java 21 Virtual Threads: Enabling Them in Production

Spring Boot is the most widely deployed Java framework, and virtual thread support landed in Spring Boot 3.2. Enabling it requires one property change.

What Spring Boot 3.2 does with virtual threads: When enabled, Tomcat switches to a virtual-thread-per-request model instead of its default fixed thread pool. Every incoming HTTP request gets its own virtual thread. The Tomcat thread pool maximum (server.tomcat.threads.max) becomes irrelevant because the JVM schedules virtual threads โ€” not OS threads โ€” on a small pool of carrier threads.

# application.yml โ€” Spring Boot 3.2+
spring:
  threads:
    virtual:
      enabled: true    # Switches Tomcat and @Async to use virtual threads

# You can remove or significantly relax thread pool tuning
# server:
#   tomcat:
#     threads:
#       max: 200       # No longer the primary concurrency knob
// Spring Boot 3.2+ with virtual threads โ€” no code changes needed
// @RestController methods run on virtual threads automatically
@RestController
public class UserController {

    @GetMapping("/users/{id}")
    public UserProfile getUser(@PathVariable String id) {
        // This blocking call no longer holds an OS thread
        User user = userRepository.findById(id);           // blocks virtual thread, not OS thread
        List<Order> orders = orderRepository.findByUser(id); // same
        return new UserProfile(user, orders);
    }
}

The key benefit is throughput under I/O contention. A service that makes two sequential database calls per request and handles 1,000 concurrent users was previously limited by the 200-thread Tomcat pool. With virtual threads, all 1,000 requests run concurrently โ€” the JVM parks virtual threads during database I/O and runs others on the same carrier threads.

Benchmark signal: Teams migrating to Spring Boot 3.2 + virtual threads on I/O-bound services have reported 30โ€“60% throughput increases at the same concurrency level without tuning thread pool sizes. The exact number depends on I/O wait ratio โ€” the more time a request spends waiting on I/O, the more virtual threads help.

Caveat to watch: If your code uses synchronized blocks around I/O (common in legacy JDBC drivers or old connection pool implementations), those blocks will pin virtual threads to carrier OS threads and negate the benefit. Spring Boot 3.2+ uses HikariCP, which was updated to use ReentrantLock instead of synchronized for exactly this reason. Verify your JDBC driver is up to date.

For a full deep-dive on Spring Boot's reactive and virtual-thread concurrency models, see the planned follow-up post on Reactive vs. Virtual Threads in Spring Boot: When to Use Which.


๐Ÿ“š Hard-Won Lessons from 11 Years of Java Evolution

  • Features exist to remove specific pain โ€” learn the pain first. Records don't matter until you've maintained a 50-line POJO that needed a new field. Virtual threads don't matter until you've debugged thread pool exhaustion at 3am. Use this guide not as a feature checklist but as a diagnostic: which pain does your codebase currently have?

  • Optional.get() is a trap. The entire point of Optional is to force explicit handling of absence. Calling .get() without .isPresent() replaces NullPointerException with NoSuchElementException โ€” same problem, different name. Always use .orElse(), .orElseThrow(), or .ifPresent().

  • parallelStream() is not a performance shortcut. It uses the common ForkJoinPool shared across your entire application. On a server under load, throwing a parallelStream() in a request handler can starve other threads competing for the same pool. Profile before parallelizing; sequential streams are almost always faster for lists under 10,000 elements.

  • Adopt records for new code immediately; don't refactor old POJOs yet. Records are immutable, have no setters, and their accessor methods follow fieldName() not getFieldName(). Migrating existing code requires checking everywhere the POJO is used. Greenfield records are a zero-risk win.

  • Virtual threads don't eliminate all concurrency bugs. Race conditions, visibility issues, and incorrect synchronized usage are still possible with virtual threads. They solve the scalability problem of thread-per-request, not the correctness problem of shared mutable state.

  • Sealed classes shine brightest with pattern switch. Using a sealed class in Java 17 without Java 21's pattern switch means you're getting encapsulation benefits but not the exhaustiveness checking. The two features are designed as a pair.

  • var should increase readability, not reduce it. If you have to hover over a variable to find its type, var was the wrong choice. The principle: use it when the type is obvious from context; add an explicit type when it adds meaning.


๐Ÿ“Œ Java 8 โ†’ Java 25 in Seven Points

  • Java 8 gave Java a functional programming model (lambdas, streams, Optional) and made boilerplate reduction possible for the first time.
  • Java 10's var and Java 11's string methods are quality-of-life wins available on the LTS version most teams still run.
  • Records (Java 16) and text blocks (Java 15) are the easiest migrations โ€” pure additions that reduce code without breaking anything.
  • Sealed classes (Java 17) bring type-system enforcement to what was previously just documentation, and they unlock the full power of pattern switch.
  • Virtual threads (Java 21) are the most operationally impactful feature since Java 8 for server-side applications โ€” enabling millions of concurrent I/O tasks on a fraction of the OS threads previously required.
  • Pattern matching switch (Java 21) with sealed classes makes multi-type dispatch exhaustive, readable, and compiler-checked โ€” retiring both the visitor pattern and long if-instanceof chains in most cases.
  • Java 25 stabilises the concurrency model (structured concurrency, scoped values) and closes the last ergonomic gaps (unnamed variables, flexible constructors). The Java of 2025 is a genuinely modern language running on the most production-proven JVM in history.

The action to take right now: If you are on Java 11, records and text blocks are available by simply upgrading to 17. If you are on Java 17, virtual threads and pattern switch are one runtime flag away on Java 21. The upgrade cost has never been lower; the payoff has never been higher.

Quiet AI help

Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms

Related deep dives

Continue reading

Abstract Algorithms ยท ยฉ 2026 ยท Engineering learning lab