A recent hobby project to code an Android app to monitor cellular and wifi connectivity glitches involved a significant amount of coding using asynchronous callback patterns. The Android operating system provided its own challenges that required some experimentation and research to resolve. On the other hand, the basic mechanics of offloading work within a Java application to separate background threads triggered a significant portion of the work.
The CompletableFuture
class in Java doesn't turn out to be extremely difficult to use but, like many other techniques in the Java realm, the quality and clarity of examples available online can be confusing if not contradictory. What follows is an attempt at an introduction that hits the optimal point between being concise enough to not overwhelm while being complete enough to identify pitfalls and subtleties that can lead to hours of puzzlement from only partially understanding how the class works.
The Problem with Synchronous Calls
Modern computers and handheld devices such as smartphones and tablets have tremendous processing power in their CPUs but are expected to support DOZENS of applications running simultaneously. Even with mutli-core CPUs, not EVERYTHING can literally run simultaneously so, instead, the operating systems for these devices are designed to suspend and resume processing of different applications very efficiently. This context switching is normally performed so quickly that human users are left with the ILLUSION that all of their apps ARE in fact running simultaneously with each application enjoying the full attention of the operating system and hardware.
This illusion only works if all the running applications obey minimum expectations of the operating system and return control TO the operating system on a VERY consistent schedule. This is accomplished by collecting user input (clicks, keypresses, touches) via interrupt-driven mechanisms that are monitored by the application's main event loop. If no new inputs are detected, the main loop returns execution control to the OS who allows every other running application time for the same main event loop checks.
This illusion breaks if one of the running applications gets its time slice, sees pending inputs it needs to process, then fires off a call to handle one of those inputs that takes an inordinate amount of time to complete. During that wait period, the app isn't doing anything else and the execute thread cannot be used by the operating system for any other application and the overall device can appear to lock up or hang / crash.
As a simple example of the problem, imagine an application that deals with three types of business objects modeled as BusinessObjectX
, BusinessObjectY
and Location
as shown here.
static class BusinessObjectX { public int id; public String name; public BusinessObjectX() {}; public String toString() { return "BusinessObjectX = [ id=" + id + " name=" + name + "]"; } } static class BusinessObjectY { public String maker; public String model; public BusinessObjectY() {}; public String toString() { return "BusinessObjectY = [ maker=" + maker + " model=" + model + "]"; } } static class Location { public double latitude; public double longitude; public double altitude; public Location() {}; public String toString() { return "Location = [ latitude=" + latitude + " longitude=" + longitude + " altidude=" + altitude + "]"; }
Imagine that application has two methods that accept those objects, make updates to them via some imaginary process then returns a response. One method actionX()
returns the modified input object. The other method modifies the input object but returns a Location object. The method for X might look like this:
public static String actionX(BusinessObjectX objectX) { thisLog.info("asyncActionX() - starting / doing \"work\" for 7 seconds"); try { TimeUnit.SECONDS.sleep(7); } catch (InterruptedException theE) { Thread.currentThread().interrupt(); thisLog.info("asyncActionA() - thread was interrupted during sleep"); } thisLog.info("asyncActionX() - setting current datetime in delta object"); objectX.id=2112; objectX.name="Rush"; // this return value is returned as the result of the CompletableFuture that // wrapped this call return "Permanent Waves"; }
The method for Y might look like this:
public static Location actionY(BusinessObjectY objectY) { thisLog.info("asyncActionY() - starting / doing \"work\" for 3 seconds"); objectY.maker="Toyota"; objectY.model="4Runner"; try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException theE) { Thread.currentThread().interrupt(); thisLog.info("asyncActionY() - thread was interrupted during sleep"); } Location result = new Location(); result.latitude=39.9999; result.longitude=-70.999; result.altitude=140.888; // this return value is returned as the result of the CompletableFuture that // wrapped this call return result; }
If the application calls those two tasks in sequence, that calling code might look like this.
thisLog.info("============= SYNCHRONOUS / SEQUENTIAL PROCESSING (BAD) ========"); // initialize input objects with some values BusinessObjectX testX = new BusinessObjectX(); testX.id=42; testX.name="Yes"; BusinessObjectY testY = new BusinessObjectY(); testY.maker="Ford"; testY.model="F150"; // call these methods serially, sequentially and show inputs can be altered and // results are returned after 10 seconds String testA = actionX(testX); thisLog.info("asyncActionA() completed - testA = " + testA.toString()); Location testB = actionY(testY); thisLog.info("asyncActionB() completed - testB = " + testB.toString());
Notice what happens from reviewing the log messages generated by these calls:
2025-09-15 13:05:38:144 [main] INFO FUTURE - ====== SYNCHRONOUS / SEQUENTIAL PROCESSING (BAD) ========
2025-09-15 13:05:38:145 [main] INFO FUTURE - actionX() - starting / doing "work" for 7 seconds
2025-09-15 13:05:45:146 [main] INFO FUTURE - actionX() - setting current datetime in delta object
2025-09-15 13:05:45:148 [main] INFO FUTURE - asyncActionA() completed - testA = Permanent Waves
2025-09-15 13:05:45:148 [main] INFO FUTURE - actionY() - starting / doing "work" for 3 seconds
2025-09-15 13:05:48:159 [main] INFO FUTURE - asyncActionB() completed - testB = Location = [ latitude=39.9999 longitude=-70.999 altidude=140.888]
The two methods are invoked sequentially, as expected. The first takes 7 seconds as expected. The second takes 3 seconds as expected. It takes 10 seconds overall to get both results as expected. The problem is that for TEN SECONDS, the larger system could do nothing while waiting for those tasks to complete even though they were waiting on fake sleep calls.
CompletableFuture In Theory
The CompletableFuture
class was added in Java 8 released in March 2014 as a means of providing a standardized approach for shunting potentially long-lived work sequences to alternate execution threads within a JVM. In concept, a CompletableFuture
(henceforth a "future" for brevity and readability) acts much like a ticket in a fast food restaurant that is generated by the cashier and transmitted to the kitchen to trigger preparation and assembly of an ordered meal. In a restaurant, a "grill ticket" assists with these tasks:
- documents the requesting register or unique customer identifier
- identifies the work required in the kitchen (burger, no onions, large fries, large shake)
- allows the cashier to proceed with other tasks for the current customer such as payment
- allows that customer to step aside for the remaining wait while the register handles another customer
- when the kitchen work is returned, the work is paired with the ID, the ID is read by the cashier and the output is picked up by the customer who proceeds with the next task
The CompletableFuture
class fills a similar role for the Java JVM running an application. It provides these capabilities:
- assigns a unique identifier for a task defined by a block of code
- specifies the type of result expected to be returned by that code
- maps the unique identifier back to the parent program asking for the asynchronous work
- handles the future request to a separate thread manager within the JVM
- allows that thread manager to find an available thread and queue up the work
- watches the response queue from the thread manager for a result for that future
- executes any other handling logic specified by the future on the result
- resumes the parent program process and passes it the result
There are three distinct categories of methods provided by CompletableFuture
for the various phases of orchestration work.
- those used to identify the work needing asynchronous processing -- the
.supplyAsyc()
,.onAll()
and.onAny()
methods - those supplying special exception or timeout criteria to the future -- the
.exceptionally()
and.completeOnTimeout()
methods - those that surrender the current execution thread to the JVM and pause processing in the parent process until the JVM sees a response to the future -- the
.join()
, .get() and.thenApply()
methods
There ARE more methods in the class but the examples below using just these will provide a productive introduction for using them in a real application.
CompletableFuture In Practice
The sections below provide working examples of uses of CompletableFuture
. The logic in these examples was explicitly coded to optimize for all of these considerations:
- Being as visually brief as possible so the structure of the techniques isn't lost amid 45 lines of code
- Showing how variables can be passed in through a future and how changes within the future are still reflected in the passed object upon return
- Generating logs at key points to illustrate where control is changing hands from the parent code block to the side-bar future threads and back
- Allow direct comparisons between these approaches to illustrate where calling conventions need to be altered based on how a future is being created or where exception handling will be implemented.
- Allowing individual examples to be pasted into a test project with as little modification while still yielding working code.
All of these examples were wrapped in a single Java class named Main in package com.mdhlabs.future with the following import statements:
import org.slf4j.*; import java.lang.Thread; import java.text.SimpleDateFormat; import java.util.Locale; import java.util.Date; import java.util.concurrent.TimeUnit; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException;
The full source of the entire example source code is included as an appendix at the bottom of this post for convenience.
Making A Single Asynchronous Call
Executing a single block of code asynchronously is the Hello World use case for CompletableFuture
. The process involves these coding steps:
- create a new object of type
CompletableFuture
by using the class method.supplyAsync()
method to provide the code to be executed - use one of the future's completion methods (
.join()
,.get()
,.thenApply()
for examples) to hand thatCompletableFuture
request to the JVM to request a different thread to run that block and hand the main thread back to the JVM to run other work
In this example, the code to be run asynchronously is a call to actionX()
. Since that method already exists as a standalone method, the lambda call within the .supplyAsync()
call just provides a return of that method. After providing the code to execute, a variable assignment calls the future's .join()
method to hand the execute thread back to the JVM and await a response being signaled.
thisLog.info("====== ASYNCHRONOUS PROCESSING ========================"); testX.id=42; testX.name="Yes"; testY.maker="Ford"; testY.model="F150"; thisLog.info("Initiating actionX as a single CompletableFuture"); CompletableFuture<String> singleFuture = CompletableFuture.supplyAsync( () -> { return actionX(testX); } ); thisLog.info("calling singleFuture.join() to wait wihtout blocking for completion"); String singleResult = singleFuture.join(); thisLog.info("after singleFuture completion: singleResult="+singleResult);
Now compare the sequential flow of the statements above with the log messages below when the code runs:
2025-09-15 17:37:25:326 [main] INFO FUTURE - ====== ASYNCHRONOUS PROCESSING ========================
2025-09-15 17:37:25:326 [main] INFO FUTURE - Initiating actionX as a single CompletableFuture
2025-09-15 17:37:25:331 [main] INFO FUTURE - calling singleFuture.join() to wait wihtout blocking for completion
2025-09-15 17:37:25:331 [ForkJoinPool.commonPool-worker-1] INFO FUTURE - actionX() - starting / doing "work" for 7 seconds
2025-09-15 17:37:32:332 [ForkJoinPool.commonPool-worker-1] INFO FUTURE - actionX() - setting current datetime in delta object
2025-09-15 17:37:32:332 [main] INFO FUTURE - after singleFuture completion: singleResult=Permanent Waves
Note that the logs generated within actionX()
didn't come from the Main class. They came from some other JVM class that is handling the actual execution of the logic in actionX() on a different thread than Main. That's exactly the goal. Also note that the log message prior to the invocation of singleFuture.join()
is generated BEFORE logs from the execution of actionX()
began appearing. When a future is created, execution of the work referenced within is not initiated until one of the completion methods (here the .join()
method) is called. At THAT point, the JVM actually requests the separate thread for the future work and halts additional processing of the next statement and hands the execute thread back to the JVM. When the future work returns a result or an exception, that future result is mapped back to the future object that initiated it, the execution state of that future is looked up and when a thread becomes available, the JVM returns to the program state to resume execution.
Making Parallel Asynchronous Calls
A key benefit of the CompletableFuture
mechanism is the ability to combine two or more "child" futures into a "parent" future which returns a result from the children based on how the parent was created. Two methods are provided for two use cases:
- method
.allOf()
- returns when ALL specified children have completed via result or exception - method
.anyOf()
- returns when the FIRST child future returns a result or exception
When the allOf()
method is used, ALL of the futures specified in creating the new future WILL be executed to their normal or exceptional completion. That new "allOf" future itself will not house the individual results or exceptions of the original futures, those have to be accessed via their individual future objects.
When the anyOf()
method is used, the response or exception generated by whichever future is FIRST to complete is returned. The futures listed in the anyOf() method do not have to return the same result type so the call to thenAccept()
or get()
or join()
of the anyOf()
future has to determine which response type came back before processing the result.
![]() |
Because of these complexities, the CompletableFuture class does NOT serve as the basis for implementing a complex, long lived task orchestration engine. There is no support for the concept of a transaction that allows ParentFuture to kick off ChildFutureA, ChildFutureB and ChildFutureC over the course of seconds or minutes, encounter a failure from ChildFutureB and roll back work initiated by A or C. CompletableFuture is ONLY appropriate for removing web service calls or "callback" invocations out of GUI execution threads.
|
In this example, both actionX()
and actionY()
are launched as futures, then a third future is created using .allOf()
to specify the two child futures as a set, then a wait on the two child futures is triggered when the .join()
method of the parent future is called. After a response is detected for the parent future, its .thenRun()
method is used to collect information from the two children to formulate a final result returned to the application.
thisLog.info("====== PARALLEL FUTURES ================================"); testX.id=42; testX.name="Yes"; testY.maker="Ford"; testY.model="F150"; thisLog.info("Initiating actionX via CompletableFuture"); CompletableFuture<String> xFuture = CompletableFuture.supplyAsync( () -> { return actionX(testX); } ); thisLog.info("Initiating actionY via CompletableFuture"); CompletableFuture<Location> yFuture = CompletableFuture.supplyAsync( () -> { return actionY(testY); } ); thisLog.info("Creating resultFuture CompletableFuture waiting on aFuture and bFuture"); CompletableFuture<Void> resultFuture = CompletableFuture.allOf(xFuture, yFuture); thisLog.info("invoking resultFuture.join() - waiting for x and y to complete"); resultFuture.join(); resultFuture.thenRun( () -> { thisLog.info("resultFuture.thenRun() - combining async results from aFuture and bFuture"); // statements can reference variables passed TO the async methods through the CompletableFuture thisLog.info("testA = " + testA.toString()); thisLog.info("testB = " + testB.toString()); // statements can reference response values returned through the CompletableFutures String xFutureResponse = xFuture.join(); Location yFutureResponse = yFuture.join(); thisLog.info("xFutureResponse <String> = " + xFutureResponse); thisLog.info("yFutureResponse <Location> = " + yFutureResponse); } );
When combining futures into a new parent future, the type of the parent future is always specified as <Void>
. When the parent future returns, logic provided to its .thenRun()
method must individually collect the results or exceptions from the child futures and decide how to merge them together into a final result.
Here are log messages generated when this code runs.
2025-09-15 17:37:32:333 [main] INFO FUTURE - ====== PARALLEL FUTURES ================================
2025-09-15 17:37:32:333 [main] INFO FUTURE - Initiating actionX via CompletableFuture
2025-09-15 17:37:32:334 [main] INFO FUTURE - Initiating actionY via CompletableFuture
2025-09-15 17:37:32:334 [ForkJoinPool.commonPool-worker-1] INFO FUTURE - actionX() - starting / doing "work" for 7 seconds
2025-09-15 17:37:32:335 [main] INFO FUTURE - Creating resultFuture CompletableFuture waiting on aFuture and bFuture
2025-09-15 17:37:32:335 [ForkJoinPool.commonPool-worker-2] INFO FUTURE - actionY() - starting / doing "work" for 3 seconds
2025-09-15 17:37:32:335 [main] INFO FUTURE - invoking resultFuture.join() - waiting for x and y to complete
2025-09-15 17:37:39:335 [ForkJoinPool.commonPool-worker-1] INFO FUTURE - actionX() - setting current datetime in delta object
2025-09-15 17:37:39:342 [main] INFO FUTURE - resultFuture.thenRun() - combining async results from aFuture and bFuture
2025-09-15 17:37:39:343 [main] INFO FUTURE - testA = Permanent Waves
2025-09-15 17:37:39:343 [main] INFO FUTURE - testB = Location = [ latitude=39.9999 longitude=-70.999 altidude=140.888]
2025-09-15 17:37:39:343 [main] INFO FUTURE - xFutureResponse <String> = Permanent Waves
2025-09-15 17:37:39:344 [main] INFO FUTURE - yFutureResponse <Location> = Location = [ latitude=39.9999 longitude=-70.999 altidude=140.888]
Note that even though both the A and B tasks were executed and took their normal 7 and 3 seconds, execution of both tasks BEGAN at exactly the same time and the overall process only had to wait 7 seconds for the A task to complete. The B task was already done and response pending with resultFuture when the response for the A task was returned.
Setting Timeout Limits
Moving processing to a background thread to avoid locking up the core GUI execution thread is useful but it is still possible for a remote process to lock up for far longer periods which will still strain local resources. To set an upper bound on how long a call should wait for a response, a future can be wrapped with a second future that specifies a timeout limit and a default value to return. Here's sample code illustrating this process.
thisLog.info("====== TIMEOUT HANDLING ================================"); thisLog.info("Initiating actionX via unlimitedXFuture "); CompletableFuture<String> unlimitedXFuture = CompletableFuture.supplyAsync( () -> { return actionX(testX); } ); thisLog.info("creating limitedAFuture via .completeOnTimeout()"); CompletableFuture<String> limitedXFuture = unlimitedXFuture.completeOnTimeout("TIMEOUTDEFAULT", 6, TimeUnit.SECONDS); thisLog.info("starting non-blocking wait on limitedXFuture"); String limitedResult = limitedXFuture.join(); thisLog.info("limitedResult from limitedXFuture = " + limitedResult);
Here are the logs generated by the code.
025-09-15 17:37:39:344 [main] INFO FUTURE - ====== TIMEOUT HANDLING ================================
2025-09-15 17:37:39:344 [main] INFO FUTURE - Initiating actionX via unlimitedXFuture
2025-09-15 17:37:39:346 [main] INFO FUTURE - creating limitedAFuture via .completeOnTimeout()
2025-09-15 17:37:39:346 [ForkJoinPool.commonPool-worker-1] INFO FUTURE - actionX() - starting / doing "work" for 7 seconds
2025-09-15 17:37:39:350 [main] INFO FUTURE - starting non-blocking wait on limitedXFuture
2025-09-15 17:37:45:352 [main] INFO FUTURE - limitedResult from limitedXFuture = TIMEOUTDEFAULT
Handling Exceptions
The examples so far have avoided the extra complexity of exceptions being returned from the code being executed asynchronously. These patterns might allow initial functionality in a project to be off-loaded to a background thread as desired and solve an immediate problem. Eventually, however, "child" code will inevitably encounter an exception for a null pointer, some network timeout, division by zero, etc. It it LIKELY that it is NOT WORTH gold-plating a solution to perfectly "cure" these corner cases but it IS likely worth ensuring such calls are wrapped with enough handling to safely absorb these exceptions without bubbling them back to the JVM and crashing the application.
The approach to be taken to provide exception handling depends upon where any existing exception handling might be performed in the existing target code and where it is easiest to add new exception handling for the original business need and the CompletableFuture mechanism being added. These are the obvious choices:
- Containing exceptions within the target block (here, with the methods such as
actionX()
) - Handling the uncaught exceptions of the target using logic within the
.supplyAsync()
block invoking the target block - Altering the target block to throw required exceptions then adding logic in the the
.supplyAsync()
block invoking the target block - Supplying the future with explicit code to execute when exceptions are returned using the
.exceptionally()
method.
Regardless of WHERE exception handling is added, logic within that extra handling must choose between trying to intercept an exception and revert to returning a valid business object response OR tweaking / modifying the exception and using throw to push the exception on to an outer parent process. These examples will illustrate ensuring a default business object is returned.
Handling Exceptions within .supplyAsync()
In this example, the "supplyAsync()" code isn't the original target code such as the logic in actionX(), the "supplyAsync()" code is the extra wrapper code in the lambda function that is calling actionX(). Visually, it is cleaner if the lambda block only has a single line but the lambda mechanism itself doesn't care how many lines of code are in that block. This allows extra exception handling to be added WITHOUT modifying the actual actionX()
code which may be preferable in many scenarios.
In the example, the actionX()
method is the real "target" being executed but all of the other code highlighted in green is the "async" code supplied to the future. By making the main call to actionX() within a try / catch block, any exceptions (caught/thrown or uncaught) by actionX()
can be intercepted upon return to the future and an alternate response value for the Location object returned from the future.
thisLog.info("====== EXCEPTION PROCESSING VIA .supplyAsync() CODE ====="); testY.maker="Ford"; testY.model="F150"; thisLog.info("input value of testy=" + testY); thisLog.info("Initiating actionYRawException via CompletableFuture"); CompletableFuture<Location> uncaughtFutureTypedException = CompletableFuture.supplyAsync( () -> { try { return actionYRawException(testY); } catch (Exception theE) { Location newresult = new Location(); newresult.latitude=99; newresult.longitude=-99; newresult.altitude=999; thisLog.info("catch() in supplyAsync() block returning rigged Location=" + newresult); return newresult; // return this result as the outer result } } ); thisLog.info("calling inner future .join() for non-blocking wait to completion"); Location locationResult2 = uncaughtFutureTypedException.join(); thisLog.info("inner future .join() result = " + locationResult2); thisLog.info("final value of testy=" + testY);
Here is what the log output looks like when this code executes:
2025-09-15 17:37:45:353 [main] INFO FUTURE - ====== EXCEPTION PROCESSING VIA .supplyAsync() CODE =====
2025-09-15 17:37:45:359 [main] INFO FUTURE - input value of testy=BusinessObjectY = [ maker=Ford model=F150]
2025-09-15 17:37:45:359 [main] INFO FUTURE - Initiating actionYRawException via CompletableFuture
2025-09-15 17:37:45:360 [main] INFO FUTURE - calling inner future .join() for non-blocking wait to completion
2025-09-15 17:37:45:360 [ForkJoinPool.commonPool-worker-2] INFO FUTURE - actionYRawException() - starting / doing "work" for 3 seconds
2025-09-15 17:37:46:348 [ForkJoinPool.commonPool-worker-1] INFO FUTURE - actionX() - setting current datetime in delta object
2025-09-15 17:37:48:362 [ForkJoinPool.commonPool-worker-2] INFO FUTURE - catch() in supplyAsync() block returning rigged Location=Location = [ latitude=99.0 longitude=-99.0 altidude=999.0]
2025-09-15 17:37:48:365 [main] INFO FUTURE - inner future .join() result = Location = [ latitude=99.0 longitude=-99.0 altidude=999.0]
2025-09-15 17:37:48:366 [main] INFO FUTURE - final value of testy=BusinessObjectY = [ maker=Toyota model=4Runner]
Handling Exceptions using .exceptionally()
The .exceptionally()
method of a future provides a different way of supplying exception handling code to the future to run when it sees an exception returned from child code. Code supplied via .exceptionally()
is ONLY executed if a child process returns an exception to the future. For most scenarios, this is the cleanest approach for handling exceptions since the naming convention of the methods adds to code clarity and documenting intent.
In the example here, a future that calls actionYRawException()
method is invoked without any extra guardrails in the supplyAsync()
wrapper code. A second future is created by calling the .exceptionally()
method of the first future and supplying the desired exception handling code as a lambda to that method. The wait for the result is triggered by calling the .join()
of the second future then the result is available to the parent code.
thisLog.info("====== EXCEPTION VIA INNER FUTURE .exceptionally() ==="); testY.maker="Ford"; testY.model="F150"; thisLog.info("input value of testy=" + testY); thisLog.info("Initiating actionYRawException via CompletableFuture"); CompletableFuture<Location> uncaughtFutureRawException = CompletableFuture.supplyAsync( () -> { return actionYRawException(testY); } ); CompletableFuture<Location> caughtFutureRawException = uncaughtFutureRawException.exceptionally( exception -> { thisLog.error("caughtFutureRawException.exceptionally() -- " + exception); Location newresult = new Location(); newresult.latitude=45; newresult.longitude=-75; newresult.altitude=999; thisLog.info("exceptionally() returning rigged Location=" + newresult); return newresult; // return this result as the outer result } ); thisLog.info("calling outer future .join() for non-blocking wait until completion"); Location locationResult3 = caughtFutureRawException.join(); thisLog.info("outer result from outer future - Location="+ locationResult3); thisLog.info("final value of testy=" + testY);
Handling Exceptions using .handle()
The .handle()
method of a future differs from the .exceptionally() method because it ALWAYS executes when the future's original code completes whether a normal result or exception is returned. The response flow of the .handle()
call means the logic provided must handle BOTH successful responses AND exceptions. If logic is not included to return the preliminary result object for success, nothing will be returned. This seems to require duplicating code unnecessarily and can lead to confusion during testing and makes the .get()
call not terribly favored among developers.
thisLog.info("====== EXCEPTION PROCESSING VIA INNER .handle() ========"); testY.maker="Ford"; testY.model="F150"; thisLog.info("input value of testy=" + testY); thisLog.info("Initiating uncaughtFutureRawException via CompletableFuture"); CompletableFuture<Location> uncaughtFutureRawException2 = CompletableFuture.supplyAsync( () -> { return actionYRawException(testY); } ); CompletableFuture<Location> caughtFutureRawException2 = uncaughtFutureRawException2.handle( (result, exception) -> { if (exception !=null) { thisLog.error("caughtFutureRawException.exceptionally() -- " + exception); Location newresult = new Location(); newresult.latitude=45; newresult.longitude=-75; newresult.altitude=999; thisLog.info(".handle() returning rigged Location=" + newresult); return newresult; // return this result as the outer result } else { thisLog.info(".handle() returning original Location=" + result); return result; } } ); thisLog.info("calling outer future .join() for non-blocking wait until completion"); Location locationResult4 = caughtFutureRawException2.join(); thisLog.info("final .get() response= "+ locationResult4); thisLog.info("final value of testy=" + testY);
Here are the resulting logs.
2025-09-15 17:37:51:380 [main] INFO FUTURE - ====== EXCEPTION PROCESSING VIA INNER .handle() ========
2025-09-15 17:37:51:380 [main] INFO FUTURE - input value of testy=BusinessObjectY = [ maker=Ford model=F150]
2025-09-15 17:37:51:380 [main] INFO FUTURE - Initiating uncaughtFutureRawException via CompletableFuture
2025-09-15 17:37:51:381 [ForkJoinPool.commonPool-worker-2] INFO FUTURE - actionYRawException() - starting / doing "work" for 3 seconds
2025-09-15 17:37:51:382 [main] INFO FUTURE - calling outer future .join() for non-blocking wait until completion
2025-09-15 17:37:54:383 [ForkJoinPool.commonPool-worker-2] ERROR FUTURE - caughtFutureRawException.exceptionally() -- java.util.concurrent.CompletionException: java.lang.ArithmeticException: / by zero
2025-09-15 17:37:54:385 [ForkJoinPool.commonPool-worker-2] INFO FUTURE - .handle() returning rigged Location=Location = [ latitude=45.0 longitude=-75.0 altidude=999.0]
2025-09-15 17:37:54:387 [main] INFO FUTURE - final .get() response= Location = [ latitude=45.0 longitude=-75.0 altidude=999.0]
2025-09-15 17:37:54:388 [main] INFO FUTURE - final value of testy=BusinessObjectY = [ maker=Toyota model=4Runner]
Handling Exceptions using .get()
The .get()
method of a future is code to return the future's response object for the happy path but return one of three types of exceptions reflecting any error status of the future. Since these exception types are formally thrown by the method declaration, the call to .get()
must be performed within a try / catch / finally block.
In the example below, the actionYRawException()
method is called by the future. The .get()
method of that only future is called within a try block that includes catch clauses for these types of exceptions:
CancelationException |
returned if prior code has called the .cancel() method of this future |
ExecutionException |
returned if an exception during execution of the wrapped future was returned |
InterrupedException |
returned if the current thread waiting on this future itself has been interrupted |
Here is the sample code.
thisLog.info("====== EXCEPTION PROCESSING VIA INNER .get() ============"); testY.maker="Ford"; testY.model="F150"; thisLog.info("input value of testy=" + testY); thisLog.info("Initiating uncaughtFutureRawException via CompletableFuture"); CompletableFuture<Location> uncaughtFutureRawException3 = CompletableFuture.supplyAsync( () -> { return actionYRawException(testY); } ); thisLog.info("calling outer future .get() within try/catch to wait without blocking for completion"); Location locationResult5=null; try { locationResult5 = uncaughtFutureRawException3.get(); } catch (CancellationException ie) { thisLog.error("exception " + ie); } catch (ExecutionException ee) { thisLog.error("exception " + ee); thisLog.error("setting rigged Location result -- 55 / 55 / 55"); locationResult5 = new Location(); locationResult5.latitude=55.0; locationResult5.longitude=55.0; locationResult5.altitude=55.0; } catch (InterruptedException ie) { thisLog.error("exception " + ie); } thisLog.info("final result from .get() or catch: location="+ locationResult5); thisLog.info("final value of testy=" + testY);
Here are the resulting logs from executing that code.
2025-09-15 17:37:54:389 [main] INFO FUTURE - ====== EXCEPTION PROCESSING VIA INNER .get() ============
2025-09-15 17:37:54:390 [main] INFO FUTURE - input value of testy=BusinessObjectY = [ maker=Ford model=F150]
2025-09-15 17:37:54:391 [main] INFO FUTURE - Initiating uncaughtFutureRawException via CompletableFuture
2025-09-15 17:37:54:392 [main] INFO FUTURE - calling outer future .get() within try/catch to wait without blocking for completion
2025-09-15 17:37:54:392 [ForkJoinPool.commonPool-worker-2] INFO FUTURE - actionYRawException() - starting / doing "work" for 3 seconds
2025-09-15 17:37:57:394 [main] ERROR FUTURE - exception java.util.concurrent.ExecutionException: java.lang.ArithmeticException: / by zero
2025-09-15 17:37:57:395 [main] ERROR FUTURE - setting rigged Location result -- 55 / 55 / 55
2025-09-15 17:37:57:396 [main] INFO FUTURE - final result from .get() or catch: location=Location = [ latitude=55.0 longitude=55.0 altidude=55.0]
2025-09-15 17:37:57:397 [main] INFO FUTURE - final value of testy=BusinessObjectY = [ maker=Toyota model=4Runner]
Appendix - Full Main.java Source
The entire source code of the Main.java class file containing all of these illustrations is provided below for convenience.
package com.mdhlabs.future; import org.slf4j.*; import java.lang.Thread; import java.text.SimpleDateFormat; import java.util.Locale; import java.util.Date; import java.util.concurrent.TimeUnit; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; public class Main { private static Logger thisLog = LoggerFactory.getLogger("FUTURE"); static class BusinessObjectX { public int id; public String name; public BusinessObjectX() {}; public String toString() { return "BusinessObjectX = [ id=" + id + " name=" + name + "]"; } } static class BusinessObjectY { public String maker; public String model; public BusinessObjectY() {}; public String toString() { return "BusinessObjectY = [ maker=" + maker + " model=" + model + "]"; } } static class Location { public double latitude; public double longitude; public double altitude; public Location() {}; public String toString() { return "Location = [ latitude=" + latitude + " longitude=" + longitude + " altidude=" + altitude + "]"; } } public static String actionX(BusinessObjectX objectX) { thisLog.info("actionX() - starting / doing \"work\" for 7 seconds"); try { TimeUnit.SECONDS.sleep(7); } catch (InterruptedException theE) { Thread.currentThread().interrupt(); thisLog.info("actionX() - thread was interrupted during sleep"); } thisLog.info("actionX() - setting current datetime in delta object"); objectX.id=2112; objectX.name="Rush"; // this return value is returned as the result of the CompletableFuture that // wrapped this call return "Permanent Waves"; } public static Location actionY(BusinessObjectY objectY) { thisLog.info("actionY() - starting / doing \"work\" for 3 seconds"); objectY.maker="Toyota"; objectY.model="4Runner"; try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException theE) { Thread.currentThread().interrupt(); thisLog.info("actionY() - thread was interrupted during sleep"); } Location result = new Location(); result.latitude=39.9999; result.longitude=-70.999; result.altitude=140.888; // this return value is returned as the result of the CompletableFuture that // wrapped this call return result; } public static Location actionYRawException(BusinessObjectY objectY) { thisLog.info("actionYRawException() - starting / doing \"work\" for 3 seconds"); objectY.maker="Toyota"; // alter these to show we have access to input parameters objectY.model="4Runner"; // alter these to show we have access to input parameters try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException theE) { Thread.currentThread().interrupt(); thisLog.info("asyncActionB() - thread was interrupted during sleep"); } Location result = new Location(); // this will throw an uncaught exception for divide by zero. int denominator = 0; int numerator = 20/denominator; thisLog.info("Attempted to force divide by zero exception " + numerator); result.latitude=39.9999/0; result.longitude=-70.999; result.altitude=140.888; // this return value is returned as the result of the CompletableFuture that // wrapped this call return result; } public static Location actionYTypedException(BusinessObjectY objectY) throws Exception { thisLog.info("actionYTypedException() - starting / doing \"work\" for 3 seconds"); objectY.maker="Toyota"; // alter these to show we have access to input parameters objectY.model="4Runner"; // alter these to show we have access to input parameters try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException theE) { Thread.currentThread().interrupt(); thisLog.info("asyncActionB() - thread was interrupted during sleep"); } Location result = new Location(); // this will throw an uncaught exception for divide by zero. int denominator = 0; int numerator = 20/denominator; thisLog.info("Attempted to force divide by zero exception " + numerator); result.latitude=39.9999/0; result.longitude=-70.999; result.altitude=140.888; // this return value is returned as the result of the CompletableFuture that // wrapped this call return result; } public static void main(String[] args) { System.out.println("Example coding for CompletableFuture operations!"); thisLog.info("====== SYNCHRONOUS / SEQUENTIAL PROCESSING (BAD) ========"); // initialize input objects with some values BusinessObjectX testX = new BusinessObjectX(); testX.id=42; testX.name="Yes"; BusinessObjectY testY = new BusinessObjectY(); testY.maker="Ford"; testY.model="F150"; // call these methods serially, sequentially and show inputs can be altered and // results are returned after 10 seconds String testA = actionX(testX); thisLog.info("actionX() completed - testA = " + testA.toString()); Location testB = actionY(testY); thisLog.info("actionY() completed - testB = " + testB.toString()); // call A and B within CompletableFuture wrappers, then combine those wrappers in // an outer CompletableFuture wrapper that waits for both asynchronously // reset the input objects back to original values // demonstrate a single CompletableFuture call and non-blocking wait thisLog.info("====== ASYNCHRONOUS PROCESSING ========================"); testX.id=42; testX.name="Yes"; testY.maker="Ford"; testY.model="F150"; thisLog.info("Initiating actionX as a single CompletableFuture"); CompletableFuture<String> singleFuture = CompletableFuture.supplyAsync( () -> { return actionX(testX); } ); thisLog.info("calling singleFuture.join() to wait wihtout blocking for completion"); String singleResult = singleFuture.join(); thisLog.info("after singleFuture completion: singleResult="+singleResult); // reset the input objects back to original values // demonstrate executing two CompletableFuture calls in parallel and non-blocking wait thisLog.info("====== PARALLEL FUTURES ================================"); testX.id=42; testX.name="Yes"; testY.maker="Ford"; testY.model="F150"; thisLog.info("Initiating actionX via CompletableFuture"); CompletableFuture<String> xFuture = CompletableFuture.supplyAsync( () -> { return actionX(testX); } ); thisLog.info("Initiating actionY via CompletableFuture"); CompletableFuture<Location> yFuture = CompletableFuture.supplyAsync( () -> { return actionY(testY); } ); thisLog.info("Creating resultFuture CompletableFuture waiting on aFuture and bFuture"); CompletableFuture<Void> resultFuture = CompletableFuture.allOf(xFuture, yFuture); thisLog.info("invoking resultFuture.join() - waiting for x and y to complete"); resultFuture.join(); resultFuture.thenRun( () -> { thisLog.info("resultFuture.thenRun() - combining async results from aFuture and bFuture"); // statements can reference variables passed TO the async methods through the CompletableFuture thisLog.info("testA = " + testA.toString()); thisLog.info("testB = " + testB.toString()); // statements can reference response values returned through the CompletableFutures String xFutureResponse = xFuture.join(); Location yFutureResponse = yFuture.join(); thisLog.info("xFutureResponse= " + xFutureResponse); thisLog.info("yFutureResponse = " + yFutureResponse); } ); // demonstrate timeout / default handling thisLog.info("====== TIMEOUT HANDLING ================================"); thisLog.info("Initiating actionX via unlimitedXFuture "); CompletableFuture<String> unlimitedXFuture = CompletableFuture.supplyAsync( () -> { return actionX(testX); } ); thisLog.info("creating limitedAFuture via .completeOnTimeout()"); CompletableFuture<String> limitedXFuture = unlimitedXFuture.completeOnTimeout("TIMEOUTDEFAULT", 6, TimeUnit.SECONDS); thisLog.info("starting non-blocking wait on limitedXFuture"); String limitedResult = limitedXFuture.join(); thisLog.info("limitedResult from limitedXFuture = " + limitedResult); /* // demonstrate behavior with an uncaught exception in a single future thisLog.info("======================= UNCAUGHT EXCEPTION - THIS FAILS ========"); thisLog.info("This approach doesn't handle the exception a) inside the method,"); thisLog.info("b) in the supplyAsync() wrapper calling it, or c) by using "); thisLog.info(".handle() or .exceptionally() methods of CompletableFuture"); thisLog.info("so the exception will crash the entire program."); thisLog.info("Initiating asyncActionException via CompletableFuture"); CompletableFuture<Location> uncaughtFuture = CompletableFuture.supplyAsync( () -> { return asyncActionRawException(testY); } ); thisLog.info("calling uncaughtFuture.join() to wait wihtout blocking for completion"); Location locationResult = uncaughtFuture.join(); thisLog.info("after uncaughtFuture.join() has returned with result or exception "+ locationResult); */ // dthis code illustrates how an inner/outer CompletableFuture pair invokes code // that returns an Exception and catches that thrown exception to convert to a // approropriate default value to return to business logic thisLog.info("====== EXCEPTION PROCESSING VIA .supplyAsync() CODE ====="); testY.maker="Ford"; testY.model="F150"; thisLog.info("input value of testy=" + testY); thisLog.info("Initiating actionYRawException via CompletableFuture"); CompletableFuture<Location> uncaughtFutureTypedException = CompletableFuture.supplyAsync( () -> { try { return actionYRawException(testY); } catch (Exception theE) { Location newresult = new Location(); newresult.latitude=99; newresult.longitude=-99; newresult.altitude=999; thisLog.info("catch() in supplyAsync() block returning rigged Location=" + newresult); return newresult; // return this result as the outer result } } ); thisLog.info("calling inner future .join() for non-blocking wait to completion"); Location locationResult2 = uncaughtFutureTypedException.join(); thisLog.info("inner future .join() result = " + locationResult2); thisLog.info("final value of testy=" + testY); // this code illustrates how an inner/outer CompletableFuture pair invokes code // that isn't declared to throw exceptions but might anyway, requiring this code // to catch the Exception and synthesize an alternate response value instead // of propgating an uncaught exception thisLog.info("====== EXCEPTION VIA INNER FUTURE .exceptionally() ==="); testY.maker="Ford"; testY.model="F150"; thisLog.info("input value of testy=" + testY); thisLog.info("Initiating actionYRawException via CompletableFuture"); CompletableFuture<Location> uncaughtFutureRawException = CompletableFuture.supplyAsync( () -> { return actionYRawException(testY); } ); CompletableFuture<Location> caughtFutureRawException = uncaughtFutureRawException.exceptionally( exception -> { thisLog.error("caughtFutureRawException.exceptionally() -- " + exception); Location newresult = new Location(); newresult.latitude=45; newresult.longitude=-75; newresult.altitude=999; thisLog.info("exceptionally() returning rigged Location=" + newresult); return newresult; // return this result as the outer result } ); thisLog.info("calling outer future .join() for non-blocking wait until completion"); Location locationResult3 = caughtFutureRawException.join(); thisLog.info("outer result from outer future - Location="+ locationResult3); thisLog.info("final value of testy=" + testY); thisLog.info("====== EXCEPTION PROCESSING VIA INNER .handle() ========"); testY.maker="Ford"; testY.model="F150"; thisLog.info("input value of testy=" + testY); thisLog.info("Initiating uncaughtFutureRawException via CompletableFuture"); CompletableFuture<Location> uncaughtFutureRawException2 = CompletableFuture.supplyAsync( () -> { return actionYRawException(testY); } ); CompletableFuture<Location> caughtFutureRawException2 = uncaughtFutureRawException2.handle( (result, exception) -> { if (exception !=null) { thisLog.error("caughtFutureRawException.exceptionally() -- " + exception); Location newresult = new Location(); newresult.latitude=45; newresult.longitude=-75; newresult.altitude=999; thisLog.info(".handle() returning rigged Location=" + newresult); return newresult; // return this result as the outer result } else { thisLog.info(".handle() returning original Location=" + result); return result; } } ); thisLog.info("calling outer future .join() for non-blocking wait until completion"); Location locationResult4 = caughtFutureRawException2.join(); thisLog.info("final .get() response= "+ locationResult4); thisLog.info("final value of testy=" + testY); thisLog.info("====== EXCEPTION PROCESSING VIA INNER .get() ============"); testY.maker="Ford"; testY.model="F150"; thisLog.info("input value of testy=" + testY); thisLog.info("Initiating uncaughtFutureRawException via CompletableFuture"); CompletableFuture<Location> uncaughtFutureRawException3 = CompletableFuture.supplyAsync( () -> { return actionYRawException(testY); } ); thisLog.info("calling outer future .get() within try/catch to wait without blocking for completion"); Location locationResult5=null; try { locationResult5 = uncaughtFutureRawException3.get(); } catch (CancellationException ie) { thisLog.error("exception " + ie); } catch (ExecutionException ee) { thisLog.error("exception " + ee); thisLog.error("setting rigged Location result -- 55 / 55 / 55"); locationResult5 = new Location(); locationResult5.latitude=55.0; locationResult5.longitude=55.0; locationResult5.altitude=55.0; } catch (InterruptedException ie) { thisLog.error("exception " + ie); } thisLog.info("final result from .get() or catch: location="+ locationResult5); thisLog.info("final value of testy=" + testY); thisLog.info("====== END OF ALL DEMOS ================================="); } }