Java Exception Handling: Try-Catch, Custom Exceptions, Best Practices
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:
- Include getters for relevant data fields — callers need them for recovery and logging.
- Provide a meaningful detail message.
- 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. - Keep the exception class hierarchy shallow (no more than one level deep).
- 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
- Swallowing exceptions: Empty catch blocks are the worst sin. Always log or rethrow.
- Catching generic
ExceptionorThrowable: This catchesRuntimeExceptionandErrorsubtypes you cannot handle. Catch the most specific type. - Using exceptions for control flow: Do not throw exceptions for expected conditions like end-of-file. Use return values or
Optional. - Throwing
Throwablein signatures: Never declarethrows Throwable— it forces callers to catch everything. - Overly broad exception types: Define specific exception types. A
DataAccessExceptionhierarchy withConnectionFailureExceptionandQueryExecutionExceptionis better than a genericException. - Resource leaks in pre-Java 7 code: Always use try-with-resources in Java 7+. Legacy
finallycleanup 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
JVMcaching for immutable exceptions (not recommended — stack traces lose location data). - Use
warn()ordebug()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:
- Catch the most specific exception type at the narrowest scope possible.
- Use try-with-resources for all
AutoCloseableresources — never close manually. - Define custom exception classes for each domain concept (one per module minimum).
- Include the causing exception in the constructor chain (
super(message, cause)). - Never log and rethrow in the same catch block. Log at the boundary, throw at the source.
- Avoid
throws Exceptionin method signatures — declare specific checked exceptions. - Use
Optionalfor return types that may be empty (Java 8+) instead of returningnull. - Validate arguments early with
Objects.requireNonNull()and Guava’sPreconditions. - In REST APIs, map exceptions to HTTP status codes consistently using
@ControllerAdviceorExceptionMapper. - Document thrown exceptions in Javadoc with
@throwstags.
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.