Lecture #6: Exceptions in Java
Introduction
Every program eventually runs into situations it cannot handle through its normal flow: a file that isn’t there, a division by zero, an array index that doesn’t exist, a network connection that drops. Java’s answer to this problem is the exception mechanism — a structured way to interrupt normal execution when something abnormal happens, and to give the program (rather than the operating system) a chance to recover.
This chapter looks at what exceptions are, how Java represents and propagates them, the difference between checked and unchecked exceptions, and how to design your own exception classes to make the components you write more robust and easier to reuse.
What Is an Exception, and Why Bother?
An exception is an event that interrupts the normal sequence of instructions in a program. Unlike a crash, it is recoverable: the running program gets a chance to notice that something went wrong and to decide what to do about it, instead of being killed outright by the operating system.
This idea was originally developed for safety-critical and fault-tolerant systems — think of an ATM, an industrial control system, or a satellite, where “just crash” is never an acceptable response to an unexpected condition. Over time it became a general-purpose programming technique, useful anywhere you want to keep two concerns separate:
- the normal case: what a piece of code does when everything goes as expected;
- the exceptional case: what should happen when it doesn’t.
Without exceptions, both concerns tend to get tangled into the same return value — a classic example is a C function that returns -1 or some special code to mean “error,” which the caller can easily forget to check, and which competes with legitimate return values. Exceptions let a method’s normal signature describe only the normal case, while error conditions travel through a separate, impossible-to-ignore channel.
One heads-up before diving in: unlike references and aliasing (which at least rhyme with C’s pointers), Java’s checked exceptions — covered a few sections below — have no C equivalent whatsoever. Treat that specific idea as entirely new, not as “the Java version of something you already know.”
Consider a small function that performs integer division:
int divide(int x, int y) {
return x / y;
}
If y is zero, this throws an ArithmeticException at runtime. The code is perfectly valid Java — it compiles without complaint — but a particular execution, with particular inputs, fails. That failure cannot be caught by the compiler; it can only be detected and handled while the program is running. This is exactly the gap exceptions are built to fill.
Catching Exceptions: try/catch
The basic mechanism for handling an exception is the try/catch block:
int safeDivide(int x, int y) {
int result;
try {
result = x / y;
} catch (ArithmeticException ex) {
System.out.println("Cannot divide by zero, defaulting to 0");
result = 0;
}
return result;
}
Here is what happens step by step when an exception is thrown inside the try block:
- Normal execution of the block stops immediately at the point where the exception occurred.
- Java looks for a
catchclause, attached to the sametry, whose declared exception type matches (or is a superclass of) the exception that was thrown. - If a match is found, the code inside that
catchblock runs, and execution continues normally after the wholetry/catchstatement. - If no matching
catchis found in the current method, the exception is propagated: Java unwinds the call stack, checking each calling method in turn for atry/catchthat could handle it. - If the exception reaches
mainwithout ever being caught, the Java runtime prints a stack trace (viaprintStackTrace()) and terminates the program.
You can chain several catch clauses after one try to handle different exception types differently:
try {
int[] values = { 10, 20, 30 };
int index = readIndexFromUser();
System.out.println(values[index] / 0);
} catch (ArrayIndexOutOfBoundsException ex) {
System.out.println("Index out of range");
} catch (ArithmeticException ex) {
System.out.println("Division by zero");
}
Java tries the catch clauses in order and runs the first one that matches, so put more specific exception types before more general ones. In the example above this is just good style, since ArrayIndexOutOfBoundsException and ArithmeticException are unrelated siblings and either order would compile. But if one caught type is actually a superclass of another — say catch (RuntimeException ex) before catch (ArithmeticException ex) — putting the supertype first isn’t just bad style, it’s a compile error: the more specific catch becomes unreachable, and javac refuses to build.
Finally, a finally block, if present, always runs after the try (and any matching catch), whether or not an exception occurred — it’s the natural place to release a resource:
void readFirstLine(String path) {
java.io.BufferedReader reader = null;
try {
reader = new java.io.BufferedReader(new java.io.FileReader(path));
System.out.println(reader.readLine());
} catch (java.io.IOException ex) {
System.out.println("Could not read file: " + ex.getMessage());
} finally {
try {
if (reader != null) reader.close();
} catch (java.io.IOException ex) {
// closing failed; nothing more we can do here
}
}
}
Exceptions Are Objects
In Java, an exception is not a status code — it is a full object, instantiated at the moment the exceptional condition occurs. Every exception class in the standard library descends from the single root class Throwable, most of them (as you’ll see below) indirectly through Exception. Because exceptions form a class hierarchy, catching a supertype also catches all of its subtypes — a catch (RuntimeException ex) clause will catch an ArithmeticException, a NullPointerException, and so on, since they are all subclasses of RuntimeException.
A small slice of that hierarchy, showing some exceptions you have likely already met at runtime:
Throwable
└── Exception
├── RuntimeException
│ ├── ArithmeticException // e.g. division by zero
│ ├── ClassCastException // invalid downcast
│ ├── NullPointerException // method/field access on null
│ └── IndexOutOfBoundsException
│ ├── ArrayIndexOutOfBoundsException
│ └── StringIndexOutOfBoundsException
└── IOException
├── FileNotFoundException
└── EOFException
The standard library defines around thirty exception classes in java.lang alone, and several hundred once you include the rest of the platform’s packages — the hierarchy is what keeps that number manageable, since you can always choose to catch at whatever level of generality makes sense for the code you’re writing.
Because every exception is an object, it can carry information along with it: a message, the state of the program at the time it was thrown, and — most usefully for debugging — the stack trace, the sequence of method calls that led to the failure. Calling ex.printStackTrace() prints that trace to the console, which is exactly what the Java runtime does automatically for any exception that reaches main uncaught. This is often the fastest way to locate the origin of a bug: read the top of the trace, and it tells you the exact class, method, and line number where things went wrong.
Checked vs. Unchecked Exceptions
Java splits exceptions into two families that behave very differently at compile time.
Unchecked exceptions are subclasses of RuntimeException. They represent programming mistakes — a bad array index, a null reference, an illegal cast — that could in principle have been avoided by the programmer. A separate category, Error (e.g. OutOfMemoryError), sits alongside Exception directly under Throwable and covers serious problems that applications generally shouldn’t try to catch at all. Both RuntimeExceptions and Errors are unchecked: the compiler does not require you to catch them or declare them — they can be thrown from anywhere with no compile-time obligation, and will propagate up the stack, printing a full stack trace, until something catches them or the program terminates.
Checked exceptions are subclasses of Exception that are not subclasses of RuntimeException. They represent conditions external to the program’s own logic — a missing file, a network failure, invalid user input from a stream — that a well-written program is expected to anticipate and handle. The compiler enforces this: if a method can throw a checked exception, it must either catch it or declare it in its signature with throws, and any caller of that method faces the same obligation.
// Checked: the compiler forces callers to deal with it
void loadConfig(String path) throws java.io.IOException {
java.io.FileReader reader = new java.io.FileReader(path); // may throw IOException
reader.close();
}
// Unchecked: no throws clause needed, no obligation on the caller
int firstElement(int[] array) {
return array[0]; // may throw ArrayIndexOutOfBoundsException at runtime
}
The throws clause is part of the method’s signature, exactly like its parameter types and return type. This matters in particular when overriding a method: an overriding method cannot declare broader checked exceptions than the method it overrides, because that would break the promise the original signature made to its callers.
A practical rule of thumb: use unchecked exceptions for bugs (things the caller should fix in their code), and checked exceptions for conditions the caller genuinely needs to plan a response for. Overusing checked exceptions tends to clutter code with throws clauses and boilerplate try/catch blocks for situations that are really just bugs, which is why many modern Java APIs lean toward unchecked exceptions even for conditions that once might have been checked.
Throwing Your Own Exceptions
Sometimes the built-in exceptions don’t describe the failure precisely enough. A method on a Stack class that overflows isn’t really suffering an “array index out of bounds” from the caller’s point of view — it’s suffering a “stack is full,” which is a concept specific to that class. Defining your own exception type lets the failure speak the vocabulary of your own API rather than leaking the implementation detail underneath it.
Creating and using a custom exception involves three steps:
- Define the class, extending
Exception(for a checked exception) orRuntimeException(for an unchecked one). - Throw it explicitly with the
throwstatement wherever the exceptional condition is detected. - Declare it in the
throwsclause of any method that can throw it without catching it (required for checked exceptions; optional, but often good documentation, for unchecked ones).
class StackFullException extends Exception {
public StackFullException(String message) {
super(message);
}
}
class BoundedStack {
private int[] data;
private int top = -1;
BoundedStack(int capacity) {
data = new int[capacity];
}
boolean isFull() {
return top == data.length - 1;
}
void push(int value) throws StackFullException {
if (isFull()) {
throw new StackFullException("Cannot push " + value + ": stack is full");
}
top += 1;
data[top] = value;
}
}
The unchecked version of the same idea looks almost identical, just extending RuntimeException instead — and without any compiler-enforced obligation on the caller:
class NegativeCapacityException extends RuntimeException {
public NegativeCapacityException(int capacity) {
super("capacity must be >= 0, got " + capacity);
}
}
BoundedStack(int capacity) {
if (capacity < 0) {
throw new NegativeCapacityException(capacity); // no throws clause needed or allowed to help
}
data = new int[capacity];
}
Here the choice of RuntimeException is deliberate: passing a negative capacity is a programming mistake, not a condition a well-written caller needs to plan a recovery path for — exactly the kind of failure unchecked exceptions are meant for (see “Checked vs. Unchecked Exceptions” above). StackFullException, by contrast, is a checked exception on purpose: a full stack is a routine, expected condition for any caller pushing values from an unpredictable source, and the compiler forcing a decision about it is a feature, not friction.
A client of BoundedStack now has to decide, at compile time, how to react to a full stack — the compiler will not let push be called without either catching StackFullException or declaring it further up:
class Client {
void run() {
BoundedStack stack = new BoundedStack(3);
try {
stack.push(1);
stack.push(2);
stack.push(3);
stack.push(4); // triggers StackFullException
} catch (StackFullException ex) {
System.out.println("Overflow avoided: " + ex.getMessage());
}
}
}
Notice what this buys you: BoundedStack itself never decides how to react to being full — it doesn’t print anything, doesn’t log anything, doesn’t know anything about the environment it runs in. It only signals the exceptional condition through the type system. The decision of what to do about it (recover, log, abort, ask for new input) belongs entirely to the calling code, which is exactly where that decision should live. This separation is one of the main reasons exceptions make components more reusable: the same BoundedStack class can be embedded in a command-line tool, a GUI, or a server, and each context can react to a full stack in whatever way makes sense there.
Programming with Exceptions: Repair Locally, or Propagate
A method that catches an exception has a genuine choice to make, and it’s worth being deliberate about it:
- Repair locally: undo whatever partial change was made, restore a consistent state, and continue as if nothing happened.
- Propagate: acknowledge that this method cannot fully resolve the problem, and let a different exception (possibly of a more meaningful type) travel up to a caller who is better positioned to decide.
Both can appear in the same class, depending on which layer of responsibility is doing the deciding. Note this next BoundedStack takes a deliberately different implementation strategy from the earlier one: instead of checking isFull()/isEmpty() before acting, it lets the array’s own bounds check do the detection work, then catches and translates the resulting ArrayIndexOutOfBoundsException — a “catch and translate” pattern that’s worth recognizing on its own:
class StackEmptyException extends Exception {}
class StackFullException extends Exception {}
class BoundedStack {
private int[] data;
private int top = -1;
BoundedStack(int capacity) {
data = new int[capacity];
}
int peek() throws StackEmptyException {
try {
return data[top];
} catch (ArrayIndexOutOfBoundsException ex) {
// Internal detail (a negative index) translated into
// a meaningful, class-specific exception, then propagated.
throw new StackEmptyException();
}
}
void push(int value) throws StackFullException {
try {
top += 1;
data[top] = value;
} catch (ArrayIndexOutOfBoundsException ex) {
top -= 1; // repaired locally: undo the increment
throw new StackFullException(); // still propagated, as a clearer type
}
}
}
In push, the low-level ArrayIndexOutOfBoundsException is caught, the object’s internal state is repaired (the top counter is rolled back so the stack stays consistent), and a new, more meaningful exception is thrown in its place. The caller never needs to know that an array was involved at all.
Common Pitfalls
A few habits are worth avoiding deliberately, because they defeat the purpose of the exception mechanism rather than simply being suboptimal style.
Swallowing exceptions. Catching an exception and doing nothing with it hides failures instead of handling them — the program limps on in a state nobody intended, and by the time a symptom shows up, the actual cause is long gone from the stack trace.
// Don't do this: the failure disappears silently
try {
riskyOperation();
} catch (Exception ex) {
// empty — the problem is now invisible
}
At the very least, log the exception; better yet, decide explicitly whether to recover, propagate, or abort.
Catching too broadly. Writing catch (Exception ex) (or worse, catch (Throwable ex)) catches everything, including bugs that have nothing to do with the condition you meant to handle — a NullPointerException from an unrelated typo gets silently treated the same way as the specific failure you were watching for. Catch the most specific exception type the situation calls for, and let anything else propagate.
// Too broad: masks unrelated bugs as if they were the expected failure
try {
stack.push(x);
} catch (Exception ex) {
System.out.println("Something went wrong");
}
// Better: catches exactly what was anticipated
try {
stack.push(x);
} catch (StackFullException ex) {
stack.clear();
}
try-with-resources for cleanup. Manually closing a resource in a finally block, as shown earlier, is verbose and easy to get wrong (what if close() itself throws?). Any class implementing AutoCloseable — including BufferedReader, FileReader, and most I/O classes — can be managed with try-with-resources instead, which closes it automatically, in reverse order of declaration, even if an exception is thrown:
void readFirstLine(String path) throws java.io.IOException {
try (java.io.BufferedReader reader =
new java.io.BufferedReader(new java.io.FileReader(path))) {
System.out.println(reader.readLine());
} // reader.close() is called automatically here
}
This is shorter, and it correctly handles the case where an exception occurs both inside the block and during close() — something the earlier manual finally version had to handle by hand.
Recap
- An exception is an object representing an abnormal, but recoverable, interruption of normal execution; it is instantiated when the exceptional condition occurs and can carry a message and a stack trace.
try/catchintercepts an exception where you can meaningfully react to it;finallyruns regardless of whether an exception occurred, and is the traditional place for cleanup (though try-with-resources is usually cleaner forAutoCloseableresources).- If no
catchblock matches, the exception propagates up the call stack until one does, or until it reachesmainand terminates the program with a printed stack trace. - Unchecked exceptions (subclasses of
RuntimeException) represent programming errors and need nothrowsdeclaration; checked exceptions (subclasses ofExceptionthat are notRuntimeException) represent conditions callers must plan for, and the compiler enforces their declaration viathrows. - Defining your own exception classes lets a component signal failure in its own vocabulary, keeping the “what went wrong” logic separate from the “what to do about it” logic, which belongs to the caller.
- When handling an exception, decide deliberately whether to repair the situation locally or propagate it as a more meaningful type — and avoid swallowing exceptions silently or catching more broadly than the situation calls for.