Handling Exceptions in Java Thread Pool Tasks
Recently I needed to use a thread pool for multithreading in an application. Sometimes logs and monitoring showed that an asynchronous task had suddenly stopped, leaving me bewildered and unable to investigate. A senior colleague eventually inspected the business code and found that a task in a new thread had thrown a runtime exception, causing the user thread I started to “fall over.” Why do threads in a thread pool not expose the exception? What state does a thread that throws an exception enter? This post analyzes that.
Reproducing the cases
#submit
- The task executed by the thread throws an exception, but it is not handled.
- The user thread with the business exception does not “die”; it becomes
WATTING. - The program continues to run.
#execute
- The task executed by the thread throws an exception, and it is successfully captured.
- The user thread with the business exception ends directly and becomes
TERMINATED. - The system does not end and continues running.
#schedule
- The task executed by the thread throws an exception, but it is not handled.
- The thread-pool thread is
WATTING. - The program continues running, but the periodic task that threw the exception no longer runs.
Cause analysis
#execute
For execute, this is easy to understand. The source code shows that when a thread-pool thread runs runWorker, if a task throws an exception, the thread throws it directly:
1 | final void runWorker(Worker w) { |
After an exception, to prevent the exceptional task from contaminating its thread, the worker that executed it is destroyed and a new non-core worker with no initial task is created. Therefore, even if every task in the pool fails, as long as the core-pool size is nonzero, the program remains blocked in workQueue#poll and the JVM does not exit.
1 | private void processWorkerExit(Worker w, boolean completedAbruptly) { |
#submit
submit is somewhat special because its task has a return value. When it is submitted, the pool wraps it in a FutureTask:
1 | public Future<?> submit(Runnable task) { |
Thus, when the pool runs runWorker, it actually enters the following method:
1 | public void run() { |
The explanation now becomes clear: FutureTask wraps the exception as the outcome Object. When FuntureTask#get calls report, it wraps and rethrows the exception as ExecutionException:
1 | public V get() throws InterruptedException, ExecutionException { |
The pool thread is WAITING because after this task ends, Worker is blocked while calling workQueue#poll.
#schedule
#schedule executes in a scheduled thread pool. In theory, a task should still run periodically after throwing an exception, so why does it stop? Look at ScheduledFutureTask, a subclass of FutureTask: normally, after a scheduled task runs, its thread puts it back into the execution queue.
1 | public void run() { |
But in #runAndReset, an exception makes #runAndReset return false. The periodic task is then not requeued, so a scheduled task that throws an exception is not run again.
1 | protected boolean runAndReset() { |
Solutions
- Catch and handle exceptions inside tasks so they do not interfere with thread-pool execution.
- With
#execute, useThreadFactoryto set logic for uncaught exceptions. - With
#submitor#schedule, which invoke throughCallable, call#getto explicitly handle task exceptions.
Some thoughts
This problem is simple once thread pools are understood, but it exposed how insufficient my fundamentals were. I used to memorize interview material and could readily recite thread-pool ctl and every configuration; after some time, though, I was bewildered when actually using it and encountering a problem. As the saying goes: “What you have only read belongs to others; only what you have experienced is your own.”
How threads are shut down
When a thread pool shuts down, it closes some threads. How? On #shutdown, the pool traverses all workers and interrupts each idle thread. When an interrupted thread escapes the blocking #getTask, it checks two things again:
- Whether the queue is empty.
- Whether the current state is STOP (when
shutDownNowis called).
1 | private Runnable getTask() { |
If these conditions apply, #getTask returns null. The worker enters #processWorkerExit and closes itself. Entering #addWorker then reveals two further conditions:
- STOP state.
- SHUTDOWN with an empty queue.
1 | private boolean addWorker(Runnable firstTask, boolean core) { |
If they apply, no new thread is created.
Core idea: Interrupt idle threads to release them from **#poll** and close them. In SHUTDOWN state, they can close only after tasks finish; in STOP state, they close without waiting for tasks to finish.
Thread-pool knowledge
After reading ThreadPoolExecutor code, I took time to draw a diagram to deepen my understanding:
Likewise, as a subclass of ThreadPoolExecutor, ScheduledThreadPoolExecutor is broadly similar. Its biggest difference is the queue: the scheduled pool customizes DelayedWorkQueue. When a worker gets a task, the queue checks time and dequeues it only when the time is met. After a task runs, the scheduled pool requeues it for later invocation.