Question 14 · Section 9

What does ExecutorService do?

ExecutorService is the primary interface in Java for managing thread pools and executing asynchronous tasks.

Language versions: English Russian Ukrainian

Junior Level

Basic Understanding

ExecutorService is the primary interface in Java for managing thread pools and executing asynchronous tasks.

Without ExecutorService: you manually create threads (new Thread().start()), control their lifecycle, and handle errors. With ExecutorService: you only submit tasks and get results — the framework handles thread management for you. It separates “what to do” (the task) from “how and when to do it” (the execution mechanism).

Simple Example

// Creating ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(3);

// Submitting a task
executor.submit(() -> {
    System.out.println("Task executed!");
});

// Shutdown
executor.shutdown();

Main Ways to Submit Tasks

Method Returns Exceptions When to Use
execute(Runnable) void Printed to console “Fire and forget”
submit(Runnable) Future<?> Wrapped in ExecutionException — wrapper is needed to distinguish task errors from ExecutorService errors. Original exception available via e.getCause(). Need to track completion
submit(Callable<T>) Future Wrapped in ExecutionException — wrapper is needed to distinguish task errors from ExecutorService errors. Original exception available via e.getCause(). Need a result

execute() vs submit()

// execute() — "fire and forget"
executor.execute(() -> {
    System.out.println("Running...");
    // Error here — just prints to System.err
});

// submit() — can get result and error
Future<?> future = executor.submit(() -> {
    System.out.println("Running...");
    // Error here — will be in future.get()
});

// Checking completion
if (future.isDone()) {
    System.out.println("Task completed");
}

Shutting Down the Pool

// Method 1: Graceful shutdown
executor.shutdown(); // Stops accepting new tasks
executor.awaitTermination(60, TimeUnit.SECONDS); // Wait up to 60 seconds

// Method 2: Forceful shutdown
executor.shutdownNow(); // Interrupts active tasks

Middle Level

ExecutorService Lifecycle

┌──────────┐    shutdown()    ┌───────────┐  all tasks  ┌─────────┐
│ RUNNING  │ ──────────────→ │ SHUTDOWN  │ ────────────→ │ TIDYING │
│          │                 │           │               │         │
│ accepts  │  shutdownNow()  │ does not  │               │         │
│ tasks    │ ──────────────→ │ accept    │               │         │
│          │                 │ tasks     │               │         │
│ processes│  ←───────────── │ finishes  │               │         │
│          │   interrupted    │ queue     │               │         │
└──────────┘                 └───────────┘               └────┬────┘
      │                                                       │
      │                                                       │ terminated()
      │                                                       ▼
      │                                                 ┌───────────┐
      └────────────────────────────────────────────────│ TERMINATED│
                                                       └───────────┘
State Description
RUNNING Accepts and processes tasks
SHUTDOWN Does not accept new, finishes queue
STOP Does not accept, interrupts active
TIDYING All tasks finished, threads stopped
TERMINATED terminated() executed

submit(): Future and Error Handling

// Callable — returns a result
Future<String> future = executor.submit(() -> {
    return "Result";
});

// Getting the result (blocks!)
String result = future.get();

// Getting with timeout
try {
    String result = future.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    future.cancel(true); // Cancel the task
}
// cancel(true) sends interrupt() to the task.
// The task must check Thread.interrupted() itself and terminate.
// If the task doesn't handle interrupts — it will continue running!

invokeAll() and invokeAny()

// invokeAll() — waits for ALL tasks
List<Callable<String>> tasks = List.of(
    () -> "Task 1",
    () -> "Task 2",
    () -> "Task 3"
);
List<Future<String>> futures = executor.invokeAll(tasks);
// Blocks until all complete

// invokeAny() — waits for the FIRST successful task
String result = executor.invokeAny(tasks);
// Returns the result of the first completed task, cancels the rest

Proper Pool Shutdown

public void shutdownGracefully(ExecutorService executor) {
    executor.shutdown(); // 1. Stop accepting
    try {
        if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { // 2. Wait
            executor.shutdownNow(); // 3. Force
            if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
                System.err.println("Pool did not terminate");
            }
        }
    } catch (InterruptedException e) {
        executor.shutdownNow();
        Thread.currentThread().interrupt(); // Restore interrupt flag
    }
}

submit() vs execute() — The Exception Trap

// execute() — exception thrown immediately
executor.execute(() -> {
    throw new RuntimeException("Error!"); // → UncaughtExceptionHandler
});

// submit() — exception "hidden" in Future
Future<?> future = executor.submit(() -> {
    throw new RuntimeException("Error!");
});
// No error until you call future.get()!
try {
    future.get(); // → ExecutionException
} catch (ExecutionException e) {
    System.out.println(e.getCause()); // → RuntimeException: Error!
}

Senior Level

Implementation details of ThreadPoolExecutor (ctl, queues, ThreadFactory) — in file [[12. What is a Thread Pool]].

Thread Pool Leaks

// BAD: creating pool inside a method
public void process() {
    ExecutorService executor = Executors.newFixedThreadPool(10);
    executor.submit(() -> doWork());
    executor.shutdown();
    // If doWork() throws an exception — shutdown() is not called
    // Threads leak!
}

// GOOD: pool is a singleton or managed by a container
@Component
public class TaskProcessor {
    private final ExecutorService executor;

    public TaskProcessor() {
        this.executor = new ThreadPoolExecutor(...);
    }

    @PreDestroy
    public void shutdown() {
        shutdownGracefully(executor);
    }
}

Correlation ID / Trace Context

// Problem: Trace ID is not propagated automatically
executor.submit(() -> {
    // MDC.get("traceId") = null!
});

// Solution: wrapper
public class TracingExecutorService implements ExecutorService {
    private final ExecutorService delegate;

    public Future<?> submit(Runnable task) {
        Map<String, String> mdcContext = MDC.getCopyOfContextMap();
        return delegate.submit(() -> {
            if (mdcContext != null) MDC.setContextMap(mdcContext);
            try {
                task.run();
            } finally {
                MDC.clear();
            }
        });
    }
}

Diagnostics

Metrics for Monitoring

// Output to Prometheus:
executor.getActiveCount();         // Active threads
executor.getQueue().size();        // Queue size
executor.getCompletedTaskCount();  // Completed tasks
executor.getTaskCount();           // Total tasks
executor.getPoolSize();            // Pool size

Future.isDone() / isCancelled()

Future<?> future = executor.submit(task);

// Without blocking
if (future.isDone()) {
    System.out.println("Completed");
}
if (future.isCancelled()) {
    System.out.println("Cancelled");
}

Best Practices

  1. Do not create ExecutorService inside a method — thread leak
  2. Use submit() — if you need error handling
  3. Always call shutdown() — in finally or @PreDestroy
  4. ArrayBlockingQueue — protection against OOM
  5. CallerRunsPolicy — back-pressure under overload
  6. Custom ThreadFactory — thread naming
  7. Trace ID propagation — for microservices
  8. Monitor queue.size() — growing queue = degradation

Interview Cheat Sheet

Must know:

  • ExecutorService — interface for managing thread pools: task submission, result retrieval, graceful shutdown
  • execute(Runnable) = void, exceptions print to console; submit() = Future, exceptions hidden in ExecutionException
  • submit(Callable) returns Future — can get result and handle error via get()
  • Lifecycle: RUNNING → SHUTDOWN (shutdown()) → STOP (shutdownNow()) → TIDYING → TERMINATED
  • invokeAll() — waits for ALL tasks; invokeAny() — returns result of FIRST successful, cancels the rest
  • Graceful shutdown: shutdown() → awaitTermination(timeout) → shutdownNow() → awaitTermination
  • Future.get(timeout) throws TimeoutException — on timeout, call future.cancel(true)
  • ExecutorService should be managed by a container (Spring @Bean) or singleton, NOT created inside a method

Frequent follow-up questions:

  • What’s the difference between execute() vs submit()? — execute is “fire and forget”, submit allows tracking result and error via Future
  • Why doesn’t submit() throw an exception immediately? — The exception is wrapped in ExecutionException and only available via future.get().getCause()
  • How does shutdown() differ from shutdownNow()? — shutdown finishes the queue, shutdownNow interrupts active tasks via interrupt()
  • How to propagate MDC/Trace ID in ExecutorService? — Wrapper: MDC.getCopyOfContextMap() before submit, MDC.setContextMap() inside the task

Red flags (DO NOT say):

  • “I create ExecutorService inside a method” — thread leak if shutdown() is not called
  • “submit() will show the exception immediately” — no, you need to call future.get()
  • “shutdownNow() guaranteed stops all tasks” — no, it only sends interrupt(), tasks must handle it themselves
  • “cancel(true) instantly kills the task” — no, it only sets the interrupted flag, the task must check it

Related topics:

  • [[12. What is a Thread Pool]]
  • [[13. What types of Thread Pool exist in Java]]
  • [[16. What is the difference between Executors.newFixedThreadPool() and newCachedThreadPool()]]
  • [[17. What is ForkJoinPool]]