1. Overview

ExecutorService is the central mechanism to execute tasks in Java. When we run our tasks in a thread pool backed by an ExecutorService, we must pay attention to exception handling. Remember that Java doesn't require a method to handle or declare an unchecked RuntimeException, thus any code can throw a RuntimeException without us knowing. Upon getting an exception, we can log the error, notify a system, or take other diagnostic actions. In this tutorial, we'll examine how we can handle exceptions thrown from the tasks running in an ExecutorService.

2. Default Behavior with execute()

If a thread terminates due to an uncaught exception, the JVM notifies the thread's registered UncaughtExceptionHandler. If there is no registered handler, it prints the stack trace to System.err. 

public void executeThenThrowUnchecked() {
    final ExecutorService executorService = Executors.newFixedThreadPool(1);

    executorService.execute(() -> {
        System.out.println("I will throw RuntimeException now.");
        throw new RuntimeException("Planned exception after execute()");
    });

    executorService.shutdown();
}

Here, we're first creating a thread pool by invoking newFixedThreadPool. Keep in mind that Executors.newFixedThread uses the DefaultThreadFactory class to create the worker threads. And DefaultThreadFactory doesn't assign an UncaughtExceptionHandler to new threads. After we initialize the thread pool, we're calling execute() with a Runnable task that throws a RuntimeException.

A sample run shows:

I will throw RuntimeException now.
Exception in thread "pool-1-thread-1" java.lang.RuntimeException: Planned exception after execute()
  at com.javabyexamples.java.concurrency.cancellation.exceptionhandling.DefaultBehavior.lambda$...
  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
  at java.lang.Thread.run(Thread.java:748)

Here, the JVM prints the exception stack trace to the console, since the worker thread doesn't have a registered UncaughtExceptionHandler.

2.1. Default Behavior with submit()

In the previous example, we executed a Runnable task and the JDK tried to report the exception to a registered handler, but didn't find one. Alternatively, if we submit a task using the submit () method, ExecutorService returns a Future handle. The uncaught exception - if one occurs - is considered as a part of this Future. Thus the JDK doesn't try to notify the handler:

public void submitThenThrowUnchecked() {
    final ExecutorService executorService = Executors.newFixedThreadPool(1);

    final Future<Object> futureHandle = executorService.submit(() -> {
        System.out.println("I will throw RuntimeException now.");
        throw new RuntimeException("Planned exception after submit()");
    });

    executorService.shutdown();
}

In this example, we're calling submit() instead of execute(). When we run the code, it doesn't print the exception stack trace:

I will throw RuntimeException now.

We see the exception when we invoke the Future get method:

public void submitThenThrowUncheckedThenGet() {
    final ExecutorService executorService = Executors.newFixedThreadPool(1);
    final Future<Object> future = executorService.submit(() -> {
        System.out.println("I will throw RuntimeException now.");
        throw new RuntimeException("Planned exception after submit()");
    });

    try {
        future.get();
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (ExecutionException e) {
        e.printStackTrace();
    }

    executorService.shutdown();
}

Here, when we invoke the get method, an ExecutionException will be thrown wrapping the original RuntimeException.

So we can conclude that even if a worker thread has an UncaughtExceptionHandler, the JDK won't notify the handler for an uncaught exception that occurred in the task.

3. Handle with UncaughtExceptionHandler

Next, we'll register an UncaughtExceptionHandler to the worker threads. Remember that ExecutorService implementations use a ThreadFactory to create a new worker thread. For our purposes, we'll create a new ThreadFactory implementation that sets an UncaughtExceptionHandler.

We'll first define our handler:

public static class AppExceptionHandler implements UncaughtExceptionHandler {

    @Override
    public void uncaughtException(Thread t, Throwable e) {
        System.out.println("Uncaught Exception occurred on thread: " + t.getName());
        System.out.println("Exception message: " + e.getMessage());
    }
}

AppExceptionHandler simply logs the thread name and exception message.

Then we must implement a new ThreadFactory:

public static class AppThreadFactory implements ThreadFactory {

    @Override
    public Thread newThread(Runnable r) {
        final Thread thread = new Thread(r);
        thread.setUncaughtExceptionHandler(new AppExceptionHandler());
        return thread;
    }
}

AppThreadFactory sets a new AppExceptionHandler instance to every new thread invoking setUncaughtExceptionHandler.

Now that we have our thread factory, let's use it when creating a thread pool:

public void executeThenThrowUnchecked() {
    final ExecutorService executorService = Executors.newFixedThreadPool(1, new AppThreadFactory());
    
    executorService.execute(() -> {
        System.out.println("I will throw RuntimeException now.");
        throw new RuntimeException("Planned exception after execute()");
    });

    executorService.shutdown();
}

We're using the custom AppThreadFactory instead of DefaultThreadFactory. 

A sample run prints:

I will throw RuntimeException now.
Uncaught Exception occurred on thread: Thread-0
Exception message: Planned exception after execute()

There is no exception stack trace in the output since the thread is using our handler implementation.

4. Handle with Wrapper Task

We'll now investigate how we can handle an uncaught exception wrapping the original task. The previous UncaughtExceptionHandler approach applies to all threads and tasks in a thread pool. However, if we're running different tasks in the same thread pool and they require different exception-handling logic, this may not be optimal. Or we aren't even allowed to set a handler because the task submission code is using a preconfigured pool. In these cases, we can wrap our original task in another Runnable or Callable. The wrapper class catches the exception and takes the appropriate action.

We'll create a Runnable wrapper:

public static class CatchingRunnable implements Runnable {

    private final Runnable delegate;

    public CatchingRunnable(Runnable delegate) {
        this.delegate = delegate;
    }

    @Override
    public void run() {
        try {
            delegate.run();
        } catch (RuntimeException e) {
            System.out.println(e.getMessage()); // Log, notify etc...
            throw e;
        }
    }
}

CatchingRunnable contains a Runnable delegate. Notice the try/catch statement in the run method. If an exception occurs when running the delegate, we print the exception message. However, this can be any other action to diagnose or notify the exception. Then we're rethrowing the exception to not alter the original flow.

Let's see the task submission code:

public void executeThenThrowUnchecked() {
    final ExecutorService executorService = Executors.newFixedThreadPool(1);
    final CatchingRunnable catchingRunnable = new CatchingRunnable(() -> {
        System.out.println("I will throw RuntimeException now.");
        throw new RuntimeException("Planned exception after execute()");
    });
    executorService.execute(catchingRunnable);

    executorService.shutdown();
}

Similar to the previous examples, we're throwing a RuntimeException in our Runnable task.

When we run, it prints:

I will throw RuntimeException now.
Planned exception after execute()
Exception in thread "pool-1-thread-1" java.lang.RuntimeException: Planned exception after execute()
  at com.javabyexamples.java.concurrency.cancellation.exceptionhandling.WithWrappingTask.lambda$...
  at com.javabyexamples.java.concurrency.cancellation.exceptionhandling.WithWrappingTask$CatchingRunnable.run...
  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
  at java.lang.Thread.run(Thread.java:748)

In the output, we see lines from multiple parts of the code. The first line comes from the original Runnable task. Then CatchingRunnable prints the exception message. Lastly, the JDK prints the stack trace since there is no registered UncaughtExceptionHandler.

5. Handle with Overriding afterExecute

Lastly, we'll extend the ThreadPoolExecutor class to handle the uncaught exceptions. For this purpose, we'll use the afterExecute hook method that ThreadPoolExecutor provides:

protected void afterExecute(Runnable r, Throwable t) { }

If the task completes normally, the Throwable argument is null. Otherwise, it contains the exception that caused the termination.

Now, we'll extend ThreadPoolExecutor:

public static class MonitoringThreadPoolExecutor extends ThreadPoolExecutor {

    public MonitoringThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit,
      BlockingQueue<Runnable> workQueue) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
    }

    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        super.afterExecute(r, t);
        if(t != null){
            System.out.println("Exception message: " + t.getMessage());    
        }
    }
}

Here, we have the MonitoringThreadPoolExecutor class. In the afterExecute method, we print the exception message if one occurs.

Next, instead of using Executors, we'll directly instantiate the thread pool:

public void executeThenThrowUnchecked() {
    final ExecutorService executorService = new MonitoringThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS,
      new LinkedBlockingQueue<>());
    executorService.execute(() -> {
        System.out.println("I will throw RuntimeException now.");
        throw new RuntimeException("Planned exception after execute()");
    });

    executorService.shutdown();
}

A sample run prints the following.

I will throw RuntimeException now.
Exception message: Planned exception after execute()
Exception in thread "pool-1-thread-1" java.lang.RuntimeException: Planned exception after execute()
  at com.javabyexamples.java.concurrency.cancellation.exceptionhandling.WithOverridingAfterExecute.lambda$...
  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
  at java.lang.Thread.run(Thread.java:748)

6. Summary

In this tutorial, we investigate how to handle an exception for tasks running in a thread pool. We first looked at the default exception handling behavior and the UncaughtExceptionHandler interface. Then we examined the wrapper tasks for more control of the handling logic. Then we extended the ThreadPoolExecutor class as a more general approach.

Lastly, check out the source code for all examples over on Github.