Skip to content

Exception Handling

Throwable
├── Error
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ ├── NoClassDefFoundError
│ └── InternalError
└── Exception
├── RuntimeException (unchecked)
│ ├── NullPointerException
│ ├── IllegalArgumentException
│ ├── IllegalStateException
│ ├── IndexOutOfBoundsException
│ ├── ConcurrentModificationException
│ └── UnsupportedOperationException
└── Checked Exceptions
├── IOException
│ ├── FileNotFoundException
│ └── EOFException
├── SQLException
├── ClassNotFoundException
└── InterruptedException

Throwable sits at the top. The JVM only throws subclasses of Throwable. The split into Error And Exception is the first critical decision point:

  • Error: JVM-level failures. Application code should almost never catch these. If you catch OutOfMemoryErrorYou are guessing about JVM state invariants that may no longer hold.
  • Exception: Application-level failures. This is where you design error handling.

Within ExceptionThe RuntimeException subclass marks unchecked exceptions — the compiler Does not force you to declare or handle them. Everything else is checked.

FactorCheckedUnchecked
Recovery expected?Yes — caller can meaningfully handle itNo — a programming error
Compiler enforcementRequired throws declarationNo declaration needed
API surface impactPropagates through every caller in the chainStops where it stops
ExampleIOException``SQLExceptionNullPointerException``IllegalArgumentException

The pragmatic rule: use checked exceptions for conditions where the caller reasonably can and Should take corrective action. Use unchecked exceptions for programming errors and precondition Violations.

This is not a bright line. The Java standard library itself is inconsistent — CloneNotSupportedException is checked but almost never handled meaningfully, while IllegalArgumentException is unchecked despite being a recoverable validation failure.

// Programming errors (unchecked)
throw new NullPointerException("config must not be null");
throw new IllegalArgumentException("port must be positive, got: " + port);
throw new IllegalStateException("connection not initialized");
throw new IndexOutOfBoundsException("index: " + idx + ", size: " + size);
throw new UnsupportedOperationException("TLS 1.3 not available");
// Environmental failures (checked)
throw new FileNotFoundException("/etc/app/config.properties");
throw new IOException("failed to read from socket");
throw new SQLException("constraint violation: duplicate key");
throw new ClassNotFoundException("com.mysql.cj.jdbc.Driver");
InputStream in = null;
try {
in = new FileInputStream("data.bin");
int data = in.read();
// process data
} catch (FileNotFoundException e) {
System.err.println("file not found: " + e.getMessage());
} catch (IOException e) {
System.err.println("I/O error: " + e.getMessage());
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
// swallowed — this is the pattern that try-with-resources fixes
}
}
}

The order matters. Catch blocks are evaluated top-to-bottom; the first matching exception type wins. A subclass catch block placed after its superclass will never execute:

// COMPILER ERROR: unreachable catch block
try {
riskyOperation();
} catch (Exception e) {
// catches everything
} catch (IOException e) {
// DEAD CODE — IOException is a subclass of Exception
}
try {
parseConfig(path);
} catch (FileNotFoundException | NoSuchFileException e) {
System.err.println("config file missing: " + path);
} catch (AccessDeniedException | SecurityException e) {
System.err.println("permission denied: " + path);
}

The pipe operator lets you handle multiple exception types with identical logic. The variable e is Implicitly final. The alternatives cannot be related by subtyping — if one exception type is a Subtype of another, it is a compiler error (e.g., IOException | FileNotFoundException).

public String readFile(Path path) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(path)) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n');
}
return sb.toString();
}
// reader.close() is called automatically, even on exception or return
}

Multiple resources are closed in reverse declaration order, just like nested finally blocks:

try (InputStream in = new FileInputStream("data.bin");
OutputStream out = new FileOutputStream("copy.bin")) {
in.transferTo(out);
}
// out.close() is called first, then in.close()

The resource must implement AutoCloseable. Its close() method is called even if the try block Throws. If both the try block and close() throw, the close() exception is attached as a suppressed exception on the primary exception:

public class DebugResource implements AutoCloseable {
@Override
public void close() throws Exception {
throw new RuntimeException("close failed");
}
public static void main(String[] args) {
try (DebugResource r = new DebugResource()) {
throw new RuntimeException("operation failed");
} catch (RuntimeException e) {
System.err.println("primary: " + e.getMessage());
for (Throwable suppressed : e.getSuppressed()) {
System.err.println("suppressed: " + suppressed.getMessage());
}
}
}
// Output:
// primary: operation failed
// suppressed: close failed
}

finally executes unless the JVM exits (via System.exit() or a fatal Error that terminates the Thread). An OutOfMemoryError does not prevent finally from running, but a truly fatal Error (e.g., StackOverflowError leaving no stack space, or VirtualMachineError) can. There are Also subtle traps:

Gotcha: Return in finally silently discards the try/catch return value

public static int dangerousReturn() {
try {
return computeValue();
} catch (Exception e) {
return -1;
} finally {
return 0; // ALWAYS wins — the try/catch return is silently discarded
}
}

This compiles without warnings. Static analysis tools like SpotBugs and SonarQube flag this pattern. Never return from a finally block.

Gotcha: Exception in finally masks the original exception

try {
throw new RuntimeException("primary failure");
} finally {
throw new RuntimeException("finally failure");
// The primary exception is LOST. Only "finally failure" propagates.
}

Use try-with-resources or manually add suppressed exceptions:

try {
throw new RuntimeException("primary failure");
} finally {
try {
// cleanup that might fail
} catch (Exception e) {
throw new RuntimeException("finally failure", e);
}
}

Design exception classes that reflect your domain’s error taxonomy:

public abstract class AppException extends RuntimeException {
private final String errorCode;
protected AppException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
protected AppException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
public class ValidationException extends AppException {
private final String field;
public ValidationException(String field, String message) {
super("VALIDATION_" + field.toUpperCase(), message);
this.field = field;
}
public String getField() {
return field;
}
}
public class ResourceNotFoundException extends AppException {
private final String resourceType;
private final Object resourceId;
public ResourceNotFoundException(String resourceType, Object resourceId) {
super("NOT_FOUND", resourceType + " not found: " + resourceId);
this.resourceType = resourceType;
this.resourceId = resourceId;
}
public String getResourceType() {
return resourceType;
}
public Object getResourceId() {
return resourceId;
}
}

Base your hierarchy on a common superclass so callers can catch at the appropriate granularity:

try {
userService.createUser(request);
} catch (ValidationException e) {
return Response.badRequest(e.getField(), e.getMessage());
} catch (ResourceNotFoundException e) {
return Response.notFound(e.getMessage());
} catch (AppException e) {
return Response.error(e.getErrorCode(), e.getMessage());
}

Always preserve the original cause when wrapping exceptions. The three-argument constructor of RuntimeException and Exception accepts a Throwable cause:

public List<User> loadUsers(Path file) {
try {
return Files.lines(file)
.map(this::parseUser)
.collect(Collectors.toList());
} catch (IOException e) {
throw new DataLoadingException("failed to load users from: " + file, e);
}
}
// Stack trace preserves the full chain:
// DataLoadingException: failed to load users from: users.csv
// at com.example.UserService.loadUsers(UserService.java:42)
// Caused by: java.io.IOException: users.csv: Permission denied
// at java.base/java.nio.file.Files.lines(Files.java:4210)
// at com.example.UserService.loadUsers(UserService.java:39)

Without the cause chain, the original failure point is invisible. This is one of the most common Mistakes in Java error handling.

Translate exceptions at architectural boundaries to prevent implementation details from leaking:

// Repository layer — infrastructure detail
public class JpaUserRepository implements UserRepository {
@Override
public User findById(Long id) {
try {
return entityManager.find(User.class, id);
} catch (PersistenceException e) {
throw new RepositoryException("failed to find user: " + id, e);
}
}
}
// Service layer — business logic boundary
public class UserService {
public User getUser(Long id) {
try {
User user = userRepository.findById(id);
if (user == null) {
throw new ResourceNotFoundException("User", id);
}
return user;
} catch (RepositoryException e) {
throw new ServiceException("unable to retrieve user: " + id, e);
}
}
}

The rule: each layer should only throw exceptions meaningful to its callers. A service layer caller Should never see SQLException or PersistenceException.

Fail-fast: Throw immediately when an invariant is violated. Detect problems as early as Possible:

public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("age out of range: " + age);
}
this.age = age;
}
// Objects.requireNonNull is the standard fail-fast guard
public void setConfig(Config config) {
this.config = Objects.requireNonNull(config, "config must not be null");
}

Fail-safe: Attempt to recover gracefully, returning a default or skipping invalid data:

public List<Integer> parseNumbers(List<String> inputs) {
List<Integer> results = new ArrayList<>();
for (String input : inputs) {
try {
results.add(Integer.parseInt(input));
} catch (NumberFormatException e) {
// skip invalid entries instead of failing the entire operation
}
}
return results;
}

Default to fail-fast. It produces clearer diagnostics and prevents corrupted state from propagating. Choose fail-safe only when partial results are meaningful and the cost of failure is high.

  • Compiler-enforced documentation: The method signature tells you what can go wrong without reading implementation
  • Handling is mandatory: Callers cannot accidentally ignore error conditions
  • Appropriate for recoverable conditions: IOException``SQLException are conditions a well-written program should handle
  • Signature pollution: Adding a checked exception to a low-level method forces every caller up the chain to declare or handle it
  • Encourages antipatterns: Developers write catch (Exception e) {} or throws Exception to satisfy the compiler
  • Versioning friction: Adding a checked exception to an interface method breaks all implementations
  • Lambdas friction: Checked exceptions are painful with functional interfaces

Optional replaces some checked exception use cases:

// Instead of throwing checked exceptions for "not found"
public Optional<User> findByName(String name) {
return users.stream()
.filter(u -> u.getName().equals(name))
.findFirst();
}
// Caller decides how to handle absence
User user = userService.findByName("Alice")
.orElseThrow(() -> new ResourceNotFoundException("User", "Alice"));

Result types (not in the JDK, but common in libraries):

public sealed interface Result<T> {
record Success<T>(T value) implements Result<T> {}
record Failure<T>(Throwable error) implements Result<T> {}
default T getOrThrow() {
return switch (this) {
case Success<T> s -> s.value();
case Failure<T> f -> {
if (f.error() instanceof RuntimeException re) throw re;
throw new RuntimeException(f.error());
}
};
}
}

The standard functional interfaces (Function``SupplierEtc.) do not declare checked exceptions. Workarounds:

Utility wrapper:

@FunctionalInterface
public interface ThrowingSupplier<T> {
T get() throws Exception;
}
public static <T> Supplier<T> unchecked(ThrowingSupplier<T> supplier) {
return () -> {
try {
return supplier.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
};
}
// Usage
List<Path> files = stream.map(unchecked(() -> Paths.get(config.getString("path"))))
.toList();

Lombok’s @SneakyThrows:

import lombok.SneakyThrows;
public class FileLoader {
@SneakyThrows
public String readContent(Path path) {
return Files.readString(path);
}
// The IOException is thrown without being declared in the signature
// Bytecode-level: the method does not have a throws clause
// This bypasses the compiler check — use sparingly and only when
// the exception truly should not be handled at this level
}

@SneakyThrows works by generating bytecode that throws the checked exception without declaring it. The JVM does not enforce checked exceptions — only the compiler does. Use it when wrapping every Call in a try-catch would add noise without safety.

Creating an exception is expensive because Throwable captures the stack trace by calling Throwable.fillInStackTrace()Which walks the stack via StackTraceElement:

// Benchmark: exception creation vs simple object creation
// Creating a simple RuntimeException: ~1-5 microseconds
// Creating with full stack trace: ~1-5 microseconds (stack trace is captured eagerly in HotSpot)
// fillInStackTrace() explicitly: ~1-10 microseconds
// Throwing and catching (no stack trace): ~0.01 microseconds (near-zero)

For hot paths, avoid exceptions entirely. Use return codes, OptionalOr null checks:

// Bad: using exceptions for control flow in a hot path
public int parseIntFast(String s) {
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return -1;
}
}
// Good: validate first, or use a non-throwing alternative
public int parseIntFast(String s) {
if (s == null || s.isEmpty()) return -1;
for (int i = 0; i < s.length(); i++) {
if (!Character.isDigit(s.charAt(i))) return -1;
}
return Integer.parseInt(s);
}

If you throw exceptions in a hot path and do not need stack traces:

public class FastException extends RuntimeException {
public FastException(String message) {
super(message, null, false, false);
// super(message, cause, enableSuppression, writableStackTrace)
// writableStackTrace=false skips fillInStackTrace()
}
}

This avoids the stack walk cost entirely. The exception still propagates normally, but getStackTrace() returns an empty array.

HotSpot treats exception paths as “cold” code. Methods that frequently throw exceptions may be Deoptimized or prevented from being compiled by C2. The JIT compiler assumes the fast (non-throwing) Path is the common case. If exceptions are your normal control flow, you are fighting the optimizer.

When Exceptions Are Appropriate vs Return Codes

Section titled “When Exceptions Are Appropriate vs Return Codes”
CriterionExceptionsReturn Codes
Frequency of failureRare / exceptionalCommon / expected
Performance sensitivityLowHigh
Separation of happy/unhappy pathClean separationMixed in control flow
Forced handlingYes (checked)No — ignored
ComposabilityBreaks lambdasComposes cleanly

Rule of thumb: if it happens more than once per thousand calls on the hot path, consider a return Code or Optional.

The Java class file format includes an exception table for each method. Each entry maps a range of Bytecode instructions to a handler:

Exception table:
from to target type
0 8 16 Class java/io/FileNotFoundException
0 8 24 Class java/io/IOException

This is what javap -c shows:

public void readFile() throws IOException;
Code:
0: new #2 // class FileInputStream
3: dup
4: ldc #3 // String data.bin
6: invokespecial #4 // Method FileInputStream."<init>":(Ljava/lang/String;)V
9: astore_1
10: return
11: astore_2 // store exception reference
12: aload_2
13: invokevirtual #5 // Method FileNotFoundException.printStackTrace
16: return
17: astore_3 // store exception reference
18: aload_3
19: invokevirtual #6 // Method IOException.printStackTrace
22: return
Exception table:
from to target type
0 10 11 Class java/io/FileNotFoundException
0 10 17 Class java/io/IOException

The exception table is a list of (start_pc, end_pc, handler_pc, catch_type) tuples. When an Exception is thrown, the JVM scans the exception table of the current method for a matching entry Where start_pc &lt;= pc &lt; end_pc and the thrown exception is assignable to catch_type. If no Handler is found, the method frame is popped and the search continues in the caller.

When an exception is thrown:

  1. The JVM creates a Throwable instance (or uses the one being thrown)
  2. fillInStackTrace() captures the current stack trace (unless disabled)
  3. The JVM searches the current method’s exception table for a matching handler
  4. If no handler is found, the current frame is popped and the process repeats in the calling method
  5. This continues up the call stack until a matching handler is found
  6. If no handler is found on any frame, the thread’s UncaughtExceptionHandler is invoked
  7. If there is no uncaught handler, the thread terminates and prints the stack trace to stderr

Throwable.addSuppressed() allows attaching secondary exceptions that would otherwise be lost. The Primary use case is try-with-resources:

try (Reader r = new ThrowingReader(); // close() throws IOException
Writer w = new ThrowingWriter()) { // close() throws IOException
throw new IllegalStateException("ops"); // try block throws
}
// The IllegalStateException has two suppressed exceptions:
// [0] IOException from w.close()
// [1] IOException from r.close()

addSuppressed() is also available manually:

try {
operation();
} catch (Exception primary) {
Exception secondary = cleanup();
if (secondary != null) {
primary.addSuppressed(secondary);
}
throw primary;
}
// BAD: catches everything including RuntimeException subclasses
try {
riskyOperation();
} catch (Exception e) {
log.error("something went wrong", e);
}
// WORSE: catches Errors too — including OutOfMemoryError
try {
riskyOperation();
} catch (Throwable t) {
log.error("something went wrong", t);
}

Catching broadly hides bugs. A NullPointerException or ClassCastException indicates a Programming error that should propagate, not be swallowed. Catch the most specific exception type Possible.

// GOOD: catch only what you can actually handle
try {
socket = new Socket(host, port);
} catch (UnknownHostException e) {
throw new ServiceConfigurationException("unknown host: " + host, e);
} catch (IOException e) {
throw new ServiceUnavailableException("cannot connect to " + host + ":" + port, e);
}
// BAD: silently swallowing exceptions
try {
int value = Integer.parseInt(input);
} catch (NumberFormatException e) {
// intentionally empty
}
// BAD: only slightly better
try {
int value = Integer.parseInt(input);
} catch (NumberFormatException e) {
e.printStackTrace(); // goes to stderr, not your logging system
}

At minimum, log the exception with context. If you truly intend to ignore it, comment explaining Why:

try {
props.load(new FileInputStream(configFile));
} catch (FileNotFoundException e) {
// expected on first run — defaults will be used
log.debug("no config file found at {}, using defaults", configFile);
}
// BAD: exception as a loop termination condition
public Iterator<String> parseLines(String text) {
return new Iterator<>() {
private int pos = 0;
@Override
public boolean hasNext() {
try {
text.substring(pos, pos + 1);
return true;
} catch (StringIndexOutOfBoundsException e) {
return false;
}
}
@Override
public String next() {
// ...
}
};
}
// GOOD: use a proper condition
public Iterator<String> parseLines(String text) {
return new Iterator<>() {
private int pos = 0;
@Override
public boolean hasNext() {
return pos < text.length();
}
@Override
public String next() {
// ...
}
};
}

Exception-driven control flow is slow, obscure, and defeats JIT optimization. It also makes Debugging harder because the stack trace is noise.

// BAD: creating a new exception without preserving the cause
try {
parseConfig(configFile);
} catch (IOException e) {
throw new ConfigurationException("config load failed");
// the IOException stack trace is LOST
}
// GOOD: pass the cause
try {
parseConfig(configFile);
} catch (IOException e) {
throw new ConfigurationException("config load failed", e);
}
// BAD: logging and rethrowing — stack trace shows the logging line, not the throw line
try {
parseConfig(configFile);
} catch (IOException e) {
log.error("config load failed", e);
throw new ConfigurationException("config load failed");
}
// GOOD: log and rethrow with cause
try {
parseConfig(configFile);
} catch (IOException e) {
log.error("config load failed", e);
throw new ConfigurationException("config load failed", e);
}

Catching and Wrapping Without Adding Context

Section titled “Catching and Wrapping Without Adding Context”
// BAD: no additional context — the wrapper adds nothing useful
try {
db.query(sql);
} catch (SQLException e) {
throw new RuntimeException(e);
}
// GOOD: add the SQL and parameters that caused the failure
try {
db.query(sql);
} catch (SQLException e) {
throw new DataAccessException("query failed: " + sql + ", params: " + params, e);
}
// BAD: manual NPE — inconsistent with JVM-generated NPEs
if (config == null) {
throw new NullPointerException("config is null");
}
// GOOD: use the standard utility — produces cleaner stack traces
Objects.requireNonNull(config, "config must not be null");
// GOOD (Java 14+): use null checks in the signature
public void process(@NonNull String input) {
// javac or IDE generates the null check
}

Objects.requireNonNull is the canonical way to fail-fast on null arguments. It throws NullPointerException with your message and appears as a single frame in the stack trace, making it Clear where the null check failed.

// BAD: interrupt flag is cleared, thread may never stop
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
log.warn("sleep interrupted");
}
// GOOD: restore the interrupt status
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("sleep interrupted");
}
// GOOD: propagate as a checked or unchecked exception
try {
blockingQueue.put(element);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new OperationCancelledException("put cancelled", e);
}

When InterruptedException is caught, the thread’s interrupt flag is automatically cleared. If You do not restore it (via Thread.currentThread().interrupt()), the interruption is lost and Cooperative cancellation in the caller breaks.

This topic covers the core concepts of exception handling, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • OOP principles (encapsulation, inheritance, polymorphism)
  • collections framework
  • streams and lambda expressions
  • exception handling
  • the JVM and garbage collection

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Exception handling is like the immune system of your program — it detects threats (errors), responds to them (catch blocks), and tries to restore normal function (finally blocks). Checked exceptions are like vaccines: the compiler forces you to prepare for known dangers before they happen. Unchecked exceptions are like sudden infections: they indicate something has gone fundamentally wrong in the code itself. The try-with-resources pattern is the automatic cleanup crew that ensures resources are released even if something goes wrong. The key design principle is fail-fast: detect problems as early as possible and throw exceptions with clear, actionable messages.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.