Robust Oop
Exceptions
Exceptions keep error handling apart from normal logic. Java splits them into checked exceptions, which the compiler forces you to handle or declare (recoverable conditions), and unchecked ones (RuntimeException, for programming bugs). try/catch recovers, several catch clauses or one multi-catch handle different types, throw raises, throws declares, and finally always cleans up. try-with-resources closes resources automatically in reverse order and keeps close failures as suppressed exceptions. Also covers the hierarchy, stack traces, custom exceptions and the return-in-finally trap, all with verified output.
Suggest an editExceptions — Handling What Goes Wrong
You've already watched programs crash: a NullPointerException, an ArithmeticException, a bad parse. Now it's time to take control of them.
- An exception is Java's way of signaling "something went wrong". It transfers control to code that can deal with the problem, instead of returning error codes that callers forget to check.
- The defining design choice is a split. Checked exceptions are recoverable conditions that the compiler forces you to handle or declare. Unchecked ones (
RuntimeException) signal programming bugs, and carry no such requirement. - Around that split, you get
try/catchto recover,throw/throwsto raise and declare,finallyto always clean up, andtry-with-resources to close things automatically.
💡 The core idea.
- An exception signals "something went wrong" and transfers control to code that can handle it.
- The defining split: checked (the compiler forces you to handle or declare them) vs unchecked (
RuntimeException, for bugs). - Around that split sit
try/catch,throw/throws,finally, andtry-with-resources.
Every output below was produced by compiling and running the code on Java 25.
You'll be able to: trace which statements run when a try block throws, and read from a stack trace which method threw; predict whether a call compiles based on whether its exception is checked, and fix unreported exception with catch or throws; write a custom exception, and catch two unrelated exception types in a single multi-catch clause; predict the order of opening, closing and suppressed exceptions in try-with-resources; explain why a return inside finally loses an exception.
📘 How to read the Intuition boxes. Each one is built in three moves:
- The mechanism — what the compiler and the JVM do.
- A concrete bite — a specific, runnable failure (often a real compiler error), shown so the trap is visible.
- The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.
Table of contents
try/catch: recover instead of crash- The hierarchy: checked vs unchecked
- Several
catchclauses, and multi-catch throw,throws, and custom exceptionsfinally: cleanup that always runstry-with-resources- Mental-model summary
- Gotcha checklist
- Check yourself
- Sources
1. try/catch: recover instead of crash
Wrap risky code in a try. If it throws, control jumps to a matching catch, which handles the problem — so the program keeps going instead of dying.
Output:
parsed: 42
bad input: oops
parsed: 7
doneAnalysis. parseInt("oops") threw a NumberFormatException. Back in Input & Output, that crashed the program. This time, the catch handled it: it printed bad input: oops, and the loop carried on to "7".
- The exception transferred control from the failing
parseIntstraight to thecatch. - It skipped the
println("parsed: ...")for that iteration. - One bad input no longer sinks the whole program.
Intuition.
Mechanism. When code in a try throws, the JVM abandons the rest of the try block. It looks for a catch whose type matches the exception (here, NumberFormatException). If one matches, its body runs, and execution continues after the whole try/catch. The thrown object (e) carries the details: a message and a stack trace.
Concrete bite. The win here is targeted recovery. You guard only the operations that can fail, and the handler decides what "recover" means (skip, retry, use a default). Catch too broadly (catch (Exception e)), and you may swallow bugs you need to see. Catch the specific type, and unexpected exceptions still propagate.
💡 Earned rule. Wrap exactly the operation that can fail in a try, and catch the specific exception you can handle.
- The cost is extra structure, plus the temptation to over-catch.
- The payoff: anticipated failures become recoverable events instead of crashes. The
catch's scope even documents which line was expected to fail.
2. The hierarchy: checked vs unchecked
Every exception is a Throwable, and the tree splits into Error and Exception [1]:
- An
Errorindicates "serious problems that a reasonable application should not try to catch" [10], such as running out of memory. - Within
Exception, the subclassRuntimeExceptionand all its descendants are unchecked, just like everyError. - Everything else under
Exceptionis checked.
The difference comes down to one compiler rule: checked exceptions must be caught or declared.
An unchecked exception (a RuntimeException such as ArithmeticException) needs no declaration. The code compiles, and fails only at run time, if and when it throws:
Output (prints 5, then crashes):
5
Exception in thread "main" java.lang.ArithmeticException: / by zeroAnalysis. divide can throw ArithmeticException. Since that's an unchecked exception, it needed no throws and no try. The code compiled without complaint, ran divide(10, 2) just fine (5), and then threw on divide(10, 0). Unchecked exceptions represent bugs (a zero divisor, a null dereference, a bad index) that the compiler doesn't force you to anticipate everywhere.
Intuition.
Mechanism. The compiler keeps track of checked exceptions: any code that can throw one must either catch it or declare it with throws [2]. RuntimeExceptions and Errors are exempt. They could happen almost anywhere, so requiring declarations for them would be unbearable.
Concrete bite. Call something that throws a checked exception without handling it, and the compiler refuses:
Compiler error:
Main.java:8: error: unreported exception IOException; must be caught or declared to be thrown
risky();
^IOException is checked, so calling risky() without a try/catch (or a throws on main) won't compile. Wrapping the call fixes it: try { risky(); } catch (IOException e) { … }.
The rule cuts the other way, too. A catch for a checked exception that its try block can never throw is also an error [3]. For example, catch (IOException e) around a plain println gives exception IOException is never thrown in body of corresponding try statement.
💡 Earned rule. Use checked exceptions for recoverable, expected conditions that a caller should consciously handle (a missing file, a network timeout). Use unchecked ones (RuntimeException) for programming errors (null, a bad argument, a bad index): those are bugs to fix, not cases to handle.
- The cost is the famous friction of checked exceptions: they propagate up through every signature.
- The payoff is that the compiler guarantees anticipated failures aren't silently ignored.
3. Several catch clauses, and multi-catch
A try can have several catch clauses. The JVM tries them in order, from top to bottom, and runs the first one whose type matches. When two exceptions need the same handling, a single multi-catch clause can list both types, separated by | [4]:
Output:
value: 10
skipped 1: NumberFormatException
skipped 2: ArrayIndexOutOfBoundsExceptionAnalysis. Index 1 held "ten", so parseInt threw a NumberFormatException. Index 2 is past the end of the two-element array, so parts[i] threw an ArrayIndexOutOfBoundsException. One clause caught both, and e.getClass() reported which one had arrived.
Intuition.
Mechanism. In a multi-catch, e has the closest common supertype of the listed types, so you can call only the methods they share. e is also implicitly final, which means the handler can't assign to it [4].
Concrete bite. The alternatives may not be related by subclassing [4]. NumberFormatException extends IllegalArgumentException, so listing both is redundant — and javac tells you so:
Compiler error:
Main.java:5: error: Alternatives in a multi-catch statement cannot be related by subclassing
} catch (NumberFormatException | IllegalArgumentException e) {
^
Alternative NumberFormatException is a subclass of alternative IllegalArgumentException
1 errorKeep only IllegalArgumentException, since it already catches its subclass.
Non-example: a general catch before a specific one. Clauses are tried in order. Put catch (Exception e) first, and it catches everything, so a later catch (NumberFormatException e) could never run. That's a compile-time error [3]:
Compiler error:
Main.java:7: error: exception NumberFormatException has already been caught
} catch (NumberFormatException e) {
^
1 errorThe fix: put the specific clause first, and the general one last.
💡 Earned rule. Order catch clauses from specific to general. When two unrelated exceptions need the same handling, write one multi-catch clause instead of two identical bodies.
- The cost: inside a multi-catch,
eoffers only what the types share, and you can't reassign it. - The payoff: one handler, no duplicated code, and a compiler that rejects clauses that could never run.
4. throw, throws, and custom exceptions
Three tools let you raise failures and give them names:
throwraises an exception [8].throwsdeclares that a method may raise a checked exception [9].- Your own exception class extends either
Exception(checked) orRuntimeException(unchecked).
A custom type gives the failure a name, and carries its data along.
Output:
balance: 50
denied: need 100, have 50Analysis.
withdraw(50)succeeded (leaving a balance of50).withdraw(100)saw thatamount > balanceand threw anInsufficientFundsException. The exception unwound out ofwithdrawto thecatch, which printed the message it carried.InsufficientFundsException extends Exception, so it's checked. That's whywithdrawdeclaresthrows InsufficientFundsException, and why the caller must handle it.
The custom type turns the failure into a named, catchable thing with its own data.
Intuition.
Mechanism. throw stops the current method and propagates the exception up the call stack. Each caller in turn is abandoned, until a matching catch turns up. If none does, the thread ends, and by default a stack trace is printed: first the exception, then one at line per method it passed through, innermost first. throws declares that propagation in the method's signature.
In this example, no method catches the exception, so the trace shows the whole path:
Output (prints 1030, then a thrown exception):
1030
Exception in thread "main" java.lang.IllegalArgumentException: negative age: -5
at Main.checkAge(Main.java:4)
at Main.register(Main.java:9)
at Main.main(Main.java:13)Read it from the top down. The first at line is where the exception was thrown: checkAge, line 4. Each line below it is a caller that was waiting: register at line 9, then main at line 13. When you're debugging, the first line under the message is the one to open first.
Concrete bite. A custom exception beats a generic one (throw new Exception("...")). Callers can catch (InsufficientFundsException e) specifically, and tell it apart from other failures. The type itself documents the condition, and the message (super(msg)) and any extra fields travel with it to the handler.
💡 Earned rule. throw a specific exception type (a custom one when no standard type fits), and declare checked ones with throws. Extend Exception for conditions callers should handle, and RuntimeException for misuse and bugs.
- The cost is a class per condition, plus the
throwsplumbing. - The payoff: failures that are named, catchable by type, and self-documenting — far better than error codes or a bare boolean return.
5. finally: cleanup that always runs
A finally block attached to a try runs no matter how the try exits — normal completion, a caught exception, or even a return. That makes it the place for cleanup that must always happen.
Output:
finally ran
from tryAnalysis. Look closely at the order. process() hit return "from try", but the finally ran before the method actually returned. So finally ran printed first, followed by the returned value.
finally intercepts every exit path, including a return in the middle of the try. That's what makes it reliable for cleanup.
Intuition.
Mechanism. Whatever ends the try — completion, an exception, or a return — the finally block runs on the way out [5]. For a return, the value is computed first, then finally runs, and then control leaves. The only ways to skip it are for the try to never end, or for the JVM to stop first — and System.exit never returns [11].
Output:
in tryThe JVM stopped inside the try, so there's no finally ran.
Concrete bite. The surprise is that finally ran comes before from try, because people expect a return to leave immediately. It doesn't. finally always gets its turn first — and that's the whole point. You can release the lock or close the file, even if you bailed out early.
Non-example: a return inside finally. If finally itself ends abruptly, through a return or a throw, that outcome replaces the try's, and the original exception is discarded [5]:
Output:
from finallyThe IllegalStateException simply vanished: no crash, no trace, just an ordinary return value. javac compiles this without a word. Only if you pass the -Xlint:finally option does it warn you: finally clause cannot complete normally.
💡 Earned rule. Put must-always-run cleanup in finally, and never return (or throw) from inside it.
- The cost:
finallyruns on every path, so a misused one can mask thetry's outcome. - The payoff: cleanup that's guaranteed no matter how the block exits. For resources, the next section's tool is even cleaner.
6. try-with-resources
Closing resources by hand in finally is verbose and easy to get wrong. With try-with-resources, you declare the resource in parentheses right after try. Any object that implements AutoCloseable is then closed automatically when the block exits, in the reverse order of opening [6].
Output:
open file
use file
close file
afterAnalysis. The resource opened and got used. Then close() ran automatically as the try block exited — before after printed, and with no explicit finally. Had use() thrown, close() would still have run. Think of try-with-resources as finally-based cleanup, specialized for anything AutoCloseable (files, streams, connections, locks), and generated correctly for you.
Intuition.
Mechanism. The compiler expands try (R r = ...) into a try/finally that calls r.close() on exit [7]. It also handles the edge cases: it closes the resource even if the body throws. And if the body threw and close() throws too, the close() exception is suppressed: it gets attached to the body's exception, which is the one you catch [6]:
Output:
open file
close file
caught: body failed
suppressed: close of file failedAnalysis. The resource was closed before the catch ran. The body's body failed is the exception that arrived, so the real cause isn't lost. The close() failure rode along with it, and getSuppressed() [12] returned it.
Concrete bite. The alternative — a manual finally { r.close(); } — is where leaks hide. Forget it, get the ordering wrong, or let close() itself throw and mask the real error, and a file handle or connection leaks. try-with-resources removes that whole class of bug by construction. The close isn't optional, and it isn't yours to forget.
💡 Earned rule. Use try-with-resources for anything you must close — files, streams, sockets, connections, locks. Save a bare finally for cleanup that isn't an AutoCloseable.
- The cost: your resource type must implement
AutoCloseable. - The payoff: guaranteed, correctly ordered, exception-safe cleanup, with none of the manual
finallyboilerplate. I/O, Files & NIO.2 uses it for real file I/O.
7. Mental-model summary
| Principle | Consequence |
|---|---|
try/catch transfers control to a handler on a throw |
An anticipated failure becomes recoverable, not a crash |
| Checked exceptions must be caught or declared; unchecked need not | A checked call without handling won't compile; a RuntimeException only fails at run time |
catch clauses are tried in order; multi-catch joins unrelated types with | |
Specific before general; related alternatives or an unreachable clause won't compile |
throw raises, throws declares, custom types name the failure |
Callers can catch a specific type and read its data |
| An uncaught exception unwinds every caller | The stack trace lists the thrower first, then each waiting caller |
finally runs on every exit path, even a return |
Reliable cleanup — but a return/throw inside it discards the try's exception |
try-with-resources auto-closes any AutoCloseable, last opened first |
Guaranteed, ordered close; a failing close() is kept as a suppressed exception |
8. Gotcha checklist
| Symptom | Likely cause | Fix |
|---|---|---|
unreported exception IOException; must be caught or declared to be thrown |
a checked exception isn't handled | wrap it in try/catch, or add throws to the method |
exception IOException is never thrown in body of corresponding try statement |
a catch for a checked exception the try cannot throw |
remove the catch, or catch what the code does throw |
exception NumberFormatException has already been caught |
a general catch sits above a specific one |
put the specific clause first |
Alternatives in a multi-catch statement cannot be related by subclassing |
one listed type extends another | keep only the supertype |
An exception crashes despite a catch |
the catch type doesn't match the thrown one |
catch the actual type (the trace's first line names it) |
Exception in thread "main" and a list of at lines |
an exception no method caught | the first at line is where it was thrown; the first line naming your own code is where to look |
Cleanup didn't run on an early return |
it was not in finally |
put it in finally, or use try-with-resources |
| An exception "disappeared" | a return or throw inside finally replaced it |
never exit from finally; compile with -Xlint:finally to be warned |
finally didn't run |
System.exit stopped the JVM inside the try |
don't call System.exit where cleanup is pending |
A close() failure seems lost |
it was suppressed behind the body's exception | read e.getSuppressed() |
| A file/stream/connection leaked | it was closed by hand, or not at all | use try-with-resources so close() is guaranteed |
✅ Check yourself
One check per objective. Answer before you open anything.
The 🧪 box below: the {"1", "x", "3"} total, read() without a try, and two resources.
Output:
open A
open B
use both
close B
close A
skipped x
total: 4Bcloses beforeA, because resources close in the reverse order of opening. A resource opened later may depend on an earlier one (think of a reader built on a file), so it gets closed while the earlier one still works."x"was skipped, so the total is1 + 3 = 4.- Calling
void read() throws IOExceptionfrommainwithout atrydoesn't compile: you getunreported exception IOException; must be caught or declared to be thrown. Addthrows IOExceptiontomain, or wrap the call.
📚 Sources
- The Java Language Specification, Java SE 25, §11.1.1 "The Kinds of Exceptions" (the checked and unchecked exception classes) — https://docs.oracle.com/javase/specs/jls/se25/html/jls-11.html#jls-11.1.1
- The Java Language Specification, Java SE 25, §11.2 "Compile-Time Checking of Exceptions" — https://docs.oracle.com/javase/specs/jls/se25/html/jls-11.html#jls-11.2
- The Java Language Specification, Java SE 25, §11.2.3 "Exception Checking" (a
catchthetrycannot reach; acatcha preceding clause already covers) — https://docs.oracle.com/javase/specs/jls/se25/html/jls-11.html#jls-11.2.3 - The Java Language Specification, Java SE 25, §14.20 "The
trystatement" (multi-catch; alternatives may not be subtypes; the parameter is implicitlyfinal) — https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.20 - The Java Language Specification, Java SE 25, §14.20.2 "Execution of
try-finallyandtry-catch-finally" (an abruptfinallydiscards the earlier reason) — https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.20.2 - The Java Language Specification, Java SE 25, §14.20.3 "
try-with-resources" ("closed in the reverse order"; a later exception "is suppressed") — https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.20.3 - The Java Language Specification, Java SE 25, §14.20.3.1 "Basic
try-with-resources" (the translation totry/finally) — https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.20.3.1 - The Java Language Specification, Java SE 25, §14.18 "The
throwStatement" — https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.18 - The Java Language Specification, Java SE 25, §8.4.6 "Method Throws" — https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html#jls-8.4.6
java.lang.Error, Java SE 25 API ("serious problems that a reasonable application should not try to catch") — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Error.htmljava.lang.Runtime, Java SE 25 API (exit: "An invocation of this method never returns normally") — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Runtime.htmljava.lang.Throwable, Java SE 25 API (getSuppressed: exceptions "suppressed, typically by the try-with-resources statement") — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Throwable.html
🧪 Predict, then check.
- Predict the output of a loop that
parseInts{"1", "x", "3"}inside atry/catch, sums the valid values, and prints the total. - Predict whether you can call a method
void read() throws IOExceptionfrommainwithout atry, and what error you get if you can't. - Predict the exact order of lines printed by a
try-with-resources that opens two resources,AthenB(each printing on open and on close). Explain whyBcloses beforeA.
Your Turn
Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.