
Top 5 Features Released in Java 21-23 all developers should know
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.
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.
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.
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 usingStructuredConcurrency
). This can be accessed usingScopedValue.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
Related Posts
How to run tasks in parallel in Java and Spring Boot applications
Learn how to run tasks in parallel in Java and Spring Boot applications using CompletableFuture, @Async annotation, Virtual Threads and Structured Concurrency. We will see how to improve the performance of the application by running tasks in parallel.
Stream Gatherers using Java 22
This blog introduces Stream Gatherers, a new feature in Java 22, which allows developers to add custom intermediate operations to stream processing. It explains how to create and use Stream Gatherers to enhance data transformation capabilities in Java streams.