Exception Handling in Multithreaded Java Code: Best Practices and Examples

Illustration for Exception Handling in Multithreaded Java Code: Best Practices and Examples
By Last updated:

Imagine a relay race: if one runner falls, does the whole team fail? In multithreaded programming, exceptions in one thread don’t automatically propagate to others. Without proper handling, they can be lost silently, leaving applications in unpredictable states.

This tutorial covers how to handle exceptions in multithreaded Java code using traditional threads, ExecutorService, CompletableFuture, and structured concurrency.


Purpose of Java Exception Handling

  • Ensure threads fail safely without corrupting shared state.
  • Prevent hidden failures in background tasks.
  • Provide clear diagnostics for debugging concurrent systems.

Real-world analogy: Exception handling in multithreading is like safety nets in a circus—each performer (thread) may fall, but the net ensures the show continues.


Errors vs Exceptions in Java

At the root of Java’s throwable system is Throwable:

  • Error: Serious, unrecoverable problems (e.g., OutOfMemoryError).
  • Exception: Recoverable conditions (e.g., IOException).

Exception Hierarchy

Throwable
 ├── Error (unrecoverable)
 │    └── OutOfMemoryError, StackOverflowError
 └── Exception
      ├── Checked (must be declared or handled)
      │    └── IOException, SQLException
      └── Unchecked (RuntimeException)
           └── NullPointerException, ArithmeticException

Basic Syntax: try-catch-finally in Threads

public class Worker extends Thread {
    @Override
    public void run() {
        try {
            int result = 10 / 0;
        } catch (ArithmeticException e) {
            System.out.println("Handled in thread: " + e.getMessage());
        }
    }
}
  • Exceptions in run() must be caught inside the thread.
  • If uncaught, they terminate only that thread, not the whole program.

Exception Handling with ExecutorService

When using thread pools, exceptions are wrapped in Future.

ExecutorService executor = Executors.newFixedThreadPool(2);

Future<Integer> future = executor.submit(() -> {
    return 10 / 0; // ArithmeticException
});

try {
    future.get(); // throws ExecutionException
} catch (ExecutionException e) {
    System.out.println("Cause: " + e.getCause());
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

Key points:

  • Exceptions are wrapped in ExecutionException.
  • Always check e.getCause() for the root exception.

Exception Handling with CompletableFuture

CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> {
    return 10 / 0;
}).exceptionally(ex -> {
    System.out.println("Handled: " + ex);
    return 0;
});

System.out.println(cf.join());
  • exceptionally() handles exceptions gracefully.
  • handle() allows both normal and exceptional outcomes.
CompletableFuture<Integer> cf2 = CompletableFuture.supplyAsync(() -> {
    throw new IllegalArgumentException("Invalid input");
}).handle((result, ex) -> {
    if (ex != null) {
        System.out.println("Error: " + ex.getMessage());
        return -1;
    }
    return result;
});

Global Exception Handling in Threads

Set a default handler:

Thread.setDefaultUncaughtExceptionHandler((thread, e) -> {
    System.out.println("Uncaught in " + thread.getName() + ": " + e);
});

Useful for catching exceptions missed in individual threads.


Structured Concurrency (Java 21+)

Java 21 introduced structured concurrency:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<Integer> f1 = scope.fork(() -> computeA());
    Future<Integer> f2 = scope.fork(() -> computeB());
    scope.join();           // Wait for all tasks
    scope.throwIfFailed();  // Propagates first exception
    System.out.println(f1.resultNow() + f2.resultNow());
}
  • Automatically cancels sibling tasks if one fails.
  • Exceptions propagate predictably.

Best Practices

  • Catch exceptions inside threads if recovery is possible.
  • For pooled threads, always handle ExecutionException.
  • Prefer CompletableFuture for async pipelines.
  • Use global uncaught exception handlers for monitoring.
  • In modern Java, adopt structured concurrency.

Anti-Patterns

  • Swallowing exceptions silently.
  • Assuming exceptions propagate automatically across threads.
  • Blocking excessively in Future.get().
  • Ignoring suppressed exceptions.

Real-World Scenarios

File I/O in Multithreaded Apps

Future<String> f = executor.submit(() -> new String(Files.readAllBytes(Path.of("file.txt"))));

JDBC in Thread Pools

Future<ResultSet> rs = executor.submit(() -> stmt.executeQuery("SELECT * FROM users"));

REST APIs with CompletableFuture

CompletableFuture.supplyAsync(() -> restTemplate.getForObject(url, String.class))
    .exceptionally(ex -> "Fallback response");

Multithreading in Spring Boot

@Async
public CompletableFuture<User> fetchUser(int id) {
    try {
        return CompletableFuture.completedFuture(findUser(id));
    } catch (Exception e) {
        return CompletableFuture.failedFuture(e);
    }
}

📌 What's New in Java Exception Handling

  • Java 7+: Multi-catch, try-with-resources.
  • Java 8: CompletableFuture with exception handling.
  • Java 9+: Enhanced stack-walking APIs.
  • Java 14+: Helpful NullPointerException messages.
  • Java 21: Structured concurrency improves async exception handling.

FAQ: Expert-Level Questions

Q1. Why can’t exceptions propagate automatically across threads?
Each thread has its own call stack—exceptions don’t cross stack boundaries.

Q2. How do I catch exceptions in ExecutorService tasks?
Always call Future.get() and check ExecutionException.

Q3. How is CompletableFuture better than plain threads?
It provides async exception handling and chaining methods.

Q4. Can I log exceptions globally?
Yes, via Thread.setDefaultUncaughtExceptionHandler.

Q5. What happens if I don’t handle exceptions in tasks?
Threads die silently, potentially leaving inconsistent state.

Q6. Should I use checked or unchecked exceptions in threads?
Prefer unchecked for async tasks—simplifies signatures.

Q7. What is structured concurrency?
A new Java 21 feature ensuring sibling tasks share lifecycles and propagate exceptions predictably.

Q8. Do suppressed exceptions apply to threads?
Yes, when multiple tasks fail, suppressed exceptions can capture secondary failures.

Q9. Can I retry tasks after exceptions?
Yes, build retry logic into thread pools or CompletableFuture handlers.

Q10. How does exception handling work in reactive frameworks?
Frameworks like Reactor provide error channels (onErrorResume).


Conclusion and Key Takeaways

  • Exceptions in multithreaded code require explicit handling.
  • ExecutorService wraps them in ExecutionException.
  • CompletableFuture offers functional error handling.
  • Use uncaught handlers for global monitoring.
  • Structured concurrency (Java 21) modernizes exception propagation.

By mastering these techniques, you’ll build robust, resilient, and production-ready multithreaded Java applications.