Extremely Serious

Category: Java 21

Mastering Asynchronous Programming in Java with CompletableFuture and Virtual Threads

Java's CompletableFuture provides a powerful and flexible framework for asynchronous programming. Introduced in Java 8, it allows writing non-blocking, event-driven applications with simple and readable code. With Java 21 and Project Loom, virtual threads can be combined with CompletableFutures to achieve highly scalable concurrency with minimal overhead. This article explores the core usage patterns of CompletableFuture and how to leverage virtual threads effectively.

Basics of CompletableFuture Usage

At its simplest, a CompletableFuture represents a future result of an asynchronous computation. You can create one that runs asynchronously using the supplyAsync() or runAsync() methods.

  • supplyAsync() runs a background task that returns a result.
  • runAsync() runs a background task with no returned result.

Example:

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello World");
System.out.println(future.get());  // Blocks until the result is ready

In this example, the supplier runs asynchronously, and the main thread waits for its result using get(). The computation executes in the common ForkJoinPool by default.

Chaining and Composing Tasks

CompletableFuture excels at composing asynchronous tasks without nested callbacks:

  • thenApply() transforms the result of a completed future.
  • thenAccept() consumes the result without returning anything.
  • thenRun() runs a task once the future is complete.
  • thenCombine() combines results of two independent futures.
  • thenCompose() chains dependent futures for sequential asynchronous steps.

Example of chaining:

CompletableFuture.supplyAsync(() -> 10)
    .thenApply(result -> result + 20)
    .thenAccept(result -> System.out.println("Result: " + result));

Example of combining:

CompletableFuture<Integer> f1 = CompletableFuture.supplyAsync(() -> 10);
CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> 20);
CompletableFuture<Integer> combined = f1.thenCombine(f2, Integer::sum);
System.out.println(combined.get());  // 30

These patterns allow building complex, non-blocking workflows with clean and expressive code.

Exception Handling

CompletableFuture allows robust error handling without complicating the flow:

  • Use exceptionally() to recover with a default value on error.
  • Use handle() to process outcome result or exception.
  • Use whenComplete() to perform an action regardless of success or failure.

Example:

CompletableFuture.supplyAsync(() -> {
    if (true) throw new RuntimeException("Failure");
    return "Success";
}).exceptionally(ex -> "Recovered from " + ex.getMessage())
  .thenAccept(System.out::println);  // Outputs: Recovered from java.lang.RuntimeException: Failure

Waiting for Multiple Futures

The allOf() method is used to wait for multiple CompletableFutures to finish:

List<CompletableFuture<String>> futures = List.of(
    CompletableFuture.completedFuture("A"),
    CompletableFuture.completedFuture("B"),
    CompletableFuture.completedFuture("C"));

CompletableFuture<Void> allDone = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
allDone.join();  // Wait until all futures complete

This enables executing parallel asynchronous operations efficiently.

Using CompletableFuture with Virtual Threads

Java 21 introduces virtual threads, lightweight threads that allow massive concurrency with minimal resource consumption. To use CompletableFutures on virtual threads, create an executor backed by virtual threads and pass it to async methods.

Example:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
        System.out.println("Running in virtual thread: " + Thread.currentThread());
        try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        System.out.println("Task completed");
    }, executor);

    future.join();
}
  • The executor is created with Executors.newVirtualThreadPerTaskExecutor().
  • Async tasks run on virtual threads, offering high scalability.
  • The executor must be closed to release resources and stop accepting tasks; using try-with-resources is recommended.

All operations such as thenApplyAsync() or thenCombineAsync() can similarly take the virtual thread executor to keep subsequent stages on virtual threads.

Summary

  • CompletableFuture allows flexible, readable asynchronous programming.
  • Tasks can be created, chained, combined, and composed easily.
  • Robust exception handling is built-in.
  • allOf() allows waiting on multiple futures.
  • With virtual threads, CompletableFuture scales brilliantly by offloading async tasks to lightweight threads.
  • Always close virtual thread executors to properly release resources.

Using CompletableFuture with virtual threads simplifies asynchronous programming and enables writing performant scalable Java applications with clean and maintainable code.

Demystifying Virtual Threads

Java 21 introduces a game-changer for concurrent programming: virtual threads. This article explores what virtual threads are and how they can revolutionize the way you build high-performance applications.

Traditional Threads vs. Virtual Threads

Java developers have long relied on platform threads, the fundamental unit of processing that runs concurrently. However, creating and managing a large number of platform threads can be resource-intensive. This becomes a bottleneck for applications handling high volumes of requests.

Virtual threads offer a lightweight alternative. They are managed by the Java runtime environment, allowing for a much larger number to coexist within a single process compared to platform threads. This translates to significant benefits:

  • Reduced Overhead: Creating and managing virtual threads requires fewer resources, making them ideal for applications that thrive on high concurrency.
  • Efficient Hardware Utilization: Virtual threads don't directly map to operating system threads, enabling them to better leverage available hardware cores. This translates to handling more concurrent requests and improved application throughput.
  • Simpler Concurrency Model: Virtual threads adhere to the familiar "one thread per request" approach used with platform threads. This makes the transition for developers already comfortable with traditional concurrency patterns much smoother. There's no need to learn entirely new paradigms or complex APIs.

Creating Virtual Threads

Java 21 offers two primary ways to create virtual threads:

  1. Thread.Builder Interface: This approach provides a familiar interface for creating virtual threads. You can use a static builder method or a builder object to configure properties like thread name before starting it.

    Here's an example of using the Thread.Builder interface:

    Runnable runnable = () -> {
       var name = Thread.currentThread().getName();
       System.out.printf("Hello, %s!%n", name.isEmpty() ? "anonymous" : name);
    };
    
    try {
       // Using a static builder method
       Thread virtualThread = Thread.startVirtualThread(runnable);
    
       // Using a builder with a custom name
       Thread namedThread = Thread.ofVirtual()
               .name("my-virtual-thread")
               .start(runnable);
    
       // Wait for the threads to finish (optional)
       virtualThread.join();
       namedThread.join();
    } catch (InterruptedException e) {
       throw new RuntimeException(e);
    }
  2. ExecutorService with Virtual Threads: This method leverages an ExecutorService specifically designed to create virtual threads for each submitted task. This approach simplifies thread management and ensures proper cleanup of resources.

    Here's an example of using an ExecutorService with virtual threads:

    try (ExecutorService myExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
       Future future = myExecutor.submit(() -> System.out.println("Running thread"));
       future.get(); // Wait for the task to complete
       System.out.println("Task completed");
    } catch (ExecutionException | InterruptedException e) {
       throw new RuntimeException(e);
    }

Embrace a New Era of Concurrency

Virtual threads represent a significant leap forward in Java concurrency. Their efficiency, better hardware utilization, and familiar approach make them a powerful tool for building high-performance and scalable applications.

Demystifying Switch Type Patterns

Instead of simply matching against constant values, switch type patterns allow you to match against the types and their specific characteristics of the evaluated expression. This translates to cleaner, more readable code compared to traditional if-else statements or cumbersome instanceof checks.

Key Features

  • Type patterns: These match against the exact type of the evaluated expression (e.g., case String s).
  • Deconstruction patterns: These extract specific elements from record objects of a certain type (e.g., case Point(int x, int y)).
  • Guarded patterns: These add additional conditions to be met alongside the type pattern, utilizing the when clause (e.g., case String s when s.length() > 5).
  • Null handling: You can now explicitly handle the null case within the switch statement.

Benefits

  • Enhanced Readability: Code becomes more intuitive by directly matching against types and extracting relevant information.
  • Reduced Boilerplate: Eliminate the need for extensive instanceof checks and type casting, leading to cleaner code.
  • Improved Type Safety: Explicit type checks within the switch statement prevent potential runtime errors.
  • Fine-grained Control Flow: The when clause enables precise matching based on both type and additional conditions.

Examples in Action

  1. Type Patterns:

    Number number = 10l;
    
    switch (number) {
       case Integer i -> System.out.printf("%d is an integer!", i);
       case Long l -> System.out.printf("%d is a long!", l);
       default -> System.out.println("Unknown type");
    }

    In this example, the switch statement checks the exact type of number using the Long type pattern.

  2. Deconstruction Patterns:

    record Point(int x, int y) {}
    
    Point point = new Point(2, 3);
    
    switch (point) {
       case Point(var x, var y) -> System.out.println("Point coordinates: (" + x + ", " + y + ")");
       default -> System.out.println("Unknown object type");
    }

    Here, the deconstruction pattern extracts the x and y coordinates from the Point record object and assigns them to variables within the case block.

  3. Guarded Patterns with the when Clause:

    String name = "John Doe";
    
    switch (name) {
       case String s when s.length() > 5 -> System.out.println("Long name!");
       case String s -> System.out.println("It's a string.");
    }

    This example demonstrates a guarded pattern. The first case checks if the evaluated expression is a String and its length is greater than 5 using the when clause.

  4. Null Handling:

    Object object = null;
    
    switch (object) {
     case null -> System.out.println("The object is null.");
     case String s -> System.out.println("It's a string!");
     default -> System.out.println("Unknown object type");
    }

    Finally, this example showcases the ability to explicitly handle the null case within the switch statement, improving code safety.

Conclusion

Switch type patterns in Java 21 offer a powerful and versatile way to write concise, readable, and type-safe code. By leveraging its features, including the when clause for guarded patterns, you can significantly enhance the maintainability and expressiveness of your Java applications.

Understanding Sequenced Collections

Java 21 introduced a significant enhancement to the collection framework: SequencedCollection. This new interface brings order to the world of collections, providing standardized ways to interact with elements based on their sequence.

What are Sequenced Collections?

Imagine a list where the order of elements matters. That's the essence of a SequencedCollection. It extends the existing Collection interface, offering additional functionalities specific to ordered collections.

Key Features:

  • Accessing first and last elements: Methods like getFirst() and getLast() grant direct access to the first and last elements in the collection, respectively.
  • Adding and removing elements at ends: Efficiently manipulate the beginning and end of the sequence with methods like addFirst(), addLast(), removeFirst(), and removeLast().
  • Reversed view: The reversed() method provides a view of the collection in reverse order. Any changes made to the original collection are reflected in the reversed view.

Benefits:

  • Simplified code: SequencedCollection provides clear and concise methods for working with ordered collections, making code easier to read and maintain.
  • Improved readability: The intent of operations becomes more evident when using methods like addFirst() and removeLast(), leading to better understanding of code.

Example Usage:

Consider a Deque (double-ended queue) implemented using ArrayDeque:

import java.util.ArrayDeque;
import java.util.Deque;

public class SequencedCollectionExample {
    public static void main(String ... args) {
        Deque<String> tasks = new ArrayDeque<>();

        // Add tasks (FIFO order)
        tasks.addLast("Buy groceries");
        tasks.addLast("Finish homework");
        tasks.addLast("Call mom");

        // Access and process elements
        System.out.println("First task: " + tasks.getFirst());

        // Process elements in reverse order
        Deque<String> reversedTasks = tasks.reversed();
        for (String task : reversedTasks) {
            System.out.println("Reversed: " + task);
        }
    }
}

This example demonstrates how SequencedCollection allows for efficient access and manipulation of elements based on their order, both forward and backward.

Implementation Classes:

While SequencedCollection is an interface, existing collection classes automatically become SequencedCollection by virtue of inheriting from Collection. Here's a brief overview:

  • Lists: ArrayList, LinkedList, and Vector
  • Sets: Not directly applicable, but LinkedHashSet maintains order within sets.
  • Queues: ArrayDeque and LinkedList
  • Maps: Not directly applicable, but LinkedHashMap and TreeMap (based on key order) maintain order for key-value pairs.

Remember, specific functionalities and behaviors might vary within these classes. Refer to the official Java documentation for detailed information.

Conclusion:

SequencedCollection is a valuable addition to the Java collection framework, offering a structured and efficient way to work with ordered collections. By understanding its features and functionalities, you can write more readable, maintainable, and expressive code when dealing with ordered data structures in Java 21 and beyond.