How do I use ReentrantLock vs synchronized?

In Java, both synchronized and ReentrantLock are used to manage thread safety, but they offer different levels of control and flexibility.

1. synchronized Keyword

The synchronized keyword is the built-in mechanism in Java for mutual exclusion. It is simpler to use because the JVM automatically handles acquiring and releasing the lock.

  • Usage: Can be applied to methods or used as a block.
  • Automatic Release: The lock is automatically released when the thread exits the block or method, even if an exception occurs.
  • Performance: In modern JVMs, synchronized is highly optimized (biased locking, lightweight locking) and often performs just as well as ReentrantLock.

Example:

package org.kodejava.util.concurrent;

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public void incrementBlock() {
        synchronized (this) {
            count++;
        }
    }
}

2. ReentrantLock Class

ReentrantLock (from java.util.concurrent.locks) is an explicit lock implementation that provides advanced features not available with synchronized.

  • Manual Control: You must explicitly call lock() and unlock(). It is critical to use a try-finally block to ensure the lock is released.
  • Fairness: You can create a “fair” lock that gives preference to the longest-waiting thread.
  • Try Lock: tryLock() allows a thread to attempt to acquire the lock without blocking indefinitely.
  • Interruptible: A thread waiting for a ReentrantLock can be interrupted via lockInterruptibly().

Example:

package org.kodejava.util.concurrent;

import java.util.concurrent.locks.ReentrantLock;

public class LockCounter {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            // Always unlock in finally to prevent deadlocks
            lock.unlock();
        }
    }
}

Key Differences

Feature synchronized ReentrantLock
Ease of Use Very easy; handled by JVM. Requires manual try-finally.
Fairness Not supported. Supported (optional constructor parameter).
Flexibility Rigid block structure. Highly flexible (can lock in one method, unlock in another).
Non-blocking No (thread always waits). Yes, via tryLock().
Interruptibility No. Yes, via lockInterruptibly().

Which should you choose?

  • Use synchronized by default. It’s cleaner, less error-prone, and sufficient for most basic thread-safety needs.
  • Use ReentrantLock when you need advanced features like:
    • Timing out while waiting for a lock (tryLock(timeout)).
    • Allowing the lock attempt to be interrupted.
    • Using a “Fair” lock strategy.
    • Complex locking structures that aren’t strictly nested.

If you are debugging concurrency issues, IntelliJ IDEA provides tools to inspect thread states and detect blocked monitors [1]. You can also use the Threads tab in the Debugger to see which thread owns a monitor or is waiting for one [2].

How do I use CountDownLatch for coordination?

A CountDownLatch is a synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes. It’s initialized with a given count, and the await() methods block until the current count reaches zero due to invocations of the countDown() method.

Here is a practical guide on how to coordinate threads using CountDownLatch.

1. Basic Coordination Pattern

The most common use case is having a main thread wait for several worker threads to finish their tasks.

package org.kodejava.util.concurrent;

import java.util.concurrent.CountDownLatch;

public class LatchCoordination {
    public static void main(String[] args) throws InterruptedException {
        int numberOfWorkers = 3;
        // 1. Initialize with the number of events to wait for
        CountDownLatch latch = new CountDownLatch(numberOfWorkers);

        for (int i = 0; i < numberOfWorkers; i++) {
            new Thread(new Worker(latch, "Worker-" + i)).start();
        }

        System.out.println("Main thread is waiting...");
        // 2. Wait until the count reaches zero
        latch.await();

        System.out.println("All workers finished. Main thread proceeding.");
    }
}

class Worker implements Runnable {
    private final CountDownLatch latch;
    private final String name;

    Worker(CountDownLatch latch, String name) {
        this.latch = latch;
        this.name = name;
    }

    @Override
    public void run() {
        try {
            System.out.println(name + " is performing work...");
            Thread.sleep(1000); // Simulate work
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            // 3. Crucial: Always decrement the count in a finally block
            latch.countDown();
            System.out.println(name + " finished. Remaining: " + latch.getCount());
        }
    }
}

2. Key Methods to Remember

  • new CountDownLatch(int count): Sets the initial count. This count cannot be reset. If you need to reset the count, consider using a CyclicBarrier.
  • countDown(): Decrements the count of the latch, releasing all waiting threads if the count reaches zero.
  • await(): Causes the current thread to wait until the latch has counted down to zero, unless the thread is interrupted.
  • await(long timeout, TimeUnit unit): A safer version that waits for a specific duration. It returns true if the count reached zero and false if the waiting time elapsed before the count reached zero.

3. Advanced Use: The “Starting Gun”

You can also use a CountDownLatch with a count of 1 as a “starting gun” to release many threads at the exact same moment (useful for load testing).

CountDownLatch startSignal = new CountDownLatch(1);

for (int i = 0; i < 10; i++) {
    new Thread(() -> {
        try {
            startSignal.await(); // All threads stall here
            // Do concurrent work...
        } catch (InterruptedException e) { /* handle */ }
    }).start();
}

// All threads start simultaneously when this is called
startSignal.countDown();

Best Practices

  • Always use finally: Place countDown() inside a finally block to ensure the latch is decremented even if a worker thread encounters an exception. Otherwise, the waiting thread might block indefinitely.
  • Don’t reuse: Once a CountDownLatch reaches zero, it cannot be reused. If your algorithm requires repeating the synchronization point, use a CyclicBarrier or Phaser.
  • Check the count: You can use latch.getCount() for debugging or monitoring, but don’t use it to make control flow decisions in production code as it is volatile.

How do I use CyclicBarrier for synchronization?

In Java’s concurrency utilities, CyclicBarrier is a synchronization aid that allows a set of threads to all wait for each other to reach a common barrier point. It is called “cyclic” because it can be re-used after the waiting threads are released.

Here is a breakdown of how to use it effectively.

1. Basic Concepts

  • Parties: The number of threads that must invoke await() before the barrier is tripped.
  • Barrier Action: An optional Runnable that is executed once per barrier point, after the last thread arrives, but before any threads are released.
  • await(): The core method threads call to wait. It blocks until all parties have arrived.

2. Implementation Steps

Create the Barrier

You initialize it with the number of participating threads.

import java.util.concurrent.CyclicBarrier;

// ...
int parties = 3;
CyclicBarrier barrier = new CyclicBarrier(parties, () -> {
    // This runs once all threads reach the barrier
    System.out.println("Barrier reached! Processing combined results...");
});

Define the Worker

Each thread performs its task and then calls barrier.await().

package org.kodejava.util.concurrent;

import java.util.concurrent.CyclicBarrier;

class Task implements Runnable {
    private final CyclicBarrier barrier;

    Task(CyclicBarrier barrier) {
        this.barrier = barrier;
    }

    @Override
    public void run() {
        try {
            System.out.println(Thread.currentThread().getName() + " is working...");
            // Simulate work
            Thread.sleep(1000);

            System.out.println(Thread.currentThread().getName() + " waiting at barrier.");
            barrier.await(); // Thread blocks here

            System.out.println(Thread.currentThread().getName() + " passed the barrier!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

3. Key Differences from CountDownLatch

While both coordinate threads, they have distinct use cases:

  • Reusability: A CyclicBarrier can be reset and used again (hence “cyclic”). A CountDownLatch count cannot be reset once it reaches zero.
  • Waiting Mechanism: In a CyclicBarrier, the workers wait for each other. In a CountDownLatch, a main thread typically waits for workers to finish (workers don’t necessarily block).
  • Barrier Action: CyclicBarrier supports a custom action when the barrier trips; CountDownLatch does not.

4. Handling Broken Barriers

If a thread leaves the barrier prematurely (due to interruption, timeout, or failure), the barrier is considered “broken.” Any other threads waiting at the barrier will receive a BrokenBarrierException. You can check this status using barrier.isBroken().

Example Use Case

CyclicBarrier is ideal for parallel algorithms that involve multiple phases (iterative methods), where each phase must be completed by all threads before anyone starts the next phase.

How do I use Semaphore for resource control?

Semaphore is an advanced synchronization mechanism used to control access to a shared resource by multiple threads. It can maintain a set of permits, restricting how many threads can concurrently access a critical section or shared resource. If the permits are exhausted, additional threads will block until permits are released.

How to Use Semaphore for Resource Control

Here are the key steps for using a Semaphore:


1. Initialization

  • Permits: When creating Semaphore, specify the number of permits. This determines the maximum number of threads that can access the resource simultaneously.
  • Fairness: Optionally, you can specify a fairness policy (true for FIFO access to permits, false for default behavior).

    Example:

    Semaphore semaphore = new Semaphore(2, true); // 2 permits, FIFO fairness
    

2. Acquiring Permits

Threads must acquire permits before accessing the shared resource. The acquire() method blocks the thread if no permits are available.

  • Interruptible Acquire: acquire() blocks until a permit becomes available.
    semaphore.acquire();
    
  • Immediate Acquire: tryAcquire() attempts to acquire and doesn’t block. Returns true if successful, false otherwise.
    if (semaphore.tryAcquire()) {
        // Acquired permit
    }
    
  • Timed Acquire: tryAcquire(timeout, TimeUnit) waits for a permit for a specified amount of time before giving up.
    if (semaphore.tryAcquire(2, TimeUnit.SECONDS)) {
        // Acquired permit
    }
    

3. Using the Shared Resource

After acquiring a permit, the thread performs its task within the critical section or accesses the shared resource.

Example:

// Critical section
System.out.println(Thread.currentThread().getName() + " is using the resource");

4. Releasing Permits

After completing the task, the thread should release the permit it acquired. This allows other threads to proceed.

  • Use release() to give up the permit:
    semaphore.release();
    

If a thread fails to release its permit due to an exception or oversight, other threads might starve waiting for permits.


Example of Semaphore in Practice

Here’s a practical example:

package org.kodejava.util.concurrent;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

public class SemaphoreExample {

    // Semaphore initialized with 2 permits (only 2 threads can access simultaneously).
    private static final Semaphore semaphore = new Semaphore(2);

    public static void main(String[] args) {
        // Create a thread pool with 5 threads
        try (ExecutorService executorService = Executors.newFixedThreadPool(5)) {

            // Let each thread try to acquire a permit and access a shared resource
            for (int i = 1; i <= 5; i++) {
                final int threadId = i;
                executorService.submit(() -> {
                    try {
                        System.out.println("Thread " + threadId + " is trying to acquire a permit.");
                        semaphore.acquire();

                        System.out.println("Thread " + threadId + " has acquired a permit.");
                        Thread.sleep(2000);  // Simulate using the shared resource

                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    } finally {
                        System.out.println("Thread " + threadId + " is releasing the permit.");
                        semaphore.release();
                    }
                });
            }
        }
    }
}

Key Concepts of Semaphores

  1. Permits:
    • Semaphore tracks the number of remaining permits.
    • Initial permits are specified at the time of creation.
  2. Blocking vs Non-blocking Acquire:
    • Threads may block (acquire()), attempt immediate access (tryAcquire()), or timeout (tryAcquire(timeout)).
  3. Fairness:
    • Semaphore fairness ensures FIFO granting of permits if fairness is enabled.
  4. Common Usage Scenarios:
    • Throttling: Limit the number of threads accessing resources like database connections or file IO simultaneously.
    • Rate Limiting: Control the frequency of tasks or API calls.
  5. Thread-Safe: The semaphore internally ensures thread-safety using synchronization primitives.


By using these steps, you can effectively use semaphore to control access to a shared resource, ensuring both mutual exclusion and efficient resource utilization.

How do I use ForkJoinPool for recursive tasks?

ForkJoinPool in Java is a part of the java.util.concurrent package and is designed to efficiently execute recursive tasks using a work-stealing algorithm. It works particularly well for problems that can be split into smaller subproblems and then combined to form the final result, adhering to the divide-and-conquer paradigm.

Here’s how you can use ForkJoinPool for recursive tasks:


1. Define Recursive Behavior with RecursiveTask/RecursiveAction

The main entities to use with ForkJoinPool are:

  • RecursiveTask<T>: Returns a result.
  • RecursiveAction: Performs an action without returning a result.

You define the recursive logic within these classes by overriding the compute() method.


2. Implement Recursive Splitting

  • A base case is defined where small tasks are computed directly.
  • For larger tasks, the work is split into subtasks, and fork() is invoked to execute them asynchronously. Results are aggregated using join().

3. Run Tasks in a ForkJoinPool

The tasks are submitted to a ForkJoinPool. This pool can manage multiple tasks simultaneously and perform work-stealing to optimize performance.


Example: Parallel Sum Using RecursiveTask

package org.kodejava.util.concurrent;

import java.util.concurrent.RecursiveTask;
import java.util.concurrent.ForkJoinPool;

// RecursiveTask to calculate the sum of an array
class ParallelSumTask extends RecursiveTask<Long> {
    private static final int THRESHOLD = 10; // Splitting threshold
    private final int[] arr;
    private final int start, end;

    public ParallelSumTask(int[] arr, int start, int end) {
        this.arr = arr;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        int length = end - start;

        // Base case: if below a threshold, compute directly
        if (length <= THRESHOLD) {
            long sum = 0;
            for (int i = start; i < end; i++) {
                sum += arr[i];
            }
            return sum;
        }

        // Recursive splitting
        int mid = start + length / 2;
        ParallelSumTask leftTask = new ParallelSumTask(arr, start, mid);
        ParallelSumTask rightTask = new ParallelSumTask(arr, mid, end);

        leftTask.fork();          // Fork the left task
        long rightResult = rightTask.compute();  // Compute the right task
        long leftResult = leftTask.join();       // Wait for the left task

        return leftResult + rightResult;        // Combine results
    }
}

public class ForkJoinExample {
    public static void main(String[] args) {
        int[] numbers = new int[100];
        for (int i = 0; i < numbers.length; i++) {
            numbers[i] = i + 1; // Fill the array with 1 to 100
        }

        ForkJoinPool pool = new ForkJoinPool(); // Create ForkJoinPool
        ParallelSumTask task = new ParallelSumTask(numbers, 0, numbers.length);

        long result = pool.invoke(task); // Start the task and get the result
        System.out.println("Sum: " + result);
    }
}

Key Methods in ForkJoinTask

  • fork(): Asynchronously executes the task in the pool.
  • join(): Waits for the task to finish and retrieves its result.
  • invoke(): A shortcut for fork() + join().
  • compute(): Defines the logic for splitting and computation of tasks.

Advantages of ForkJoinPool

  1. Work-Stealing Algorithm: Idle threads steal tasks from busy threads, ensuring an even workload distribution.
  2. Efficient for Recursive Tasks: Particularly suited for algorithms like QuickSort, MergeSort, and calculations like Fibonacci or array sums.
  3. Dynamic Thread Management: ForkJoinPool manages the number of threads for optimal utilization based on available cores.

When to Use

  • Large, recursive tasks with problems that are computationally expensive.
  • Divide-and-conquer problems where each subproblem is independent after splitting.

Things to Consider

  • Splitting Threshold: Choosing a suitable threshold is crucial for balancing computation and task overhead.
  • Thread Contention: Ensure your tasks do not rely on a shared mutable state to avoid contention between threads.

How do I schedule tasks using ScheduledExecutorService?

To schedule tasks using ScheduledExecutorService in Java, follow these steps:

1. Create a ScheduledExecutorService

  • Use Executors.newScheduledThreadPool(int corePoolSize) to get an instance of ScheduledExecutorService.
    • corePoolSize: Number of threads to keep in the pool.

2. Schedule Tasks

The ScheduledExecutorService provides three methods for scheduling tasks:

  • schedule: Schedule a task to run after a specific delay.
    scheduler.schedule(() -> {
             System.out.println("Task executed after a delay");
         }, delay, TimeUnit.SECONDS);
    
    • delay: Time to wait before executing the task.
  • scheduleAtFixedRate: Schedule tasks to start at a fixed rate.
    scheduler.scheduleAtFixedRate(() -> {
             System.out.println("Task executed at a fixed rate");
         }, initialDelay, period, TimeUnit.SECONDS);
    
    • initialDelay: The delay before the first execution.
    • period: The interval between successive executions.
  • scheduleWithFixedDelay: Schedule tasks with a fixed delay between the end of one execution and the start of the next.
    scheduler.scheduleWithFixedDelay(() -> {
             System.out.println("Task executed with a delay");
         }, initialDelay, delay, TimeUnit.SECONDS);
    
    • delay: Time to wait between the previous task’s completion and the start of the next.

3. Shut Down the Scheduler

  • Always shut down the ScheduledExecutorService once tasks are no longer needed.
    • shutdown() to initiate an orderly shutdown.
    • shutdownNow() to stop all tasks immediately.
    scheduler.shutdown();
    

Points to Remember:

  1. Thread Efficiency: Reuse threads from the pool to handle multiple tasks efficiently.
  2. Exception Handling: If a task throws an exception, that thread may stop entirely. Either implement proper exception handling or use a ThreadFactory to manage threads (e.g., restart them).
  3. Fixed Rate vs Fixed Delay:
    • scheduleAtFixedRate: The interval is measured from the start of one task to the start of the next.
    • scheduleWithFixedDelay: The interval is measured from the end of one task to the start of the next.

Example:

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

// Task to run after 2 seconds
scheduler.schedule(() -> System.out.println("One-time task executed"), 2, TimeUnit.SECONDS);

// Task to run initially after 1 second, then every 3 seconds
scheduler.scheduleAtFixedRate(() -> System.out.println("Fixed rate task"), 1, 3, TimeUnit.SECONDS);

// Task to run initially after 2 seconds, then with a delay of 4 seconds
scheduler.scheduleWithFixedDelay(() -> System.out.println("Fixed delay task"), 2, 4, TimeUnit.SECONDS);

// Shutdown the executor after 15 seconds
scheduler.schedule(() -> {
    System.out.println("Shutting down the scheduler");
    scheduler.shutdown();
}, 15, TimeUnit.SECONDS);

The above demonstrates how to use ScheduledExecutorService.

How do I use ExecutorService with virtual threads?

To use ExecutorService with virtual threads in Java, you can leverage the Executors.newVirtualThreadPerTaskExecutor() method. This method creates an ExecutorService where each task is executed on a new virtual thread, managed by the Java runtime. Here’s a step-by-step guide:


1. Dependencies & Setup

Ensure you are using Java 19 or newer. Virtual threads were introduced as a preview feature, but from Java 21 onward, they are part of the platform. You may need --enable-preview as a JVM option for Java 19 and 20.


2. Creating an ExecutorService with Virtual Threads

The Executors.newVirtualThreadPerTaskExecutor() method provides an easy way to create an executor service for virtual threads.

Example:

package org.kodejava.util.concurrent;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualThreadExample {
    public static void main(String[] args) {
        // Creates an ExecutorService with virtual threads
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            // Submitting tasks to executor
            executor.submit(() -> System.out.println("Task 1 on virtual thread"));
            executor.submit(() -> System.out.println("Task 2 on another virtual thread"));
        } // The executor is automatically closed after the try block
    }
}

In this example:

  • Each task runs in its own virtual thread, allowing them to scale efficiently.
  • The try block ensures the resources are cleaned up when the executor is closed.

3. Advantages

  • Concurrency: High-concurrency tasks, such as I/O-bound operations, benefit from virtual threads.
  • Scalability: You don’t have to limit the number of threads since virtual threads don’t demand system OS threads.
  • Simplicity: Virtual threads make it easier to adopt a thread-per-task model without resource overhead.

4. Important Use Cases

  • Concurrent workloads like handling multiple incoming web requests.
  • Tasks that rely on blocking operations, such as database access or network I/O.

5. Combining with Structured Concurrency (Optional)

Using structured concurrency (Java 21+) simplifies managing tasks by controlling their lifecycle. Here’s a snippet combining virtual threads with structured concurrency:

Example:

package org.kodejava.util.concurrent;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class StructuredConcurrencyExample {
    public static void main(String[] args) throws Exception {
        // Using an ExecutorService with virtual threads
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var task1 = executor.submit(() -> {
                Thread.sleep(500); // Simulating a long-running task
                return "Result from task 1";
            });

            var task2 = executor.submit(() -> {
                Thread.sleep(300); // Another long-running task
                return "Result from task 2";
            });

            // Getting results from tasks
            System.out.println(task1.get());
            System.out.println(task2.get());
        }
    }
}

6. Considerations

  • Resource Efficiency: Virtual threads work well for blocking I/O tasks. However, for CPU-bound tasks, you’re limited by the number of available processors.
  • Preview Feature (If Applicable): Ensure you run the program with --enable-preview if using a preview version of Java.

Virtual threads offer a significant leap in simplifying multithreaded programming while improving scalability. Transitioning to virtual threads in most legacy multithreaded systems is straightforward because they integrate seamlessly with the existing threading APIs.

How do I combine multiple CompletableFutures?

To combine multiple CompletableFuture objects in Java, you can use the following approaches, depending on your specific use case:


1. Combine Two Futures

If you have two CompletableFuture instances and need to combine their results, use the thenCombine method. It lets you specify how to merge the results of both futures.

Example:

CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Task1");
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "Task2");

// Combine their results
CompletableFuture<String> combinedFuture = future1.thenCombine(future2, (result1, result2) -> {
    return result1 + " & " + result2; // Merge both results
});

combinedFuture.thenAccept(result -> System.out.println("Combined Result: " + result));

Output:

Combined Result: Task1 & Task2

2. Wait for All Futures to Complete

If you want to wait for multiple CompletableFuture tasks to complete (without necessarily combining their results immediately), you can use the allOf method. This is useful when you want to ensure that all tasks are completed before proceeding further.

Example:

CompletableFuture<Void> allTasks = CompletableFuture.allOf(
    CompletableFuture.runAsync(() -> System.out.println("Task 1 completed")),
    CompletableFuture.runAsync(() -> System.out.println("Task 2 completed")),
    CompletableFuture.runAsync(() -> System.out.println("Task 3 completed"))
);

// Wait for all tasks to complete
allTasks.join();

System.out.println("All tasks completed.");

3. Combine Results of Multiple Futures

To combine the individual results of a list (or array) of CompletableFuture objects, you would use allOf in conjunction with further processing.

For example:

  1. Create a list/array of CompletableFuture.
  2. Use CompletableFuture.allOf() to wait for all of them.
  3. Extract and combine results using join.

Example:

List<CompletableFuture<String>> futures = Arrays.asList(
    CompletableFuture.supplyAsync(() -> "Result1"),
    CompletableFuture.supplyAsync(() -> "Result2"),
    CompletableFuture.supplyAsync(() -> "Result3")
);

CompletableFuture<Void> allOf = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));

// Gather results once all are completed
CompletableFuture<List<String>> resultFuture = allOf.thenApply(v ->
    futures.stream()
           .map(CompletableFuture::join) // Join and collect results
           .collect(Collectors.toList())
);

resultFuture.thenAccept(results -> System.out.println("Results: " + results));

Output:

Results: [Result1, Result2, Result3]

4. Wait for the First to Complete

If you are waiting for the first CompletableFuture to complete instead of all, use anyOf. It is useful when results from the first completed task suffice.

Example:

CompletableFuture<Object> anyOf = CompletableFuture.anyOf(
    CompletableFuture.supplyAsync(() -> "First Result"),
    CompletableFuture.supplyAsync(() -> "Second Result"),
    CompletableFuture.supplyAsync(() -> "Third Result")
);

anyOf.thenAccept(result -> System.out.println("First completed task result: " + result));

Comparison of APIs for Combining Futures:

Method Purpose
thenCombine Combines two futures’ results into one.
thenCompose Chains dependent tasks (where one task’s result is input for the next).
allOf Waits for all futures in a list/array to complete but does not return their results.
anyOf Waits for the first future from a list/array to complete and returns its result.

By selecting the appropriate method (thenCombine, allOf, anyOf, or others), you can efficiently handle multiple asynchronous computations in Java with CompletableFuture.

How do I use CompletableFuture for async tasks?

CompletableFuture is a powerful tool in Java to implement asynchronous programming. It allows you to perform tasks in the background, chain multiple async tasks together, handle both success and failure scenarios, and combine multiple async computations.

Here’s a summary of how to use CompletableFuture with examples:


1. Run a task asynchronously

Use supplyAsync() (if the task produces a result) or runAsync() (if the task doesn’t return anything).

CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
    System.out.println("Running task in background...");
});

Or with a result:

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    return "Hello, World!";
});

2. Process the result

You can process the result of a CompletableFuture using methods like thenAccept or thenApply.

future.thenApply(result -> {
    System.out.println("Received result: " + result);
    return result.toUpperCase();
}).thenAccept(uppercaseResult -> {
    System.out.println("Transformed result: " + uppercaseResult);
});

3. Combine multiple tasks

You can run multiple tasks in parallel and combine their results.

CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Task1");
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "Task2");

CompletableFuture<String> combinedFuture = future1.thenCombine(future2, (task1Result, task2Result) -> {
    return task1Result + " and " + task2Result;
});

combinedFuture.thenAccept(result -> {
    System.out.println("Combined Result: " + result);
});

4. Wait for all tasks

If you have multiple tasks and want to wait for all of them to complete, use CompletableFuture.allOf.

CompletableFuture<Void> allTasks = CompletableFuture.allOf(
    CompletableFuture.runAsync(() -> System.out.println("Task 1 completed")),
    CompletableFuture.runAsync(() -> System.out.println("Task 2 completed"))
);

allTasks.join(); // Blocks the thread and waits for completion
System.out.println("All tasks completed.");

5. Handle errors

You can handle exceptions in async tasks using exceptionally().

CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
    throw new RuntimeException("Oops, something went wrong!");
}).exceptionally(ex -> {
    System.out.println("Error: " + ex.getMessage());
    return null;
});

6. Compose dependent tasks

Use thenCompose to chain dependent tasks where the second task depends on the result of the first one.

CompletableFuture.supplyAsync(() -> "Task 1 Result")
    .thenCompose(result -> CompletableFuture.supplyAsync(() -> result + " Task 2 Result"))
    .thenAccept(finalResult -> System.out.println("Final Result: " + finalResult));

7. Custom Executor

By default, CompletableFuture uses the ForkJoinPool.commonPool for async tasks. You can provide a custom executor for better control over threads.

ExecutorService executor = Executors.newFixedThreadPool(10);

CompletableFuture.runAsync(() -> {
    System.out.println("Running task on custom executor");
}, executor);

Key Methods in CompletableFuture

Method Description
runAsync Run a task asynchronously (does not return result).
supplyAsync Run a task asynchronously and return a result.
thenApply Transform the result of a CompletableFuture.
thenAccept Consumes the result of a CompletableFuture (no further processing).
thenCompose Chains dependent tasks where the next uses the result of the previous.
thenCombine Combines two CompletableFuture results.
allOf / anyOf Wait for all or any of multiple futures to complete.
exceptionally Handle exceptions thrown during the async computation.
join / get Block and wait for the task to complete and retrieve its result (not recommended for non-blocking).

Example Workflow with Pipeline

Here’s an example pipeline:

  1. Fetch data
  2. Process it
  3. Save it
  4. Notify the user
ExecutorService executor = Executors.newFixedThreadPool(4);

CompletableFuture.supplyAsync(() -> {
    System.out.println("Fetching data...");
    return "Raw Data";
}, executor).thenApply(data -> {
    System.out.println("Processing data...");
    return data.toUpperCase();
}).thenAccept(processedData -> {
    System.out.println("Saving: " + processedData);
}).thenRun(() -> {
    System.out.println("Notification: Done!");
}).exceptionally(ex -> {
    System.err.println("Pipeline failed due to: " + ex.getMessage());
    return null;
}).join();

Using CompletableFuture, you can build flexible, high-performance, and non-blocking applications.

How do I use the Virtual Threads API Project Loom?

The Virtual Threads API is part of Project Loom in the Java platform. With Java 19, virtual threads became available as a preview feature, enabling the creation of lightweight threads that can run concurrently. They work similarly to traditional threads but are much cheaper in terms of memory and thread management because they are managed by the Java runtime, not the operating system. This makes it possible to scale the number of threads easily, even in the millions.

Here’s how you can use the Virtual Threads API:

1. Enable Virtual Threads

Virtual threads are available in Java 19+ as an incubating feature. To use them:

  • Ensure that you’re using a compatible version of Java (Java 19 or later).
  • Add the JVM flag --enable-preview to enable preview features when running your program.

2. Creating Virtual Threads

Java provides the java.lang.Thread class and the Executors utility to work with virtual threads. For example:

Creating a Virtual Thread

You can create and start a virtual thread like this:

Thread.startVirtualThread(() -> {
    System.out.println("This is a virtual thread!");
});

Using Virtual Threads with Executors

The Executors.newVirtualThreadPerTaskExecutor() method creates an ExecutorService that launches a new virtual thread for each task:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> System.out.println("Task on a virtual thread"));
    executor.submit(() -> System.out.println("Another virtual thread task"));
}

3. Advantages of Virtual Threads

  1. Lightweight: Virtual threads are less resource-intensive because they use the Java runtime scheduler rather than the OS scheduler. Millions of threads can be created.
  2. Non-blocking: Blocking operations in virtual threads don’t block OS resources, making them very efficient for I/O-intensive workloads like web servers or concurrent network communication.
  3. Easier Scaling: They simplify concurrent programming by allowing you to continue using the familiar thread-per-task model without worrying about resource limits.
  4. Works with Existing Code: Virtual threads integrate well with existing Java APIs like java.util.concurrent.

4. When to Use Virtual Threads

Virtual threads are ideal for:

  • Concurrent I/O tasks like HTTP servers or database connections.
  • High-concurrency environments where traditional threads might run out of OS resources.
  • Migrating legacy multithreaded code to take advantage of better scalability.

5. Example: HTTP Server with Virtual Threads

Here’s a minimal example showcasing how to use virtual threads for handling multiple HTTP requests:

import java.net.ServerSocket;
import java.net.Socket;
import java.io.InputStreamReader;
import java.io.BufferedReader;
import java.io.PrintWriter;

public class VirtualThreadHttpServer {
    public static void main(String[] args) throws Exception {
        try (var serverSocket = new ServerSocket(8080)) {
            while (true) {
                Socket clientSocket = serverSocket.accept();
                Thread.startVirtualThread(() -> handleClient(clientSocket));
            }
        }
    }

    private static void handleClient(Socket clientSocket) {
        try (var in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
             var out = new PrintWriter(clientSocket.getOutputStream(), true)) {
            out.println("Hello from the Virtual Thread server!");
            String input;
            while ((input = in.readLine()) != null) {
                System.out.println("Received: " + input);
                if ("exit".equalsIgnoreCase(input)) {
                    break;
                }
                out.println("You said: " + input);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

This makes use of virtual threads to handle each incoming socket connection, which scales efficiently for high-concurrency workloads.

6. Integration with Structured Concurrency

Virtual threads can be combined with structured concurrency (introduced in Java 21) for safer and more manageable multithreading. Structured concurrency allows parent threads to manage the lifecycle of child threads.

Example of Structured Concurrency:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class StructuredConcurrencyExample {
    public static void main(String[] args) throws Exception {
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var future1 = executor.submit(() -> {
                Thread.sleep(500);
                return "Task 1 completed";
            });

            var future2 = executor.submit(() -> {
                Thread.sleep(300);
                return "Task 2 completed";
            });

            // Wait for results
            System.out.println(future1.get());
            System.out.println(future2.get());
        }
    }
}

Keynotes:

  • Virtual threads require no changes in application logic. Code written for traditional Thread can immediately benefit from using virtual threads.
  • They simplify thread management while maintaining excellent performance for non-blocking I/O operations.

Limitations:

  • Virtual threads won’t improve performance for CPU-bound tasks; you still need to consider the number of logical CPUs in your system.
  • JVM preview features need to be enabled since virtual threads are not yet finalized in the standard Java API. Check the latest Java release notes for updates.