An exception is an unexpected event that occurs during the execution of a program and disrupts the normal flow of instructions. Exception handling is the mechanism Java provides to detect these events and respond to them gracefully, so your program does not crash.
Think of it like a safety net under a tightrope walker. The walker (your program) is crossing a bridge (executing code). If the bridge breaks (an error occurs), instead of falling to the ground (crashing), the walker lands safely in the net (the exception handler) and can decide what to do next – try again, take a different path, or alert someone for help.
Without exception handling, any unexpected situation – a missing file, a network timeout, dividing by zero, accessing a null reference – causes your program to terminate immediately with an ugly stack trace. In a production application serving thousands of users, that is unacceptable.
Here is what happens when you do not handle an exception:
public class NoHandling {
public static void main(String[] args) {
int numerator = 10;
int denominator = 0;
// This line throws ArithmeticException -- program crashes here
int result = numerator / denominator;
// This line NEVER executes
System.out.println("Result: " + result);
System.out.println("Program finished.");
}
}
// Output:
// Exception in thread "main" java.lang.ArithmeticException: / by zero
// at NoHandling.main(NoHandling.java:7)
Notice two important things: the program crashed immediately at line 7, and the remaining lines never executed. Now compare that with proper exception handling:
public class WithHandling {
public static void main(String[] args) {
int numerator = 10;
int denominator = 0;
try {
int result = numerator / denominator;
System.out.println("Result: " + result);
} catch (ArithmeticException e) {
System.out.println("Error: Cannot divide by zero.");
}
// This line DOES execute because the exception was handled
System.out.println("Program finished.");
}
}
// Output:
// Error: Cannot divide by zero.
// Program finished.
With exception handling, the program catches the error, handles it gracefully, and continues running. This is the fundamental purpose of exception handling: keeping your program alive and responsive, even when things go wrong.
Common situations that cause exceptions:
FileNotFoundException)ArrayIndexOutOfBoundsException)null reference (NullPointerException)SQLException)NumberFormatException)OutOfMemoryError)
Java organizes all exceptions and errors in a class hierarchy rooted at the Throwable class. Understanding this hierarchy is essential because it determines which exceptions you can catch, which you must declare, and which you should never try to handle.
java.lang.Object
|
java.lang.Throwable
/ \
java.lang.Error java.lang.Exception
| / \
(Do NOT catch) Checked RuntimeException
| Exceptions (Unchecked)
| | |
OutOfMemoryError IOException NullPointerException
StackOverflowError SQLException IllegalArgumentException
VirtualMachineError FileNotFound ArrayIndexOutOfBounds
ClassNotFound ArithmeticException
NumberFormatException
ClassCastException
Throwable is the root class of Java’s exception hierarchy. Only objects that are instances of Throwable (or its subclasses) can be thrown with the throw keyword or caught with a catch block. It has two direct subclasses: Error and Exception.
Error represents serious problems that a reasonable application should not try to catch or recover from. These are typically conditions where the JVM itself is in trouble:
OutOfMemoryError – The JVM ran out of heap memory. You cannot meaningfully recover from this.StackOverflowError – A method called itself recursively too many times, exhausting the call stack.VirtualMachineError – The JVM is broken or has run out of resources to operate.If you see an Error, the correct response is usually to fix the root cause in your code (e.g., fix the infinite recursion) or increase JVM resources (e.g., increase heap size with -Xmx), not to catch it at runtime.
Exception represents conditions that a program might reasonably want to catch and recover from. This class branches into two categories based on whether the compiler forces you to handle them:
Exception (but not RuntimeException). The compiler requires you to handle or declare them.RuntimeException. The compiler does not force you to handle them.
This is one of the most important distinctions in Java and a common interview question. The difference comes down to one question: does the compiler force you to handle it?
Checked exceptions are exceptions that the compiler checks at compile time. If a method can throw a checked exception, you must either catch it with a try-catch block or declare it in the method signature with the throws keyword. If you do neither, your code will not compile.
Checked exceptions represent recoverable conditions that are outside your program’s control – things like a file not existing, a network connection failing, or a database being unavailable. The compiler forces you to think about these scenarios.
Common checked exceptions:
IOException – Input/output operation failedFileNotFoundException – File does not exist at the specified pathSQLException – Database access errorClassNotFoundException – A class could not be found at runtimeInterruptedException – A thread was interrupted while waitingParseException – Error while parsing a date or other formatted textimport java.io.FileReader;
import java.io.IOException;
public class CheckedExample {
public static void main(String[] args) {
// This will NOT compile without handling IOException
// FileReader constructor throws FileNotFoundException (a checked exception)
// Option 1: Handle with try-catch
try {
FileReader reader = new FileReader("data.txt");
int character = reader.read(); // read() throws IOException
System.out.println((char) character);
reader.close();
} catch (IOException e) {
System.out.println("Could not read file: " + e.getMessage());
}
}
// Option 2: Declare with throws
public static void readFile() throws IOException {
FileReader reader = new FileReader("data.txt");
int character = reader.read();
System.out.println((char) character);
reader.close();
}
}
Unchecked exceptions are subclasses of RuntimeException. The compiler does not require you to handle or declare them. They typically represent programming bugs – mistakes in your logic that should be fixed in the code itself, not caught at runtime.
Common unchecked exceptions:
NullPointerException – Called a method on a null referenceArrayIndexOutOfBoundsException – Accessed an invalid array indexIllegalArgumentException – Passed an invalid argument to a methodArithmeticException – Illegal arithmetic operation (e.g., divide by zero)NumberFormatException – Tried to parse a non-numeric stringClassCastException – Invalid type castIllegalStateException – Method called at an inappropriate timeUnsupportedOperationException – Requested operation is not supportedpublic class UncheckedExample {
public static void main(String[] args) {
// NullPointerException -- calling method on null
String name = null;
// name.length(); // Throws NullPointerException
// ArrayIndexOutOfBoundsException -- invalid index
int[] numbers = {1, 2, 3};
// int value = numbers[5]; // Throws ArrayIndexOutOfBoundsException
// NumberFormatException -- invalid number format
// int num = Integer.parseInt("abc"); // Throws NumberFormatException
// ArithmeticException -- divide by zero
// int result = 10 / 0; // Throws ArithmeticException
// IllegalArgumentException -- invalid argument
// Thread.sleep(-1); // Throws IllegalArgumentException
// You CAN catch unchecked exceptions, but often it is better
// to prevent them with proper validation
String input = "abc";
try {
int parsed = Integer.parseInt(input);
System.out.println("Parsed: " + parsed);
} catch (NumberFormatException e) {
System.out.println("'" + input + "' is not a valid number");
}
}
}
// Output:
// 'abc' is not a valid number
| Feature | Checked Exception | Unchecked Exception |
|---|---|---|
| Superclass | Exception (excluding RuntimeException) |
RuntimeException |
| Compile-time check | Yes – must handle or declare | No – compiler does not enforce |
| Typical cause | External conditions (file, network, database) | Programming bugs (null, bad index, bad cast) |
| Recovery | Often recoverable (retry, fallback, user prompt) | Usually indicates a bug to fix in code |
| Examples | IOException, SQLException, FileNotFoundException |
NullPointerException, IllegalArgumentException |
| Best approach | Catch and handle, or propagate with throws |
Prevent with validation and null checks |
The try-catch block is the fundamental mechanism for handling exceptions in Java. You place risky code inside the try block, and the code that handles the error goes in the catch block.
try {
// Code that might throw an exception
// If an exception occurs, execution jumps to the matching catch block
} catch (ExceptionType variableName) {
// Code to handle the exception
// Only runs if the exception type matches
}
Here is how it works step by step:
try block line by line.try block runs, the catch block is skipped, and execution continues after the catch.catch block. The remaining lines in the try block are skipped.catch block finishes, execution continues normally after the entire try-catch structure.public class TryCatchFlow {
public static void main(String[] args) {
System.out.println("1. Before try block");
try {
System.out.println("2. Inside try -- before risky code");
int result = 10 / 0; // ArithmeticException thrown here
System.out.println("3. Inside try -- after risky code"); // SKIPPED
} catch (ArithmeticException e) {
System.out.println("4. Inside catch -- handling error");
}
System.out.println("5. After try-catch -- program continues");
}
}
// Output:
// 1. Before try block
// 2. Inside try -- before risky code
// 4. Inside catch -- handling error
// 5. After try-catch -- program continues
A single try block can have multiple catch blocks to handle different types of exceptions differently. Java checks them from top to bottom and executes the first matching block. Because of this, you must order catch blocks from most specific to most general. If you put a parent exception class before a child, the child catch will be unreachable, and the compiler will report an error.
public class MultipleCatch {
public static void main(String[] args) {
String[] data = {"10", "abc", null};
for (String item : data) {
try {
// Could throw NullPointerException if item is null
int length = item.length();
// Could throw NumberFormatException if item is not numeric
int number = Integer.parseInt(item);
// Could throw ArithmeticException
int result = 100 / number;
System.out.println("100 / " + number + " = " + result);
} catch (NullPointerException e) {
System.out.println("Error: input is null");
} catch (NumberFormatException e) {
System.out.println("Error: '" + item + "' is not a number");
} catch (ArithmeticException e) {
System.out.println("Error: division by zero");
}
}
}
}
// Output:
// 100 / 10 = 10
// Error: 'abc' is not a number
// Error: input is null
When you catch an exception, you receive an exception object that contains useful information for debugging. Here are the most commonly used methods:
| Method | Returns | Purpose |
|---|---|---|
getMessage() |
String |
A human-readable description of the error |
printStackTrace() |
void |
Prints the full stack trace to standard error (useful for debugging) |
getCause() |
Throwable |
Returns the underlying exception that caused this one (may be null) |
getClass().getName() |
String |
Returns the fully qualified class name of the exception |
getStackTrace() |
StackTraceElement[] |
Returns the stack trace as an array (for programmatic access) |
public class ExceptionMethods {
public static void main(String[] args) {
try {
int[] arr = new int[3];
arr[10] = 42; // Invalid index
} catch (ArrayIndexOutOfBoundsException e) {
// getMessage() -- short description
System.out.println("Message: " + e.getMessage());
// getClass().getName() -- exception type
System.out.println("Type: " + e.getClass().getName());
// getCause() -- underlying cause (null if none)
System.out.println("Cause: " + e.getCause());
// printStackTrace() -- full stack trace (printed to System.err)
System.out.println("\nFull stack trace:");
e.printStackTrace();
}
}
}
// Output:
// Message: Index 10 out of bounds for length 3
// Type: java.lang.ArrayIndexOutOfBoundsException
// Cause: null
//
// Full stack trace:
// java.lang.ArrayIndexOutOfBoundsException: Index 10 out of bounds for length 3
// at ExceptionMethods.main(ExceptionMethods.java:5)
The finally block is an optional block that comes after try and catch. Its purpose is to execute cleanup code that should always run, regardless of whether an exception occurred or not. This makes it perfect for releasing resources like file handles, database connections, or network sockets.
The finally block runs in all of these scenarios:
try block completes normally (no exception)catch blocktry or catch block contains a return statement (finally runs before the return)The only situation where finally does not run is if the JVM itself shuts down (via System.exit()) or the JVM crashes.
public class FinallyDemo {
public static void main(String[] args) {
// Scenario 1: No exception
System.out.println("--- Scenario 1: No exception ---");
try {
System.out.println("Try: doing work...");
} catch (Exception e) {
System.out.println("Catch: handling error");
} finally {
System.out.println("Finally: cleanup runs");
}
// Scenario 2: Exception caught
System.out.println("\n--- Scenario 2: Exception caught ---");
try {
int result = 10 / 0;
} catch (ArithmeticException e) {
System.out.println("Catch: " + e.getMessage());
} finally {
System.out.println("Finally: cleanup runs");
}
// Scenario 3: finally runs even with return
System.out.println("\n--- Scenario 3: Return in try ---");
System.out.println("getValue() returned: " + getValue());
}
public static String getValue() {
try {
System.out.println("Try: about to return");
return "from try";
} finally {
System.out.println("Finally: runs BEFORE the return");
}
}
}
// Output:
// --- Scenario 1: No exception ---
// Try: doing work...
// Finally: cleanup runs
//
// --- Scenario 2: Exception caught ---
// Catch: / by zero
// Finally: cleanup runs
//
// --- Scenario 3: Return in try ---
// Try: about to return
// Finally: runs BEFORE the return
// getValue() returned: from try
Before Java 7, the standard pattern for working with resources was to open them in the try block and close them in finally. This ensured resources were always released, even if an exception occurred:
import java.io.FileReader;
import java.io.BufferedReader;
import java.io.IOException;
public class FinallyResourceCleanup {
public static void main(String[] args) {
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader("data.txt"));
String line = reader.readLine();
System.out.println("First line: " + line);
} catch (IOException e) {
System.out.println("Error reading file: " + e.getMessage());
} finally {
// Always close the resource, even if an exception occurred
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
System.out.println("Error closing reader: " + e.getMessage());
}
}
}
}
}
Notice how ugly this code is. The finally block needs its own try-catch because close() can also throw an IOException. You also need a null check because the reader might not have been created if the constructor threw an exception. This boilerplate is exactly why Java 7 introduced try-with-resources.
The try-with-resources statement, introduced in Java 7, automatically closes resources when the try block finishes. A resource is any object that implements the AutoCloseable interface (which has a single close() method). This eliminates the need for verbose finally blocks when working with I/O, database connections, or any closeable resource.
// Resource is declared inside parentheses after 'try'
// It is automatically closed when the try block ends
try (ResourceType resource = new ResourceType()) {
// Use the resource
// resource.close() is called automatically when this block ends
} catch (ExceptionType e) {
// Handle exceptions
}
// No finally block needed for closing!
Compare the same file-reading code from the previous section, now using try-with-resources:
import java.io.FileReader;
import java.io.BufferedReader;
import java.io.IOException;
public class TryWithResourcesDemo {
public static void main(String[] args) {
// BufferedReader is AutoCloseable -- it will be closed automatically
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
System.out.println("Error: " + e.getMessage());
}
// reader.close() is called automatically -- no finally needed!
}
}
You can declare multiple resources separated by semicolons. They are closed in reverse order of declaration (last opened, first closed):
import java.io.*;
public class MultipleResources {
public static void main(String[] args) {
// Both reader and writer are auto-closed when the block ends
// writer is closed first, then reader (reverse order)
try (
BufferedReader reader = new BufferedReader(new FileReader("input.txt"));
BufferedWriter writer = new BufferedWriter(new FileWriter("output.txt"))
) {
String line;
while ((line = reader.readLine()) != null) {
writer.write(line.toUpperCase());
writer.newLine();
}
System.out.println("File copied and converted to uppercase.");
} catch (IOException e) {
System.out.println("Error: " + e.getMessage());
}
}
}
Any class can be used with try-with-resources by implementing the AutoCloseable interface:
public class DatabaseConnection implements AutoCloseable {
private String connectionId;
public DatabaseConnection(String url) {
this.connectionId = "conn-" + System.currentTimeMillis();
System.out.println("Opening connection " + connectionId + " to " + url);
}
public void executeQuery(String sql) {
System.out.println("[" + connectionId + "] Executing: " + sql);
}
@Override
public void close() {
System.out.println("Closing connection " + connectionId);
}
public static void main(String[] args) {
try (DatabaseConnection db = new DatabaseConnection("jdbc:mysql://localhost/mydb")) {
db.executeQuery("SELECT * FROM users");
db.executeQuery("UPDATE users SET active = true WHERE id = 1");
}
// close() is called automatically
}
}
// Output:
// Opening connection conn-1709123456789 to jdbc:mysql://localhost/mydb
// [conn-1709123456789] Executing: SELECT * FROM users
// [conn-1709123456789] Executing: UPDATE users SET active = true WHERE id = 1
// Closing connection conn-1709123456789
So far we have been catching exceptions that Java throws for us. But you can also throw exceptions yourself using the throw keyword. This is how you signal that something has gone wrong in your own code – for example, when a method receives an invalid argument or a business rule is violated.
The throw keyword is followed by an exception object (created with new). When Java encounters a throw statement, it immediately stops executing the current method and looks for a matching catch block in the call stack.
public class ThrowExample {
public static void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative: " + age);
}
if (age > 150) {
throw new IllegalArgumentException("Age is unrealistic: " + age);
}
System.out.println("Age set to: " + age);
}
public static void main(String[] args) {
setAge(25); // Works fine
try {
setAge(-5); // Throws IllegalArgumentException
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}
try {
setAge(200); // Throws IllegalArgumentException
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}
}
}
// Output:
// Age set to: 25
// Error: Age cannot be negative: -5
// Error: Age is unrealistic: 200
Sometimes you want to catch an exception, do something with it (like logging), and then re-throw it so the caller can handle it too. You can re-throw the same exception or wrap it in a different one:
import java.io.IOException;
public class RethrowExample {
public static void processFile(String path) throws IOException {
try {
// Simulate file processing
if (path == null) {
throw new IOException("File path is null");
}
System.out.println("Processing: " + path);
} catch (IOException e) {
System.out.println("LOG: Error processing file - " + e.getMessage());
throw e; // Re-throw the same exception to the caller
}
}
public static void main(String[] args) {
try {
processFile(null);
} catch (IOException e) {
System.out.println("MAIN: Caught re-thrown exception - " + e.getMessage());
}
}
}
// Output:
// LOG: Error processing file - File path is null
// MAIN: Caught re-thrown exception - File path is null
These two keywords are related but serve different purposes. Do not confuse them:
| Feature | throw |
throws |
|---|---|---|
| Purpose | Actually throws an exception object | Declares that a method might throw an exception |
| Used with | An exception instance (throw new IOException()) |
Exception class names in method signature |
| Location | Inside a method body | In the method declaration (after parameter list) |
| Count | Throws one exception at a time | Can declare multiple exceptions |
| Example | throw new IOException("fail"); |
void read() throws IOException, SQLException |
When a method can throw a checked exception but does not want to handle it internally, it must declare the exception using the throws clause in its method signature. This tells the caller: “I might throw this exception, so you need to deal with it.”
This mechanism allows exceptions to propagate up the call stack until some method catches them. If no method catches the exception, the JVM terminates the program and prints the stack trace.
import java.io.*;
public class ThrowsDemo {
// Level 3: This method throws IOException -- does not handle it
public static String readFirstLine(String path) throws IOException {
BufferedReader reader = new BufferedReader(new FileReader(path));
String line = reader.readLine();
reader.close();
return line;
}
// Level 2: This method also throws IOException -- passes it up
public static void processConfig(String path) throws IOException {
String firstLine = readFirstLine(path);
System.out.println("Config: " + firstLine);
}
// Level 1: This method catches the exception -- the buck stops here
public static void main(String[] args) {
try {
processConfig("config.properties");
} catch (IOException e) {
System.out.println("Could not load config: " + e.getMessage());
System.out.println("Using default settings instead.");
}
}
}
// If config.properties does not exist:
// Could not load config: config.properties (No such file or directory)
// Using default settings instead.
A method can declare multiple checked exceptions, separated by commas:
import java.io.IOException;
import java.sql.SQLException;
import java.text.ParseException;
public class MultipleThrows {
// Declares three different checked exceptions
public static void importData(String filePath, String dbUrl)
throws IOException, SQLException, ParseException {
// Could throw IOException
// readFile(filePath);
// Could throw ParseException
// parseData(rawData);
// Could throw SQLException
// saveToDatabase(dbUrl, parsedData);
System.out.println("Data imported successfully.");
}
public static void main(String[] args) {
try {
importData("data.csv", "jdbc:mysql://localhost/mydb");
} catch (IOException e) {
System.out.println("File error: " + e.getMessage());
} catch (SQLException e) {
System.out.println("Database error: " + e.getMessage());
} catch (ParseException e) {
System.out.println("Parse error at position: " + e.getErrorOffset());
}
}
}
A common question is: “Should I catch the exception here, or declare it with throws and let the caller handle it?” Here is a practical guide:
| Handle it here (catch) when… | Propagate it (throws) when… |
|---|---|
| You can meaningfully recover (retry, use default, prompt user) | You do not know how the caller wants to handle it |
| You are at the top-level entry point (main, controller, servlet) | You are writing a library or utility method |
| You want to translate it to a different exception type | The caller has more context to make a recovery decision |
| Logging and continuing is the right behavior | The exception represents a condition the caller should know about |
Rule of thumb: Handle the exception at the level that has enough context to decide what to do about it. Utility methods usually propagate; application-level code usually catches.
Java provides many built-in exception classes, but real-world applications often need their own custom exceptions to represent domain-specific error conditions. For example, a banking application might need InsufficientFundsException, and a user management system might need UserNotFoundException.
InsufficientFundsException is far more meaningful than a generic Exception("Not enough money")Exception for a checked custom exception (caller must handle)RuntimeException for an unchecked custom exception (caller does not have to handle)// Checked custom exception -- caller MUST handle or declare
public class InsufficientFundsException extends Exception {
private double currentBalance;
private double withdrawAmount;
// Constructor with message only
public InsufficientFundsException(String message) {
super(message);
}
// Constructor with all details
public InsufficientFundsException(String message, double currentBalance, double withdrawAmount) {
super(message);
this.currentBalance = currentBalance;
this.withdrawAmount = withdrawAmount;
}
// Constructor with message and cause (for exception chaining)
public InsufficientFundsException(String message, Throwable cause) {
super(message, cause);
}
public double getCurrentBalance() {
return currentBalance;
}
public double getWithdrawAmount() {
return withdrawAmount;
}
public double getDeficit() {
return withdrawAmount - currentBalance;
}
}
Now let us use this custom exception in a practical banking example:
public class BankAccount {
private String accountNumber;
private double balance;
public BankAccount(String accountNumber, double initialBalance) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
}
// This method throws our custom checked exception
public void withdraw(double amount) throws InsufficientFundsException {
if (amount <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be positive: " + amount);
}
if (amount > balance) {
throw new InsufficientFundsException(
"Account " + accountNumber + ": Cannot withdraw $" + amount
+ " (balance: $" + balance + ")",
balance,
amount
);
}
balance -= amount;
System.out.println("Withdrew $" + amount + ". New balance: $" + balance);
}
public static void main(String[] args) {
BankAccount account = new BankAccount("ACC-1001", 500.00);
try {
account.withdraw(200.00); // Success
account.withdraw(400.00); // Fails -- only $300 left
} catch (InsufficientFundsException e) {
System.out.println("Transaction failed: " + e.getMessage());
System.out.println("You are short by: $" + e.getDeficit());
}
}
}
// Output:
// Withdrew $200.0. New balance: $300.0
// Transaction failed: Account ACC-1001: Cannot withdraw $400.0 (balance: $300.0)
// You are short by: $100.0
If your exception represents a programming error (misuse of an API) rather than a recoverable condition, extend RuntimeException:
// Unchecked custom exception -- caller does NOT have to handle
public class UserNotFoundException extends RuntimeException {
private String userId;
public UserNotFoundException(String userId) {
super("User not found with ID: " + userId);
this.userId = userId;
}
public UserNotFoundException(String userId, Throwable cause) {
super("User not found with ID: " + userId, cause);
this.userId = userId;
}
public String getUserId() {
return userId;
}
}
// Usage
public class UserService {
public User findById(String userId) {
User user = database.lookup(userId); // returns null if not found
if (user == null) {
throw new UserNotFoundException(userId);
}
return user;
}
}
Before Java 7, if you wanted to handle multiple exception types the same way, you had to write separate catch blocks with duplicated code. Java 7 introduced multi-catch, which lets you catch multiple exception types in a single catch block using the pipe (|) operator.
import java.io.IOException;
import java.sql.SQLException;
import java.text.ParseException;
public class MultiCatchDemo {
// Before Java 7 -- duplicated catch blocks
public static void beforeJava7(String input) {
try {
processInput(input);
} catch (IOException e) {
System.out.println("Error: " + e.getMessage());
logError(e);
} catch (SQLException e) {
System.out.println("Error: " + e.getMessage()); // Same code!
logError(e);
} catch (ParseException e) {
System.out.println("Error: " + e.getMessage()); // Same code!
logError(e);
}
}
// Java 7+ -- single multi-catch block
public static void withMultiCatch(String input) {
try {
processInput(input);
} catch (IOException | SQLException | ParseException e) {
// One block handles all three exception types
System.out.println("Error: " + e.getMessage());
logError(e);
}
}
private static void processInput(String input)
throws IOException, SQLException, ParseException {
// Processing logic that could throw any of these
}
private static void logError(Exception e) {
// Log the error
}
}
IOException | FileNotFoundException is a compile error because FileNotFoundException extends IOException. The parent already covers the child.e = new IOException() will not compile). This is because the type is a union type.public class MultiCatchCombined {
public static void process(String data) {
try {
int number = Integer.parseInt(data);
int[] arr = new int[number];
arr[number] = 42; // Out of bounds if number > 0
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
// Handle these two the same way
System.out.println("Input error: " + e.getMessage());
} catch (NegativeArraySizeException e) {
// Handle this one differently
System.out.println("Cannot create array with negative size");
}
}
public static void main(String[] args) {
process("abc"); // NumberFormatException
process("5"); // ArrayIndexOutOfBoundsException
process("-3"); // NegativeArraySizeException
}
}
// Output:
// Input error: For input string: "abc"
// Input error: Index 5 out of bounds for length 5
// Cannot create array with negative size
Exception chaining (also called wrapping or nesting) is the practice of catching one exception and throwing a new, higher-level exception while preserving the original exception as the cause. This is critical in layered applications where low-level details (like SQL errors) should be translated into meaningful business-level exceptions without losing debugging information.
For example, a UserService catches a SQLException from the database layer but throws a UserNotFoundException to the controller layer. The original SQLException is preserved as the cause so developers can still see the root problem in logs.
import java.sql.SQLException;
public class ExceptionChaining {
// Low-level method -- throws SQLException
public static String queryDatabase(String userId) throws SQLException {
// Simulating a database error
throw new SQLException("Connection refused: database server is down");
}
// Mid-level method -- translates to a business exception
public static String findUserName(String userId) {
try {
return queryDatabase(userId);
} catch (SQLException e) {
// Wrap the low-level exception in a business-level exception
// The original SQLException is preserved as the cause
throw new RuntimeException("Failed to find user: " + userId, e);
}
}
public static void main(String[] args) {
try {
String name = findUserName("user-42");
} catch (RuntimeException e) {
System.out.println("Error: " + e.getMessage());
System.out.println("Root cause: " + e.getCause().getMessage());
System.out.println("Root cause type: " + e.getCause().getClass().getName());
}
}
}
// Output:
// Error: Failed to find user: user-42
// Root cause: Connection refused: database server is down
// Root cause type: java.sql.SQLException
You can attach a cause exception in two ways:
public class ChainingMethods {
public static void main(String[] args) {
// Method 1: Via constructor (preferred -- cleaner and more common)
try {
throw new Exception("original problem");
} catch (Exception original) {
RuntimeException wrapper = new RuntimeException("Higher-level error", original);
// wrapper.getCause() returns the original exception
System.out.println("Wrapper message: " + wrapper.getMessage());
System.out.println("Cause message: " + wrapper.getCause().getMessage());
}
// Method 2: Via initCause() (useful when constructor does not accept cause)
try {
throw new Exception("original problem");
} catch (Exception original) {
RuntimeException wrapper = new RuntimeException("Higher-level error");
wrapper.initCause(original); // Set cause after construction
System.out.println("Wrapper message: " + wrapper.getMessage());
System.out.println("Cause message: " + wrapper.getCause().getMessage());
}
}
}
// Output:
// Wrapper message: Higher-level error
// Cause message: original problem
// Wrapper message: Higher-level error
// Cause message: original problem
Without exception chaining, you lose the original error information:
// BAD: Original exception is lost -- debugging becomes very difficult
try {
riskyOperation();
} catch (SQLException e) {
throw new RuntimeException("Operation failed"); // WHERE did it fail? WHY?
}
// GOOD: Original exception is preserved -- full debugging context available
try {
riskyOperation();
} catch (SQLException e) {
throw new RuntimeException("Operation failed", e); // Full cause chain preserved
}
// When you print the stack trace of the GOOD version, you see:
// RuntimeException: Operation failed
// at MyClass.myMethod(MyClass.java:15)
// Caused by: SQLException: Connection refused
// at Database.query(Database.java:42)
// ... 3 more
Proper exception handling separates professional, production-quality code from code that breaks in unexpected ways. Here are the practices that experienced Java developers follow:
Never catch Exception or Throwable unless you have a very good reason (like a top-level error handler in a web framework). Catching too broadly hides bugs and makes debugging nearly impossible.
// BAD: Catches everything, including bugs you should fix
try {
processOrder(order);
} catch (Exception e) {
System.out.println("Something went wrong"); // What went wrong? No idea.
}
// GOOD: Catch only what you expect and can handle
try {
processOrder(order);
} catch (InsufficientFundsException e) {
notifyUser("Not enough funds. Balance: $" + e.getCurrentBalance());
} catch (ProductOutOfStockException e) {
notifyUser("Sorry, " + e.getProductName() + " is out of stock.");
}
An empty catch block silently swallows the exception. The error still happened, but now you have no idea it did. At minimum, log the exception.
// TERRIBLE: Exception is completely swallowed -- silent failure
try {
saveToDatabase(data);
} catch (SQLException e) {
// Nothing here -- the data was NOT saved, but nobody knows!
}
// ACCEPTABLE: At minimum, log it
try {
saveToDatabase(data);
} catch (SQLException e) {
logger.error("Failed to save data: {}", e.getMessage(), e);
}
// BEST: Log and take appropriate action
try {
saveToDatabase(data);
} catch (SQLException e) {
logger.error("Failed to save to primary DB, trying backup", e);
saveToBackupDatabase(data); // Fallback strategy
}
Exceptions should represent exceptional conditions, not normal program logic. They are significantly slower than regular control flow because the JVM has to build a stack trace object.
// BAD: Using exception as a loop terminator
try {
int i = 0;
while (true) {
System.out.println(array[i++]); // Eventually throws ArrayIndexOutOfBoundsException
}
} catch (ArrayIndexOutOfBoundsException e) {
// Loop "finished"
}
// GOOD: Use normal loop bounds
for (int i = 0; i < array.length; i++) {
System.out.println(array[i]);
}
// BAD: Using exception to check if a string is a number
public static boolean isNumber(String s) {
try {
Integer.parseInt(s);
return true;
} catch (NumberFormatException e) {
return false;
}
}
// GOOD: Use validation instead (for performance-critical code)
public static boolean isNumber(String s) {
if (s == null || s.isEmpty()) return false;
for (char c : s.toCharArray()) {
if (!Character.isDigit(c)) return false;
}
return true;
}
Validate inputs at the beginning of a method and throw immediately if something is wrong. This prevents the method from doing partial work that is hard to undo.
// GOOD: Validate early, fail fast
public void transferMoney(BankAccount from, BankAccount to, double amount) {
// Validate all inputs FIRST, before doing any work
if (from == null) throw new IllegalArgumentException("Source account cannot be null");
if (to == null) throw new IllegalArgumentException("Target account cannot be null");
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive: " + amount);
if (from.equals(to)) throw new IllegalArgumentException("Cannot transfer to the same account");
// Now proceed with the actual transfer
from.withdraw(amount);
to.deposit(amount);
}
Do not use generic exception types like Exception or RuntimeException for your business rules. Create specific exception classes that make your code self-documenting.
Use try-with-resources for anything that implements AutoCloseable. This applies to file handles, database connections, network sockets, and streams.
Exception messages should include the context needed for debugging: what was expected, what actually happened, and any relevant variable values.
// BAD: Vague messages that do not help debugging
throw new IllegalArgumentException("Invalid input");
throw new RuntimeException("Error occurred");
throw new IOException("Failed");
// GOOD: Specific messages with context
throw new IllegalArgumentException("Age must be between 0 and 150, but was: " + age);
throw new RuntimeException("Failed to send email to " + recipient + " via SMTP server " + host);
throw new IOException("Cannot read config file: " + path + " (file does not exist)");
Use checked exceptions for recoverable conditions that the caller must handle (file not found, network timeout). Use unchecked exceptions for programming bugs that should be fixed in the code (null arguments, invalid state).
| Practice | Do | Do Not |
|---|---|---|
| Catch specificity | Catch the most specific exception type | Catch Exception or Throwable |
| Empty catch | Log the error or take action | Leave catch block empty |
| Flow control | Use normal control flow (if/else, loops) | Use try-catch as if/else replacement |
| Validation | Fail fast with descriptive messages | Let errors propagate silently |
| Resources | Use try-with-resources | Rely on manual close in finally |
| Messages | Include context (values, identifiers) | Write vague messages ("Error occurred") |
| Chaining | Preserve the original cause | Throw new exception without cause |
Even experienced developers make exception handling mistakes. Here are the most common anti-patterns and how to avoid them.
This is the single most dangerous mistake. Catching an exception and doing nothing with it means errors happen silently, and you will spend hours debugging something that the exception message would have told you in two seconds.
// MISTAKE: Silent failure -- data is NOT saved, but the program acts like it was
public void saveUser(User user) {
try {
database.insert(user);
} catch (SQLException e) {
// Silently swallowed -- the user thinks their data was saved
}
}
// FIX: Always handle the error meaningfully
public void saveUser(User user) {
try {
database.insert(user);
} catch (SQLException e) {
logger.error("Failed to save user {}: {}", user.getId(), e.getMessage(), e);
throw new DataAccessException("Could not save user " + user.getId(), e);
}
}
Catching Exception catches everything, including NullPointerException, ClassCastException, and other bugs you should find and fix, not catch and ignore.
// MISTAKE: Catches bugs along with expected exceptions
try {
String name = null;
processUser(name.toUpperCase()); // NullPointerException -- this is a bug!
} catch (Exception e) {
System.out.println("User processing failed"); // Hides the NPE bug
}
// FIX: Catch only specific expected exceptions
try {
processUser(name);
} catch (UserNotFoundException e) {
System.out.println("User not found: " + e.getMessage());
}
// Let NullPointerException propagate -- it is a bug that needs fixing!
When you catch one exception and throw another, always pass the original as the cause. Without it, you lose the information about where the problem actually started.
// MISTAKE: Original exception is lost
try {
readConfigFile();
} catch (IOException e) {
throw new RuntimeException("Config loading failed");
// The IOException and its stack trace are gone forever
}
// FIX: Preserve the original exception as the cause
try {
readConfigFile();
} catch (IOException e) {
throw new RuntimeException("Config loading failed", e);
// Now the full chain is available: RuntimeException -> IOException
}
Forgetting to close resources leads to resource leaks – file handles, database connections, or network sockets that stay open and are never released, eventually causing the application to run out of resources.
// MISTAKE: Resource leak -- if readLine() throws, the reader is never closed
public String readFile(String path) throws IOException {
BufferedReader reader = new BufferedReader(new FileReader(path));
String content = reader.readLine();
reader.close(); // Never reached if readLine() throws
return content;
}
// FIX: Use try-with-resources -- close is guaranteed
public String readFile(String path) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
return reader.readLine(); // reader is closed automatically
}
}
Declaring throws Exception on a method forces every caller to catch Exception, which defeats the purpose of Java's exception type system.
// MISTAKE: Forces callers to catch the vague "Exception" type
public void doWork() throws Exception {
// ...
}
// FIX: Declare specific exceptions
public void doWork() throws IOException, SQLException {
// Now callers know exactly what can go wrong
}
Returning from a finally block silently swallows any exception thrown in the try or catch block. The exception is discarded, and the return value from finally is used instead. This is extremely confusing and should be avoided.
// MISTAKE: return in finally swallows the exception
public static int badMethod() {
try {
throw new RuntimeException("Something went wrong!");
} finally {
return 42; // The RuntimeException is silently discarded!
}
}
// The caller gets 42 and has no idea an exception occurred
System.out.println(badMethod()); // Prints: 42 (no exception!)
// FIX: Never return from a finally block
public static int goodMethod() {
try {
// do work
return 42;
} finally {
// cleanup only -- no return statement here
System.out.println("Cleanup completed");
}
}
Let us build a complete banking transaction system that demonstrates everything we have learned. This example includes custom exceptions, exception chaining, try-with-resources, proper error handling, and best practices. Study this code carefully – it shows how exception handling works in a real application.
First, we define three custom exceptions for our banking domain. Each carries relevant context for debugging and user-facing messages.
// Checked exception -- caller must handle
public class InsufficientFundsException extends Exception {
private final String accountId;
private final double requestedAmount;
private final double availableBalance;
public InsufficientFundsException(String accountId, double requested, double available) {
super(String.format("Account %s: requested $%.2f but only $%.2f available",
accountId, requested, available));
this.accountId = accountId;
this.requestedAmount = requested;
this.availableBalance = available;
}
public String getAccountId() { return accountId; }
public double getRequestedAmount() { return requestedAmount; }
public double getAvailableBalance() { return availableBalance; }
public double getDeficit() { return requestedAmount - availableBalance; }
}
// Unchecked exception -- programming error (account does not exist)
public class AccountNotFoundException extends RuntimeException {
private final String accountId;
public AccountNotFoundException(String accountId) {
super("Account not found: " + accountId);
this.accountId = accountId;
}
public AccountNotFoundException(String accountId, Throwable cause) {
super("Account not found: " + accountId, cause);
this.accountId = accountId;
}
public String getAccountId() { return accountId; }
}
// Checked exception -- represents a transaction failure
public class TransactionException extends Exception {
private final String transactionId;
public TransactionException(String transactionId, String message) {
super(message);
this.transactionId = transactionId;
}
public TransactionException(String transactionId, String message, Throwable cause) {
super(message, cause);
this.transactionId = transactionId;
}
public String getTransactionId() { return transactionId; }
}
The account class uses fail-fast validation and throws meaningful exceptions:
public class BankAccount {
private final String accountId;
private final String ownerName;
private double balance;
public BankAccount(String accountId, String ownerName, double initialBalance) {
// Fail fast -- validate all inputs immediately
if (accountId == null || accountId.isBlank()) {
throw new IllegalArgumentException("Account ID cannot be null or blank");
}
if (ownerName == null || ownerName.isBlank()) {
throw new IllegalArgumentException("Owner name cannot be null or blank");
}
if (initialBalance < 0) {
throw new IllegalArgumentException(
"Initial balance cannot be negative: " + initialBalance);
}
this.accountId = accountId;
this.ownerName = ownerName;
this.balance = initialBalance;
}
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Deposit amount must be positive: " + amount);
}
balance += amount;
}
public void withdraw(double amount) throws InsufficientFundsException {
if (amount <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be positive: " + amount);
}
if (amount > balance) {
throw new InsufficientFundsException(accountId, amount, balance);
}
balance -= amount;
}
public String getAccountId() { return accountId; }
public String getOwnerName() { return ownerName; }
public double getBalance() { return balance; }
@Override
public String toString() {
return String.format("Account[%s, %s, $%.2f]", accountId, ownerName, balance);
}
}
The service layer coordinates transfers and demonstrates exception chaining – it catches low-level exceptions and wraps them in business-level TransactionException:
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class TransactionService {
private final Map accounts = new HashMap<>();
public void addAccount(BankAccount account) {
accounts.put(account.getAccountId(), account);
}
public BankAccount getAccount(String accountId) {
BankAccount account = accounts.get(accountId);
if (account == null) {
throw new AccountNotFoundException(accountId);
}
return account;
}
public String transfer(String fromId, String toId, double amount)
throws TransactionException {
String txId = "TX-" + UUID.randomUUID().toString().substring(0, 8);
// Fail fast -- validate inputs before doing any work
if (fromId == null || toId == null) {
throw new TransactionException(txId, "Account IDs cannot be null");
}
if (fromId.equals(toId)) {
throw new TransactionException(txId, "Cannot transfer to the same account");
}
if (amount <= 0) {
throw new TransactionException(txId,
"Transfer amount must be positive: " + amount);
}
try {
// These throw AccountNotFoundException (unchecked) if not found
BankAccount fromAccount = getAccount(fromId);
BankAccount toAccount = getAccount(toId);
System.out.println("[" + txId + "] Transferring $" + amount
+ " from " + fromId + " to " + toId);
// withdraw() throws InsufficientFundsException (checked)
fromAccount.withdraw(amount);
toAccount.deposit(amount);
System.out.println("[" + txId + "] Transfer successful");
System.out.println(" " + fromAccount);
System.out.println(" " + toAccount);
return txId;
} catch (InsufficientFundsException e) {
// Exception chaining: wrap business exception in transaction exception
throw new TransactionException(txId,
"Transfer failed: insufficient funds", e);
} catch (AccountNotFoundException e) {
// Exception chaining: wrap runtime exception in transaction exception
throw new TransactionException(txId,
"Transfer failed: " + e.getMessage(), e);
}
}
}
The logger demonstrates try-with-resources with a custom AutoCloseable:
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class TransactionLogger implements AutoCloseable {
private final PrintWriter writer;
private final DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public TransactionLogger(String logFile) throws IOException {
// true = append mode
this.writer = new PrintWriter(new FileWriter(logFile, true));
log("INFO", "Transaction logger started");
}
public void log(String level, String message) {
String timestamp = LocalDateTime.now().format(formatter);
String entry = String.format("[%s] [%s] %s", timestamp, level, message);
writer.println(entry);
writer.flush();
System.out.println(entry); // Also print to console
}
public void logSuccess(String txId, String fromId, String toId, double amount) {
log("SUCCESS", String.format("%s: $%.2f transferred from %s to %s",
txId, amount, fromId, toId));
}
public void logFailure(String txId, String reason, Throwable cause) {
log("FAILURE", txId + ": " + reason);
if (cause != null) {
log("DETAIL", "Cause: " + cause.getClass().getSimpleName()
+ " - " + cause.getMessage());
}
}
@Override
public void close() {
log("INFO", "Transaction logger shutting down");
writer.close(); // Release the file handle
}
}
The main class ties everything together and demonstrates proper exception handling at every level:
import java.io.IOException;
public class BankingApp {
public static void main(String[] args) {
// Set up accounts
TransactionService service = new TransactionService();
service.addAccount(new BankAccount("ACC-001", "Alice Johnson", 1000.00));
service.addAccount(new BankAccount("ACC-002", "Bob Smith", 500.00));
service.addAccount(new BankAccount("ACC-003", "Charlie Brown", 250.00));
// try-with-resources: logger is automatically closed when block ends
try (TransactionLogger logger = new TransactionLogger("transactions.log")) {
// Transaction 1: Successful transfer
try {
String txId = service.transfer("ACC-001", "ACC-002", 200.00);
logger.logSuccess(txId, "ACC-001", "ACC-002", 200.00);
} catch (TransactionException e) {
logger.logFailure("N/A", e.getMessage(), e.getCause());
}
// Transaction 2: Insufficient funds
try {
String txId = service.transfer("ACC-003", "ACC-001", 999.99);
logger.logSuccess(txId, "ACC-003", "ACC-001", 999.99);
} catch (TransactionException e) {
logger.logFailure(e.getTransactionId(), e.getMessage(), e.getCause());
}
// Transaction 3: Account not found
try {
String txId = service.transfer("ACC-001", "ACC-999", 50.00);
logger.logSuccess(txId, "ACC-001", "ACC-999", 50.00);
} catch (TransactionException e) {
logger.logFailure(e.getTransactionId(), e.getMessage(), e.getCause());
}
// Transaction 4: Invalid amount
try {
String txId = service.transfer("ACC-001", "ACC-002", -100.00);
logger.logSuccess(txId, "ACC-001", "ACC-002", -100.00);
} catch (TransactionException e) {
logger.logFailure(e.getTransactionId(), e.getMessage(), e.getCause());
}
// Transaction 5: Same account transfer
try {
String txId = service.transfer("ACC-001", "ACC-001", 50.00);
logger.logSuccess(txId, "ACC-001", "ACC-001", 50.00);
} catch (TransactionException e) {
logger.logFailure(e.getTransactionId(), e.getMessage(), e.getCause());
}
// Print final balances
logger.log("INFO", "=== Final Balances ===");
for (String id : new String[]{"ACC-001", "ACC-002", "ACC-003"}) {
BankAccount acc = service.getAccount(id);
logger.log("INFO", acc.toString());
}
} catch (IOException e) {
// This only catches the logger's IOException
System.err.println("FATAL: Could not initialize transaction logger: "
+ e.getMessage());
}
// TransactionLogger.close() is called automatically here
}
}
// Output:
// [2026-02-28 10:30:00] [INFO] Transaction logger started
// [TX-a1b2c3d4] Transferring $200.0 from ACC-001 to ACC-002
// [TX-a1b2c3d4] Transfer successful
// Account[ACC-001, Alice Johnson, $800.00]
// Account[ACC-002, Bob Smith, $700.00]
// [2026-02-28 10:30:00] [SUCCESS] TX-a1b2c3d4: $200.00 transferred from ACC-001 to ACC-002
// [2026-02-28 10:30:00] [FAILURE] TX-e5f6g7h8: Transfer failed: insufficient funds
// [2026-02-28 10:30:00] [DETAIL] Cause: InsufficientFundsException - Account ACC-003: requested $999.99 but only $250.00 available
// [2026-02-28 10:30:00] [FAILURE] TX-i9j0k1l2: Transfer failed: Account not found: ACC-999
// [2026-02-28 10:30:00] [DETAIL] Cause: AccountNotFoundException - Account not found: ACC-999
// [2026-02-28 10:30:00] [FAILURE] TX-m3n4o5p6: Transfer amount must be positive: -100.0
// [2026-02-28 10:30:00] [DETAIL] Cause: null
// [2026-02-28 10:30:00] [FAILURE] TX-q7r8s9t0: Cannot transfer to the same account
// [2026-02-28 10:30:00] [DETAIL] Cause: null
// [2026-02-28 10:30:00] [INFO] === Final Balances ===
// [2026-02-28 10:30:00] [INFO] Account[ACC-001, Alice Johnson, $800.00]
// [2026-02-28 10:30:00] [INFO] Account[ACC-002, Bob Smith, $700.00]
// [2026-02-28 10:30:00] [INFO] Account[ACC-003, Charlie Brown, $250.00]
// [2026-02-28 10:30:00] [INFO] Transaction logger shutting down
| Concept | Where It Appears |
|---|---|
| Custom checked exception | InsufficientFundsException, TransactionException |
| Custom unchecked exception | AccountNotFoundException |
| Exception chaining | TransactionService.transfer() wraps caught exceptions in TransactionException |
| try-with-resources | TransactionLogger implements AutoCloseable, used with try-with-resources in main() |
| Fail fast validation | BankAccount constructor, transfer() method |
| Specific exception catching | Separate catch blocks for InsufficientFundsException and AccountNotFoundException |
| Meaningful error messages | Every exception includes account IDs, amounts, and context |
| throw vs throws | throw new in method bodies; throws in method signatures |
| Extra data in exceptions | Custom getters for account ID, amounts, deficit |
| Resource cleanup | Logger file handle closed automatically via AutoCloseable |
| Keyword / Concept | Purpose | Example |
|---|---|---|
try |
Wraps code that might throw an exception | try { riskyCode(); } |
catch |
Handles a specific exception type | catch (IOException e) { ... } |
finally |
Runs always, for cleanup | finally { resource.close(); } |
throw |
Throws an exception object | throw new IllegalArgumentException("msg"); |
throws |
Declares exceptions a method may throw | void read() throws IOException |
| try-with-resources | Auto-closes AutoCloseable resources |
try (var r = new FileReader("f")) { ... } |
| Multi-catch | Catches multiple types in one block | catch (IOException | SQLException e) |
| Exception chaining | Wraps an exception as the cause of another | throw new AppException("msg", originalException); |
| Checked exception | Must be caught or declared; extends Exception |
IOException, SQLException |
| Unchecked exception | Not required to catch; extends RuntimeException |
NullPointerException, IllegalArgumentException |