Skip to content
Home
Java Exception Handling: Try-Catch, Custom Exceptions, Best Practices

Java Exception Handling: Try-Catch, Custom Exceptions, Best Practices

Java Java 7 min read 1468 words Beginner ExcellentWiki Editorial Team

Exception handling is one of the most misunderstood aspects of Java programming. Done well, it produces robust systems that fail gracefully and are debuggable. Done poorly, it obscures bugs, leaks resources, and frustrates developers. This guide covers checked vs unchecked exceptions, try-catch-finally semantics, try-with-resources, custom exception design, logging strategies, and common anti-patterns — all grounded in the Java Language Specification (JLS) and Effective Java.

The Java Exception Hierarchy

All exception types in Java inherit from Throwable (JLS §11.1). The hierarchy has three branches:

Throwable
├── Error (JVM failures — OutOfMemoryError, StackOverflowError)
└── Exception
    ├── RuntimeException (unchecked — NullPointerException, IllegalArgumentException)
    └── checked exceptions (IOException, SQLException)

Error subclasses represent serious JVM-level failures that applications should not try to handle. RuntimeException and its subclasses are unchecked — the compiler does not require them to be declared or caught. All other Exception subclasses are checked — the compiler enforces either a catch block or a throws declaration.

Joshua Bloch in Effective Java (Item 70) advises: use checked exceptions for recoverable conditions and runtime exceptions for programming errors. Checked exceptions force callers to handle the problem, which is appropriate when the caller can reasonably recover (e.g., file not found, network timeout).

Try-Catch-Finally

The most basic structure is try-catch-finally (JLS §14.20):

try {
    FileReader reader = new FileReader("data.txt");
    int data = reader.read();
    // process data
--- catch (FileNotFoundException e) {
    logger.error("Configuration file missing", e);
    throw new ConfigException("Cannot start without config", e);
--- catch (IOException e) {
    logger.error("Error reading configuration", e);
--- finally {
    // always executes — use for cleanup
    if (reader != null) reader.close();
---

Multi-catch (Java 7+) reduces duplication:

try {
    // code
--- catch (IOException | SQLException e) {
    logger.error("Data access error", e);
    throw new DataAccessException("Operation failed", e);
---

The finally block always executes (unless the JVM crashes). Use it for resource cleanup — though try-with-resources is now preferred.

Try-With-Resources

Introduced in Java 7 (JEP 183), try-with-resources automatically closes resources implementing AutoCloseable. This eliminates the error-prone finally cleanup pattern:

try (BufferedReader br = new BufferedReader(new FileReader("input.txt"));
     FileWriter fw = new FileWriter("output.txt")) {
    String line;
    while ((line = br.readLine()) != null) {
        fw.write(line + "\n");
    }
--- catch (IOException e) {
    logger.error("File copy failed", e);
---

The compiler generates close() calls in the correct order. If both the try body and the implicit close() throw exceptions, the body exception is primary and close() exceptions are suppressed. Retrieve them with e.getSuppressed(). This is defined in the JLS §14.20.3.2 and documented in the Oracle Java Tutorials.

Custom Exceptions

Well-designed custom exceptions convey domain-specific error information. Follow Bloch’s guidance (Items 72, 73):

public class InsufficientFundsException extends RuntimeException {
    private final AccountId accountId;
    private final BigDecimal balance;
    private final BigDecimal requested;

    public InsufficientFundsException(AccountId accountId, BigDecimal balance, BigDecimal requested) {
        super(String.format("Account %s: balance %.2f, requested %.2f", accountId, balance, requested));
        this.accountId = accountId;
        this.balance = balance;
        this.requested = requested;
    }

    public AccountId getAccountId() { return accountId; }
    public BigDecimal getBalance() { return balance; }
    public BigDecimal getRequested() { return requested; }
---

Guidelines for custom exception design:

  1. Include getters for relevant data fields — callers need them for recovery and logging.
  2. Provide a meaningful detail message.
  3. Consider whether the exception should be checked or unchecked. Error-prone API calls that callers can recover from should throw checked exceptions. Programming mistakes should throw RuntimeException.
  4. Keep the exception class hierarchy shallow (no more than one level deep).
  5. Preserve the original cause by passing it to the superclass constructor.

The Oracle Java documentation emphasizes exception chaining. Always pass the root cause Throwable so stack traces provide a complete picture.

Logging Exceptions

Never log and rethrow. This creates duplicate log entries and loses context. Either log or throw, not both. Use structured logging (SLF4J + Logback):

try {
    processOrder(orderId);
--- catch (OrderNotFoundException e) {
    // Log once, at the boundary
    log.error("Order {} not found in warehouse {}", orderId, warehouseId, e);
    throw e;
---

Do not log e.getMessage() without the full stack trace — messages alone are insufficient for debugging. SLF4J’s parameterized logging avoids string concatenation overhead.

Common Anti-Patterns

  1. Swallowing exceptions: Empty catch blocks are the worst sin. Always log or rethrow.
  2. Catching generic Exception or Throwable: This catches RuntimeException and Error subtypes you cannot handle. Catch the most specific type.
  3. Using exceptions for control flow: Do not throw exceptions for expected conditions like end-of-file. Use return values or Optional.
  4. Throwing Throwable in signatures: Never declare throws Throwable — it forces callers to catch everything.
  5. Overly broad exception types: Define specific exception types. A DataAccessException hierarchy with ConnectionFailureException and QueryExecutionException is better than a generic Exception.
  6. Resource leaks in pre-Java 7 code: Always use try-with-resources in Java 7+. Legacy finally cleanup is error-prone.

Performance Considerations

Exception creation is expensive because the JVM captures the stack trace (filling in the StackTraceElement[]). For high-throughput scenarios:

  • Reuse exception instances with JVM caching for immutable exceptions (not recommended — stack traces lose location data).
  • Use warn() or debug() logging levels instead of creating exceptions for expected conditions.
  • Profile with Async Profiler or JMC to measure exception overhead.

The JVM optimizes the common case (no exception thrown). The cost is incurred only when exceptions are actually created — not by try blocks themselves.

Exception Handling in Virtual Threads

Java 21’s virtual threads introduce new considerations for exception handling. When a virtual thread encounters an uncaught exception, the default behavior prints the stack trace and terminates that virtual thread without affecting the carrier thread or other virtual threads.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1000; i++) {
        int taskId = i;
        executor.submit(() -> {
            if (taskId == 42) {
                throw new RuntimeException("Bad task: " + taskId);
            }
            process(taskId);
        });
    }
---

In this scenario, task 42 fails silently (the exception is logged but the executor continues). Wrap the task body in a try-catch to handle failures:

executor.submit(() -> {
    try {
        process(taskId);
    } catch (Exception e) {
        log.error("Task {} failed", taskId, e);
        // Record failure for monitoring
        failureCounter.incrementAndGet();
    }
---);

Best Practice Checklist

Apply these rules to every Java project:

  1. Catch the most specific exception type at the narrowest scope possible.
  2. Use try-with-resources for all AutoCloseable resources — never close manually.
  3. Define custom exception classes for each domain concept (one per module minimum).
  4. Include the causing exception in the constructor chain (super(message, cause)).
  5. Never log and rethrow in the same catch block. Log at the boundary, throw at the source.
  6. Avoid throws Exception in method signatures — declare specific checked exceptions.
  7. Use Optional for return types that may be empty (Java 8+) instead of returning null.
  8. Validate arguments early with Objects.requireNonNull() and Guava’s Preconditions.
  9. In REST APIs, map exceptions to HTTP status codes consistently using @ControllerAdvice or ExceptionMapper.
  10. Document thrown exceptions in Javadoc with @throws tags.

FAQ

Q: Should I use checked or unchecked exceptions for my API? A: Use checked exceptions when the caller can take a different action (file not found — use a default). Use unchecked exceptions for programming errors (null argument).

Q: How do I preserve the original exception in a custom exception? A: Pass the Throwable cause parameter to the superclass constructor. Custom exceptions should always include a constructor that accepts the cause.

Q: What is a suppressed exception in try-with-resources? A: If both the try block and the implicit close() throw exceptions, close() exceptions are suppressed and can be retrieved via getSuppressed().

Q: Is catching Exception ever acceptable? A: At thread boundaries (Runnable run, Thread.start), catching Exception to log and continue is acceptable. In business logic, always catch specific types.

Q: How does Java 21’s exception handling differ from Java 8? A: The core mechanism is unchanged, but Java 21 previews unnamed patterns (case _) in catch blocks and Throwable improvements in virtual threads.

Java 21 preview features include unnamed patterns in catch blocks (case _), which simplify exception handling when the exception type is irrelevant — useful for cleanup or logging-only catch clauses. This syntactic improvement complements the behavioral changes from virtual threads, where uncaught exceptions in virtual thread tasks are isolated to individual tasks rather than crashing the entire executor.

The Objects.requireNonNull() method provides a concise null check with a customizable error message: Objects.requireNonNull(param, "param must not be null"). This throws NullPointerException at the earliest point of failure rather than propagating a null reference to an unrelated location. Use it on all public API entry points for defensive parameter validation.

Resource cleanup with try-with-resources extends beyond I/O to any AutoCloseable implementation, including JDBC connections, JMS sessions, and custom thread pool wrappers. The compiler generates suppressed exception handling automatically, so the cleanup order is always correct and exceptions never mask each other.

For further reading, consult the Effective Java exception items (70–75) and the Oracle Java Tutorials on Exceptions. Pair this knowledge with our Java Streams API guide for exception handling in functional pipelines.

For a comprehensive overview, read our article on Java 17 21 Features.

Section: Java 1468 words 7 min read Beginner 756 articles in section Report inaccuracy Back to top