Skip to main content

Command Palette

Search for a command to run...

Executor Service and Future

Published
5 min read

ExecutorService is a Java framework that manages a pool of worker threads to execute your tasks asynchronously. Instead of manually creating and managing threads, you submit tasks to the executor and it handles all the threading complexity for you. At its core, an ExecutorService is a powerful framework that manages three key components:

[ Task Queue ] + [ Worker Threads ] + [ Lifecycle Manager ]

ThreadPoolExecutor

new ThreadPoolExecutor(
    corePoolSize = 4,
    maxPoolSize = 4,
    keepAliveTime = 0,
    workQueue = new LinkedBlockingQueue<>()
);

corePoolSize : The number of threads to keep alive even when idle. Think of this as your "baseline crew" that's always ready.

maxPoolSize : The maximum number of threads allowed in the pool. Extra threads (beyond core) are created when the queue fills up.

keepAliveTime How long excess threads (above core) stay alive when idle. After this time, they're terminated to free up resources.

workQueue The queue where tasks wait when all core threads are busy. Common types:

  • LinkedBlockingQueue - Unbounded queue (can grow infinitely)

  • ArrayBlockingQueue - Bounded queue (fixed capacity)

  • SynchronousQueue - No storage, direct handoff

Choosing the Right Thread Pool Size

For CPU-Bound Tasks (calculations, data processing)

Optimal threads = Number of CPU cores

int cores = Runtime.getRuntime().availableProcessors(); 
ExecutorService executor = Executors.newFixedThreadPool(cores);
  • CPU-bound tasks keep cores busy.

  • More threads = more context switching = slower performance.

For I/O-Bound Tasks (database calls, HTTP requests, file operations)

Optimal threads = Number of cores × (1 + Wait time / Compute time)

// If tasks spend 90% time waiting and 10% computing
// Wait/Compute ratio = 9
int cores = Runtime.getRuntime().availableProcessors();
int threads = cores * (1 + 9); // cores × 10
ExecutorService executor = Executors.newFixedThreadPool(threads);
  • Optimal threads = Number of cores × (1 + Wait time / Compute time)

  • I/O tasks spend time waiting. While one thread waits, another can use the CPU.

  • Rule of thumb: 50-100 threads for typical I/O operations

Life of a Task

Future future = executor.submit(() -> {
    return calculateTax(income);
});

Step 1: Task Wrapping

The task gets wrapped in a special object called FutureTask:

k

FutureTask implements both Runnable (for execution) and Future (for result retrieval). It's the bridge between execution and results.

Step 2: Task Submission Decision Tree

Step 3: Worker Thread Execution

Future

A Future is essentially a state machine that represents a computation that may not have completed yet.

Future States

It stores one of three things:

  • The result (when successful)

  • An exception (when failed)

  • A cancellation flag (when cancelled)

Why Future.get() Blocks ?

When we call future.get()

What happens internally:

  1. The thread calling get() parks itself using LockSupport.park()

  2. It waits (sleeps) until the worker thread completes

  3. Worker thread wakes it up when done

  4. Result is returned

Example: Using Future

ExecutorService executor = Executors.newFixedThreadPool(2);

Future future = executor.submit(() -> {
    Thread.sleep(2000); // Simulate work
    return 42;
});

System.out.println("Task submitted, doing other work...");

// This blocks until result is ready
Integer result = future.get(); // Waits up to 2 seconds
System.out.println("Result: " + result);

executor.shutdown();

Cancelling Tasks

You can attempt to cancel a running task:

future.cancel(true); // true = interrupt if running

Important: Cancellation is cooperative. Your task must check for interruption:

while (!Thread.currentThread().isInterrupted()) {
    // Do work
    // Check periodically to respect cancellation
}

Runnable vs Callable

When to Use Runnable

// Logging, fire-and-forget operations
executor.execute(() -> {
    log.info("User logged in: " + userId);
});

When to Use Callable

// Need the result
Future taxFuture = executor.submit(() -> {
    return calculateTax(income);
});

Double tax = taxFuture.get();

ExecutorService Lifecycle

Proper shutdown is critical to prevent thread leaks and resource exhaustion.

❌ Wrong - Thread Leak!

ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(task);
// Forgot to shutdown - threads never die!

✅ Correct - Graceful Shutdown

ExecutorService executor = Executors.newFixedThreadPool(4);
try {
    executor.submit(task);
    // ... more work
} finally {
    executor.shutdown(); // No new tasks accepted

    // Wait for tasks to complete
    if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
        executor.shutdownNow(); // Force shutdown
    }
}

Shutdown Methods

  • shutdown() - Graceful: no new tasks, finish existing ones

  • shutdownNow() - Forceful: interrupts running tasks, returns waiting tasks

  • awaitTermination() - Waits for shutdown to complete

Common Executor Types

Examples

// Fixed thread pool for CPU-bound work
ExecutorService cpuExecutor = 
    Executors.newFixedThreadPool(
        Runtime.getRuntime().availableProcessors()
    );

// Cached thread pool for short bursts
ExecutorService burstExecutor = 
    Executors.newCachedThreadPool();

// Single thread for sequential processing
ExecutorService sequential = 
    Executors.newSingleThreadExecutor();

// Scheduled executor for periodic tasks
ScheduledExecutorService scheduler = 
    Executors.newScheduledThreadPool(2);

scheduler.scheduleAtFixedRate(
    () -> System.out.println("Heartbeat"),
    0, 5, TimeUnit.SECONDS
);

Completable Future

While Future is blocking, CompletableFuture provides a non-blocking, composable API for asynchronous programming.

  • Non-blocking: Chain operations without blocking threads

  • Composable: Combine multiple async operations

  • Exception handling: Built-in error handling with exceptionally()

  • Callbacks: Execute code when tasks complete

// Using Future (blocking):
Future future = executor.submit(() -> fetchUserName(id));
String userName = future.get(); // BLOCKS here
System.out.println(userName);


// Using CompletableFuture (non-blocking):
CompletableFuture.supplyAsync(() -> fetchUserName(id))
    .thenApply(name -> name.toUpperCase())
    .thenAccept(name -> System.out.println(name))
    .exceptionally(ex -> {
        System.err.println("Error: " + ex);
        return null;
    });
// No blocking - continues immediately!

Combining Multiple Tasks

CompletableFuture userFuture = 
    CompletableFuture.supplyAsync(() -> fetchUser(id));

CompletableFuture orderFuture = 
    CompletableFuture.supplyAsync(() -> fetchOrders(id));

// Wait for both to complete
CompletableFuture combined = 
    CompletableFuture.allOf(userFuture, orderFuture);

combined.thenRun(() -> {
    String user = userFuture.join();
    String orders = orderFuture.join();
    System.out.println(user + " has orders: " + orders);
});

Common Pitfalls

1. Forgetting to Shutdown

Always shutdown executors in a finally block or use try-with-resources (Java 19+)

2. Deadlocks with Future.get()

// DEADLOCK! Only 1 thread, but task waits for itself
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> {
    Future f = executor.submit(() -> "Hello");
    return f.get(); // Waits forever!
});

3. Wrong Pool Size

Using CPU core count for I/O tasks = underutilisation
Using too many threads for CPU tasks = excessive context switching

4. Sharing State Without Synchronisation

ExecutorService doesn't make your code thread-safe. You still need proper synchronisation.