What you'll learn
Quick Answer
Java splits Throwable into Error, which means the JVM or environment is broken and you should not catch it, and Exception, which your code may handle. Under Exception, RuntimeException and its subclasses are unchecked; everything else is checked, so the compiler forces you to catch or declare it. Catch the narrowest type you can genuinely handle, keep the cause when you rethrow, and use try-with-resources so a failure inside close() is recorded as suppressed instead of replacing the real error.
What sits where in the Throwable tree
Everything you can throw or catch in Java descends from Throwable. It has exactly two direct children, and the split between them is the whole design.
Throwable
|
+-- Error (unchecked; do not catch)
| +-- OutOfMemoryError
| +-- StackOverflowError
| +-- NoClassDefFoundError
|
+-- Exception (checked)
+-- IOException
+-- SQLException
+-- RuntimeException (unchecked)
+-- NullPointerException
+-- IllegalArgumentException
+-- IndexOutOfBoundsException
+-- ClassCastException
Error means the JVM or the environment is in trouble: the heap is exhausted, the stack is blown, a class file is missing. Your application code cannot meaningfully repair any of that, and retrying usually makes it worse.
Exception means something happened that a program can reasonably deal with. Inside it there is one special subtree, RuntimeException. Everything under RuntimeException, and everything under Error, is unchecked. Everything else under Exception is checked.
Two practical consequences follow immediately. A catch (Exception e) does not catch an Error, so an OutOfMemoryError still tears down your request no matter how many broad catch blocks you wrote. And catch (Throwable t) does catch it, which is almost always wrong: there is nothing sensible to do after the heap is exhausted, and swallowing it leaves the process running in an unknown state.
Ordering inside a try matters, because the first matching catch wins. The compiler rejects a catch for a subclass placed after its superclass, since it could never execute.
try {
loadMarks();
} catch (Exception e) { // catches everything below Exception
...
} catch (IOException e) { // compile error: exception has already been caught
...
}
So write the specific handlers first and the general one, if you need it at all, last.
Checked vs unchecked: who is expected to react
A checked exception is one the compiler forces you to acknowledge. Call a method that declares throws IOException and you must either catch it or add throws IOException to your own signature. An unchecked exception carries no such obligation and does not need to appear anywhere in a signature.
// checked: the compiler insists you deal with it
static String readMarks(Path p) throws IOException {
return Files.readString(p);
}
// unchecked: nothing in the signature warns the caller
static int parseMarks(String s) {
return Integer.parseInt(s); // NumberFormatException
}
The original intent was a clean division. Checked means a failure the caller can plausibly recover from, such as a missing file or a dropped connection. Unchecked means a programming error: a null where a value was required, an index past the end, an argument that makes no sense.
In practice the line is blurred, and modern libraries lean heavily towards unchecked. The reason is that checked exceptions leak upwards through every layer. A low-level SQLException that must be declared by every method between your data access code and your controller makes the signature of your business logic depend on your database driver, and the usual response is a catch block that does nothing.
Which brings us to the single most expensive line in a lot of student projects.
try {
Files.delete(tempFile);
} catch (IOException e) {
// ignore
}
The file is still there, the disk slowly fills, and when someone finally investigates there is no evidence of when it started. If you genuinely do not care about a failure, prove it: log it at a low level and write a comment explaining why it is safe to continue. "Ignore" is not a reason.
One checked exception deserves special handling. By the time you catch an InterruptedException, the blocking method that threw it has already cleared the thread's interrupt flag, so swallowing it makes your thread impossible to cancel, and a shutdown that should take milliseconds hangs until someone kills the process.
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag
return; // and stop what you were doing
}
Why catch (Exception e) hides your own bugs
The problem with a broad catch is not that it catches too much in the abstract. It is that it catches your NullPointerException and reports it as someone else's outage.
try {
Order order = gateway.fetch(id);
log.info("items: " + order.getItems().size());
repository.save(order);
} catch (Exception e) {
log.error("payment gateway is down");
return Status.RETRY;
}
If getItems() returns null, the resulting NullPointerException is thrown by your own code, caught by that block, and logged as a gateway failure. Someone spends an afternoon reading gateway logs and emailing the payment provider. The catch block turned a two-line bug into a wrong diagnosis, and it has been doing that quietly since the day it was written.
Three habits keep exception handling honest.
- Catch the narrowest type you can actually handle.
catch (IOException e), notcatch (Exception e). - Keep the try block small. Wrap the call that can fail, not the twenty surrounding lines that cannot.
- Always keep the exception object.
log.error("fetching order " + id, e)passes the throwable so the stack trace and the full chain of causes reach the log. Pastinge.getMessage()into a string throws all of that away, and for aNullPointerExceptionthe message is often the least useful part.
Multi-catch covers the honest case where two unrelated failures deserve the same response. The variable is effectively final, and its type is the nearest common supertype.
try {
return parseRow(row);
} catch (NumberFormatException | DateTimeParseException e) {
log.warn("skipping malformed row: " + row, e);
return null;
}
There is one legitimate broad catch: the outermost boundary of a request handler or a long-running loop, where the job is to record the failure and keep the service alive rather than let one bad row kill a batch of ten thousand. Even there, log the complete exception, and consider rethrowing anything you did not anticipate.
Finally, the quietest killer of all. A return inside finally discards an exception that is already travelling up the stack.
static int load() {
try {
throw new IllegalStateException("database unreachable");
} finally {
return -1; // the exception vanishes completely
}
}
No stack trace, no log line, no clue: the caller just sees -1. The same thing happens if the finally block throws its own exception, which replaces the one in flight. Never return from, or throw out of, a finally block.
try-with-resources and suppressed exceptions
The old close-in-finally pattern had a defect most people never noticed. If the body throws and close() also throws, the close failure replaces the real one, and you debug the symptom instead of the cause.
BufferedReader br = null;
try {
br = Files.newBufferedReader(path);
return br.readLine();
} finally {
if (br != null) br.close(); // if this throws, the original error is lost
}
try-with-resources fixes both the verbosity and the defect. Anything implementing AutoCloseable declared in the parentheses is closed automatically, in reverse order of declaration, and a failure from close() is attached to the original exception as a suppressed exception rather than replacing it.
try (BufferedReader br = Files.newBufferedReader(path);
PrintWriter out = new PrintWriter("report.txt")) {
String line;
while ((line = br.readLine()) != null) {
out.println(line.toUpperCase());
}
} // out is closed first, then br
You can inspect what was hidden, and a good logging framework already prints suppressed exceptions under the main stack trace.
catch (IOException e) {
for (Throwable suppressed : e.getSuppressed()) {
log.warn("also failed while closing", suppressed);
}
throw e;
}
Two details worth knowing. The resource variables are implicitly final, so you cannot reassign them inside the block. And if a resource expression evaluates to null, the generated code skips the close instead of throwing a NullPointerException, so an optional resource is safe from that particular crash.
Almost everything you open implements AutoCloseable: InputStream, OutputStream, Reader, Writer, Scanner, Socket, JDBC's Connection, Statement and ResultSet. Leaking these is what produces "too many open files" on Linux, a locked file on Windows, or a connection pool that hangs after a few hundred requests. The reason it survives testing is that a short-lived JVM exits before the leak has time to matter.
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT name FROM student WHERE city = ?")) {
ps.setString(1, "Pune");
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) System.out.println(rs.getString("name"));
}
}
Writing exceptions of your own
Create a custom exception when a caller needs to distinguish this failure from other failures, not because a message needs somewhere to live. A dozen exception classes that are all caught by the same catch block are a dozen files nobody reads.
public class InsufficientBalanceException extends RuntimeException {
private final long shortfallPaise;
public InsufficientBalanceException(long shortfallPaise, Throwable cause) {
super("short by " + shortfallPaise + " paise", cause);
this.shortfallPaise = shortfallPaise;
}
public long getShortfallPaise() {
return shortfallPaise;
}
}
Three things that snippet gets right. The interesting data is a field, so calling code can act on it without parsing the message. There is a constructor that accepts a cause. And it extends RuntimeException, which is the right default unless the caller has a genuine recovery path that the compiler should force them to consider.
Chaining is the habit that saves the most debugging time. Always pass the original exception as the cause.
try {
repository.save(order);
} catch (SQLException e) {
throw new OrderStoreException("saving order " + order.id(), e); // cause preserved
}
Without that second argument the stack trace begins at your throw line, and the SQL error code, the constraint name and the driver's own frames are gone. What remains says "saving order 42 failed", which is exactly as much as you already knew.
Do not use exceptions for ordinary control flow. Constructing a Throwable captures the stack trace at that moment, and that capture is the expensive part, not the throwing. Throwing one per row while validating a large CSV is real work for a condition an if could have tested. If you ever do need a lightweight signal, a subclass can turn off the writable stack trace, but reach for it only after measuring.
protected ValidationSignal(String message) {
super(message, null, false, false); // no suppression, no stack trace
}
For everything else, the standard types already carry meaning that every Java developer recognises. Use IllegalArgumentException for a bad argument, IllegalStateException for a call made at the wrong time, and Objects.requireNonNull with a message for a null that should never have arrived. Validate at the top of the method, so the failure names the real cause instead of surfacing three calls later.
public Student(String name, int marks) {
this.name = Objects.requireNonNull(name, "name must not be null");
if (marks < 0 || marks > 100) {
throw new IllegalArgumentException("marks out of range: " + marks);
}
this.marks = marks;
}
