Java Exceptions — Complete Notes
Checked vs unchecked, the hierarchy, try-with-resources, custom exceptions, and the judgment call that actually matters in real code — when to wrap, when to propagate, and when to shut up and let it fly.
00. What an exception actually is
An exception is Java's way of saying "I cannot finish this method, and I refuse to pretend I did." It is a second return channel — one that carries a failure instead of a value, and that you cannot silently ignore.
Before exceptions, languages signalled failure with magic return values: return
-1, or null, or set a global error flag. The problem is that nothing
forces the caller to look:
int result = divide(10, 0); // returns -1 to mean "error"
System.out.println(result); // prints -1 and carries on happily
// The bug travels silently through your whole program.
An exception flips this around. The failing method stops, and the error travels up the call stack on its own until someone handles it. If nobody does, the program dies loudly with a stack trace — which is far better than quietly computing the wrong answer.
int divide(int a, int b) {
if (b == 0) throw new ArithmeticException("cannot divide by zero");
return a / b;
}
int result = divide(10, 0); // this line never completes
System.out.println(result); // never runs — control has already left
An exception separates the happy path from the error path. Your main logic reads top-to-bottom as if nothing can fail, and the failure handling lives in one place off to the side. Compare that to checking a return code after literally every single call.
What happens when you throw — stack unwinding
When you throw, the JVM abandons the current method and looks for a matching
catch in the caller. If there isn't one, it abandons that method too, and keeps going
up the call stack. This walk is called stack unwinding. If
it reaches the top of the thread with no handler, the thread dies and the trace is printed.
void main() { a(); } // 3. no catch here either -> thread dies, trace printed
void a() { b(); } // 2. no catch here -> keep climbing
void b() { throw new IllegalStateException("boom"); } // 1. thrown here
// Output:
// Exception in thread "main" java.lang.IllegalStateException: boom
// at Demo.b(Demo.java:8)
// at Demo.a(Demo.java:6)
// at Demo.main(Demo.java:4)
Throwing is pulling the emergency cord on a train. The current carriage stops
immediately and the alarm passes down the train, carriage by carriage, until someone with a key
(a catch) responds. If nobody on the whole train has a key, the train stops dead
and a report is printed of exactly which carriage pulled the cord and who was standing behind
it.
01. The hierarchy — everything is a Throwable
Every single thing you can throw or catch in Java descends from one
class: Throwable. Learn this tree and 80% of exception questions answer themselves.
Object
|
Throwable <- the root of everything throwable
/ \
Error Exception
| / \
| | RuntimeException
| | |
| | - NullPointerException
| | - IllegalArgumentException
| | - IllegalStateException
| | - IndexOutOfBoundsException
| | - ArithmeticException
| | - ClassCastException
| |
| (checked exceptions)
| - IOException
| - FileNotFoundException
| - SQLException
| - InterruptedException
|
(unrecoverable JVM problems)
- OutOfMemoryError
- StackOverflowError
- NoClassDefFoundError
The tree splits into three practical groups:
| Group | Who caused it | Checked? | Should you catch it? |
|---|---|---|---|
Error |
The JVM itself is in trouble | No | No — you cannot meaningfully fix it |
RuntimeException |
A bug in your code | No | Usually no — fix the bug instead |
Other Exception |
The outside world misbehaved | Yes | Yes — this is expected and recoverable |
An exception is checked if it extends Exception but
not RuntimeException. That's the whole rule.
Error and RuntimeException and all their subclasses are
unchecked. Nothing else about the class matters — not its name, not its
package.
OutOfMemoryError means the heap is exhausted; StackOverflowError means
the stack is. Catching them and "carrying on" leaves the JVM in a state where nothing is
trustworthy — your catch block itself may need memory it can't get. Let the process die and
restart it.
02. Checked vs unchecked — the real distinction
Checked exceptions are enforced by the compiler: you must either
catch them or declare them with throws. Unchecked
exceptions are not — you can ignore them entirely and the code still compiles.
// IOException is checked, so ONE of these two is mandatory:
// Option A — handle it here
void readA() {
try {
Files.readString(Path.of("a.txt"));
} catch (IOException e) {
System.out.println("could not read: " + e.getMessage());
}
}
// Option B — declare it and make it the caller's problem
void readB() throws IOException {
Files.readString(Path.of("a.txt"));
}
// Option C — do neither
void readC() {
Files.readString(Path.of("a.txt")); // COMPILE ERROR:
} // unreported exception IOException; must be caught or declared
void parse(String s) {
int n = Integer.parseInt(s); // throws NumberFormatException (unchecked)
} // Compiles fine. No try, no throws. Blows up at runtime if s is "abc".
Why the distinction exists
The intended meaning is a statement about whose fault it is and whether the caller can do anything:
| Checked | Unchecked | |
|---|---|---|
| Means | "Something outside your control failed" | "You have a bug" |
| Example | The network dropped, the file is gone | You passed null, index was -1 |
| Caller can react? | Yes — retry, use a default, tell the user | No — there's nothing sensible to do |
| Right fix | Handle it | Change the code |
Checked is the restaurant telling you "we're out of salmon" — annoying, but expected, and you can just order something else. Unchecked is the waiter tripping and dropping the plate — that isn't a menu decision you respond to, it's a mistake that should be fixed in the kitchen.
The honest modern take
Checked exceptions are the most argued-about feature in Java, and it is worth knowing why so you can answer with nuance rather than dogma. In practice they have two well-known failure modes:
try {
riskyThing();
} catch (IOException e) {
// nothing here. "I'll deal with it later."
}
// The failure is now invisible. No log, no trace, no clue.
// Debugging this at 3am is genuinely miserable.
String load() throws IOException, SQLException, ParseException { ... }
String service() throws IOException, SQLException, ParseException { return load(); }
String handler() throws IOException, SQLException, ParseException { return service(); }
// Every layer is forced to name low-level failures it has no interest in.
Most modern Java (and every language designed after Java — C#, Kotlin, Go, Scala) leans unchecked. The common convention: use unchecked for programming errors and for failures the caller genuinely can't fix, and reserve checked for the rare case where a caller has a realistic, specific recovery. When crossing a layer boundary, wrap the checked exception in a meaningful unchecked one (see section 07).
03. try / catch / finally — the mechanics
try marks code that might fail, catch handles a specific failure, and
finally runs cleanup no matter what happens.
try {
// code that might throw
} catch (FileNotFoundException e) {
// runs ONLY for this type (or a subclass of it)
} catch (IOException e) {
// runs for other IOExceptions
} finally {
// ALWAYS runs — exception or not, return or not
}
Catch order matters — most specific first
Java picks the first catch whose type matches. So a broad catch placed above a narrow one makes the narrow one unreachable — and that is a compile error, not a warning.
try {
read();
} catch (IOException e) { // broad
} catch (FileNotFoundException e) { // COMPILE ERROR:
} // exception FileNotFoundException has already been caught
// Correct: subclass first, superclass second.
try {
read();
} catch (FileNotFoundException e) { // specific
} catch (IOException e) { // general fallback
}
Multi-catch (Java 7+)
try {
process();
} catch (IOException | SQLException e) { // handle both the same way
log.error("processing failed", e);
throw new ProcessingException(e);
}
1. The types must not be related by inheritance —
catch (IOException | FileNotFoundException e) is a compile error, because the
second is already covered by the first. 2. The variable e is
implicitly final — you cannot reassign it inside the block.
finally — and the three ways it can betray you
finally runs on the way out no matter how you leave the try: normal
completion, an exception, or even a return.
int f() {
try {
return 1; // value 1 is computed and held...
} finally {
System.out.println("cleanup"); // ...but this runs BEFORE the method actually returns
}
}
// prints "cleanup", then returns 1
A return in finally discards the exception that was
travelling and overrides the real return value. It is the most effective way to make a bug
invisible. Most linters flag it; treat it as banned.
int bad() {
try {
throw new RuntimeException("the real problem");
} finally {
return 42; // the exception is thrown away. Caller sees 42 and thinks all is well.
}
}
// bad() returns 42. The RuntimeException vanishes without trace.
// 1. The JVM exits
try { System.exit(0); } finally { System.out.println("never printed"); }
// 2. The thread is killed, or the JVM crashes / is SIGKILLed
// 3. An infinite loop or deadlock inside try means you never leave it
04. try-with-resources — the only way to close things
try-with-resources (Java 7+) automatically closes anything you open. It
replaces the entire class of "I forgot to close the file" bugs, and it handles an edge case that
hand-written finally almost always gets wrong.
BufferedReader br = null;
try {
br = new BufferedReader(new FileReader("a.txt"));
System.out.println(br.readLine());
} catch (IOException e) {
log.error("read failed", e);
} finally {
if (br != null) {
try { br.close(); } // close() itself throws IOException, so another try...
catch (IOException ignored) {} // ...and now you're swallowing again
}
}
try (BufferedReader br = new BufferedReader(new FileReader("a.txt"))) {
System.out.println(br.readLine());
} catch (IOException e) {
log.error("read failed", e);
}
// br.close() is called automatically, guaranteed, even if readLine() throws.
How it works
Anything declared in the try (...) header must implement
AutoCloseable (or Closeable, which extends it). The compiler generates
the finally that calls close() for you.
class Connection implements AutoCloseable {
Connection() { System.out.println("open"); }
void use() { System.out.println("using"); }
@Override public void close() { System.out.println("closed"); } // narrowed: throws nothing
}
try (Connection c = new Connection()) {
c.use();
}
// open
// using
// closed
Just like nested finally blocks would. The last thing opened is the first thing
closed — which is exactly what you want, since later resources usually depend on earlier ones.
try (Connection c = new Connection();
Statement s = c.createStatement();
ResultSet r = s.executeQuery(sql)) {
// use r
}
// close order: r, then s, then c
// Note: the semicolon after the LAST resource is optional.
Suppressed exceptions — the edge case that matters
Here is the problem the old finally style silently got wrong. Suppose the body
throws, and then close() also throws. You now have two exceptions and only
one can propagate. Which one wins?
That's the right answer — the body's failure is the real problem; the close failure is
usually a consequence of it. But the close exception is not thrown away: it is
attached to the primary one and retrievable via getSuppressed().
Hand-written finally does the opposite — the close exception replaces and destroys
the original.
class Bad implements AutoCloseable {
void use() { throw new RuntimeException("primary — the real bug"); }
public void close() { throw new RuntimeException("secondary — from close()"); }
}
try (Bad b = new Bad()) {
b.use();
} catch (Exception e) {
System.out.println(e.getMessage()); // primary — the real bug
System.out.println(e.getSuppressed()[0].getMessage()); // secondary — from close()
}
Bad b = new Bad();
try {
b.use(); // throws "primary" ...
} finally {
b.close(); // ... then this throws "secondary", REPLACING it entirely.
}
// Caller sees only "secondary". The actual bug is gone forever.
Before Java 9 you had to declare the resource inside the parentheses. Since Java 9 you can use
an existing variable, provided it is final or effectively final:
try (br) { ... }.
05. throw vs throws, and propagation
One letter apart, completely different jobs. throw is an action;
throws is a declaration.
throw |
throws |
|
|---|---|---|
| What it is | A statement — actually raises it now | Part of the method signature |
| Where | Inside a method body | After the parameter list |
| How many | One object at a time | A comma-separated list of types |
| Means | "Failing, right now" | "This might fail — caller beware" |
void withdraw(int amount) throws InsufficientFundsException { // declaration
if (amount > balance) {
throw new InsufficientFundsException("need " + amount + ", have " + balance); // action
}
balance -= amount;
}
Overriding rules — a subclass may never widen the contract
If you override a method, you cannot declare new or broader checked exceptions than the parent. If you could, code holding a parent reference would be blindsided by an exception it was never told about — breaking the Liskov substitution principle.
class Parent {
void go() throws IOException { }
}
class Child extends Parent {
// ALLOWED — same exception
void go() throws IOException { }
}
class Child2 extends Parent {
// ALLOWED — narrower (FileNotFoundException extends IOException)
void go() throws FileNotFoundException { }
}
class Child3 extends Parent {
// ALLOWED — fewer: declaring nothing at all is always fine
void go() { }
}
class Child4 extends Parent {
// COMPILE ERROR — broader. Exception is a supertype of IOException.
void go() throws Exception { }
}
class Child5 extends Parent {
// COMPILE ERROR — brand new checked exception the parent never mentioned
void go() throws SQLException { }
}
class Child6 extends Parent {
// ALLOWED — unchecked exceptions are never part of the contract, declare freely
void go() throws IllegalStateException { }
}
06. Custom exceptions
Write your own exception when the failure is meaningful in your domain and a caller might want to catch that specific thing. Not before.
// Unchecked: extend RuntimeException (the usual default)
public class InsufficientFundsException extends RuntimeException {
private final BigDecimal shortfall; // carry USEFUL data, not just a string
public InsufficientFundsException(String message) {
super(message);
this.shortfall = null;
}
public InsufficientFundsException(String message, Throwable cause) {
super(message, cause); // NEVER drop the cause
this.shortfall = null;
}
public InsufficientFundsException(String message, BigDecimal shortfall) {
super(message);
this.shortfall = shortfall;
}
public BigDecimal getShortfall() { return shortfall; }
}
// Checked: extend Exception instead — everything else is identical
public class ConfigurationException extends Exception {
public ConfigurationException(String message, Throwable cause) { super(message, cause); }
}
Ask: "can the caller realistically do something different if they catch this?" If yes (retry, fall back, prompt the user) → consider checked. If it just means "someone made a mistake" or "this is broken and nothing will help" → unchecked. When unsure, choose unchecked; it's the modern default and far easier to change later.
Don't invent MyNullException. Java already has precise, universally understood
names: IllegalArgumentException (bad input),
IllegalStateException (wrong time to call this),
UnsupportedOperationException (not implemented / not allowed),
NullPointerException (use Objects.requireNonNull).
void setAge(int age) {
if (age < 0) throw new IllegalArgumentException("age must be >= 0, got: " + age);
this.age = age;
}
void send() {
if (!connected) throw new IllegalStateException("call connect() before send()");
...
}
Order(Customer c) {
// throws NPE with a clear message instead of a mystery NPE three calls later
this.customer = Objects.requireNonNull(c, "customer must not be null");
}
"Invalid input" is worthless at 3am.
"age must be >= 0, got: -5" tells you the rule and the actual value.
Include the values — never include passwords, tokens or personal data.
07. Wrap vs propagate — the design judgment
This is the part that separates people who "know the syntax" from people who write good Java. There are exactly four things you can do with an exception, and choosing correctly is the whole skill.
| Option | Do it when… |
|---|---|
| 1. Handle it | You can genuinely fix or recover here — retry, use a cached value, use a default |
| 2. Propagate | The caller is in a better position to decide. The default for most methods. |
| 3. Wrap and rethrow | You are crossing a layer boundary and the low-level type would leak |
| 4. Swallow | Almost never. Only when the failure is truly irrelevant — and log it anyway. |
Propagate — say nothing, do nothing
If you can't add value, get out of the way. The worst code catches, logs, and rethrows at every single level, producing the same stack trace six times in the log.
String read(Path p) throws IOException {
return Files.readString(p); // nothing useful to add here — let the caller deal with it
}
catch (X e) { log.error("failed", e); throw e; } at every layer means one failure
prints five near-identical stack traces, and it becomes genuinely hard to tell whether you had
one problem or five. Log once, at the boundary where you actually handle it.
Wrap — translate at a layer boundary
Your UserService shouldn't throw SQLException. That leaks the fact that
you use a database — the caller can't act on it, and the day you switch to a REST API, every
caller breaks. Wrap it in something meaningful for your layer.
public User findUser(long id) {
try {
return jdbc.query("SELECT ...", id);
} catch (SQLException e) {
// Translate to MY layer's vocabulary — but keep `e` as the cause!
throw new UserLookupException("could not load user " + id, e);
}
}
Always pass the original exception as the cause.
new MyException(msg, e) — never new MyException(msg). Drop the cause
and you destroy the stack trace pointing at the actual line that failed, leaving you with a
useless "could not load user 7" and no idea why.
UserLookupException: could not load user 7
at UserService.findUser(UserService.java:22)
at UserController.get(UserController.java:14)
Caused by: java.sql.SQLException: connection refused <- the actual reason
at org.postgresql.Driver.connect(Driver.java:198)
... 12 more
✓ Do
- Throw at the level of abstraction the caller understands.
- Always chain the cause:
new X("msg", e). - Catch the narrowest type you can actually handle.
-
Handle (and log) once, at a real boundary — a controller, a job runner,
main. - Include the offending values in the message.
✗ Don't
- Swallow:
catch (Exception e) {}. catch (Exception e)orcatch (Throwable t)as a blanket.- Log and rethrow at every layer.
returnorthrowfromfinally.- Use exceptions for normal control flow (see gotchas).
- Drop the cause when wrapping.
Exception at the very top is fine
The blanket ban has one exception: the outermost boundary. A web framework's
error handler, a scheduled job's loop, or main should catch broadly so that one bad
request doesn't kill the server. That's a deliberate safety net, not laziness — and it logs.
08. Reading a stack trace
A stack trace is not noise. It tells you what failed, where, and how you got there — read it top-down and you rarely need a debugger.
Exception in thread "main" java.lang.NullPointerException: Cannot invoke
"String.length()" because "name" is null <- (1) WHAT + why (helpful NPE, Java 14+)
at com.app.User.nameLength(User.java:42) <- (2) WHERE it was thrown — start here
at com.app.UserService.process(UserService.java:18) <- (3) who called it
at com.app.Main.main(Main.java:7) <- (4) the entry point
Caused by: java.sql.SQLException: connection refused <- (5) the ROOT cause — often the real story
at org.postgresql.Driver.connect(Driver.java:198)
... 12 more <- (6) frames identical to the trace above
1. Scroll to the bottom — the last Caused by is
usually the real problem. 2. Then read the
topmost line of your own package in that block — library frames are rarely the
bug. 3. The exception type and message tell you what invariant broke.
Modern Java tells you which reference was null:
Cannot invoke "String.length()" because "name" is null. On older versions you'd get
a bare NullPointerException and a line with five possible culprits. This alone is a
strong reason to be on a recent JDK.
09. Gotchas — where Java surprises you
1. Exceptions are expensive — but not for the reason you think.
The cost is almost entirely in fillInStackTrace(), which walks the whole stack when
the exception is constructed (not when thrown or caught). A try
block that doesn't throw costs essentially nothing. This is why using exceptions for normal
control flow — like ending a loop — is slow as well as unreadable.
2. catch (Exception e) does not catch everything.
It misses Error (e.g. OutOfMemoryError,
StackOverflowError). Only catch (Throwable t) catches literally
everything — and you should not.
3. finally overrides the try's return value.
If finally has its own return, it wins and any in-flight exception is
discarded. But note the subtlety: finally mutating a primitive local after
return x does not change the returned value — the value was
already copied. Mutating an object's fields does show up, because the
reference was copied, not the object.
4. Swallowing InterruptedException breaks cancellation.
Catching it clears the thread's interrupt flag. If you don't rethrow it, at
minimum restore the flag with Thread.currentThread().interrupt() — otherwise the
code above you never learns it was asked to stop, and your shutdown hangs.
5. Lambdas and streams cannot throw checked exceptions.
Function<T,R>.apply declares no checked exceptions, so
list.forEach(f -> Files.readString(f)) won't compile. You must catch inside the
lambda and wrap in an unchecked exception (or use a plain for loop). This friction
is a big part of why modern Java drifted toward unchecked.
6. A constructor that throws leaves you with no object — but its resources may leak.
If a constructor throws partway, the object is never handed to you and becomes garbage. Anything it already opened is now unreachable and unclosed. Open resources last, or clean up on the way out.
7. e.printStackTrace() is not logging.
It writes to System.err, bypassing your log framework — so it has no timestamp, no
level, no correlation id, and in production it's very often routed straight to
/dev/null. Use log.error("context", e).
10. Interview Q&A
Q: Checked vs unchecked — what's the actual rule?
Unchecked = RuntimeException and Error and their subclasses. Checked =
everything else under Exception. Checked exceptions must be caught or declared with
throws; the compiler enforces it. The intent: checked for recoverable, external
problems; unchecked for programming bugs.
Q: Will finally always run?
Practically always — including when try returns or throws. It won't run if
System.exit() is called, the JVM crashes or is killed, or the thread never leaves
the try (infinite loop/deadlock).
Q: What's the point of try-with-resources over finally?
Less code, impossible to forget, closes in reverse order — and crucially it gets
suppressed exceptions right. If the body and close() both throw,
the body's exception propagates and the close exception is attached via
getSuppressed(). Hand-written finally loses the original entirely.
Q: throw vs throws?
throw is a statement that raises one exception object now. throws is a
signature clause declaring which checked exceptions a method may emit.
Q: Can an overriding method throw a broader checked exception?
No. It may throw the same, a subclass, fewer, or none. It may declare any unchecked exceptions freely, since those aren't part of the contract. Widening would break callers holding a supertype reference.
Q: Why shouldn't you catch Exception or Throwable?
You catch things you never anticipated — including bugs like
NullPointerException and, with Throwable,
OutOfMemoryError — and then carry on as if fine. Catch the narrowest type you can
genuinely handle. The one legitimate exception is a top-level boundary handler that logs and
keeps the process alive.
Q: What is exception chaining and why does it matter?
Passing the original as the cause: new MyException("msg", e). It preserves the
original stack trace under Caused by:, so you keep the real point of failure while
presenting a meaningful type to your callers. Without it, wrapping destroys the evidence.
Q: Are exceptions slow?
Building one is — fillInStackTrace() walks the stack at construction time. Entering
a try that doesn't throw is effectively free. So: fine for exceptional cases,
genuinely bad for control flow in a hot loop.
Q: What happens if an exception is thrown inside a catch block?
It propagates normally — sibling catch blocks of the same try do
not get a chance at it. The finally still runs, and if
finally throws too, its exception replaces the one from the catch block.
11. Cheat sheet
-
Hierarchy:
Throwable→Error(don't catch) +Exception→RuntimeException(unchecked) + the rest (checked). -
Checked rule: extends
Exceptionbut notRuntimeException→ must catch or declare. - Meaning: checked = the world broke (recoverable) · unchecked = you broke it (fix the code).
- Catch order: most specific first — broad-before-narrow is a compile error.
-
Multi-catch:
catch (A | B e)— unrelated types only,eis final. -
finally: always runs (except
System.exit/crash). Neverreturnorthrowfrom it. -
try-with-resources: needs
AutoCloseable· closes in reverse order · body's exception wins, close's isgetSuppressed(). - throw = do it now (statement) · throws = might happen (signature).
- Override: same / narrower / fewer / none — never broader checked.
- Wrapping: always chain the cause —
new X("msg", e). -
Built-ins:
IllegalArgumentException(bad input) ·IllegalStateException(bad timing) ·UnsupportedOperationException(not allowed) ·Objects.requireNonNull(null checks). -
Never: swallow ·
printStackTrace()· log-and-rethrow at every layer · exceptions as control flow. -
Reading a trace: bottom-most
Caused byfirst, then the top frame in your own package.