CodeWiz Logo

    CodeWiz

    Top 5 Features Released in Java 21-23 all developers should know

    Top 5 Features Released in Java 21-23 all developers should know

    06/02/2025

    Introduction

    Java has introduced several powerful features from versions 21 to 23 that significantly enhance the language's capabilities. In this blog post, we will explore the below top 5 features released during this period which I feel all developers should know.

    1. Virtual Threads

    2. Pattern Matching with Records and Sealed Classes

    3. Structured Concurrency

    4. Scoped Values

    5. Stream Gatherers

    Let's dive into each of these features and understand how they can enhance your Java applications.

    1. Virtual Threads

    Virtual Threads are lightweight threads that are managed by the Java Virtual Machine (JVM) rather than the operating system introduced as part of project Loom. This feature allows us to run millions of threads in a normal commodity machine.

    Before we dive into virtual threads, let us understand why we need them.

    Why Virtual Threads

    Traditionally threading model in java which has been there since the first version of Java has some limitations like:

    • Each java thread is a thin wrapper on an OS thread which consumes memory and resources.
    • Creating and destroying threads is expensive. This is usually solved by using thread pools.
    • Threads are not lightweight and can't be created in large numbers. A typical machine can run only few thousand threads.

    When you run an API in Spring Boot application, for each incoming request, a thread is allocated from the thread pool to process the request.


    alt text


    In normal business application often we will be making some blocking calls like fetching data from database, calling external service etc. In such cases, threads are blocked and are not doing any work and CPU is idle while waiting for response. Since each thread uses a platform thread with consumes around 1 MB of memory, we can't create too many threads. In a typical machine you can run only few thousand threads.To completely utilize the CPU, we need to run more threads, but we are limited by the number of threads we can create due to the memory usage mostly.

    This is where virtual threads come into picture.

    How Virtual Threads work

    Virtual threads are lightweight threads which takes only few KB of memory. Multiple virtual threads can run on a single OS thread. When a virtual thread is blocked, its current state is saved to heap using Continuation and the OS thread is released and available for other virtual threads. When the blocking operation is completed, the virtual thread is resumed from the saved state. This allows us to run millions of virtual threads on a typical machine where we can run only few thousand normal threads.


    alt text


    How to create Virtual Threads

    We can create virtual threads using Thread.ofVirtual() method. Below is an example of creating and starting a virtual thread.

    
    var vThread = Thread.ofVirtual().unstarted(() -> {
        System.out.println("virtual " + Thread.currentThread());
    });
    vThread.start();
    vThread.join();
    
    
    var vThread1 = Thread.ofVirtual().start(() -> {
        System.out.println("virtual " + Thread.currentThread());
    });
    vThread1.join();
    

    If you are using ExecutorService, you can create virtual threads using Executors.newVirtualThreadPerTaskExecutor() method.

    try(ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
        IntStream.range(0, 10).forEach(index ->
                executorService.submit(() -> {
                    System.out.println("virtual executor " + Thread.currentThread());
                })
        );
    }

    Enabling Virtual Threads in Spring Boot

    To enable virtual threads in Spring Boot application, we need to add the below properties in application.properties.

    spring.threads.virtual.enabled=true

    Using virtual threads will improve the throughput and parallelism of the application if you have blocking operations like database access, network calls etc. But if your application is mostly CPU bound, then virtual threads may not provide too much benefit.

    Current virtual thread has a limitation that if a blocking call is executed in a synchronized block, platform thread is not released. Solution for this problem is to use other locking mechanisms like ReentrantLock instead of synchronized block. This is expected to be fixed in future versions of Java.

    2. Pattern Matching with Records and Sealed Classes

    Pattern matching allows you to test an object against a pattern and extract data if it matches. This feature simplifies code and enhances readability.

    By combining pattern matching with records and sealed classes, we can write more concise and readable code.

    Below is an example of using pattern matching with records and sealed classes.

    You can learn more about pattern matching with records and sealed classes in Pattern Matching in Java 23.

    
    public sealed interface Transaction permits Deposit, Withdrawal, Transfer {}
    
    public record Deposit(String accountNumber, int amount) implements Transaction {}
    public record Withdrawal(String accountNumber, int amount) implements Transaction {}
    public record Transfer(String fromAccountNo, String toAccountNo, int amount) implements Transaction {}
    
    public void handleTransaction(Transaction transaction) {
        switch (transaction) {
            case Deposit(String accNo, int amt) ->
                    System.out.printf("Handling deposit of %d to account %s%n", amt, accNo);
            case Withdrawal(String accNo, int amt) when amt > 10_000 ->
                    // send out alert
                    System.out.printf("Handling withdrawal of %d from account %s%n", amt, accNo);
            case Withdrawal(String accNo, int amt) ->
                    System.out.printf("Handling withdrawal of %d from account %s%n", amt, accNo);
            case Transfer(String fromAccNo, String toAccNo, int amt) ->
                System.out.printf("Handling transfer of %d from account %s to account %s%n", amt, fromAccNo, toAccNo);
        }
    }
    

    3. Structured Concurrency

    While using existing concurrency APIs like ExecutorService, CompletableFuture, etc., it is difficult to manage the communication between threads and handle exceptions. For example if one task fails, how to cancel other tasks, how to handle exceptions, etc. This is where structured concurrency comes into picture. This is still a preview feature in Java 23.


    alt text


    With Structured Concurrency, we can create a StructuredTaskScope usually in a try with resources block. You then fork tasks using fork method and join them using join method. If any task fails, the StructuredTaskScope will throw an exception and the other tasks will be cancelled since we are using a task scope of type ShutdownOnFailure

    public StockHolding getStockPositionStructuredConcurreny(String symbol) {
        long startTime = System.currentTimeMillis();
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            var ordersTask = scope.fork(() -> stockOrderService.getOrdersBySymbol(symbol));
            var priceTask = scope.fork(() -> stockInformationService.getPrice(symbol));
            var companyDetailsTask = scope.fork(() -> stockInformationService.getCompanyDetails(symbol));
            scope.join();
            scope.throwIfFailed();
            List<StockOrder> orders = ordersTask.get();
            StockPrice price = priceTask.get();
            CompanyDetails company = companyDetailsTask.get();
            log.info("getStockPositionStructuredConcurreny.Time taken to get stock position: {}ms", System.currentTimeMillis() - startTime);
            return getStockHolding(orders, price, company);
        } catch (Exception e) {
            log.error("Error while getting stock position", e);
            return null;
        }
    }

    Similar to ShutdownOnFailure there is ShutdownOnSuccess which will cancel other tasks if any of the task completes successfully. This is useful in scenarios when you have multiple end points, and you want to get the result from the first end point which completes successfully.

    public StockPrice getPrice(String symbol) {
        try (var scope = new StructuredTaskScope.ShutdownOnSuccess<StockPrice>()) {
            var priceTask1 = scope.fork(() -> getPriceFromService1(symbol));
            var priceTask2 = scope.fork(() -> getPriceFromService2(symbol));
            scope.join();
            return scope.result();
        } catch (Exception e) {
            log.error("Error while getting price", e);
            return null;
        }
    }

    Also, you can create a custom scope which can be used for specific requirements.

    4. Scoped Values

    Scoped values allow sharing immutable data across all components in the call hierarchy without passing it as a method or constructor argument. ThreadLocal variables have been used in Java since 1998 to create variables local to a thread and share data across the call stack. However, thread-local variables have several limitations including unconstrained mutability and unbounded lifetime. Scoped Values, a preview feature in Java 23, provides a clean way to share immutable data within a thread and its child threads addressing the limitations of ThreadLocal.

    public class UserContext {
        public static final ScopedValue<String> userScopedVal = ScopedValue.newInstance();
    }
    public class UserContextDemo {
        public static void main(String[] args) {
            List<String> users = List.of("Alice", "Bob", "Charlie", "David", "Eve");
            try(ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()){
                for (String user : users) {
                    executor.submit(() -> {
                        ScopedValue.runWhere(UserContext.userScopedVal, user, () -> {
                            var stocks = new StockRepository().getStockSymbols();
                        });
                    });
                }
            }
        }
    }
    
    class StockRepository{
        Map<String,List<String>> userStocks = Map.of(
                "Alice", List.of("AAPL", "GOOGL", "AMZN"),
                "Bob", List.of("MSFT", "TSLA"),
                "Charlie", List.of("AAPL", "AMZN"),
                "David", List.of("GOOGL", "MSFT"),
                "Eve", List.of("TSLA")
        );
        public List<String> getStockSymbols(){
            return userStocks.get(UserContext.userScopedVal.get());
        }
    }
    • First we initialize a scoped value using ScopedValue.newInstance()
    • Then we use ScopedValue.runWhere() to set the value of the scoped value within a context. The value set in the scoped value is accessible to the current thread and any child threads created within that context (while using StructuredConcurrency). This can be accessed using ScopedValue.get().
    • Once the context is over, the value is automatically cleared.
    • Scoped values are immutable, so they don't need to be copied to child threads, reducing the memory footprint.

    You can learn more about Scoped Values in my blog post Scoped Values in J ava 23.

    5. Stream Gatherers

    Stream Gatherers, introduced as a preview feature in Java 22, allow developers to add custom intermediate operations to stream processing. This feature enhances the flexibility and power of the Stream API by enabling operations that were previously difficult to achieve with the built-in intermediate operations.

    Stream Gatherers are defined by four main components: Initializer, Integrator, Finisher, and Combiner. The Initializer provides an object to maintain state during stream processing. The Integrator applies business logic to the elements in the stream and passes them to the next stage. The Finisher performs actions after all elements are processed, and the Combiner is used for parallel streams to combine data from parallel runs.

    For example, a fixed window gatherer can be created to group elements in a stream into fixed-size windows:

    private static <T> Gatherer<T, List<T>, List<T>> getFixedWindowGatherer(int limit) {
        Supplier<List<T>> initializer = ArrayList::new;
        Gatherer.Integrator<List<T>, T, List<T>> integrator = (state, element, downstream) -> {
            state.add(element);
            if (state.size() == limit) {
                var group = List.copyOf(state);
                downstream.push(group);
                state.clear();
            }
            return true;
        };
        BiConsumer<List<T>, Gatherer.Downstream<? super List<T>>> finisher = (state, downStream) -> {
            if (!state.isEmpty()) {
                downStream.push(List.copyOf(state));
            }
        };
        return Gatherer.ofSequential(initializer, integrator, finisher);
    }

    Using this gatherer in a stream pipeline:

    var employeePairList = employees.stream()
            .filter(employee -> employee.department().equals("Engineering"))
            .map(Employee::name)
            .gather(getFixedWindowGatherer(2))
            .toList();
    System.out.println(employeePairList); // [[Alice, Mary], [John, Ramesh], [Jen]]

    You can learn more about Stream Gatherers in my blog post Stream Gatherers in Java 22.

    Conclusion

    In this blog post, we explored the top 5 features released from Java 21 to Java 23, including Virtual Threads, Pattern Matching with Records and Sealed Classes, Structured Concurrency, Scoped Values, and Stream Gatherers. These features significantly enhance the capabilities of Java and provide developers with powerful tools to build efficient and maintainable applications. I hope you found this blog post informative and helpful.

    To stay updated with the latest updates in Java and Spring, follow us on YouTube, LinkedIn, and Medium.

    Related Videos