Sooner or later, every program hits the point where something goes wrong: a file is not found, the network drops, a method receives null. An exception is Java's way of saying "this code can't continue" and handing control to a place where the error can be handled. Let's look at how exceptions work and how to deal with them painlessly.
Why exceptions at all
You could return an error code from a method instead — say -1 or null. But then you'd have to write a "did it break?" check after every call, and it's easy to forget one. Exceptions solve this differently: the problematic code is interrupted, and the error "bubbles up" the call stack until someone catches it.
Short formula: an exception separates the normal path of execution from error handling — they aren't mixed together in a single flow of code.
int parse(String s) {
return Integer.parseInt(s); // throws NumberFormatException if s is not a number
}
If s equals "abc", the method won't return garbage — it interrupts and throws an exception that the calling code is obliged to handle.
The hierarchy: Throwable, Error, Exception
At the root of everything sits the Throwable class — only its subclasses can be thrown (throw) and caught (catch). It has two main branches:
Error— serious failures of the JVM itself:OutOfMemoryError,StackOverflowError. These are not caught and not handled — in such a state the program is usually a goner already.Exception— application-level errors that you can and should work with.
Inside Exception there is a special sub-branch — RuntimeException. It is precisely this line that marks the border between checked and unchecked exceptions.
Checked and unchecked
This split is one of Java's most debated features.
Checked exceptions — everything that inherits Exception but not RuntimeException (for example IOException, SQLException). The compiler forces you to handle them: either wrap the call in a try/catch, or declare it in the method signature via throws. Fail to do so, and the code won't compile.
// either throws — pass the responsibility upward
String read(Path path) throws IOException {
return Files.readString(path);
}
// or try/catch — handle it here
String readSafe(Path path) {
try {
return Files.readString(path);
} catch (IOException e) {
return ""; // decided that an empty string is acceptable
}
}
Unchecked exceptions — subclasses of RuntimeException (NullPointerException, IllegalArgumentException, IllegalStateException, NumberFormatException). The compiler doesn't police them: you can catch them, but you don't have to. These are usually bugs in the code — dereferencing null, an invalid argument, a violation of a method's contract.
Short formula: checked — "an expected external problem the caller should do something about"; unchecked — "the programmer made a mistake, fix the code rather than catch the exception".
The debate around checked
The idea behind checked exceptions is to force developers not to ignore errors. In practice it has its critics: in long call chains throws IOException drags through dozens of methods, and lazy developers write an empty catch just to shut the compiler up — which is worse than nothing at all. Many modern libraries and frameworks (including a large part of the Spring ecosystem) lean toward unchecked exceptions, wrapping checked ones in runtime wrappers. There's no ready-made "correct" answer — what matters is understanding both sides.
try / catch / finally
The basic handling construct:
try {
process(); // code that may throw an exception
} catch (IOException e) {
log.error("I/O error", e);
} catch (IllegalArgumentException e) {
log.warn("invalid argument", e);
} finally {
cleanup(); // runs ALWAYS — both on error and without one
}
Things worth knowing:
- Multiple
catchblocks are checked top to bottom — the first one matching by type fires. That's why more specific types go above more general ones. - Multi-catch merges branches with identical handling:
catch (IOException | SQLException e). finallyruns in any case — even if there was areturninside thetry. This is where releasing resources (closing a file, a connection) was historically placed.- Catching "everything" via
catch (Exception e)should be done carefully — it's easy to intercept things you never meant to handle.
try-with-resources and AutoCloseable
Manual closing in finally is noisy and easy to get wrong (what if close() itself throws an exception?). Since Java 7 there is try-with-resources for this: resources are declared in parentheses after try and closed automatically, in reverse order, even if an exception occurs.
This works for any class implementing the AutoCloseable interface (it has a single method — close()). Most standard "closeable" types (streams, files, connections) implement it.
// the file closes itself — no finally needed
String firstLine(Path path) throws IOException {
try (var reader = Files.newBufferedReader(path)) {
return reader.readLine();
}
}
// several resources — closed in reverse order
void copy(Path from, Path to) throws IOException {
try (var in = Files.newInputStream(from);
var out = Files.newOutputStream(to)) {
in.transferTo(out);
}
}
Short formula: if an object has something to close — open it in a try-with-resources rather than closing it by hand.
Custom exceptions
When standard types don't convey the meaning of an error in your domain, you create a custom exception. It usually inherits from RuntimeException (if you don't want to impose mandatory handling) or from Exception (if you do).
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(long orderId) {
super("Order not found: " + orderId); // a clear message
}
}
Order findOrder(long id) {
return repository.find(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
Useful habits:
- A clear message — what happened and with what data.
- Preserve the cause. If you wrap another exception, pass it as the second argument:
throw new MyException("failed to load", e). Then the log will show the full chain (Caused by: ...) instead of a broken trail.
Antipatterns
The most common ways to shoot yourself in the foot:
// 1. Empty catch — the exception vanishes without a trace
try {
risky();
} catch (Exception e) {
// silence — now no one will ever know what broke
}
// 2. Swallowing with loss of the cause
try {
risky();
} catch (IOException e) {
throw new RuntimeException("error"); // lost the original e!
}
// 3. Control flow through exceptions — slow and unreadable
try {
return list.get(index);
} catch (IndexOutOfBoundsException e) {
return null; // better to just check index beforehand
}
Do it right: never leave a catch empty (at minimum, log it), always preserve the cause when wrapping, and don't use exceptions as an ordinary if — they're for exceptional situations, not for regular logic.
In short
- Exceptions separate error handling from the main code: the problematic section is interrupted, and the error bubbles up the stack.
- At the root is
Throwable.Errorisn't caught (JVM failures),Exceptionis handled. - Checked (subclasses of
Exception, exceptRuntimeException) — the compiler forces you to handle them; unchecked (RuntimeException) — it doesn't, and these are usually bugs in the code. - The debate around checked is real: they impose discipline but breed noise and empty
catchblocks; many frameworks choose unchecked. try/catch/finally:finallyalways runs; for resources use try-with-resources (AutoCloseable) instead of manual closing.- Custom exceptions give meaningful errors — write a clear message and preserve the cause.
- The main antipatterns: an empty
catch, loss of the original cause, control flow through exceptions.
What to read next
- Syntax and data types — the basics everything else stands on.
- Developer tooling — how to build, run and debug code.
- Collections — where
IndexOutOfBoundsExceptionandNullPointerExceptionshow up often.