Welcome back! In the previous lessons, we learned about custom thread pools and CompletableFuture, two powerful tools for managing concurrent and asynchronous tasks. In this lesson, let's combine them together and explore how integrating CompletableFuture with custom thread pools can optimize task execution and resource management.
In this lesson, we'll focus on:
- How to execute
CompletableFuture
tasks using custom executors. - The importance of choosing the right thread pool size for different types of tasks.
- How
CompletableFuture
enhances parallel task execution with custom thread pools.
By the end of this lesson, you’ll have a practical understanding of how to effectively manage asynchronous tasks while controlling the thread pool's size and behavior.
We’ve already explored CompletableFuture
, which allows you to run tasks asynchronously without blocking the main thread. Additionally, we’ve worked with executors, which let us manage thread pools and control how tasks are executed by threads.
Now, by combining custom thread pools with CompletableFuture
, you can better manage thread allocation, task execution, and overall performance. This integration allows for:
- Custom control over how many threads are used to process tasks.
- Flexible management of asynchronous tasks.
- Better resource utilization by configuring the pool to match task demands.
Let’s now move into the practical implementation of this concept.
To better understand how threads are created in our custom executor, let’s first look at the CustomThreadFactory
:
Java1// Custom Thread Factory 2public class CustomThreadFactory implements ThreadFactory { 3 private final AtomicInteger counter = new AtomicInteger(1); 4 5 @Override 6 public Thread newThread(Runnable r) { 7 return new Thread(r, "CustomExecutorThread-" + counter.getAndIncrement()); 8 } 9}
This class generates custom-named threads, making it easier to monitor task execution and identify which threads are handling which tasks. Each thread is named sequentially using counter.getAndIncrement()
, so the first thread might be called CustomExecutorThread-1
, and the next one will be CustomExecutorThread-2
, and so on.
We use AtomicInteger
for the counter
variable to ensure thread-safe operations when multiple threads attempt to increment the counter at the same time.
We start by creating a custom thread pool with a fixed size of 3 threads. This limits the number of simultaneous tasks that can run, providing control over resource usage.
Java1// Custom ThreadPoolExecutor with 3 threads 2ExecutorService customExecutor = Executors.newFixedThreadPool(3, new CustomThreadFactory());
By using newFixedThreadPool(3)
, we ensure that no more than 3 tasks can be executed at the same time. Any additional tasks will wait in the queue until a thread becomes available. The CustomThreadFactory
ensures that each thread has a unique name for easier tracking.
In this part, we define two independent tasks using CompletableFuture.supplyAsync()
. Here, we explicitly pass the customExecutor
as an argument, which means these tasks will be executed by the custom thread pool we just created.
Java1// Two independent tasks 2CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> { 3 System.out.println("Task 1 executed by " + Thread.currentThread().getName()); 4 sleep(1000); 5 return "Data from Task 1"; 6}, customExecutor); 7 8CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> { 9 System.out.println("Task 2 executed by " + Thread.currentThread().getName()); 10 sleep(1500); 11 return 100; 12}, customExecutor);
Here's what we're doing in the above code:
-
Passing the
customExecutor
: By passing thecustomExecutor
as the second argument tosupplyAsync()
, we are explicitly tellingCompletableFuture
to run these tasks on threads from our custom thread pool, rather than using the default pool. This allows us to control the number of threads and monitor which threads handle each task. -
Task Distribution: The tasks will be distributed among the 3 available threads in the pool. For example, Task 1 might run on
CustomExecutorThread-1
, while Task 2 might run onCustomExecutorThread-2
. This provides visibility into how tasks are handled by specific threads.
This integration of custom thread pools with CompletableFuture
is particularly useful when you need to control how resources are allocated for concurrent tasks, preventing resource exhaustion while improving overall performance.
After defining and running the tasks, we can combine their results using thenAcceptBoth()
. This method takes a BiConsumer
that processes the results of both tasks after they complete.
Java1// Use thenAcceptBoth to combine results from both tasks 2// Void signifies that the future doesn't return a result after completion 3CompletableFuture<Void> combinedFuture = future1.thenAcceptBoth(future2, (result1, result2) -> { 4 System.out.println("Task 1 result: " + result1 + ", Task 2 result: " + result2); 5});
This is where the power of combining asynchronous tasks comes in—by running tasks independently and combining their results when both are done.
Finally, we use CompletableFuture.allOf()
to ensure the program waits for both tasks, as well as their combined result, to finish before proceeding.
Java1// Wait for all tasks to finish 2CompletableFuture.allOf(future1, future2, combinedFuture).get();
This ensures that the main thread waits until both future1
and future2
, as well as the combined future, are completed.
Integrating CompletableFuture
with custom executors has several advantages:
- Resource Management: By defining the number of threads in a pool, you can balance load and resource consumption based on task requirements.
- Performance Optimization: A correctly sized thread pool enhances task execution efficiency, crucial for performance-critical applications.
- Parallel Processing: Custom thread pools combined with
CompletableFuture
enable parallel task execution, improving application throughput and responsiveness.
These concepts are vital for building robust applications that perform well under varying loads. Understanding this integration prepares you for real-world scenarios where efficiency and scalability are key.
Now, let's move to the practice section, where you'll apply these concepts and experience how combining CompletableFuture
with custom thread pools can optimize task execution.