Error handling
Java provides exceptions as the principal error-propagation mechanism, with the distinguishing feature of checked exceptions: methods declare which exceptions they may throw, and callers must either catch them or re-declare. The exception model integrates with AutoCloseable and the try-with-resources statement for deterministic cleanup; the language additionally provides the assert keyword for invariants. Java’s checked-exception mechanism is broadly considered a mixed feature — it forces explicit error handling, which catches more bugs at compile time, but produces verbose declarations and is occasionally circumvented by wrapping in unchecked exceptions. The conventional contemporary position is that checked exceptions are appropriate for recoverable errors and unchecked exceptions for programming bugs and non-recoverable errors.
This page covers the exception model, the checked/unchecked distinction, exception filters, the try-with-resources mechanism, the dispose pattern, and the conventional discipline.
The exception model
An exception is raised by throw, propagates up the call stack, and is caught by the nearest matching catch clause:
public double divide(int numerator, int denominator) {
if (denominator == 0) {
throw new ArithmeticException("denominator must not be zero");
}
return (double) numerator / denominator;
}
try {
double r = divide(a, b);
use(r);
} catch (ArithmeticException e) {
System.err.println("error: " + e.getMessage());
}
The principal operations:
throw expr;— raises an exception of the type ofexpr.try { … } catch (T e) { … }— registers a handler for exceptions of typeT(or types derived fromT).try { … } catch (T1 | T2 e) { … }— multi-catch (since Java 7); handles either type.throw e;(in a catch handler) — re-raisese. Ifewas caught, the exception’s stack trace is preserved.
Each try may have multiple catch clauses, ordered from most specific to least specific:
try {
doWork();
} catch (FileNotFoundException e) {
logMissing(e);
} catch (UnauthorizedAccessException e) {
logPermission(e);
} catch (IOException e) {
logIO(e);
} catch (Exception e) {
logUnexpected(e);
}
The finally clause runs whether the try block completes normally or by exception:
try {
var stream = openStream();
use(stream);
} finally {
stream.close();
}
The conventional contemporary form for cleanup is try-with-resources, treated below.
Multi-catch
Java 7 introduced multi-catch to handle several exception types with one clause:
try {
doWork();
} catch (IOException | InterruptedException e) {
log.warn("operation failed", e);
}
The bound variable e has the static type that is the common supertype of the listed exceptions. Multi-catch reduces duplication when the handling logic is the same for several exception types.
The exception hierarchy
The standard library provides a hierarchy rooted at Throwable:
Throwable
├─ Error (severe, do not catch)
│ ├─ OutOfMemoryError
│ ├─ StackOverflowError
│ ├─ NoClassDefFoundError
│ └─ AssertionError
└─ Exception (catch as appropriate)
├─ RuntimeException (UNCHECKED — need not be declared)
│ ├─ NullPointerException
│ ├─ IllegalArgumentException
│ ├─ IllegalStateException
│ ├─ ClassCastException
│ ├─ ArithmeticException
│ ├─ ArrayIndexOutOfBoundsException
│ ├─ NumberFormatException
│ ├─ IndexOutOfBoundsException
│ ├─ UnsupportedOperationException
│ └─ ConcurrentModificationException
└─ (other Exception subtypes) (CHECKED — must be declared or caught)
├─ IOException
├─ SQLException
├─ InterruptedException
├─ ClassNotFoundException
└─ NoSuchMethodException
The principal subtypes of Exception (other than RuntimeException) are checked exceptions; they must appear in the throws clause of any method that may propagate them, or be caught.
Throwable provides:
getMessage()— a textual description.getCause()— the wrapped cause (for chained exceptions).getStackTrace()— the call stack at the point of throw.printStackTrace()— convenience for diagnostic output.addSuppressed(t)— suppressed exceptions (used bytry-with-resources).getSuppressed()— array of suppressed exceptions.
User-defined exceptions conventionally derive from RuntimeException (for unchecked, modern Java idiom) or Exception (for checked, classical idiom) and follow the four-constructor pattern:
public class ParseException extends RuntimeException {
private final int line;
public ParseException(int line, String message) {
super("line " + line + ": " + message);
this.line = line;
}
public ParseException(int line, String message, Throwable cause) {
super("line " + line + ": " + message, cause);
this.line = line;
}
public int line() { return line; }
}
Checked vs unchecked
The principal Java-specific concept: every exception is either checked or unchecked.
| Category | Subclass of | Method declaration | When to use |
|---|---|---|---|
| Unchecked | RuntimeException (or Error) | Not required | Programming bugs, non-recoverable errors, internal invariants |
| Checked | Exception (not RuntimeException) | Must declare throws | Recoverable failures, environmental errors |
A method that may throw a checked exception declares it:
public String readFirstLine(File file) throws IOException {
try (var reader = Files.newBufferedReader(file.toPath())) {
return reader.readLine();
}
}
The caller must either:
- Catch the checked exception:
try {
String line = readFirstLine(file);
} catch (IOException e) {
log.warn("read failed", e);
}
- Re-declare it:
public void process(File file) throws IOException {
String line = readFirstLine(file); // re-thrown
}
- Declare a broader supertype:
public void process(File file) throws Exception { // catches all checked
String line = readFirstLine(file);
}
The compiler enforces. The mechanism is the principal Java-specific aspect of error handling and is broadly considered a mixed blessing:
- Pros: explicit; catches missing handling at compile time; documents the contract.
- Cons: verbose; can drive developers to wrap in unchecked exceptions; lambdas and streams interact awkwardly with checked exceptions.
The conventional contemporary discipline:
- Use unchecked exceptions for programming bugs (illegal arguments, broken invariants), non-recoverable errors (out of memory), and library-level failures that almost no caller will handle.
- Use checked exceptions for recoverable failures that a typical caller should handle (file not found, network unreachable).
- Avoid catching
ExceptionorThrowableindiscriminately; catch the specific types.
The trend in modern Java code (and in libraries like Spring) is toward unchecked exceptions, with checked exceptions reserved for cases where the recovery is genuinely typical.
Exception filters and chaining
Re-throwing with cause
When wrapping an exception in another, attach the original as the cause:
try {
parseInput(s);
} catch (IOException e) {
throw new ApplicationException("input is invalid", e);
}
The cause is preserved through chained re-throws and appears in the stack trace as caused by:.
addSuppressed
When multiple exceptions occur during cleanup (e.g., the original exception and a secondary exception during close()), Java attaches the secondary as a suppressed exception:
try (var stream = openStream()) {
process(stream); // throws original
} // close() throws secondary
// the secondary is added to original.getSuppressed()
The mechanism is the conventional Java idiom for “don’t lose the secondary exception during cleanup”.
try-with-resources
The try-with-resources statement (since Java 7) guarantees close() is called at the end of the try block:
try (var stream = new FileInputStream(path)) {
// use stream
} // stream.close() called here, even on exception
Equivalent to the explicit:
FileInputStream stream = new FileInputStream(path);
try {
// use stream
} finally {
stream.close();
}
Multiple resources may be acquired together; they are closed in reverse order:
try (var input = new FileInputStream(in_path);
var output = new FileOutputStream(out_path)) {
input.transferTo(output);
}
Java 9 admitted using effectively-final variables as resources:
final var stream = new FileInputStream(path);
try (stream) { // since Java 9
// use stream
}
The form is occasionally cleaner when the resource is constructed elsewhere.
The standard library implements AutoCloseable on most resource-holding types: InputStream, OutputStream, Reader, Writer, Connection (JDBC), Channel (NIO), Lock (concurrent), Stream (streams API), and many others.
AutoCloseable and Closeable
Two interfaces govern the resource model:
AutoCloseable(introduced in Java 7) declaresvoid close() throws Exception— the parent of all resource types.Closeable(older) declaresvoid close() throws IOException— used by I/O types.
public class ResourceHolder implements AutoCloseable {
private final FileChannel channel;
public ResourceHolder(Path path) throws IOException {
this.channel = FileChannel.open(path);
}
@Override
public void close() throws IOException {
channel.close();
}
}
The implementation is the conventional Java pattern for any resource-holding type; the try-with-resources mechanism takes over the close() discipline.
The dispose pattern
For complex types holding multiple resources or with non-trivial cleanup, the conventional pattern:
public class ComplexHolder implements AutoCloseable {
private final Resource1 r1;
private final Resource2 r2;
private boolean closed = false;
public ComplexHolder() throws IOException {
this.r1 = openR1();
try {
this.r2 = openR2();
} catch (Exception e) {
try {
r1.close();
} catch (IOException ignored) { }
throw e;
}
}
@Override
public void close() throws IOException {
if (closed) return;
closed = true;
try {
r2.close();
} finally {
r1.close();
}
}
}
The pattern handles partial-construction failure (if openR2 throws, r1 is closed before re-raising) and idempotent close.
AggregateException equivalents
Java does not have a direct equivalent to C#‘s AggregateException. For multi-failure scenarios — typically in concurrent code — the conventional patterns:
- Use
addSuppressedto attach secondary exceptions to a primary. - For parallel streams, use the
CompletableFuture-based composition (which surfaces exceptions through.exceptionally()and.handle()). - For collected failures across many tasks, manually accumulate and throw or report at the end.
List<Throwable> errors = new ArrayList<>();
for (var task : tasks) {
try {
task.run();
} catch (Throwable t) {
errors.add(t);
}
}
if (!errors.isEmpty()) {
var primary = new RuntimeException("multiple failures");
for (var e : errors) primary.addSuppressed(e);
throw primary;
}
The pattern is verbose; modern code typically structures the work to avoid the multi-failure case.
assert
Java’s assert keyword admits invariant checks:
public void process(int[] data, int n) {
assert data != null : "data must not be null";
assert n >= 0 && n <= data.length : "n out of range";
// ...
}
assert is conditionally compiled — it runs only when the JVM is started with -ea (enable assertions). In default JVM startup, assertions are disabled and the call has zero cost.
The mechanism is the conventional way to express invariants without paying the cost in production. The conventional discipline:
- Use
assertfor internal invariants — conditions the program is responsible for. - Use
if (cond) throw new IllegalArgumentException(...)for external contracts — conditions the caller is responsible for. - Do not use
assertfor environmental checks (file existence, user input validity); those should produce ordinary exceptions.
For unconditional assertions that should always run, the conventional pattern is:
if (!condition) throw new IllegalStateException("invariant violated");
The Apache Commons Validate and Guava’s Preconditions provide convenience helpers.
Error codes versus exceptions
Java admits both exceptions and value-returned error codes. The conventional discipline:
| Use exceptions for | Use return-codes / Optional for |
|---|---|
| Conditions that should never occur in correct usage | Conditions the caller will routinely handle |
| Constructor failures | Search and parse failures |
| Programming errors (null arguments, range violations) | Domain results that may not exist |
| Failures requiring stack unwinding through many layers | Failures handled at the immediate caller |
The standard library provides several Optional-returning methods that pair with the older exception-based forms:
Optional<User> maybeUser = repository.findById(id); // explicit absence
User user = repository.getById(id); // throws if absent
if (maybeUser.isPresent()) { /* ... */ }
Modern code increasingly favours Optional<T> over exceptions for “may not have a value”; the older exception-based pattern remains valid for “should always have a value, exception is a programming error”.
For richer error encoding, third-party libraries (Vavr’s Try<T> and Either<L, R>) provide value-level error types. The standard library does not include these; the conventional Java idiom for value-level errors is custom result types or Optional<T>.
Common defects
| Defect | Description |
|---|---|
Catching Exception indiscriminately | Catches everything, including bugs that should propagate. Use specific types. |
| Empty catch clauses | catch (Exception e) { } swallows errors silently. Either log, re-throw, or do not catch. |
| Catching and re-throwing without preserving the cause | throw new RuntimeException() loses the original; use throw new RuntimeException(cause). |
Forgetting try-with-resources | Streams, sockets, file handles — all leak resources without explicit close. |
Catching InterruptedException and ignoring | The thread’s interrupt status is cleared; subsequent code does not see the interrupt. Re-throw or restore: Thread.currentThread().interrupt(). |
NullPointerException from auto-unboxing | Integer i = null; int n = i; throws NPE. Use Optional<Integer> for explicit absence. |
| Using exceptions for control flow | Exceptions are slow (stack capture); avoid for ordinary flow. Use Optional or value-returning APIs. |
| Not closing streams in pipelines | Files.lines(path) returns a Stream<String> that holds the file open. Always wrap in try-with-resources. |
The combination of try-with-resources, the standard exception types, multi-catch, and the discipline of marking checked vs unchecked thoughtfully is the conventional toolkit for error handling in modern Java. The discipline of writing exception-correct code — using try-with-resources for resources, validating arguments at the boundary, throwing specific exception types, and choosing return-codes versus exceptions appropriately — is one of the principal skills of writing reliable Java.