Java, from nothing · chapter 9 of 33
Exceptions, and choosing what to throw
Chapter 1.9 · Part 1, Java from nothing · about 30 minutes
What you need before this chapter: chapters 1.1 through 1.8. You should be comfortable with classes, objects, fields, methods, constructors, private, references versus values, == versus equals, and null. You should also know how inheritance and polymorphism work, how interfaces and abstract classes differ, and how to use List/Map/Set and generics.
When you finish this chapter you will be able to:
- Explain the difference between a checked and an unchecked exception, and say which Java forces you to handle
- Read a real stack trace top to bottom and find the line that actually caused the crash
- Decide, for a given method, whether it should throw an exception or return an empty
Optional - Say why catching
Exceptionand doing nothing with it is the worst habit you can pick up in Java
1. The idea in plain words
Every method in this course so far has assumed things go right. Ticket gets a real plate and a real arrival time; Map.get gets a key that is actually in the map. Real programs cannot assume that. A parking lot fills up. A caller passes -5 as an arrival minute, or looks up a plate nobody has heard of. Something has to happen when a method cannot do what it promised, and "return some made-up value and hope nobody notices" is how a small mistake becomes a mystery three files away.
An exception is Java's answer: an object that carries what went wrong, thrown from the point of failure. Throwing it stops normal execution and unwinds the call stack until something agrees to handle it. If nothing does, the program stops and prints exactly where the failure started, and every method call that led there. That printout is called a stack trace, and you have already seen several without being told what to call them.
Java splits exceptions into two families, and the compiler enforces the split rather than leaving it as a convention. RuntimeException and its subclasses are unchecked. ClassCastException, IndexOutOfBoundsException, and NullPointerException, all three of which have already crashed a program in this course, are unchecked. Nothing requires you to declare or catch them; the compiler trusts you to keep the code correct enough that they simply do not happen.
Everything else that extends Exception is checked. The compiler will not let you call a method that throws one unless you catch it, or declare that your own method throws it too. A checked exception becomes part of a method's signature: you cannot forget it exists, because the code will not compile until you decide what to do about it.
That leaves the question this chapter spends the most time on. When your own code discovers a problem, should it throw, and if so, which kind? The rule that actually holds up is this.
Throw when the caller broke a rule the method is entitled to assume. A negative arrival time, a full parking lot asked to accept one more car, and an argument that should never have been passed are all examples. Return an empty Optional when absence is a normal, expected answer, not a mistake. A plate that simply has not parked here today is not a bug in the program. Treating it like one by throwing forces every caller to write a try/catch around an everyday case.
2. Type this
A parking lot that has run out of space is the caller-broke-a-rule case. Nobody did anything wrong by asking, but the lot genuinely cannot grant the request, and the caller needs to know that in a way it cannot accidentally ignore. That is what a checked exception is for. Type NoSpotAvailableException.java:
public class NoSpotAvailableException extends Exception {
public NoSpotAvailableException(String message) {
super(message);
}
}
Now SmallLot.java, a lot with a fixed capacity that throws the exception once it is full:
import java.util.ArrayList;
import java.util.List;
public class SmallLot {
private int capacity;
private List<String> occupied = new ArrayList<>();
public SmallLot(int capacity) {
this.capacity = capacity;
}
public void park(String plate) throws NoSpotAvailableException {
if (occupied.size() >= capacity) {
throw new NoSpotAvailableException("lot is full: " + capacity + " spots taken");
}
occupied.add(plate);
}
}
And ParkCars.java, which calls it:
public class ParkCars {
public static void main(String[] args) {
SmallLot lot = new SmallLot(1);
try {
lot.park("KA-01-4432");
lot.park("MH-12-9001");
} catch (NoSpotAvailableException e) {
System.out.println("could not park: " + e.getMessage());
}
}
}
3. Run it
javac NoSpotAvailableException.java SmallLot.java ParkCars.java
java ParkCars
Output:
could not park: lot is full: 1 spots taken
4. What just happened, line by line
public class NoSpotAvailableException extends Exception makes this a checked exception, because it extends Exception directly rather than RuntimeException. super(message) forwards the message up to Exception's own constructor, which is where getMessage() later reads it from.
public void park(String plate) throws NoSpotAvailableException is a throws clause. It tells every caller, and the compiler, "calling this might produce a NoSpotAvailableException, and I am not handling it myself; you have to." This is not a comment. It is checked by the compiler exactly like a method's return type.
throw new NoSpotAvailableException(...) creates the exception object and throws it, right where the rule was broken: the lot is full, and park has no sensible value to return instead. Execution of park stops immediately at this line. It does not reach occupied.add(plate).
try { ... } catch (NoSpotAvailableException e) { ... } in ParkCars is what makes calling park legal despite the throws clause. Java requires this pairing for a checked exception: declare it further up the call chain, or catch it here. e.getMessage() retrieves the string you passed to super(message) when the exception was built.
A RuntimeException skips all of this. Chapter 1.7's IndexOutOfBoundsException and this chapter's own IllegalArgumentException, coming up next, both extend RuntimeException, so nothing forces a throws clause or a catch block. The compiler assumes these represent bugs in the calling code rather than conditions a well-written caller needs to plan for.
5. Errors, and the two decisions that produce them
Forgetting to handle a checked exception does not compile, on purpose. Delete the try/catch from ParkCars and call park directly:
public class ParkCarsNoCatch {
public static void main(String[] args) {
SmallLot lot = new SmallLot(1);
lot.park("KA-01-4432");
lot.park("MH-12-9001");
}
}
ParkCarsNoCatch.java:4: error: unreported exception NoSpotAvailableException; must be caught or declared to be thrown
lot.park("KA-01-4432");
^
ParkCarsNoCatch.java:5: error: unreported exception NoSpotAvailableException; must be caught or declared to be thrown
lot.park("MH-12-9001");
^
2 errors
This is the compiler enforcing the contract throws NoSpotAvailableException made. Either wrap the calls in a try/catch, or add throws NoSpotAvailableException to main itself, passing the decision one level further up.
An unchecked exception for a broken caller rule, and a real stack trace. A negative arrival minute is not a normal outcome to plan around. It means the caller passed a value that should never have been passed. That is exactly the case for an unchecked IllegalArgumentException, thrown from the constructor before a bad Ticket can even be created:
public class Ticket {
private String plate;
private int arrivedAtMinute;
public Ticket(String plate, int arrivedAtMinute) {
if (arrivedAtMinute < 0) {
throw new IllegalArgumentException("arrivedAtMinute cannot be negative: " + arrivedAtMinute);
}
this.plate = plate;
this.arrivedAtMinute = arrivedAtMinute;
}
public String getPlate() { return plate; }
public int getArrivedAtMinute() { return arrivedAtMinute; }
}
public class BadTicket {
public static void main(String[] args) {
Ticket t = new Ticket("KA-01-4432", -5);
System.out.println(t.getPlate());
}
}
Exception in thread "main" java.lang.IllegalArgumentException: arrivedAtMinute cannot be negative: -5
at Ticket.<init>(Ticket.java:7)
at BadTicket.main(BadTicket.java:3)
Read a stack trace from the top down. The first line names the exception and its message: -5 was rejected. The second line, at Ticket.<init>(Ticket.java:7), is where the exception was thrown; <init> is the constructor. The third line, at BadTicket.main(BadTicket.java:3), is who called it. Each at line below the first is one step further back in the chain of calls that led to the failure. The line you almost always care about first is the one right after the exception name.
A missing plate is not a broken rule, so an exception is the wrong tool. Looking up a ticket by plate and finding nothing is an everyday outcome, not a bug, so the method returns Optional<Ticket> instead of throwing:
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class Lot2 {
private List<Ticket> issued = new ArrayList<>();
public void issue(Ticket t) {
issued.add(t);
}
public Optional<Ticket> findByPlate(String plate) {
for (Ticket t : issued) {
if (t.getPlate().equals(plate)) {
return Optional.of(t);
}
}
return Optional.empty();
}
}
Lot2 lot = new Lot2();
lot.issue(new Ticket("KA-01-4432", 555));
Optional<Ticket> found = lot.findByPlate("KA-01-4432");
if (found.isPresent()) {
System.out.println("found: " + found.get().getPlate());
}
Optional<Ticket> missing = lot.findByPlate("DL-3C-0001");
System.out.println("missing present? " + missing.isPresent());
if (missing.isEmpty()) {
System.out.println("no ticket for that plate");
}
found: KA-01-4432
missing present? false
no ticket for that plate
Optional<Ticket> is a small wrapper that either holds a Ticket or holds nothing, and it forces the caller to check which one before reaching in with .get(). Compare this to the Map.get behaviour from chapter 1.7: Map.get on a missing key also returns something representing absence, but it does that by returning null, silently, with no type-level warning. Optional.empty() makes the same "nothing here" outcome visible in the method's own return type, which is why new code in this course prefers it over returning null directly.
Catching Exception and doing nothing is the worst habit in Java, and here is why. It looks like error handling. It is the opposite:
SmallLot lot = new SmallLot(1);
try {
lot.park("KA-01-4432");
lot.park("MH-12-9001");
} catch (Exception e) {
}
System.out.println("program finished, apparently fine");
program finished, apparently fine
The second car was never parked, the caller was never told, and nothing about this output admits that anything went wrong. catch (Exception e) {} swallows every exception type at once, including ones you never anticipated, and throws away the information that would have told you what broke. If you must catch broadly, at minimum log the exception with its message and stack trace, or handle it in a way that actually recovers. An empty catch block is a program lying to whoever runs it.
6. Your turn
Add a method to Lot2, findFirstArrivedAfter(int minute), that returns Optional<Ticket> for the first ticket in issued that arrived after the given minute, or an empty Optional if none did. Test it against a lot holding tickets at minutes 555 and 630, once with 600 and once with 700.
Do it before reading on.
The answer:
public Optional<Ticket> findFirstArrivedAfter(int minute) {
for (Ticket t : issued) {
if (t.getArrivedAtMinute() > minute) {
return Optional.of(t);
}
}
return Optional.empty();
}
Lot2 lot = new Lot2();
lot.issue(new Ticket("KA-01-4432", 555));
lot.issue(new Ticket("TN-22-7788", 630));
System.out.println(lot.findFirstArrivedAfter(600).isPresent());
System.out.println(lot.findFirstArrivedAfter(700).isPresent());
true
false
No exception anywhere. Not finding a match after minute 700 is exactly as valid an outcome as finding one after minute 600, and the return type says so.
Going deeper
Throwing an exception is not free, and the cost is not the object allocation you might guess. It is a method called fillInStackTrace, and it runs automatically, every time, the moment you write `throw new SomeException(...)`. It walks the entire call stack at that instant and records every frame, which is how a stack trace can print the full chain of calls after the stack has already unwound. That walk costs real time, and the cost scales with how deep the call stack is when the exception is thrown.
This is measurable directly, by overriding fillInStackTrace to skip the walk and comparing:
public class StackTraceCost {
static class NormalException extends RuntimeException {
NormalException(String message) { super(message); }
}
static class NoTraceException extends RuntimeException {
NoTraceException(String message) { super(message); }
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
}
static long timeThrows(int count, boolean withTrace) {
long start = System.nanoTime();
for (int i = 0; i < count; i++) {
try {
if (withTrace) {
throw new NormalException("boom");
} else {
throw new NoTraceException("boom");
}
} catch (RuntimeException e) {
// discard, this loop only measures the throw
}
}
return System.nanoTime() - start;
}
public static void main(String[] args) {
timeThrows(50_000, true);
timeThrows(50_000, false);
int count = 200_000;
long withTrace = timeThrows(count, true);
long withoutTrace = timeThrows(count, false);
System.out.println("with stack trace: " + (withTrace / 1_000_000) + " ms for " + count + " throws");
System.out.println("without stack trace: " + (withoutTrace / 1_000_000) + " ms for " + count + " throws");
}
}
Run on the machine used to write this course, JDK 21, after a warm-up loop to let the JIT compiler settle:
with stack trace: 87 ms for 200000 throws
without stack trace: 2 ms for 200000 throws
That is roughly forty times slower with the stack trace filled in, and repeated runs on the same machine varied between 15x and 40x, never close. The exact multiple depends on stack depth and the machine it runs on, but the direction never changes: capturing the trace dominates the cost of the throw itself.
The lesson is not "avoid exceptions." It is "never use an exception as a substitute for an if statement in a hot loop." Picture a for loop that throws and catches an exception on every iteration to signal "value not found, try the next one." That is not a style mistake, it is a real performance bug, because every one of those throws pays the full stack-walk cost whether or not anyone ever reads the trace. Optional, a boolean return, or a plain loop with a normal exit condition all cost close to nothing by comparison. Reserve throw for the case it was designed for: something genuinely exceptional, not a routine branch dressed up as one.
7. Why this matters in an interview
You will make the throw-versus-Optional decision from this chapter dozens of times while designing a system under pressure. Interviewers notice it precisely because most candidates never think about it consciously. A findSpot method that throws on "no spot available" forces every caller to wrap it in a try/catch, for what is, in a parking lot, an entirely ordinary state of the world. Reaching for Optional there, and reserving throw for genuine rule violations, reads as design judgment rather than syntax fluency.
The stack trace in section 5 is also a skill by itself. Interviewers will show you a crash and ask what happened. "Read the second line, that's where it was thrown, then the exception name and message above it" is the entire method. Candidates who stare at a stack trace looking for the answer to appear lose real time in a round that is already tight.
The empty catch (Exception e) {} in section 5 is worth remembering by its consequence, not its syntax. It is one of the most common ways a real production bug goes unnoticed for months, because the program never says anything is wrong.
Next: chapter 1.10, enum and record: the two types you will reach for most, the last chapter of Part 1. These are the two Java features you will use in nearly every class you design from here on.
← 1.8 Generics, as far as you actually need them · All chapters · 1.10 enum and record: the two types you will reach for most →