The Thread.UncaughtExceptionHandler interface defines one method, uncaughtException(Thread, Throwable), which the JVM calls when a thread is about to terminate because of an exception that no code caught. The handler cannot keep the failing thread alive, but it runs on that thread right before it ends, so it can log the failure with context, raise an alert or start a replacement thread.
We use an UncaughtExceptionHandler for background threads that must not fail unnoticed, such as a message consumer, an import job or a cache refresher. Without one, the failure only shows up as a stack trace on the standard error stream.
The following example starts a thread with a handler through the Thread.Builder API and reads what the handler recorded.
List<String> failures = new CopyOnWriteArrayList<>();
Thread importer = Thread.ofPlatform()
.name("importer")
.uncaughtExceptionHandler((t, e) -> failures.add(t.getName() + ": " + e.getClass().getSimpleName()))
.start(() -> Integer.parseInt("12x"));
importer.join();
boolean alive = importer.isAlive(); // false, the thread still ends
String logged = failures.get(0); // "importer: NumberFormatException"
Notice that the thread terminated anyway and that the handler received both the thread and the exception. We look at what the JVM does without a handler, the order in which it looks for one, a bounded way to restart failed threads, and why executors pass exceptions from submit() to the Future instead of the handler.
1. What Happens When a Thread Throws an Uncaught Exception
The run() method of a Runnable cannot declare checked exceptions, so we must catch those inside it. Unchecked exceptions, i.e. subclasses of RuntimeException and Error, can still escape from run(), as explained in the checked vs unchecked exceptions article.
When an exception escapes run(), the JVM terminates that one thread. The rest of the application keeps running, and the JVM exits only if no other non-daemon thread is left. Before the thread ends, the JVM looks for a handler in a fixed order.

- If the thread has its own handler, set with setUncaughtExceptionHandler() or with the builder, the JVM calls it.
- Otherwise the thread’s ThreadGroup gets the exception, and its uncaughtException() method passes it to the parent group.
- The top-level group calls the default handler set with Thread.setDefaultUncaughtExceptionHandler(), if there is one.
- Without a default handler, the group prints Exception in thread “name” and the stack trace to System.err.
Subclassing ThreadGroup is legacy code today. In practice, we set a handler per thread or through a ThreadFactory, and one default handler for the whole app at startup.
2. UncaughtExceptionHandler Example
In this example, a thread parses order quantities that come from a CSV import. One of the values is XYZ, so Integer.parseInt() throws a NumberFormatException, and the code does not catch it. The task adds each parsed value to a thread-safe list, so we can see how far it got.
static Runnable parseQuantities(List<String> quantities, List<Integer> parsed) {
return () -> {
for (String quantity : quantities) {
parsed.add(Integer.parseInt(quantity));
}
};
}
2.1. Without UncaughtExceptionHandler
Without a handler, the exception ends the thread after the third value, and the fifth value is never parsed. The main thread is not affected, so it can still join the importer thread and read the partial result.
List<Integer> parsed = new CopyOnWriteArrayList<>();
Thread importer = new Thread(parseQuantities(List.of("123", "234", "345", "XYZ", "456"), parsed), "importer");
importer.start();
importer.join();
List<Integer> done = List.copyOf(parsed); // [123, 234, 345]
boolean alive = importer.isAlive(); // false
The only trace of the failure is the message that the top-level ThreadGroup prints to the standard error stream.
Exception in thread "importer" java.lang.NumberFormatException: For input string: "XYZ"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:565)
at java.base/java.lang.Integer.parseInt(Integer.java:662)
at ImportJob.lambda$parseQuantities$0(ImportJob.java:17)
at java.base/java.lang.Thread.run(Thread.java:1474)
In a server app, that line goes to the console, which nobody watches in production, and the import stays half done without anyone noticing.
2.2. With UncaughtExceptionHandler
We add a handler class that records the thread name, the thread state and the exception type. In a real app, the handler writes to the logger and increments an error metric.
class ImportFailureHandler implements Thread.UncaughtExceptionHandler {
final List<String> log = new CopyOnWriteArrayList<>();
@Override
public void uncaughtException(Thread t, Throwable e) {
log.add(t.getName() + " " + t.getState() + " " + e.getClass().getSimpleName());
}
}
ImportFailureHandler handler = new ImportFailureHandler();
List<Integer> parsed = new CopyOnWriteArrayList<>();
Thread importer = new Thread(parseQuantities(List.of("123", "XYZ"), parsed), "importer");
importer.setUncaughtExceptionHandler(handler);
importer.start();
importer.join();
List<Integer> done = List.copyOf(parsed); // [123]
String entry = handler.log.get(0); // "importer RUNNABLE NumberFormatException"
We can see that the thread state is still RUNNABLE inside the handler, because the handler runs on the failing thread before it terminates. Nothing is printed to System.err this time, because the JVM found a handler on the thread and did not reach the ThreadGroup.
To cover every thread of the app, including threads created by libraries, we set a default handler once at startup.
List<String> appLog = new CopyOnWriteArrayList<>();
Thread.setDefaultUncaughtExceptionHandler((t, e) -> appLog.add(t.getName() + ": " + e.getClass().getSimpleName()));
Thread job = Thread.ofPlatform().name("nightly-job").start(() -> List.of().getFirst());
job.join();
String logged = appLog.get(0); // "nightly-job: NoSuchElementException"
The default handler also receives exceptions that escape the main thread. A per-thread handler always wins over the default one, so a library can still install its own handler on its own threads.
3. Restarting a Thread After a Failure
A terminated thread cannot run again. Calling start() a second time on the same Thread object throws an IllegalThreadStateException, so restarting means starting a new thread with the same Runnable.
Thread once = Thread.ofPlatform().start(() -> {});
once.join();
once.start(); // IllegalThreadStateException
A handler that restarts the thread must limit the number of restarts. A task that fails on every run, such as one with a wrong configuration value, otherwise restarts forever and floods the log. The following helper starts a new thread from the handler until a maximum number of starts is reached.
static void runWithRestarts(Runnable task, int maxStarts, AtomicInteger starts, List<String> log) {
int attempt = starts.incrementAndGet();
Thread.ofPlatform()
.name("consumer-" + attempt)
.uncaughtExceptionHandler((t, e) -> {
log.add(t.getName() + " failed: " + e.getMessage());
if (attempt < maxStarts) {
runWithRestarts(task, maxStarts, starts, log); // new thread, same task
}
})
.start(task);
}
A message consumer that connects to a broker during application startup is a realistic case. The broker is not ready for the first two attempts, and the third attempt connects.
AtomicInteger starts = new AtomicInteger();
List<String> log = new CopyOnWriteArrayList<>();
CountDownLatch connected = new CountDownLatch(1);
Runnable consumer = () -> {
if (starts.get() < 3) {
throw new IllegalStateException("broker not ready");
}
connected.countDown();
};
runWithRestarts(consumer, 5, starts, log);
boolean ok = connected.await(2, TimeUnit.SECONDS); // true
int attempts = starts.get(); // 3
String firstFailure = log.get(0); // "consumer-1 failed: broker not ready"
Restarting from the handler works, but it is not the first tool to pick. A retry loop with a pause inside run() keeps the logic in one place, and a ScheduledExecutorService can rerun a task at a fixed delay. The handler-based restart fits threads that we do not control, or as a last line of defense around a long-running worker.
4. Exceptions in ExecutorService: execute() vs submit()
Thread pools add one more rule, and it explains why many developers think their handler is broken. A task passed to execute() runs as a plain Runnable, so its exception reaches the worker thread’s handler. A task passed to submit() is wrapped in a FutureTask, which catches every exception and stores it in the returned Future, so the handler never sees it.
The following example gives a fixed thread pool a Thread.Builder factory with a handler and sends one failing task through each method.
List<String> seen = new CopyOnWriteArrayList<>();
CountDownLatch reported = new CountDownLatch(1);
ThreadFactory factory = Thread.ofPlatform().name("job-", 1).uncaughtExceptionHandler((t, e) -> {
seen.add(e.getMessage());
reported.countDown();
}).factory();
Future<?> submitted;
try (ExecutorService pool = Executors.newFixedThreadPool(2, factory)) {
pool.execute(() -> Integer.parseInt("from execute"));
submitted = pool.submit(() -> Integer.parseInt("from submit"));
reported.await(1, TimeUnit.SECONDS);
}
int handledCount = seen.size(); // 1
Future.State state = submitted.state(); // FAILED
String stored = submitted.exceptionNow().getClass().getSimpleName(); // "NumberFormatException"
Only the execute() failure reached the handler. The submit() failure sits in the Future until someone calls get() or exceptionNow(), and if nobody does, the exception is lost without a trace. The Callable and Future article shows how to read it.
Four options keep exceptions from submit() visible.
- Read every Future, for example with invokeAll() and state().
- Catch and log exceptions inside the task itself, so the task never lets one escape.
- Extend ThreadPoolExecutor and override afterExecute(Runnable, Throwable), which receives the exception of each execute() task and lets us unwrap the FutureTask for submit() tasks.
- Use CompletableFuture with exceptionally() or whenComplete() to log the failure.
Virtual threads follow the same rules. A per-thread handler comes from Thread.ofVirtual().uncaughtExceptionHandler(…), and Executors.newThreadPerTaskExecutor(factory) builds an executor from such a factory. The Java concurrency guide collects the other thread and executor tutorials.
5. UncaughtExceptionHandler FAQs
Each answer starts from a symptom, such as a worker thread that vanished from a running app or a handler that never fires.
5.1. Can a dead thread be restarted in Java?
No. A Thread object runs once, and a second start() call throws IllegalThreadStateException. We create a new Thread with the same Runnable, as shown in section 3.
5.2. Does an uncaught exception in a thread stop the JVM?
No. It terminates only that thread. The JVM exits when the last non-daemon thread ends, so an exception in a worker thread leaves the rest of the app running, often in a broken state that nobody notices.
5.3. Why is my UncaughtExceptionHandler not called for ExecutorService tasks?
Because the tasks were sent with submit(). The executor wraps them in a FutureTask that stores the exception in the Future. Tasks sent with execute() reach the handler of the worker thread.
5.4. What is the difference between setUncaughtExceptionHandler() and setDefaultUncaughtExceptionHandler()?
The instance method sets a handler for one thread. The static method sets a fallback for every thread in the JVM that has no handler of its own, so we call it once at startup.
5.5. Does UncaughtExceptionHandler work with virtual threads?
Yes. We set it per thread with Thread.ofVirtual().uncaughtExceptionHandler(handler), and the default handler also applies to virtual threads.
6. Conclusion
An UncaughtExceptionHandler runs right before a thread dies from an exception. The JVM checks the thread’s own handler first, the ThreadGroup second and the default handler third, and it prints to System.err only when it finds none.
The handler cannot rescue the failing thread, but it can log the failure and start a new thread, as long as the number of restarts is limited. In thread pools, it sees only tasks sent with execute(), because submit() keeps exceptions in the Future.
7. References
- Thread.UncaughtExceptionHandler Javadoc (Java 25)
- ThreadGroup.uncaughtException() Javadoc (Java 25)
- Thread.Builder Javadoc (Java 25)
- ThreadPoolExecutor.afterExecute() Javadoc (Java 25)
Happy Learning !!
UncaughtExceptionHandler concept is not working for Executor..
then what is way ? could you please tell me.
please correct me if i am wrong
Its not restart, its different thread,just print the ID and you will know it.
Hi Lokesh,
Also default exception handler imp…
Thread thread = new Thread(task);
thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.printf(“An exception has been capturedn”);
System.out.printf(“Thread: %sn”, t.getId());
System.out.printf(“Exception: %s: %sn”,
e.getClass().getName(), e.getMessage());
System.out.printf(“Stack Trace: n”);
e.printStackTrace(System.out);
System.out.printf(“Thread status: %sn”, t.getState());
new Thread(new Task()).start();
}
});
thread.start();
Wish you a prosperous happy new year 2015
Regards,
Himansu
Thanks Himansu. Happy new year to you as well.
good, I like this one.
Nice post!!
Well why can’t we initiate new thread execution in catch block of run instead? I know it’s not advisable to have try/catch block for unchecked exceptions, but doing so will serve our purpose.
Like below,
@Override
public void run()
{
// Thread.currentThread().setUncaughtExceptionHandler(new ExceptionHandler());
try{
System.out.println(Integer.parseInt(“123”));
System.out.println(Integer.parseInt(“234”));
System.out.println(Integer.parseInt(“345”));
System.out.println(Integer.parseInt(“XYZ”)); //This will cause NumberFormatException
System.out.println(Integer.parseInt(“456”));
}catch(Exception e){
System.out.println(“Exception occured”);
e.printStackTrace();
new Thread(new Task()).start();
}
}
You know the reason : “not advisable” :-) Otherwise both will serve the purpose.
If catch i not advisable then why uncaughtexception handler is advisable? I am confused here, both will serve the same purpose – catching unchecked exceptions and doing remedy. So why one is not advisable and other is recommanded?
You catch only checked exceptions… right? Unchecked exceptions are not caught until you are catching all instances of
Throwablein catch block.That’s not true.. if e.g. you know it is possible user input might not be numeric, you can specifically catch NumberFormatException — there’s nothing wrong with this (aside from, in the example I gave, the fact you should check if the input is numeric first and avoid the exception) — and it works without catching Exception or Throwable.
This is very specific scenario and I will call it input validation. If at this step, program fails – I will call it a bad design.
Further, technically both approaches serve the same purpose, its more of choice matter.