“Why is try-with-resources better than closing in finally?” is a common question in Java interviews, along with “What happens if both the body and close throw?” and “In what order are resources closed?” The answers reveal whether you understand exception masking, suppressed exceptions and resource lifecycles, all of which matter when a service leaks file handles or database connections.
Resource leaks are a classic production failure. A connection pool exhausts, a process hits its open-file limit, or a lock stays held. try-with-resources exists to make the correct cleanup pattern the easy one.
Before you start
You should know try, catch and finally, and the difference between checked and unchecked exceptions. Understand that resources such as files, sockets, JDBC connections and locks are held by the operating system or a pool, not just the heap, so the garbage collector cannot be relied on to release them promptly.
The short answer
try-with-resources declares one or more AutoCloseable resources in the try header and guarantees that close() is called on each when the block exits, normally or by an exception. Resources are closed in the reverse order of declaration, and closing happens before any catch or finally clause runs. If the body throws and close() also throws, the body’s exception is propagated and the close failure is attached to it with addSuppressed, so nothing is lost. A hand-written finally gets this wrong by default, because an exception from close replaces the original.
How it works
The compiler expands this:
try (BufferedReader in = Files.newBufferedReader(path)) {
return in.readLine();
}into roughly this:
BufferedReader in = Files.newBufferedReader(path);
Throwable primary = null;
try {
return in.readLine();
} catch (Throwable t) {
primary = t;
throw t;
} finally {
if (in != null) {
if (primary != null) {
try {
in.close();
} catch (Throwable closeFailure) {
primary.addSuppressed(closeFailure);
}
} else {
in.close();
}
}
}Notice three details. If the resource initializer itself throws, there is nothing to close and the exception propagates directly. A null resource is allowed and simply skipped. And if only close() throws, its exception is the one propagated.
AutoCloseable.close() may throw Exception; the narrower java.io.Closeable.close() throws IOException and is required to be idempotent, so calling it twice has no effect. Implementations of AutoCloseable are encouraged, but not required, to be idempotent too.
Compare the pre-Java 7 version that many codebases still contain:
BufferedReader in = null;
try {
in = Files.newBufferedReader(path);
return in.readLine(); // suppose this throws "disk read error"
} finally {
if (in != null) {
in.close(); // suppose this throws too: it replaces the read error
}
}The original failure disappears from the logs, and you debug the wrong problem.
Step-by-step walkthrough
Step 1: Declare each resource in the header
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT name FROM users WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString(1) : null;
}
}Declare each closeable object as its own resource. Wrapping in one expression, like new BufferedReader(new FileReader(file)), means that if the outer constructor throws, the inner reader is never closed.
Step 2: Know the closing order
Resources close in reverse order: the ResultSet, then the PreparedStatement, then the Connection. That matches dependencies, since later resources usually depend on earlier ones.
Step 3: Inspect suppressed exceptions when debugging
try {
processFile(path);
} catch (IOException e) {
log.error("Processing failed", e); // stack trace prints "Suppressed:" sections
for (Throwable s : e.getSuppressed()) {
metrics.increment("close.failures");
}
}Logging frameworks print suppressed exceptions automatically as part of the stack trace.
Step 4: Make your own types AutoCloseable
public final class TimedSection implements AutoCloseable {
private final String name;
private final long start = System.nanoTime();
public TimedSection(String name) { this.name = name; }
@Override
public void close() { // no checked exception: callers need no catch
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println(name + " took " + ms + " ms");
}
}
try (var timer = new TimedSection("import")) {
runImport();
}Narrow the throws clause of close() to what it can really throw, ideally nothing, so callers are not forced to catch Exception.
Step 5: Reuse existing variables on Java 9+
BufferedWriter out = Files.newBufferedWriter(target);
try (out) { // Java 9+: effectively final variable
out.write(report);
}The variable must be final or effectively final.
Worked scenario
A batch exporter streams rows into a CSV file and occasionally fails with “Too many open files” after running for hours. The code:
for (Path input : inputs) {
Stream<String> lines = Files.lines(input);
lines.map(this::transform).forEach(writer::println);
}Files.lines opens a file handle that is released only when the stream is closed, and nothing closes it. Collection-backed streams hold no resources, so developers forget that I/O-backed streams do. After a few thousand inputs, the process exhausts its file descriptors.
A second bug lurked in the writer cleanup:
finally {
writer.close(); // if this threw, the export's real exception vanished
}The fix uses try-with-resources for both:
try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter(target))) {
for (Path input : inputs) {
try (Stream<String> lines = Files.lines(input)) {
lines.map(this::transform).forEach(writer::println);
}
}
if (writer.checkError()) {
throw new IOException("Failed writing " + target);
}
}Each input’s handle is released per iteration, and any failure while closing the writer is attached to the primary exception. Note that PrintWriter swallows I/O errors, so the explicit checkError() call is needed to surface them.
Common mistake
- Nesting constructors in one resource. If the outer constructor throws, the inner resource leaks; declare each separately.
- Thinking catch runs before close. Resources are already closed by the time
catchorfinallyexecutes, so you cannot use them there. - Forgetting I/O-backed streams.
Files.lines,Files.listandFiles.walkmust be closed. - Closing a borrowed resource. If a method receives a connection from its caller, the caller owns it; closing it inside the method breaks the caller.
- Declaring close() throws Exception. It forces every user into a broad catch.
Verify the behavior
import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import org.junit.jupiter.api.Test;
class TwrTest {
record Res(String name, List<String> log, boolean failOnClose) implements AutoCloseable {
@Override public void close() {
log.add("close " + name);
if (failOnClose) throw new IllegalStateException("close " + name);
}
}
@Test
void closesInReverseOrder() {
List<String> log = new ArrayList<>();
try (var a = new Res("a", log, false); var b = new Res("b", log, false)) {
log.add("body");
}
assertEquals(List.of("body", "close b", "close a"), log);
}
@Test
void bodyExceptionWinsAndCloseIsSuppressed() {
List<String> log = new ArrayList<>();
var ex = assertThrows(RuntimeException.class, () -> {
try (var a = new Res("a", log, true)) {
throw new RuntimeException("body");
}
});
assertEquals("body", ex.getMessage());
assertEquals("close a", ex.getSuppressed()[0].getMessage());
}
}Follow-up questions
What is the difference between AutoCloseable and Closeable? Closeable extends AutoCloseable, narrows the exception to IOException, and requires idempotent close().
Can you use try-with-resources with locks? Lock is not AutoCloseable. You can write a small wrapper whose close() calls unlock(), though the explicit lock() then try { } finally { unlock(); } pattern is more common and clearer.
Does try-with-resources replace finalize or Cleaner? Yes for deterministic cleanup. finalize is deprecated for removal; java.lang.ref.Cleaner is a safety net for resources that callers forget to close, not a substitute for closing them.
What happens if a resource is null? It is skipped; close() is not called and no NullPointerException occurs.
Interview exercise
Two resources are declared, A then B. The body throws E1, B.close() throws E2 and A.close() throws E3. What does the caller receive?
Answer and reasoning
The caller receives E1, with E2 and E3 attached as suppressed exceptions in that order. Resources close in reverse order, so B closes first and its E2 is added to E1 with addSuppressed; then A closes and its E3 is added as well. Both closes still run even though the first one failed, so neither resource leaks. If the body had completed normally, the caller would receive E2 (the first close failure) with E3 suppressed on it. This ordering is defined in the Java Language Specification section 14.20.3, and the reasoning shows you know that try-with-resources never lets a cleanup failure hide the real error.
Continue learning
Practise more with the Java interview questions and the Java MCQs. Related notes: exceptions and recovery boundaries, stream reuse and ownership and garbage collection and retained objects. The language rules are in the Java Language Specification, chapter 14, and the interface contract is in the AutoCloseable API documentation.