Exceptions in Java, Explained
Before we get to exceptions specifically, it helps to zoom out and look at the three kinds of errors you'll run into as a Java developer.
Three kinds of errors
Compile-time errors — usually caused by bad syntax. The code won't even build.
Logical errors — the code compiles and runs fine, but produces the wrong result because the logic is flawed, not the syntax.
Runtime errors — the code compiles fine, but something goes wrong while it's actually running, and execution breaks.
A classic example of a runtime problem: your application depends on a file, the file gets deleted from the server, and the moment your code tries to read it — crash. This is exactly the kind of situation exceptions exist to handle, so your application can respond gracefully instead of dying mid-execution.
Handling exceptions with try/catch
Arithmetic exception
public class StoreApplication {
public static void main(String[] args) {
int i = 45;
int j = 0;
try {
int output = i / j;
System.out.println("The result of the division: " + output);
} catch (Exception e) {
System.out.println("Execution could have stopped here due to this error: \"" + e.getMessage() + "\"");
}
System.out.println("Execution continues.");
}
}
Array index out of bounds
public class StoreApplication {
public static void main(String[] args) {
int[] nums = new int[5]; // valid indices: 0 to 4
try {
System.out.println("array value at position one: " + nums[1]);
System.out.println("array value at position five: " + nums[5]); // out of bounds
} catch (Exception e) {
System.out.println("Execution could have stopped here due to this error: \"" + e.getMessage() + "\"");
}
System.out.println("Execution continues.");
}
}
Multiple catch blocks
You can catch different exception types separately, which lets you respond differently depending on what actually went wrong:
public class StoreApplication {
public static void main(String[] args) {
int i = 45;
int j = 4;
int[] nums = new int[5];
try {
int output = i / j;
System.out.println("array value at position one: " + nums[1]);
System.out.println("array value at position five: " + nums[5]);
System.out.println("The result of the division: " + output);
} catch (ArithmeticException e) {
System.out.println("Arithmetic exception caught: \"" + e.getMessage() + "\"");
} catch (IndexOutOfBoundsException e) {
System.out.println("Index out of bounds exception caught: \"" + e.getMessage() + "\"");
} catch (Exception e) {
System.out.println("Some other exception caught: \"" + e.getMessage() + "\"");
}
System.out.println("Execution continues.");
}
}
Important ordering rule: catch blocks are checked top to bottom, and only the first match runs. If you put the general Exception catch before the more specific ones, the specific catch blocks become unreachable — the compiler will actually flag this as an error, because Exception would already catch everything below it:
This won't compile — unreachable catch blocks
try {
// ...
} catch (Exception e) {
// this catches everything first
} catch (ArithmeticException e) {
// unreachable
} catch (IndexOutOfBoundsException e) {
// unreachable
}
The rule of thumb: most specific exception first, most general last.
Multi-catch shorthand
If two exception types should be handled identically, you don't need two blocks — Java lets you combine them with |:
try {
// risky code
} catch (ArithmeticException | IndexOutOfBoundsException e) {
System.out.println("Caught a numeric or bounds error: " + e.getMessage());
}
The Throwable hierarchy
Every error and exception in Java is a subclass of Throwable, which sits directly under Object. Throwable is a class, not an interface — it's unrelated to marker/functional interfaces like Cloneable, Runnable, or Serializable, even though the naming pattern looks similar at a glance.
Object
└── Throwable
├── Error
│ ├── IOError
│ ├── ThreadDeath
│ └── VirtualMachineError (e.g. OutOfMemoryError)
│
└── Exception
├── RuntimeException
│ ├── ArithmeticException
│ ├── IndexOutOfBoundsException
│ │ └── ArrayIndexOutOfBoundsException
│ └── NullPointerException
│
├── SQLException
└── IOException
Two branches matter most in day-to-day code:
Errors — things you generally can't recover from (like running out of memory). You typically don't catch these; when they happen, execution simply stops.
Exceptions — things you can and should handle, so your program keeps running.
Checked vs. unchecked exceptions
Unchecked exceptions are RuntimeException and everything under it (ArithmeticException, NullPointerException, etc.). The compiler doesn't force you to catch or declare them.
Checked exceptions are everything else under Exception (IOException, SQLException, etc.). The compiler does force you to either catch them or declare them with throws.
This leads us into a keyword distinction that's easy to mix up.
throw vs. throws
These look similar but do completely different jobs:
throw actually throws an exception instance, at the point where something goes wrong:
if (balance < amount) {
throw new IllegalArgumentException("Insufficient balance");
}
throws goes in a method signature to declare that the method might throw a checked exception, so callers know they need to handle it:
public void readFile(String path) throws IOException {
// ...
}
The finally block
A finally block runs whether or not an exception was thrown — it's the place for cleanup code (closing a connection, releasing a resource) that absolutely must happen either way:
try {
int output = 45 / 0;
} catch (ArithmeticException e) {
System.out.println("Caught: " + e.getMessage());
} finally {
System.out.println("This always runs, exception or not.");
}
try-with-resources
For anything that needs closing (files, streams, connections), modern Java offers try-with-resources, which closes the resource automatically — no finally block needed:
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
System.out.println(reader.readLine());
} catch (IOException e) {
System.out.println("Could not read file: " + e.getMessage());
}
Custom exceptions
Sometimes the built-in exceptions don't describe your problem well enough, and a custom exception communicates intent much more clearly than a generic one. To create one, extend Exception (for a checked exception) and pass the message up to the parent via super:
class InsufficientBalanceException extends Exception {
public InsufficientBalanceException(String message) {
super(message);
}
}
public class BankAccount {
private double balance = 100.0;
public void withdraw(double amount) throws InsufficientBalanceException {
if (amount > balance) {
throw new InsufficientBalanceException("Cannot withdraw " + amount + ", balance is only " + balance);
}
balance -= amount;
}
public static void main(String[] args) {
BankAccount account = new BankAccount();
try {
account.withdraw(150.0);
} catch (InsufficientBalanceException e) {
System.out.println("Transaction failed: " + e.getMessage());
}
}
}
If you want an unchecked custom exception instead — the one callers aren't forced to catch — extend RuntimeException instead of Exception, following the same pattern.
Wrapping up
Exceptions exist so your program can fail safely instead of failing silently or crashing outright. The real skill isn't just wrapping code in try/catch — it's knowing which exceptions are worth catching specifically, which ones should bubble up, and when a custom exception communicates your intent better than a generic one.
What's a tricky exception-handling bug you've run into? Share it in the comments.
Top comments (0)