Java CompletableFuture runAsync: Usage and Examples
java completablefuture runasync: Learn how to use CompletableFuture.runAsync() to schedule Runnable tasks asynchronously, chain follow-up work, handle failures, and ma...
java completablefuture runasync refers to the static factory method CompletableFuture.runAsync(), which schedules a Runnable for asynchronous execution and returns a CompletableFuture<Void>. The Void type parameter is the key signal: the task produces no result. When the Runnable finishes normally, the future completes with a null value. When the Runnable throws, the future completes exceptionally and the exception is stored in the future rather than propagating to the calling thread.
This makes runAsync the right choice when you need to offload work that has side effects—writing to a log, sending a notification, updating a cache—but does not need to hand a computed value back to the caller.
Basic Usage
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { // perform work that does not return a value });
The Runnable is submitted to the common ForkJoinPool by default. The calling thread continues immediately; future acts as a handle that lets you wait for completion, chain follow-up work, or attach error handling.
To block until the task finishes:
future.join();
join() throws an unchecked CompletionException if the task failed, which is often more convenient than get(), which throws checked exceptions.
runAsync vs supplyAsync
The distinction between runAsync and supplyAsync is the most common source of confusion. Both are static factory methods on CompletableFuture, but they serve different return types.
| Method | Argument | Return type | Use when |
|---|---|---|---|
runAsync | Runnable | CompletableFuture<Void> | No result is needed |
supplyAsync | Supplier<T> | CompletableFuture<T> | A result must be returned |
If the asynchronous work produces a value that downstream stages consume, use supplyAsync. If the work only has side effects, runAsync is the correct API and avoids the awkwardness of a Supplier that returns null.
Supplying a Custom Executor
The single-argument overload uses the common ForkJoinPool, which is shared across the JVM. For workloads that are long-running, blocking, or latency-sensitive, submitting to a dedicated executor is usually better.
ExecutorService executor = Executors.newFixedThreadPool(4); CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { // task runs on the provided executor }, executor);
The executor parameter is the second overload. When you pass one, the task is submitted to that executor instead of the common pool. This matters because the common pool's parallelism is tied to the number of CPU cores, and blocking tasks can exhaust it.
Chaining Follow-Up Work
Because runAsync returns a CompletableFuture, you can attach dependent stages that run after the original task completes.
CompletableFuture.runAsync(() -> { // write audit record }).thenRun(() -> { // send notification after the audit record is written });
thenRun takes another Runnable and runs after the first completes normally. If the first task fails, thenRun does not execute; the failure propagates to the next stage in the chain.
Handling Failures
An exception thrown inside the Runnable completes the future exceptionally. The calling thread does not see the exception unless it joins or attaches a handler.
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { throw new IllegalStateException("task failed"); }); future.exceptionally(ex -> { // ex is a CompletionException wrapping the original cause return null; // required because the future is CompletableFuture<Void> });
The exceptionally callback must return a value of the future's type, which is Void here, so returning null is correct. The callback receives a CompletionException whose getCause() exposes the original exception.
Runtime Behavior and Thread Pool Concerns
The common pool is the default executor. Its parallelism defaults to Runtime.getRuntime().availableProcessors() - 1. For CPU-bound tasks this is usually fine. For tasks that block on I/O, a dedicated executor with a bounded queue prevents the common pool from being starved.
A practical concern: if you submit many blocking tasks to the common pool, other unrelated code in the same JVM that relies on the common pool—such as parallel streams—can stall. Using an explicit executor isolates the impact.
Production Considerations
Executor lifecycle is a frequent source of leaks. If you create an ExecutorService for runAsync, shut it down when the application or component no longer needs it.
executor.shutdown();
Also consider timeout behavior. join() and get() block indefinitely by default. For production code, prefer get(long timeout, TimeUnit unit) so a stuck task does not hold a thread forever.
future.get(5, TimeUnit.SECONDS);
This throws TimeoutException if the task does not finish within the window, giving you a chance to react rather than waiting indefinitely.