Why this matters
finallycan silently discard an exception or override a return value, and both produce bugs that are nearly impossible to spot by reading.- try-with-resources removed an entire category of resource leak and exception-masking bugs in one feature, and the manual equivalent is genuinely hard to write correctly.
- The order of
catchblocks is checked by the compiler in a way that trips people up exactly once.
The basic flow
try {
int result = Integer.parseInt(input);
System.out.println(100 / result);
} catch (NumberFormatException e) {
System.out.println("Not a number: " + input);
} catch (ArithmeticException e) {
System.out.println("Cannot divide by zero");
} finally {
System.out.println("Always runs");
}
- Execution leaves the
tryblock at the point of failure. The remaining statements in it are skipped. - The first matching
catchruns, and only that one. There is no fall-through. finallyruns after thetryand anycatch, whether or not an exception occurred, and even onreturn.- If no
catchmatches,finallystill runs and the exception then propagates to the caller.
Catch order is enforced
A catch matches the exception type or any subtype, so the most specific must come first.
// Correct: specific before general
try {
Files.readString(path);
} catch (NoSuchFileException e) { // a subtype of IOException
System.out.println("File missing");
} catch (IOException e) {
System.out.println("Read failed");
}
// Will not compile: IOException already covers NoSuchFileException
try {
Files.readString(path);
} catch (IOException e) {
} catch (NoSuchFileException e) { // error: unreachable catch block
}
The compiler rejects the second form outright, which is a rare and welcome case of a mistake being impossible to ship.
Multi-catch
When several exception types need identical handling, combine them with |.
try {
process(input);
} catch (NumberFormatException | DateTimeParseException e) {
log.warn("Malformed input: {}", e.getMessage());
throw new ValidationException("Input could not be parsed", e);
}
The parameter is implicitly final, and its static type is the nearest common supertype — so inside
the block you can only call methods available on both.
finally, and its three surprises
It runs even after return
static int value() {
try {
return 1;
} finally {
System.out.println("finally ran"); // prints before the method returns 1
}
}
The return value is computed and held, then finally runs, then the method actually returns.
A return in finally overrides everything
static int broken() {
try {
return 1;
} finally {
return 2; // the 1 is discarded
}
}
// broken() returns 2
A return in finally swallows exceptions
static int worse() {
try {
throw new IllegalStateException("something important");
} finally {
return -1; // the exception vanishes entirely
}
}
// worse() returns -1, and nobody ever learns about the exception
Mutating a local variable in finally does not change an already-returned primitive, because the
value was copied before finally ran:
static int surprising() {
int result = 1;
try {
return result; // the value 1 is captured here
} finally {
result = 99; // too late
}
}
// returns 1
When finally does not run
Only in cases where the JVM itself stops: System.exit() is called, the JVM crashes, or the thread is
killed at the operating-system level. In ordinary code it always runs.
try-with-resources
Any AutoCloseable declared in the parentheses is closed automatically, correctly, in reverse order.
try (BufferedReader reader = Files.newBufferedReader(input, UTF_8);
BufferedWriter writer = Files.newBufferedWriter(output, UTF_8)) {
reader.lines().forEach(line -> write(writer, line));
} catch (IOException e) {
throw new UncheckedIOException("Copy failed", e);
}
| Aspect | Manual finally | try-with-resources |
|---|---|---|
| Lines needed | Nested try blocks and null checks | One declaration |
| Close order | Your responsibility | Reverse of declaration, automatically |
| If close() throws | It replaces the real exception | Recorded as suppressed; the real one wins |
| If you forget | A leaked file handle or connection | Not possible |
| Effectively final resources | n/a | Can be referenced directly since Java 9 |
Lines needed
Manual finallyNested try blocks and null checkstry-with-resourcesOne declarationClose order
Manual finallyYour responsibilitytry-with-resourcesReverse of declaration, automaticallyIf close() throws
Manual finallyIt replaces the real exceptiontry-with-resourcesRecorded as suppressed; the real one winsIf you forget
Manual finallyA leaked file handle or connectiontry-with-resourcesNot possibleEffectively final resources
Manual finallyn/atry-with-resourcesCan be referenced directly since Java 9
The suppressed-exception behaviour is the part the manual version gets wrong almost every time.
The masking problem it solves is worth seeing:
// The bug this feature eliminates
BufferedReader reader = null;
try {
reader = Files.newBufferedReader(path, UTF_8);
return reader.readLine(); // suppose this throws IOException
} finally {
if (reader != null) reader.close(); // if this also throws, it replaces the first
}
The caller receives the close failure and never learns about the read failure. With try-with-resources the read failure propagates and the close failure is attached:
catch (IOException e) {
log.error("Primary failure", e);
for (Throwable suppressed : e.getSuppressed()) {
log.error("Also failed while closing", suppressed);
}
}
Propagation
An uncaught exception unwinds the stack, frame by frame, until a matching catch is found. If none
exists it reaches the thread's uncaught handler, which prints the trace and terminates that thread.
void controller() {
try {
service(); // handles it here
} catch (OrderException e) {
respondWithError(e);
}
}
void service() throws OrderException { // declares, does not handle
repository();
}
void repository() throws OrderException {
throw new OrderException("Not found");
}
Each layer makes a deliberate choice: handle, or declare and let the caller decide. The layers in between stay clean, which is the real argument in favour of exceptions over returned error codes.
Common misreadings
- "
finallydoes not run if thetryreturns." It runs after the return value is computed, before control leaves. - "Changing a local in
finallychanges the returned value." For primitives the value was already copied. For a mutable object you can still change its contents. - "You can order
catchblocks freely." A supertype before a subtype is a compile error. - "try-with-resources needs an explicit
finally." It generates one. Adding your own is only for extra cleanup unrelated to the resources. - "Closing the inner stream of a wrapped chain is necessary." Closing the outermost closes everything beneath it.
- "A
catchblock must do something." It must, in practice. An emptycatchis the single worst construct in Java — at minimum log it.
Quick recall
Everything you need if you only revisit this box.
- Only the first matching
catchruns, and specific types must precede general ones or the code will not compile. - Multi-catch with
|shares one handler; the parameter is implicitlyfinal. finallyruns even afterreturn, and onlySystem.exit()or a JVM crash prevents it.- Never
returnorthrowfromfinally— it silently discards the real result or exception. - A primitive return value is captured before
finallyruns, so reassigning the local changes nothing. - try-with-resources closes in reverse order and records a failing
close()as a suppressed exception, so the original failure survives. - Handle an exception where you have the context to act; declare it everywhere else.
Test yourself
Answer these before moving on — recall is what makes it stick.