Part 1 covered how exceptions work and how to design them. This part turns to the tools: try-with-resources, Scoped Values, structured concurrency as it looks in Java 25, the Result type, and what exceptions cost at run time.
The Modern Exception Handling Toolkit
Traditional Try-Catch: The Foundation
The classic structure remains the workhorse:
public void processFile(String path) {
try (BufferedReader reader = Files.newBufferedReader(Path.of(path))) {
return reader.readLine();
} catch (FileNotFoundException e) {
logger.error("Config file not found: {}", path, e);
return getDefaultConfig();
} catch (IOException e) {
logger.error("Error reading file: {}", path, e);
throw new ConfigLoadException("Failed to load: " + path, e);
}
}Multi-Catch: Java 7’s Gift
When handling logic is identical for multiple exceptions:
try {
database.connect();
} catch (SQLException | IOException e) {
throw new DatabaseUnavailableException("Cannot connect", e);
}Note: The variable
eis implicitly final and is declared with the union typeSQLException | IOException. Its static type is the closest common supertype of the caught exceptions, hereException.
Try-With-Resources: The Modern Way
Always use this for AutoCloseable objects-resources close automatically in reverse order:
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(SQL);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
processRow(rs);
}
} // All three closed automatically: rs → stmt → connThe generated bytecode includes Throwable.addSuppressed() to ensure exceptions from close operations don’t mask the original exception.
Scoped Values - ThreadLocal’s Successor (Java 25)
Java 25 finalizes Scoped Values (JEP 506), the modern replacement for ThreadLocal
that enables efficient context sharing across Virtual Threads and Structured Concurrency.
The ThreadLocal Problem
ThreadLocal has three fatal flaws:
- Unconstrained mutability - any code can call
set()at any time - Unbounded lifetime - values persist until explicitly removed (memory leaks in thread pools)
- Expensive inheritance - child threads must copy all parent thread-locals
With millions of Virtual Threads (Java 21+), these costs become prohibitive.
The Scoped Value Solution
Scoped values are immutable, bounded to a scope, and efficiently inherited by child threads:
private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
Response handleRequest(Request req) throws InterruptedException {
var context = new RequestContext(req.getId(), req.getUser());
return ScopedValue.where(CONTEXT, context).call(() -> {
// Context available to all callees, including forked subtasks
return processRequest();
});
// Context automatically cleaned up here-no memory leaks!
}
Response processRequest() throws InterruptedException {
var ctx = CONTEXT.get(); // Always get the right value
logger.info("Processing request {}", ctx.requestId());
try (var scope = StructuredTaskScope.open()) {
Subtask<UserData> user = scope.fork(() -> fetchUser(ctx.userId()));
Subtask<Permissions> perms = scope.fork(() -> fetchPermissions(ctx.userId()));
scope.join(); // Throws FailedException if a subtask failed
return new Response(user.get(), perms.get());
}
}Why Scoped Values Matter for Error Handling
In concurrent applications, context is crucial for error diagnosis:
Response handleRequest(Request req) throws InterruptedException {
var context = new RequestContext(req.getId(), req.getUserId());
return ScopedValue.where(CONTEXT, context).call(() -> {
try {
return processRequest();
} catch (Exception e) {
// CONTEXT.get() still available for logging!
var ctx = CONTEXT.get();
logger.error("Request {} failed for user {}",
ctx.requestId(), ctx.userId(), e);
throw e;
}
});
}Without Scoped Values, context might be lost when exceptions propagate across thread boundaries.
Scoped Values are final in Java 25 (JEP 506) and represent the best practice for context sharing in modern Java.
Structured Concurrency - Better Async Error Handling (Preview)
Structured Concurrency (JEP 505) is in its 5th preview in Java 25. While not yet finalized, it provides superior error handling for concurrent code.
The Problem with Traditional Async Error Handling
// Traditional: Errors get lost or mishandled
Future<User> userFuture = executor.submit(() -> fetchUser(id));
Future<Order> orderFuture = executor.submit(() -> fetchOrder(id));
// What if one fails? What if both fail? Complexity ensues.Structured Concurrency Solution
try (var scope = StructuredTaskScope.open()) {
Subtask<User> user = scope.fork(() -> fetchUser(id));
Subtask<Order> order = scope.fork(() -> fetchOrder(id));
scope.join(); // Wait for both; throws if either failed
return new Response(user.get(), order.get());
} catch (StructuredTaskScope.FailedException e) {
return switch (e.getCause()) { // The exception of the failed subtask
case UserNotFoundException _ -> Response.notFound("User not found");
case OrderException _ -> Response.error("Failed to load order");
default -> throw e;
};
}Key benefits:
- Fail-fast: If one task fails, others are cancelled automatically
- Clear exception hierarchy: Errors propagate cleanly to the caller
- No lost exceptions: All failures are properly reported
Scoped Values + Structured Concurrency
Together they enable clean context propagation in concurrent code:
Data handleConcurrentRequest(Request req) throws InterruptedException {
var context = new RequestContext(req.getId());
return ScopedValue.where(CONTEXT, context).call(() -> {
try (var scope = StructuredTaskScope.open()) {
// Subtasks automatically see CONTEXT
Subtask<Data> primary = scope.fork(() -> fetchPrimary(CONTEXT.get()));
Subtask<Data> backup = scope.fork(() -> fetchBackup(CONTEXT.get()));
scope.join();
return merge(primary.get(), backup.get());
}
});
}Note: Structured Concurrency is in preview (JEP 505). Enable with
--enable-previewflag. Java 25 reworked its API: a scope opens withStructuredTaskScope.open(),fork()returns aSubtask, and the exception of a failed subtask arrives as the cause ofStructuredTaskScope.FailedException. Code written forShutdownOnFailurefrom earlier previews no longer compiles.
Advanced Patterns for Modern Java
The Result Type (Functional Error Handling)
For operations that might fail but shouldn’t throw:
public sealed interface Result<T, E> {
record Success<T, E>(T value) implements Result<T, E> {}
record Failure<T, E>(E error) implements Result<T, E> {}
}
public Result<User, UserError> findUser(UserId id) {
try {
return new Result.Success<>(db.loadUser(id));
} catch (UserNotFoundException e) {
return new Result.Failure<>(UserError.NOT_FOUND);
}
}
// Usage with pattern matching (Java 21+)
var result = findUser(id);
switch (result) {
case Result.Success<User, UserError> s -> displayUser(s.value());
case Result.Failure<User, UserError> f -> showError(f.error());
}CompletableFuture Exception Handling
Async code requires special handling:
CompletableFuture.supplyAsync(this::fetchData)
.exceptionally(ex -> {
logger.error("Fetch failed, using cache", ex);
return cache.getDefault();
})
.thenAccept(this::process)
.exceptionally(ex -> {
showError("Processing failed");
return null;
});Global Exception Handlers
For uncaught exceptions at application boundaries:
Thread.setDefaultUncaughtExceptionHandler((thread, ex) -> {
logger.error("Uncaught exception in thread {}", thread.getName(), ex);
alertingService.notify("Application error", ex);
});Performance Considerations
The Cost of Exceptions
Creating an exception captures the entire stack trace-a relatively expensive operation involving:
- Walking the call stack
- Creating StackTraceElement objects
- String manipulation for class/method names
Benchmarks show throwing and catching an exception can be 100x+ slower than a normal return (nanoseconds vs. microseconds).
Optimization Strategies
-
Don’t use exceptions for control flow (see Deadly Sin #4)
-
Override fillInStackTrace() for performance-critical code:
public class FastException extends RuntimeException {
@Override
public synchronized Throwable fillInStackTrace() {
return this; // Skip stack trace creation
}
}- Use Optional for nullable returns instead of exceptions
Quick Reference
Common Checked Exceptions
IOExceptionI/O operations failRetry, use defaults, notify the userSQLExceptionDatabase errorsRetry with backoff, circuit breakerInterruptedExceptionThread interruptedRestore the interrupt, terminate gracefullyExecutionExceptionAsync computation failedExtract the cause, handle itCommon Unchecked Exceptions
NullPointerExceptionMissing null checkAdd validation, fix the bugIllegalArgumentExceptionInvalid parameterValidate at method entryIllegalStateExceptionObject misusedCheck preconditionsIndexOutOfBoundsExceptionArray or collection boundsValidate indicesModern Java Patterns
Try-With-Resources:
try (Resource r1 = openResource1();
Resource r2 = openResource2()) {
// Use resources
} catch (SpecificException e) {
// Handle
}Scoped Values (Java 25):
private static final ScopedValue<Context> CTX = ScopedValue.newInstance();
ScopedValue.where(CTX, context).run(() -> {
// Context available to all callees
CTX.get(); // Always returns the bound value
});Pattern Matching for Types (Java 21+):
String error = switch (exception) {
case IOException ioe -> "I/O: " + ioe.getMessage();
case SQLException sqle -> "DB: " + sqle.getSQLState();
default -> "Unknown: " + exception.getMessage();
};References
Java 25 Features and Documentation
- JEP 506: Scoped Values (finalized in Java 25, September 2025)
- JEP 505: Structured Concurrency (5th preview in Java 25)
- JEP 441: Pattern Matching for switch (finalized in Java 21, September 2023)
- JEP 444: Virtual Threads (finalized in Java 21)
- Oracle Java 25 Release Notes
Modern Exception Handling Practices
- Inside Java: Scoped Values Deep Dive (2024)
- InfoQ: Understanding Java’s Project Loom and Virtual Threads (2023)
- Oracle Documentation: Exceptions
- Baeldung: Java Exception Handling Best Practices (2024)