Exception Handling in Java
Exceptions are events that disrupt the normal flow of a program. Proper exception handling is the difference between a service that crashes at 3 AM and one that degrades gracefully.
Exception Hierarchy Diagram
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
T(("Throwable"))
T --> E{{"Error"}}
T --> EX{{"Exception"}}
E --> OOM(["OutOfMemoryError"])
E --> SOE(["StackOverflowError"])
E --> VE(["VirtualMachineError"])
EX --> RE{{"RuntimeException<br/>UNCHECKED"}}
EX --> CE{{"Checked Exceptions"}}
RE --> NPE(["NullPointerException"])
RE --> AIOB(["ArrayIndexOutOfBoundsException"])
RE --> CCE(["ClassCastException"])
RE --> IAE(["IllegalArgumentException"])
RE --> AE(["ArithmeticException"])
CE --> IOE(["IOException"])
CE --> SQLE(["SQLException"])
CE --> CNFE(["ClassNotFoundException"])
CE --> IE(["InterruptedException"])
style T fill:#DBEAFE,stroke:#93C5FD,color:#1E40AF
style E fill:#FEE2E2,stroke:#FCA5A5,color:#1E40AF
style OOM fill:#FEE2E2,stroke:#FCA5A5,color:#1E40AF
style SOE fill:#FEE2E2,stroke:#FCA5A5,color:#1E40AF
style VE fill:#FEE2E2,stroke:#FCA5A5,color:#1E40AF
style EX fill:#DBEAFE,stroke:#93C5FD,color:#1E40AF
style RE fill:#FCD34D,stroke:#FCA5A5,color:#1E40AF
style NPE fill:#FEF3C7,stroke:#FCD34D,color:#1E40AF
style AIOB fill:#FEF3C7,stroke:#FCD34D,color:#1E40AF
style CCE fill:#FEF3C7,stroke:#FCD34D,color:#1E40AF
style IAE fill:#FEF3C7,stroke:#FCD34D,color:#1E40AF
style AE fill:#FEF3C7,stroke:#FCD34D,color:#1E40AF
style CE fill:#DBEAFE,stroke:#93C5FD,color:#1E40AF
style IOE fill:#BFDBFE,stroke:#DBEAFE,color:#1E40AF
style SQLE fill:#BFDBFE,stroke:#DBEAFE,color:#1E40AF
style CNFE fill:#BFDBFE,stroke:#DBEAFE,color:#1E40AF
style IE fill:#BFDBFE,stroke:#DBEAFE,color:#1E40AF Exception Flow — try/catch/finally Execution
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
START(["Enter try block"]) --> CODE{{"Execute try code"}}
CODE -->|No Exception| FINALLY1{{"Execute finally block"}}
CODE -->|Exception thrown| MATCH{"Matching<br/>catch block?"}
MATCH -->|Yes| CATCH{{"Execute catch block"}}
MATCH -->|No| FINALLY2{{"Execute finally block"}}
FINALLY2 --> PROPAGATE(["Propagate exception up the call stack"])
CATCH --> FINALLY3{{"Execute finally block"}}
FINALLY1 --> RETURN1(["Return value from try"])
FINALLY3 --> RETURN2(["Continue after try-catch-finally"])
NOTE1[/"NOTE: If finally has a return,<br/>it OVERRIDES the try/catch return!<br/>Never return from finally."/]
style START fill:#DBEAFE,stroke:#6EE7B7,color:#1E40AF
style CODE fill:#EFF6FF,stroke:#DBEAFE
style MATCH fill:#FEF3C7,stroke:#FCD34D
style CATCH fill:#FFFBEB,stroke:#FCD34D
style FINALLY1 fill:#ECFDF5,stroke:#6EE7B7
style FINALLY2 fill:#ECFDF5,stroke:#6EE7B7
style FINALLY3 fill:#ECFDF5,stroke:#6EE7B7
style PROPAGATE fill:#FEE2E2,stroke:#FCA5A5,color:#1E40AF
style RETURN1 fill:#DBEAFE,stroke:#6EE7B7,color:#1E40AF
style RETURN2 fill:#DBEAFE,stroke:#6EE7B7,color:#1E40AF
style NOTE1 fill:#FEF3C7,stroke:#FCD34D Exception Hierarchy
Throwable
│
┌────────────┼────────────┐
│ │
Error Exception
(unrecoverable) │
│ ┌────────┼────────┐
StackOverflowError │ │
OutOfMemoryError RuntimeException Checked Exceptions
│ │
NullPointerException IOException
ArrayIndexOutOfBounds ClassNotFoundException
ClassCastException SQLException
ArithmeticException InterruptedException
IllegalArgumentException
Checked vs Unchecked Exceptions
| Feature | Checked | Unchecked (Runtime) |
|---|---|---|
| Checked at | Compile time | Runtime |
| Must handle? | Yes (try-catch or throws) | No (optional) |
| Extends | Exception (not RuntimeException) | RuntimeException |
| Examples | IOException, SQLException, ClassNotFoundException | NullPointerException, ArrayIndexOutOfBoundsException |
| Caused by | External factors (file not found, network down) | Programming bugs (null access, bad index) |
| Best practice | Handle or propagate meaningfully | Fix the code, don't just catch |
try-catch-finally
try {
FileReader reader = new FileReader("data.txt");
int data = reader.read();
} catch (FileNotFoundException e) {
System.out.println("File not found: " + e.getMessage());
} catch (IOException e) {
System.out.println("IO error: " + e.getMessage());
} finally {
// ALWAYS executes (even if exception is thrown or return is called)
System.out.println("Cleanup done");
}
Multi-catch (Java 7+)
try {
// risky code
} catch (IOException | SQLException e) {
log.error("Data access error", e);
}
finally Execution Rules
| Scenario | Does finally run? |
|---|---|
| No exception | Yes |
| Exception caught | Yes |
| Exception not caught | Yes |
return in try/catch | Yes (before the return completes) |
System.exit() called | No |
| JVM crashes | No |
try-with-resources (Java 7+)
Automatically closes resources that implement AutoCloseable. No need for finally.
// BEFORE — verbose, error-prone
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader("data.txt"));
String line = reader.readLine();
} catch (IOException e) {
log.error("Read failed", e);
} finally {
if (reader != null) {
try { reader.close(); } catch (IOException e) { /* swallowed */ }
}
}
// AFTER — clean, safe
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
String line = reader.readLine();
} catch (IOException e) {
log.error("Read failed", e);
}
// reader is automatically closed here
Multiple resources
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
// process results
}
}
// all three are closed in reverse order: rs → ps → conn
throw vs throws
| Keyword | Purpose | Where used |
|---|---|---|
throw | Actually throws an exception object | Inside method body |
throws | Declares that a method might throw exceptions | Method signature |
// throws — declaration
public void readFile(String path) throws IOException {
// throw — action
if (path == null) {
throw new IllegalArgumentException("Path cannot be null");
}
Files.readString(Path.of(path));
}
Custom Exceptions
Create custom exceptions for domain-specific error handling.
// Checked exception
public class OrderNotFoundException extends Exception {
private final String orderId;
public OrderNotFoundException(String orderId) {
super("Order not found: " + orderId);
this.orderId = orderId;
}
public String getOrderId() { return orderId; }
}
// Unchecked exception
public class InsufficientBalanceException extends RuntimeException {
private final double balance;
private final double required;
public InsufficientBalanceException(double balance, double required) {
super(String.format("Insufficient balance: %.2f, required: %.2f", balance, required));
this.balance = balance;
this.required = required;
}
}
When to use checked vs unchecked for custom exceptions
| Use checked when | Use unchecked when |
|---|---|
| Caller can and should recover | Error is a programming bug |
| External system failure (DB down, API timeout) | Invalid argument, null pointer, bad state |
| You want the compiler to force handling | Recovery is not realistic |
Exception Handling Best Practices
1. Never catch Exception or Throwable (too broad)
// BAD — catches everything including NullPointerException
try {
processOrder(order);
} catch (Exception e) {
log.error("Something went wrong", e);
}
// GOOD — catch specific exceptions
try {
processOrder(order);
} catch (OrderNotFoundException e) {
return ResponseEntity.notFound().build();
} catch (PaymentException e) {
return ResponseEntity.status(502).body("Payment failed");
}
2. Never swallow exceptions
// BAD — exception is silently ignored
try {
connection.close();
} catch (SQLException e) {
// empty catch block — you'll never know this failed
}
// GOOD — at minimum, log it
try {
connection.close();
} catch (SQLException e) {
log.warn("Failed to close connection", e);
}
3. Throw early, catch late
// THROW EARLY — validate at the entry point
public void transfer(Account from, Account to, double amount) {
if (from == null || to == null) throw new IllegalArgumentException("Accounts required");
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
// ... proceed with valid inputs
}
// CATCH LATE — handle at the right abstraction level (e.g., controller)
@PostMapping("/transfer")
public ResponseEntity<?> transfer(@RequestBody TransferRequest req) {
try {
accountService.transfer(req.getFrom(), req.getTo(), req.getAmount());
return ResponseEntity.ok().build();
} catch (InsufficientBalanceException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
4. Use exceptions for exceptional conditions, not control flow
// BAD — using exceptions as control flow
try {
int value = Integer.parseInt(input);
} catch (NumberFormatException e) {
value = 0; // using exception to handle "not a number"
}
// GOOD — check first
if (input.matches("-?\\d+")) {
int value = Integer.parseInt(input);
} else {
value = 0;
}
Exception Handling in Spring Boot
// Global exception handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException e) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOT_FOUND", e.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("BAD_REQUEST", e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception e) {
log.error("Unexpected error", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "Something went wrong"));
}
}
record ErrorResponse(String code, String message) {}
Interview Questions
1. What is the difference between final, finally, and finalize()?
final — keyword: variables can't be reassigned, methods can't be overridden, classes can't be extended. finally — block: always executes after try/catch for cleanup. finalize() — method called by GC before collecting an object (deprecated in Java 9, removed in Java 18 — use try-with-resources or Cleaner instead).
2. Can a finally block override a return value?
Yes. If both try and finally have return statements, the finally return wins. But this is terrible practice — never return from finally.
3. What happens if an exception is thrown in a catch block?
The original exception is lost unless you chain it. Use throw new CustomException("msg", originalException) to preserve the cause chain. If a finally block also throws, the catch exception is suppressed (available via getSuppressed() in Java 7+).
4. Why should you prefer unchecked exceptions for programming errors?
Checked exceptions force every caller up the stack to either handle or declare them, cluttering code with try-catch or throws for errors that can't be meaningfully recovered from (like NullPointerException). Unchecked exceptions propagate naturally and should be fixed by fixing the code, not by catching them.
5. How do you handle exceptions in a microservices architecture?
Use @RestControllerAdvice for centralized exception handling. Map domain exceptions to HTTP status codes (404 for not found, 400 for validation errors, 502 for downstream failures). Log with correlation IDs for distributed tracing. For async communication (Kafka), use dead-letter queues for unprocessable messages. Never expose internal stack traces to clients.