Series: Java Core for Beginners Lecture: 10 of 12 Topics: What is an exception · try/catch/finally · Checked vs unchecked · throw & throws · Custom exceptions · Best practices
What Is an Exception?
When a Java program encounters a situation it cannot handle normally — dividing by zero, accessing an array out of bounds, opening a file that does not exist — it signals the problem by throwing an exception. An exception is an object that describes an error condition, including a message, the type of error, and a stack trace showing exactly which methods were executing when the error occurred.
Without exception handling, an unhandled exception causes the JVM to print the stack trace and terminate the program immediately. With exception handling, your code can catch the exception, respond to it gracefully — log the error, show a user-friendly message, retry the operation, use a fallback value — and continue running.
A concrete example
int[] numbers = {10, 20, 30};
System.out.println(numbers[5]); // index 5 does not exist
Without handling, the JVM terminates and prints:
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException:
Index 5 out of bounds for length 3
at Main.main(Main.java:3)
The stack trace tells you:
- The exception type:
ArrayIndexOutOfBoundsException - The message:
Index 5 out of bounds for length 3 - Where it happened:
Main.java, line 3, in themainmethod
Reading stack traces fluently is one of the most important practical skills in Java development. The error is always described at the top; the cause usually sits in the first few lines of your own code (as opposed to JDK library code).
The Exception Hierarchy
All exceptions in Java are objects. They form an inheritance hierarchy rooted at Throwable:
java.lang.Throwable
├── java.lang.Error
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── ... (serious JVM-level problems — do not catch these)
│
└── java.lang.Exception
├── java.lang.RuntimeException (unchecked)
│ ├── NullPointerException
│ ├── ArrayIndexOutOfBoundsException
│ ├── IllegalArgumentException
│ ├── IllegalStateException
│ ├── ClassCastException
│ ├── NumberFormatException
│ ├── ArithmeticException
│ └── UnsupportedOperationException
│
├── java.io.IOException (checked)
│ ├── FileNotFoundException
│ └── EOFException
├── java.sql.SQLException (checked)
└── ... (other checked exceptions)
Error vs Exception
Error represents serious JVM-level problems that your application typically cannot recover from: running out of memory (OutOfMemoryError), infinite recursion blowing the call stack (StackOverflowError). You should not catch Error — if the JVM is out of memory, there is nothing useful your code can do.
Exception represents conditions your application might reasonably handle. This is what you work with in day-to-day Java programming.
The two categories of Exception
The Exception hierarchy splits into two fundamentally different categories — checked and unchecked — which are covered in depth in section 4.
try, catch, and finally
The three keywords that form Java’s core exception handling mechanism.
Basic try-catch
Wrap potentially failing code in a try block. If an exception occurs, execution jumps to the matching catch block:
try {
int[] numbers = {10, 20, 30};
System.out.println(numbers[5]); // throws ArrayIndexOutOfBoundsException
System.out.println("This line never runs");
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("Caught: " + e.getMessage()); // Index 5 out of bounds for length 3
}
System.out.println("Program continues normally.");
The catch parameter e is the exception object. Common methods on all exceptions:
e.getMessage() // human-readable description of the error
e.getClass().getName() // fully qualified exception class name
e.printStackTrace() // prints the full stack trace to stderr
e.toString() // class name + message (used by println automatically)
Multiple catch blocks
You can have multiple catch blocks to handle different exception types differently:
public static void parseAndDivide(String numberStr, int divisor) {
try {
int number = Integer.parseInt(numberStr); // may throw NumberFormatException
int result = number / divisor; // may throw ArithmeticException
System.out.println("Result: " + result);
} catch (NumberFormatException e) {
System.out.println("Invalid number format: " + e.getMessage());
} catch (ArithmeticException e) {
System.out.println("Cannot divide by zero.");
}
}
parseAndDivide("42", 6); // Result: 7
parseAndDivide("abc", 6); // Invalid number format: For input string: "abc"
parseAndDivide("42", 0); // Cannot divide by zero.
Java evaluates catch blocks from top to bottom and executes the first one that matches the thrown exception type. More specific exception types must come before more general ones:
// WRONG — IOException is a supertype of FileNotFoundException
// The second catch is unreachable and will not compile
try {
// ...
} catch (IOException e) {
// handles all IOExceptions including FileNotFoundException
} catch (FileNotFoundException e) { // COMPILE ERROR — already caught above
// never reached
}
// CORRECT — specific first, general second
try {
// ...
} catch (FileNotFoundException e) {
System.out.println("File not found: " + e.getMessage());
} catch (IOException e) {
System.out.println("I/O error: " + e.getMessage());
}
Catching Exception or Throwable
You can catch Exception to handle any exception in one block:
try {
// risky code
} catch (Exception e) {
System.out.println("Something went wrong: " + e.getMessage());
}
This is convenient but loses specificity — you handle NullPointerException, NumberFormatException, and IOException all the same way. Use it sparingly and only at the top level of your program where a catch-all makes sense (like a web server’s request handler).
Never catch Throwable — it includes Error subclasses which the JVM cannot recover from.
The finally block
Code in finally always executes — whether the try block succeeds, an exception is thrown, or the exception is caught. It runs even if the catch block rethrows the exception or the method has a return statement:
public static void riskyOperation() {
System.out.println("Opening resource...");
try {
System.out.println("Performing operation...");
if (true) throw new RuntimeException("Something failed!");
System.out.println("This does not run.");
} catch (RuntimeException e) {
System.out.println("Caught: " + e.getMessage());
} finally {
System.out.println("Closing resource — always runs.");
}
}
Output:
Opening resource...
Performing operation...
Caught: Something failed!
Closing resource — always runs.
The primary use of finally is releasing resources — closing files, database connections, network sockets — that must be freed regardless of whether the operation succeeded. In modern Java (7+), try-with-resources (section 7) handles this more elegantly for AutoCloseable resources. finally is still useful for general-purpose cleanup that does not involve AutoCloseable.
finally and return — a subtle interaction
finally executes even when return is encountered in try or catch:
public static int compute() {
try {
return 1; // return is noted, but finally runs first
} finally {
System.out.println("finally runs");
// If you wrote "return 2" here, it would override the return 1 above!
// Never return from finally — it suppresses any exception thrown in try.
}
}
// prints "finally runs" then returns 1
Never use return, break, or throw inside a finally block — it can suppress exceptions thrown in try, making bugs very difficult to diagnose.
Checked vs Unchecked Exceptions
This distinction is fundamental to Java’s exception system and affects how you write and sign method declarations.
Unchecked exceptions (RuntimeException and its subclasses)
Unchecked exceptions represent programming errors — bugs in your code that you should fix rather than handle:
| Exception | Typical cause |
|---|---|
NullPointerException |
Calling a method on a null reference |
ArrayIndexOutOfBoundsException |
Index outside [0, length-1] |
ClassCastException |
Invalid downcast |
NumberFormatException |
Parsing “abc” as an integer |
IllegalArgumentException |
Invalid method argument |
IllegalStateException |
Method called at wrong time |
ArithmeticException |
Division by zero |
UnsupportedOperationException |
Operation not supported (e.g., modifying immutable list) |
StackOverflowError |
Infinite recursion |
You are not required to catch or declare unchecked exceptions. The compiler does not force you to handle them because they represent conditions that should not occur in correctly written code.
Checked exceptions (Exception subclasses except RuntimeException)
Checked exceptions represent external conditions outside your control that a reasonable program might encounter and recover from:
| Exception | Typical cause |
|---|---|
IOException |
File not found, network failure, disk full |
FileNotFoundException |
Specific file does not exist |
SQLException |
Database connection or query failure |
ParseException |
Parsing a date or number from text |
InterruptedException |
Thread interrupted while sleeping |
ClassNotFoundException |
Class not found on the classpath |
You must handle checked exceptions — either with try-catch or by declaring them with throws in the method signature. The compiler enforces this:
// Without handling — COMPILE ERROR
public static void readFile(String path) {
FileReader fr = new FileReader(path); // FileNotFoundException is checked — must handle!
}
// Option 1: catch it
public static void readFile(String path) {
try {
FileReader fr = new FileReader(path);
// ...
} catch (FileNotFoundException e) {
System.out.println("File not found: " + path);
}
}
// Option 2: declare it with throws (caller must handle)
public static void readFile(String path) throws FileNotFoundException {
FileReader fr = new FileReader(path);
// ...
}
The philosophical difference
The intent behind the distinction:
- Unchecked: “This should not happen in correct code. If it does, the programmer made a mistake. Fix the code.”
- Checked: “This might happen even in perfect code — the file might not exist, the network might be down. The caller must decide how to handle this situation.”
In practice, the Java community has shifted toward preferring unchecked exceptions for most application code, reserving checked exceptions for truly recoverable conditions where the caller genuinely needs to make a decision. Frameworks like Spring wrap checked exceptions in unchecked ones precisely because forcing callers to catch exceptions they cannot meaningfully handle adds noise without safety.
throw and throws
throw — throwing an exception
Use throw to explicitly signal an error condition from your code:
public static double sqrt(double value) {
if (value < 0) {
throw new IllegalArgumentException(
"Cannot compute square root of a negative number: " + value);
}
return Math.sqrt(value);
}
System.out.println(sqrt(16.0)); // 4.0
System.out.println(sqrt(-1.0)); // throws IllegalArgumentException
throw takes any Throwable object. In practice, you almost always throw new SomeException(message). The new is required — you throw an instance of an exception class, not the class itself.
Execution stops immediately after throw — no code after it in the same block runs (the compiler will warn you if you write code after an unconditional throw).
Validation with throw
A common and important pattern is guarding method inputs at the start of a method — called a guard clause:
public class BankAccount {
private double balance;
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException(
"Deposit amount must be positive, got: " + amount);
}
if (Double.isNaN(amount) || Double.isInfinite(amount)) {
throw new IllegalArgumentException("Deposit amount must be a finite number.");
}
balance += amount;
}
public void withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException(
"Withdrawal amount must be positive, got: " + amount);
}
if (amount > balance) {
throw new IllegalStateException(
"Insufficient funds. Balance: " + balance + ", requested: " + amount);
}
balance -= amount;
}
}
Guard clauses make the contract of a method explicit and fail fast — errors are detected at their source, not discovered later when their effects propagate elsewhere in the program.
throws — declaring checked exceptions
throws in a method signature tells the caller that this method might throw the listed checked exceptions. The caller must then either catch them or declare them too:
// This method declares it might throw IOException
public static String readFirstLine(String filePath) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
return reader.readLine();
}
}
// Caller must handle or rethrow
public static void main(String[] args) {
try {
String line = readFirstLine("config.txt");
System.out.println(line);
} catch (IOException e) {
System.out.println("Could not read file: " + e.getMessage());
}
}
Declaring multiple exceptions
A method can declare multiple checked exceptions:
public static void processFile(String path)
throws IOException, ParseException, SQLException {
// ...
}
throws is documentation, not execution
throws does not cause anything to be thrown — it is a declaration. Listing an exception in throws that the method never actually throws is legal (though misleading). The compiler only enforces the reverse: if you call a method that throws a checked exception, you must handle it.
Creating Custom Exceptions
The JDK provides many exception types, but for domain-specific errors in your application, you should create your own. Custom exceptions make error handling more expressive and meaningful:
// Generic
throw new RuntimeException("Account balance cannot be negative");
// Specific and meaningful
throw new InsufficientFundsException(requested, available);
Creating an unchecked custom exception
Extend RuntimeException for exceptions that represent programming errors or conditions callers are not expected to recover from in most cases:
public class InsufficientFundsException extends RuntimeException {
private final double requested;
private final double available;
public InsufficientFundsException(double requested, double available) {
super(String.format(
"Insufficient funds: requested %.2f but only %.2f available.",
requested, available));
this.requested = requested;
this.available = available;
}
public double getRequested() { return requested; }
public double getAvailable() { return available; }
}
Creating a checked custom exception
Extend Exception for conditions that callers should explicitly handle:
public class UserNotFoundException extends Exception {
private final String userId;
public UserNotFoundException(String userId) {
super("No user found with ID: " + userId);
this.userId = userId;
}
// Constructor that preserves the original cause (exception chaining)
public UserNotFoundException(String userId, Throwable cause) {
super("No user found with ID: " + userId, cause);
this.userId = userId;
}
public String getUserId() { return userId; }
}
Exception constructor patterns
Well-designed custom exceptions typically offer these constructor forms:
public class AppException extends RuntimeException {
// Message only
public AppException(String message) {
super(message);
}
// Message + cause (for wrapping lower-level exceptions)
public AppException(String message, Throwable cause) {
super(message, cause);
}
// Cause only (message is inferred from cause)
public AppException(Throwable cause) {
super(cause);
}
}
Using custom exceptions
public class UserService {
private final Map<String, String> userStore = new HashMap<>();
public void addUser(String id, String name) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("User ID must not be blank.");
}
if (userStore.containsKey(id)) {
throw new IllegalStateException("User with ID " + id + " already exists.");
}
userStore.put(id, name);
}
public String getUser(String id) throws UserNotFoundException {
String name = userStore.get(id);
if (name == null) {
throw new UserNotFoundException(id);
}
return name;
}
}
UserService service = new UserService();
service.addUser("U001", "Alice");
service.addUser("U002", "Bob");
try {
System.out.println(service.getUser("U001")); // Alice
System.out.println(service.getUser("U999")); // throws UserNotFoundException
} catch (UserNotFoundException e) {
System.out.println("Error: " + e.getMessage());
System.out.println("Searched ID: " + e.getUserId());
}
try-with-resources
Any resource that implements java.lang.AutoCloseable (which includes Closeable) can be used in a try-with-resources statement. Java automatically calls close() on it when the try block exits — whether normally, via exception, or via return.
Without try-with-resources (verbose and error-prone)
BufferedReader reader = null;
try {
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 reading file: " + e.getMessage());
} finally {
if (reader != null) {
try {
reader.close(); // close() itself can throw IOException!
} catch (IOException e) {
System.out.println("Error closing reader: " + e.getMessage());
}
}
}
With try-with-resources (clean and correct)
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 reading file: " + e.getMessage());
}
// reader.close() is called automatically — even if an exception occurs
The resource declaration goes in the parentheses after try. Multiple resources can be declared, separated by semicolons — they are closed in reverse declaration order:
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users");
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getString("name"));
}
} catch (SQLException e) {
System.out.println("Database error: " + e.getMessage());
}
// rs closed first, then stmt, then conn
Making your own AutoCloseable
Any class can be used in try-with-resources by implementing AutoCloseable:
public class DatabaseConnection implements AutoCloseable {
private final String url;
private boolean open;
public DatabaseConnection(String url) {
this.url = url;
this.open = true;
System.out.println("Opened connection to " + url);
}
public void query(String sql) {
if (!open) throw new IllegalStateException("Connection is closed.");
System.out.println("Executing: " + sql);
}
@Override
public void close() {
if (open) {
open = false;
System.out.println("Closed connection to " + url);
}
}
}
try (DatabaseConnection conn = new DatabaseConnection("jdbc:postgresql://localhost/mydb")) {
conn.query("SELECT * FROM users");
conn.query("SELECT * FROM orders");
} // conn.close() called automatically here
// Output:
// Opened connection to jdbc:postgresql://localhost/mydb
// Executing: SELECT * FROM users
// Executing: SELECT * FROM orders
// Closed connection to jdbc:postgresql://localhost/mydb
Multi-catch and Exception Chaining
Multi-catch (Java 7+)
When you want to handle multiple exception types the same way, multi-catch combines them in a single catch block using |:
// Without multi-catch — repetitive
try {
process(input);
} catch (NumberFormatException e) {
logger.error("Invalid input: " + e.getMessage());
} catch (IllegalArgumentException e) {
logger.error("Invalid input: " + e.getMessage());
} catch (ArithmeticException e) {
logger.error("Invalid input: " + e.getMessage());
}
// With multi-catch — clean
try {
process(input);
} catch (NumberFormatException | IllegalArgumentException | ArithmeticException e) {
logger.error("Invalid input: " + e.getMessage());
}
In a multi-catch block, e is effectively final — you cannot reassign it. The exception types in a multi-catch must not be in a subtype relationship (if A is a subtype of B, catching A | B makes A redundant and the compiler reports an error).
Exception chaining (wrapping exceptions)
When you catch a low-level exception and throw a higher-level one, always preserve the original cause by passing it to the new exception’s constructor. This maintains the full diagnostic chain:
public class DataLoader {
public List<String> loadFromFile(String path) {
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
List<String> lines = new ArrayList<>();
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
}
return lines;
} catch (FileNotFoundException e) {
// Wrap in domain exception, preserving the original cause
throw new DataLoadException("Configuration file not found: " + path, e);
} catch (IOException e) {
throw new DataLoadException("Failed to read configuration from: " + path, e);
}
}
}
public class DataLoadException extends RuntimeException {
public DataLoadException(String message, Throwable cause) {
super(message, cause);
}
}
When the stack trace is printed, both exceptions appear — the high-level message first, then “Caused by:” showing the original low-level exception. This gives you the full picture:
Exception in thread "main" DataLoadException: Configuration file not found: config.txt
at DataLoader.loadFromFile(DataLoader.java:14)
at Main.main(Main.java:8)
Caused by: java.io.FileNotFoundException: config.txt (No such file or directory)
at java.io.FileInputStream.open0(Native Method)
...
Never swallow the cause like this:
// BAD — original exception is lost, debugging becomes very hard
} catch (IOException e) {
throw new DataLoadException("Failed to load data."); // where did e go?
}
Best Practices and Common Pitfalls
Do: catch specific exceptions
// BAD — catches everything, hides bugs
try {
processOrder(order);
} catch (Exception e) {
log.error("Something went wrong");
}
// GOOD — specific handling for each condition
try {
processOrder(order);
} catch (InsufficientFundsException e) {
notifyCustomer(e.getMessage());
} catch (ProductOutOfStockException e) {
suggestAlternatives(order);
} catch (DatabaseException e) {
log.error("Database failure processing order", e);
throw e; // re-throw — cannot recover here, let it propagate
}
Do: always log the exception object, not just the message
// BAD — stack trace is lost
log.error("Error: " + e.getMessage());
// GOOD — full exception including stack trace is logged
log.error("Failed to process payment", e);
Don’t: swallow exceptions silently
// TERRIBLE — problem disappears silently, bug is hidden
try {
riskyOperation();
} catch (Exception e) {
// empty catch — the exception is swallowed
}
If you genuinely must catch and not rethrow, at minimum log the exception with a clear explanation of why it is safe to ignore.
Don’t: use exceptions for normal control flow
// BAD — using exceptions to detect list end (slow, wrong idiom)
try {
int i = 0;
while (true) {
process(list.get(i++));
}
} catch (IndexOutOfBoundsException e) {
// end of list
}
// GOOD — use normal control flow
for (int i = 0; i < list.size(); i++) {
process(list.get(i));
}
Exceptions are expensive to create (the JVM captures the entire call stack). Using them for flow control is both slow and stylistically wrong.
Do: fail fast with clear messages
// BAD — vague, hard to debug
if (name == null) throw new RuntimeException("Error");
// GOOD — immediately actionable
if (name == null) throw new IllegalArgumentException(
"Customer name must not be null. Received null in createOrder().");
A good exception message answers: what went wrong, what value was involved, and where/why it is a problem.
Do: use try-with-resources for all AutoCloseable resources
Never manage Closeable resources with manual finally blocks in modern Java. try-with-resources is safer, shorter, and handles the edge case where close() itself throws.
Don’t: catch and rethrow without adding value
// BAD — pointless, adds noise to the stack trace
try {
doSomething();
} catch (SomeException e) {
throw e; // no added value — just let it propagate naturally
}
// GOOD — rethrow when you add context or wrap in a domain exception
try {
doSomething();
} catch (LowLevelException e) {
throw new DomainException("Failed to complete order #" + orderId, e);
}
Exception hierarchy guidelines
- Use
IllegalArgumentExceptionfor invalid method arguments. - Use
IllegalStateExceptionfor operations called at wrong times. - Use
NullPointerExceptionfor unexpected null arguments (orObjects.requireNonNull()). - Use
UnsupportedOperationExceptionfor unimplemented optional operations. - Extend
RuntimeExceptionfor custom application exceptions. - Extend
Exceptiononly when the caller genuinely must handle the condition.
Putting It Together — A Complete Example
Let us build a robust OrderProcessor that demonstrates all the concepts in this lecture:
// Custom exception hierarchy
public class OrderException extends RuntimeException {
public OrderException(String message) { super(message); }
public OrderException(String message, Throwable cause) { super(message, cause); }
}
public class InvalidOrderException extends OrderException {
private final String field;
public InvalidOrderException(String field, String reason) {
super("Invalid order field '" + field + "': " + reason);
this.field = field;
}
public String getField() { return field; }
}
public class InsufficientInventoryException extends OrderException {
private final String productId;
private final int requested;
private final int available;
public InsufficientInventoryException(String productId, int requested, int available) {
super(String.format("Product '%s': requested %d but only %d in stock.",
productId, requested, available));
this.productId = productId;
this.requested = requested;
this.available = available;
}
public String getProductId() { return productId; }
public int getRequested() { return requested; }
public int getAvailable() { return available; }
}
public class Order {
private final String orderId;
private final String productId;
private final int quantity;
private final String customerEmail;
public Order(String orderId, String productId, int quantity, String customerEmail) {
this.orderId = orderId;
this.productId = productId;
this.quantity = quantity;
this.customerEmail = customerEmail;
}
public String getOrderId() { return orderId; }
public String getProductId() { return productId; }
public int getQuantity() { return quantity; }
public String getCustomerEmail() { return customerEmail; }
}
public class OrderProcessor {
private final Map<String, Integer> inventory = new HashMap<>();
private final List<String> processedOrders = new ArrayList<>();
public OrderProcessor() {
inventory.put("PROD-001", 50);
inventory.put("PROD-002", 10);
inventory.put("PROD-003", 0);
}
// Validation — throws InvalidOrderException for bad input
private void validateOrder(Order order) {
if (order.getOrderId() == null || order.getOrderId().isBlank()) {
throw new InvalidOrderException("orderId", "must not be blank");
}
if (order.getProductId() == null || order.getProductId().isBlank()) {
throw new InvalidOrderException("productId", "must not be blank");
}
if (order.getQuantity() <= 0) {
throw new InvalidOrderException("quantity",
"must be positive, got " + order.getQuantity());
}
if (order.getCustomerEmail() == null || !order.getCustomerEmail().contains("@")) {
throw new InvalidOrderException("customerEmail",
"must be a valid email address");
}
}
// Inventory check — throws InsufficientInventoryException if stock too low
private void checkInventory(String productId, int quantity) {
Integer stock = inventory.get(productId);
if (stock == null) {
throw new OrderException("Product not found: " + productId);
}
if (stock < quantity) {
throw new InsufficientInventoryException(productId, quantity, stock);
}
}
// Main processing method — coordinates validation, inventory, and recording
public String processOrder(Order order) {
validateOrder(order);
checkInventory(order.getProductId(), order.getQuantity());
// Deduct inventory
inventory.merge(order.getProductId(), -order.getQuantity(), Integer::sum);
// Record the order
processedOrders.add(order.getOrderId());
return "Order " + order.getOrderId() + " processed successfully.";
}
// Batch processing — continues even if individual orders fail
public void processBatch(List<Order> orders) {
for (Order order : orders) {
try {
String result = processOrder(order);
System.out.println("[OK] " + result);
} catch (InvalidOrderException e) {
System.out.println("[SKIP] Invalid order " + order.getOrderId()
+ " — field '" + e.getField() + "': " + e.getMessage());
} catch (InsufficientInventoryException e) {
System.out.println("[SKIP] Stock issue for order " + order.getOrderId()
+ " — " + e.getMessage());
} catch (OrderException e) {
System.out.println("[FAIL] Unexpected error for order " + order.getOrderId()
+ ": " + e.getMessage());
}
}
System.out.println("\nProcessed " + processedOrders.size()
+ " of " + orders.size() + " orders.");
}
}
public class Main {
public static void main(String[] args) {
OrderProcessor processor = new OrderProcessor();
List<Order> batch = List.of(
new Order("ORD-001", "PROD-001", 5, "alice@example.com"), // OK
new Order("ORD-002", "PROD-002", 15, "bob@example.com"), // insufficient stock
new Order("ORD-003", "PROD-003", 1, "charlie@example.com"),// out of stock
new Order("ORD-004", "", 2, "dave@example.com"), // invalid productId
new Order("ORD-005", "PROD-001", -3, "eve@example.com"), // invalid quantity
new Order("ORD-006", "PROD-001", 10, "not-an-email"), // invalid email
new Order("ORD-007", "PROD-002", 8, "frank@example.com") // OK
);
processor.processBatch(batch);
}
}
Output:
[OK] Order ORD-001 processed successfully.
[SKIP] Stock issue for order ORD-002 — Product 'PROD-002': requested 15 but only 10 in stock.
[SKIP] Stock issue for order ORD-003 — Product 'PROD-003': requested 1 but only 0 in stock.
[SKIP] Invalid order ORD-004 — field 'productId': Invalid order field 'productId': must not be blank
[SKIP] Invalid order ORD-005 — field 'quantity': Invalid order field 'quantity': must be positive, got -3
[SKIP] Invalid order ORD-006 — field 'customerEmail': Invalid order field 'customerEmail': must be a valid email address
[OK] Order ORD-007 processed successfully.
Processed 2 of 7 orders.
Notice how the exception hierarchy lets processBatch handle different error types with different strategies — validation errors and inventory errors are “skippable,” while unexpected OrderException is flagged as a failure. The batch continues even when individual orders fail.
Summary
- An exception is an object that signals an error condition. The JVM terminates unhandled exceptions with a stack trace. Exception handling lets you respond gracefully and continue running.
- The hierarchy:
Throwable→Error(do not catch) /Exception→RuntimeException(unchecked) + checked exceptions. try-catch-finally:trywraps risky code;catchhandles specific exceptions (most specific first);finallyalways runs — use it for cleanup.- Unchecked exceptions (
RuntimeExceptionand subclasses) represent programming errors. The compiler does not require you to handle them. - Checked exceptions (
Exceptionsubclasses exceptRuntimeException) represent recoverable external conditions. You must catch them or declare them withthrows. throwexplicitly signals an exception from your code. Use guard clauses at method boundaries to fail fast with clear messages.throwsin a method signature declares that the method may throw checked exceptions. The caller must handle or propagate them.- Custom exceptions make your domain’s error conditions explicit. Extend
RuntimeExceptionfor unchecked,Exceptionfor checked. Always provide a message+cause constructor. try-with-resourcesautomatically closesAutoCloseableresources. Prefer it over manualfinallyfor all resource management.- Multi-catch combines handling of multiple exception types with
|. Exception chaining preserves the original cause when wrapping exceptions — always passcauseto the super constructor. - Key practices: catch specific types, log the full exception object, never swallow silently, fail fast with clear messages, never use exceptions for normal flow control.
Exercises
Exercise 1 — Reading stack traces Write a program with a call chain: main → processData → parseValue → Integer.parseInt. Call it with the string "hello". Let the NumberFormatException propagate uncaught, observe the full stack trace, and explain in comments: (a) where the exception was thrown, (b) how execution propagated back up the call stack, (c) what information each line of the stack trace provides.
Exercise 2 — Robust calculator Write a calculate(String expression) method that parses strings like "10 / 2" and "5 + 3". Handle: NumberFormatException (non-numeric operands), ArithmeticException (division by zero), ArrayIndexOutOfBoundsException (malformed expression — too few parts). Return a double result or throw a custom CalculationException that wraps the original cause for all error types.
Exercise 3 — Custom exception hierarchy Design a custom exception hierarchy for a library system:
LibraryException(base, unchecked)
– BookNotFoundException (with isbn field) – BookNotAvailableException (with isbn and dueDate fields) – MemberNotFoundException (with memberId field) – MaxBorrowLimitException (with limit field)
Implement a Library class with borrowBook(memberId, isbn) and returnBook(memberId, isbn) methods that throw the appropriate exceptions. Write a main that demonstrates each exception type.
Exercise 4 — try-with-resources Implement an AutoCloseable class Timer that records a start time in its constructor and prints elapsed milliseconds in close(). Use it in a try-with-resources block around code that performs a task (e.g., sorting a large list). Verify that close() is called even if an exception occurs in the middle of the timed block.
Exercise 5 — Exception chaining and wrapping Write a ConfigLoader class with a load(String filename) method that:
- Reads the file (may throw
IOException) - Parses each line as
key=value(may encounter malformed lines) - Returns a
Map
Wrap all IOExceptions in ConfigLoadException (custom unchecked) and all parse errors in ConfigParseException (custom unchecked). Always preserve the original cause. Write a caller that catches each type separately and prints a meaningful message.
Exercise 6 — Batch processing with recovery Write a DataPipeline class that processes a List of raw records (each is a comma-separated name,age,salary). For each record: parse it, validate it (name not blank, age 18–65, salary > 0), and add it to a results list. Collect all errors — do not stop on the first failure. Use a List to accumulate error messages. At the end, print both the successfully parsed records and the list of errors. Test with a mix of valid and invalid records.
Up next: Lecture 11 — File I/O & Streams — where you will learn how to read and write files, work with paths and directories, and understand the difference between byte streams and character streams.
