The previous lesson left two explicit debts. The first: the manual resource-closing pattern, with its seven requirements and its twenty lines of bookkeeping for four of real work. The second, more serious: the finally that throws its own exception while closing and makes the original failure disappear, leaving you with a message about the closing and not a clue about the real problem.

Java 7 solved both at a stroke with a single construct: try-with-resources. You declare the resources between parentheses, and the language takes care of always closing them, in the right order, and of preserving the original exception by recording the closing one as suppressed instead of letting it replace it.

But this lesson is not only about using the construct. It is about understanding what it does exactly underneath —because its equivalence to the manual try-finally is exact and can be written out— and about learning to make your own classes closeable. By the end, BiblioTech will have a LibrarySession that opens a work shift and, on closing, consolidates the statistics and releases the locks, however the block is left.

Scope warning: several examples use FileReader, BufferedReader and Scanner over a file. Input/output is module 7, and there that API is explained in depth. Here the bare minimum is used, and the focus is always on the closing, not on the reading.

Contents

  1. The syntax and its exact equivalence
  2. AutoCloseable and Closeable
  3. Several resources: reverse closing order
  4. The resource variable is implicitly final
  5. The Java 9 form: already existing variables
  6. Suppressed exceptions: the problem solved
  7. getSuppressed() and how they appear in the stack trace
  8. Combining with catch and finally
  9. Resources that must NOT be closed this way
  10. Implementing AutoCloseable in your classes
  11. Good practice in close()
  12. BiblioTech: LibrarySession and CatalogExporter
  13. Summary table: manual versus automatic management
  14. Common Mistakes and Tips
  15. Exercises

  1. The syntax and its exact equivalence

The form is a try with a list of declarations between parentheses:

try (Resource r = new Resource()) {
    // use r
}
// r is already closed here, whatever happens

And now, the two versions side by side. This is the correct manual pattern from 06-05:

// BEFORE JAVA 7: manual management
public int countLines(String path) throws IOException {
    BufferedReader reader = null;              // 1. declare outside
    try {
        reader = new BufferedReader(new FileReader(path));
        int lines = 0;
        while (reader.readLine() != null) {
            lines++;
        }
        return lines;
    } finally {
        if (reader != null) {                  // 2. check for null
            try {
                reader.close();                // 3. close() throws IOException
            } catch (IOException closeError) {
                // 4. do not rethrow: we would lose the original exception
                System.err.println("Warning: " + closeError.getMessage());
            }
        }
    }
}

And this is the equivalent with try-with-resources:

// SINCE JAVA 7: automatic management
public int countLines(String path) throws IOException {
    try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
        int lines = 0;
        while (reader.readLine() != null) {
            lines++;
        }
        return lines;
    }
}

Nine lines of bookkeeping turned into zero, and with better behaviour than the manual version, because the closing exception is not lost: it is recorded as suppressed.

The exact equivalence the compiler generates, for a single resource, is approximately this:

// What the compiler generates (simplified sketch)
BufferedReader reader = new BufferedReader(new FileReader(path));
Throwable primaryException = null;
try {
    // ... body of the try ...
} catch (Throwable t) {
    primaryException = t;
    throw t;
} finally {
    if (reader != null) {
        if (primaryException != null) {
            try {
                reader.close();
            } catch (Throwable closeThrowable) {
                primaryException.addSuppressed(closeThrowable);   // <-- the key
            }
        } else {
            reader.close();          // no previous exception: if this throws, it propagates
        }
    }
}

Look at the marked line: it is exactly the addSuppressed() you had to write by hand in exercise 3 of 06-05. Here the compiler puts it in for you, in every case and with no chance of forgetting.

An important detail of that expansion: if there was no exception in the body, close() is called unprotected, so if the closing fails, that exception does propagate. That is correct: if nothing else failed, the closing failure is the only news and must reach the top.

  1. AutoCloseable and Closeable

For a class to be usable in a try-with-resources, it must implement one of these two interfaces:

// Java 7, java.lang
public interface AutoCloseable {
    void close() throws Exception;
}

// Java 5, java.io
public interface Closeable extends AutoCloseable {
    void close() throws IOException;
}

Their differences:

AutoCloseable Closeable
Package java.lang java.io
Since Java 7 Java 5
close() declares throws Exception throws IOException
Idempotence Not required (but recommended) Required by the contract
Relationship It is the super-interface Extends AutoCloseable
When to choose it General resources: sessions, locks, transactions I/O resources

The most important practical difference is the declared exception. If your class implements AutoCloseable without narrowing the throws, the compiler will force whoever uses it to handle a generic Exception:

// close() declares Exception: the user has to catch Exception, far too broad
class BroadSession implements AutoCloseable {
    @Override
    public void close() throws Exception { }
}

try (BroadSession s = new BroadSession()) {
    // ...
} catch (Exception e) {          // forced to catch Exception: horrible
    // ...
}

The solution, and it is the correct practice: narrow the throws in your implementation. Remember from 06-03 that an override can declare fewer exceptions than the original method, never more:

// GOOD: close() with no throws. Whoever uses it will not have to catch anything.
class CleanSession implements AutoCloseable {
    @Override
    public void close() {            // no throws: more restrictive, perfectly legal
        // release resources without throwing
    }
}

try (CleanSession s = new CleanSession()) {
    // ...
}
// No compulsory catch. Much better.

Practical rule: if your close() does not need to throw a checked exception, do not declare one. Every throws you put there you are imposing on all your users, in every try-with-resources.

These are the standard library classes that are already closeable and that you will come across:

Class Interface Course module
BufferedReader, FileReader, FileWriter Closeable 7
Scanner Closeable 1 and 7
InputStream, OutputStream and their subclasses Closeable 7
Files.newBufferedReader(...) Closeable 7
Connection, Statement, ResultSet (JDBC) AutoCloseable 11
Socket, ServerSocket Closeable 9
ExecutorService (since Java 19) AutoCloseable 8
Stream AutoCloseable 10

  1. Several resources: reverse closing order

You can declare several resources separated by semicolons. They are closed in the reverse order of opening, which is what you need when one resource wraps another.

package com.nexussoftware.bibliotech.presentation;

/**
 * Demonstrates the opening and closing order with three resources.
 * The resources are simulated: there is no real I/O, so that the focus is
 * exclusively on the ORDER.
 */
public class ClosingOrder {

    /** Toy resource that announces its opening and its closing. */
    static class TracedResource implements AutoCloseable {
        private final String name;

        TracedResource(String name) {
            this.name = name;
            System.out.println("  OPEN  " + name);
        }

        void use() {
            System.out.println("  USE   " + name);
        }

        @Override
        public void close() {                  // no throws: more convenient for the user
            System.out.println("  CLOSE " + name);
        }
    }

    public static void main(String[] args) {
        System.out.println("=== No exception ===");
        try (TracedResource a = new TracedResource("A-file");
             TracedResource b = new TracedResource("B-buffer");
             TracedResource c = new TracedResource("C-parser")) {

            a.use();
            b.use();
            c.use();
        }

        System.out.println("\n=== With an exception in the body ===");
        try (TracedResource a = new TracedResource("A-file");
             TracedResource b = new TracedResource("B-buffer")) {

            a.use();
            throw new IllegalStateException("failure in the middle of the process");

        } catch (IllegalStateException e) {
            System.out.println("  CATCH " + e.getMessage());
        }

        System.out.println("\n=== With an exception OPENING the second resource ===");
        try (TracedResource a = new TracedResource("A-file");
             TracedResource b = createFailing("B-buffer")) {
            a.use();
        } catch (IllegalStateException e) {
            System.out.println("  CATCH " + e.getMessage());
        }
    }

    static TracedResource createFailing(String name) {
        throw new IllegalStateException("could not open " + name);
    }
}

Output:

=== No exception ===
  OPEN  A-file
  OPEN  B-buffer
  OPEN  C-parser
  USE   A-file
  USE   B-buffer
  USE   C-parser
  CLOSE C-parser
  CLOSE B-buffer
  CLOSE A-file

=== With an exception in the body ===
  OPEN  A-file
  OPEN  B-buffer
  USE   A-file
  CLOSE B-buffer
  CLOSE A-file
  CATCH failure in the middle of the process

=== With an exception OPENING the second resource ===
  OPEN  A-file
  CLOSE A-file
  CATCH could not open B-buffer

Three key observations:

Closing is the reverse of opening. C, B, A. It is essential when resources wrap each other: a BufferedReader built over a FileReader must be closed before the FileReader, because its close() flushes the buffer by writing to the underlying stream. If you closed them the other way round, you would be writing to an already closed stream.

The resources are closed before the catch. In the second case, CLOSE B and CLOSE A come out before CATCH. This is what you want: when the handler runs, the resources are already released and it can use them for something else, or retry.

If opening the second resource fails, the first is closed all the same. This is the case the manual pattern almost always got wrong: with the try-finally of 06-05 you had to nest a block per resource to achieve it, and a moment's carelessness was enough to leave the first one open.

  1. The resource variable is implicitly final

Inside the block, the resource cannot be reassigned:

try (BufferedReader reader = new BufferedReader(new FileReader("catalog.txt"))) {
    reader = new BufferedReader(new FileReader("other.txt"));   // DOES NOT COMPILE
    // error: auto-closeable resource reader may not be assigned
}

The reason is obvious if you think about the expansion in section 1: the generated finally closes the variable. If you could reassign it, you would close the second resource and leave the first open for ever. The restriction prevents a guaranteed leak.

You can write final explicitly if you like the clarity, although it is redundant:

try (final BufferedReader reader = new BufferedReader(new FileReader(path))) {
    // ...
}

And the resource's scope is the try block. Outside it does not exist, not even in the catch blocks or the finally of the same try:

try (TracedResource r = new TracedResource("A")) {
    r.use();
} catch (Exception e) {
    r.use();                       // DOES NOT COMPILE: cannot find symbol
} finally {
    r.use();                       // DOES NOT COMPILE: cannot find symbol
}

It makes sense: when the catch and the finally run, the resource is already closed. Its not being accessible stops you using it by mistake in that state.

  1. The Java 9 form: already existing variables

In Java 7 and 8, the resource had to be declared inside the parentheses. That forced an awkward detour when the resource came from outside:

// Java 7/8: you have to create a new variable just to be able to use it
public void process(BufferedReader receivedReader) throws IOException {
    try (BufferedReader reader = receivedReader) {     // redundant variable
        System.out.println(reader.readLine());
    }
}

Since Java 9 you can directly use an already existing final or effectively final variable:

// Java 9+: the variable is used as it is
public void process(BufferedReader receivedReader) throws IOException {
    try (receivedReader) {                             // no new declaration
        System.out.println(receivedReader.readLine());
    }
}

Effectively final means the same as in the lambdas of 04-05: a variable that is not reassigned after its initialisation, even though it does not carry the word final.

public void demo() throws Exception {
    TracedResource a = new TracedResource("A");    // effectively final: never reassigned
    TracedResource b = new TracedResource("B");

    try (a; b) {                                    // several, separated by ;
        a.use();
        b.use();
    }
    // B is closed and then A
}

And if the variable is reassigned, it does not compile:

TracedResource r = new TracedResource("A");
r = new TracedResource("B");                        // reassigned: no longer effectively final

try (r) {                                            // DOES NOT COMPILE
    // error: local variables referenced from a resource specification
    //        must be final or effectively final
}

A warning about when to use this form. It is convenient, but it carries a design decision: who owns the resource? If a method receives a BufferedReader as a parameter and closes it, it is taking a decision that probably belongs to whoever passed it:

// SUSPICIOUS: I close something I did not open
public void process(BufferedReader reader) throws IOException {
    try (reader) {                     // do I have the right to close it?
        // ...
    }
}
// The caller will not be able to use their reader again, and may not have expected that.

The general rule: close what you open. If you receive an already open resource, the normal thing is to use it and hand it back intact, leaving the closing to whoever created it. The Java 9 form is most useful when the resource has been created in the same method but in several steps, or when the method explicitly documents that it takes ownership of the resource.

  1. Suppressed exceptions: the problem solved

Here is where try-with-resources proves it is much more than syntactic sugar. Recall the exact problem from 06-05: the try throws the real failure and the close() throws another, and with a manual finally the second erased the first.

package com.nexussoftware.bibliotech.presentation;

/**
 * Compares what happens when the body AND the closing fail:
 *  - with a manual try-finally: the closing exception REPLACES the original.
 *  - with try-with-resources: the original is preserved and the closing one is SUPPRESSED.
 */
public class SuppressedExceptions {

    /** Resource whose closing also fails. */
    static class FragileResource implements AutoCloseable {
        private final String name;

        FragileResource(String name) {
            this.name = name;
        }

        void read() {
            throw new IllegalStateException("REAL FAILURE: " + name + " is corrupt");
        }

        @Override
        public void close() {
            throw new IllegalStateException("CLOSING FAILURE: " + name + " was not released");
        }
    }

    public static void main(String[] args) {

        System.out.println("=== A) MANUAL try-finally ===");
        try {
            withManualFinally();
        } catch (IllegalStateException e) {
            System.out.println("  Received  : " + e.getMessage());
            System.out.println("  Cause     : " + e.getCause());
            System.out.println("  Suppressed: " + e.getSuppressed().length);
            System.out.println("  >> The REAL FAILURE has disappeared");
        }

        System.out.println("\n=== B) try-with-resources ===");
        try {
            withTryWithResources();
        } catch (IllegalStateException e) {
            System.out.println("  Received  : " + e.getMessage());
            System.out.println("  Cause     : " + e.getCause());
            System.out.println("  Suppressed: " + e.getSuppressed().length);
            for (Throwable suppressed : e.getSuppressed()) {
                System.out.println("    - " + suppressed.getMessage());
            }
            System.out.println("  >> BOTH are preserved");
        }

        System.out.println("\n=== C) Only the closing fails ===");
        try {
            onlyTheCloseFails();
        } catch (IllegalStateException e) {
            System.out.println("  Received  : " + e.getMessage());
            System.out.println("  Suppressed: " + e.getSuppressed().length);
            System.out.println("  >> With no previous exception, the closing one propagates normally");
        }
    }

    static void withManualFinally() {
        FragileResource resource = new FragileResource("catalog.txt");
        try {
            resource.read();
        } finally {
            resource.close();         // this exception REPLACES the try's
        }
    }

    static void withTryWithResources() {
        try (FragileResource resource = new FragileResource("catalog.txt")) {
            resource.read();
        }
    }

    static void onlyTheCloseFails() {
        try (FragileResource resource = new FragileResource("loans.txt")) {
            System.out.println("  (the body finishes fine)");
        }
    }
}

Output:

=== A) MANUAL try-finally ===
  Received  : CLOSING FAILURE: catalog.txt was not released
  Cause     : null
  Suppressed: 0
  >> The REAL FAILURE has disappeared

=== B) try-with-resources ===
  Received  : REAL FAILURE: catalog.txt is corrupt
  Cause     : null
  Suppressed: 1
    - CLOSING FAILURE: catalog.txt was not released
  >> BOTH are preserved

=== C) Only the closing fails ===
  (the body finishes fine)
  Received  : CLOSING FAILURE: loans.txt was not released
  Suppressed: 0
  >> With no previous exception, the closing one propagates normally

The rules of the suppression mechanism, in full:

Situation What propagates What is suppressed
Only the body fails The body's Nothing
Only the closing fails The closing's Nothing
Body and closing fail The body's The closing's
Body and several closings fail The body's All the closing ones

The priority is deliberate and correct: the body's failure is the one explaining what went wrong; the closing one is usually a consequence. But neither of the two is lost.

And a detail about the difference between cause and suppressed, which get confused:

Cause (getCause) Suppressed (getSuppressed)
Relationship A triggered B A and B happened independently
Cardinality A single one, chained Several, in an array
Set with The constructor with a cause, or initCause addSuppressed(), almost always automatic
Example NumberFormatException caused IllegalStateException The file was corrupt and on top of that could not be closed

  1. getSuppressed() and how they appear in the stack trace

Suppressed exceptions appear in the dump with the Suppressed: label, indented under the main one:

Exception in thread "main" java.lang.IllegalStateException: REAL FAILURE: catalog.txt is corrupt
	at com.nexussoftware.bibliotech.presentation.SuppressedExceptions$FragileResource.read(SuppressedExceptions.java:22)
	at com.nexussoftware.bibliotech.presentation.SuppressedExceptions.withTryWithResources(SuppressedExceptions.java:70)
	at com.nexussoftware.bibliotech.presentation.SuppressedExceptions.main(SuppressedExceptions.java:38)
	Suppressed: java.lang.IllegalStateException: CLOSING FAILURE: catalog.txt was not released
		at com.nexussoftware.bibliotech.presentation.SuppressedExceptions$FragileResource.close(SuppressedExceptions.java:27)
		at com.nexussoftware.bibliotech.presentation.SuppressedExceptions.withTryWithResources(SuppressedExceptions.java:71)
		... 1 more

How to read it, extending what you learned in 06-01:

  • The main block is the failure that propagated.
  • Each Suppressed: block (indented one level) is a failure that happened as well, typically while closing a resource.
  • Each block can in turn have its own Caused by: and its own suppressed ones.

The recommended reading order, now complete:

  1. Class and message of the main exception: what failed.
  2. Its last Caused by:: the root cause of that failure.
  3. The Suppressed: ones: what else failed along the way, normally while releasing resources.

And a utility method that will help you diagnose:

package com.nexussoftware.bibliotech.presentation;

/** Dumps an exception with its causes and its suppressed ones, indented. */
public final class ExceptionReport {

    private ExceptionReport() { }

    public static String describe(Throwable t) {
        StringBuilder sb = new StringBuilder();
        describe(t, sb, 0, "");
        return sb.toString();
    }

    private static void describe(Throwable t, StringBuilder sb, int level, String label) {
        if (t == null || level > 10) {                 // anti-circular protection
            return;
        }
        String indent = "  ".repeat(level);
        sb.append(indent).append(label)
          .append(t.getClass().getSimpleName()).append(": ")
          .append(t.getMessage() != null ? t.getMessage() : "(no message)")
          .append('\n');

        // First own frame, to locate the failure
        for (StackTraceElement f : t.getStackTrace()) {
            if (f.getClassName().startsWith("com.nexussoftware")) {
                sb.append(indent).append("    at ").append(f.getMethodName())
                  .append(" (").append(f.getFileName()).append(':')
                  .append(f.getLineNumber()).append(")\n");
                break;
            }
        }

        // Suppressed: they happened AS WELL (same logical level, indented)
        for (Throwable suppressed : t.getSuppressed()) {
            describe(suppressed, sb, level + 1, "Suppressed: ");
        }

        // Cause: it TRIGGERED the previous one
        if (t.getCause() != null && t.getCause() != t) {
            describe(t.getCause(), sb, level + 1, "Caused by: ");
        }
    }
}

  1. Combining with catch and finally

A try-with-resources accepts catch and finally like any other try, and unlike a normal try, it can stand alone: it needs neither of the two.

// Legal: try-with-resources with no catch and no finally
try (TracedResource r = new TracedResource("A")) {
    r.use();
}

// Legal: with catch
try (TracedResource r = new TracedResource("A")) {
    r.use();
} catch (IllegalStateException e) {
    System.out.println("Failure: " + e.getMessage());
}

// Legal: with catch and finally
try (TracedResource r = new TracedResource("A")) {
    r.use();
} catch (IllegalStateException e) {
    System.out.println("Failure: " + e.getMessage());
} finally {
    System.out.println("Extra clean-up, unrelated to resources");
}

The exact order of execution when they are all there:

flowchart TB
    A["1. The resources are OPENED<br/>in the declared order"] --> B["2. The BODY of the try runs"]
    B --> C["3. The resources are CLOSED<br/>in REVERSE order"]
    C --> D{"Was there an exception?"}
    D -->|"yes, and there is a compatible catch"| E["4. The CATCH runs"]
    D -->|"no"| F["4. The catch is skipped"]
    E --> G["5. The FINALLY runs"]
    F --> G
    G --> H["6. Continues after the block"]

The essential point: the resources are closed BEFORE the catch and the finally. When the handler runs, they are already released. Check it:

package com.nexussoftware.bibliotech.presentation;

public class CompleteOrder {

    static class Resource implements AutoCloseable {
        Resource() { System.out.println("1. open"); }
        void use() { throw new IllegalStateException("failure in the body"); }
        @Override public void close() { System.out.println("3. close"); }
    }

    public static void main(String[] args) {
        try (Resource r = new Resource()) {
            System.out.println("2. body");
            r.use();
        } catch (IllegalStateException e) {
            System.out.println("4. catch: " + e.getMessage());
        } finally {
            System.out.println("5. finally");
        }
        System.out.println("6. after");
    }
}

Output:

1. open
2. body
3. close
4. catch: failure in the body
5. finally
6. after

And what is a finally for here, if the resources already close themselves? For clean-up that is not about resources: restoring a flag, reinstating state, logging the duration of the operation. That is to say, exactly the compensation pattern from 06-05, which is still needed and which now coexists with automatic resource management.

  1. Resources that must NOT be closed this way

try-with-resources closes the resource always, and that is sometimes precisely what you do not want.

The most important case: System.in.

// REAL PROBLEM: it closes System.in for the WHOLE application
public int readOption() {
    try (Scanner scanner = new Scanner(System.in)) {     // BAD
        return Integer.parseInt(scanner.nextLine());
    }
}

// From here on, any other Scanner over System.in fails:
// NoSuchElementException: No line found

Scanner implements Closeable, and its close() closes the underlying stream. If that stream is System.in, you are closing it for the whole process, not just for your method. The next Scanner anyone creates over System.in will throw NoSuchElementException on the first read, and the failure will appear far from the guilty method.

This is exactly the mistake module 1 avoided with its convention of a single shared Scanner, created once and never closed:

// CORRECT: a single instance Scanner, no try-with-resources
public class BiblioTechMenu {

    private final Scanner scanner = new Scanner(System.in);   // created once

    public int readOption() {
        // No try-with-resources: System.in is NOT closed
        return Integer.parseInt(scanner.nextLine().trim());
    }
    // It is never closed: the JVM closes System.in when the process ends
}

The general rule, which you already saw stated in the Java 9 form:

Close what you open. If the resource was handed to you already open, or it is a global process resource, you are not its owner and you must not close it.

Other cases where try-with-resources should not be used:

Resource Why not
System.in, System.out, System.err They are global to the process. Closing them affects the whole application
A resource received as a parameter The owner is whoever opened it; they probably want to keep using it
A resource that is returned to the caller You would close it before the receiver could use it
Resources from a pool (managed connections) With a pool, close() normally returns the connection to the pool, so there it is correct: check the documentation
A resource that must outlive the method Keep it as a field and close it in your own class's close()

The third case deserves a note, because it produces a very subtle bug:

// BAD: the resource is closed right before returning it
public BufferedReader openCatalog(String path) throws IOException {
    try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
        return reader;             // closed BEFORE returning (06-05: the finally runs first)
    }
}
// Whoever receives it will get "Stream closed" on the first read.

If a method returns a resource, it cannot close it: the responsibility passes to the caller, and that must be clearly documented.

  1. Implementing AutoCloseable in your classes

Your own classes can take part in a try-with-resources. It is the idiomatic way of modelling anything with an open → use → close life cycle: a session, a work shift, a transaction, a lock, a connection.

The skeleton:

public class MyResource implements AutoCloseable {

    private boolean closed = false;

    public MyResource() {
        // acquire whatever is needed
    }

    public void operation() {
        checkOpen();                 // protect against use after closing
        // ...
    }

    private void checkOpen() {
        if (closed) {
            throw new IllegalStateException("The resource is already closed");
        }
    }

    @Override
    public void close() {            // no throws: more convenient for the user
        if (closed) {
            return;                  // IDEMPOTENT: closing twice does not fail
        }
        closed = true;
        // release what was acquired
    }
}

The four elements that must not be missing:

  1. A closed flag, to know what state it is in.
  2. An idempotent close(): calling it twice neither fails nor does the work twice.
  3. A check in the usage methods, throwing IllegalStateException if it is already closed (06-03: the problem is when you ask me).
  4. A close() with no checked throws if you can avoid it.

  1. Good practice in close()

The close() contract has rules worth respecting, because whoever uses your class will take them for granted.

1. It must be idempotent. Closeable requires it literally: "if the stream is already closed, invoking this method has no effect". AutoCloseable only recommends it, but comply anyway. Without idempotence, this code blows up:

try (MyResource r = new MyResource()) {
    r.operation();
    r.close();                // early explicit closing
}                             // ...and the try-with-resources closes it AGAIN

2. It should not throw if it can be avoided. An exception in close() is especially treacherous: if the body also failed, yours is left suppressed and probably nobody reads it; and if the body went fine, your exception turns a successful operation into a failure. The reasonable thing in most cases is to log the problem and not propagate it:

@Override
public void close() {
    if (closed) { return; }
    closed = true;
    try {
        releaseUnderlyingResource();
    } catch (RuntimeException e) {
        // Logged, not propagated: closing must not break a correct operation.
        // In 06-07 this will be logger.log(Level.WARNING, "...", e).
        System.err.println("Warning: failed to release the resource: " + e.getMessage());
    }
}

With one important proviso: if close() is the moment when the data is committed, then it must throw. A BufferedWriter.close() flushes the buffer to disk; if that fails, the data has not been written and the caller has to find out. Silencing that failure would be worse than propagating it.

3. It must be fast. close() runs on the way out, often while an exception is propagating. It is not the place for long operations or heavy business logic.

4. It must leave the object unusable, not half usable. After close(), the usage methods must throw IllegalStateException, not behave strangely.

5. Do not rely on finalize(). The old finalisation mechanism has been deprecated since Java 9 and marked for removal: there is no guarantee at all about when —or whether— it will run. AutoCloseable with try-with-resources is the correct, deterministic mechanism. (Cleaner is the modern alternative for advanced cases, and it is outside this course's scope.)

  1. BiblioTech: LibrarySession and CatalogExporter

First, the work session. An employee opens a shift, performs operations, and on closing it the statistics are consolidated and the locks released. However the block is left.

package com.nexussoftware.bibliotech.service;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
 * An employee's work shift over BiblioTech.
 *
 * On closing:
 *   1. it consolidates the shift's statistics,
 *   2. it releases the catalogue lock,
 *   3. it leaves the session unusable.
 *
 * It implements AutoCloseable, not Closeable, because it is not an I/O resource.
 * And its close() does NOT declare throws: that way its users are not forced to catch anything.
 */
public class LibrarySession implements AutoCloseable {

    private final String employeeId;
    private final int openingDay;
    private final Catalog catalog;

    private final List<String> operations = new ArrayList<>();
    private int loansMade = 0;
    private int returnsMade = 0;
    private double finesCollected = 0.0;

    private boolean closed = false;

    /** Accumulated statistics of every shift (static: 03-02). */
    private static int shiftsCompleted = 0;
    private static int totalOperations = 0;

    public LibrarySession(String employeeId, int openingDay, Catalog catalog) {
        this.employeeId = Objects.requireNonNull(employeeId, "The employee cannot be null");
        this.catalog = Objects.requireNonNull(catalog, "The catalogue cannot be null");
        if (openingDay < 1) {
            throw new IllegalArgumentException("The day must be 1 or later, and it was: " + openingDay);
        }
        this.openingDay = openingDay;

        catalog.lock(employeeId);               // resource acquisition
        System.out.println("  [session of " + employeeId + " opened on day " + openingDay + "]");
    }

    // ---------- Shift operations ----------

    public void recordLoan(String reference) {
        checkOpen();
        loansMade++;
        operations.add("LOAN " + reference);
    }

    public void recordReturn(String reference, double fine) {
        checkOpen();
        if (fine < 0) {
            throw new IllegalArgumentException("The fine cannot be negative: " + fine);
        }
        returnsMade++;
        finesCollected += fine;
        operations.add(String.format("RETURN %s (fine %.2f)", reference, fine));
    }

    public List<String> getOperations() {
        checkOpen();
        return List.copyOf(operations);
    }

    /**
     * Every usage method checks the state.
     * It is IllegalStateException, not IllegalArgumentException: the problem
     * is WHEN it is asked, not what is asked (06-03).
     */
    private void checkOpen() {
        if (closed) {
            throw new IllegalStateException(
                    "The session of " + employeeId + " is already closed");
        }
    }

    // ---------- Closing ----------

    /**
     * Consolidates and releases. IDEMPOTENT and with no checked throws.
     *
     * It runs on success and on failure of the body, because try-with-resources
     * guarantees it just like a finally (06-05).
     */
    @Override
    public void close() {
        if (closed) {
            return;                              // idempotent
        }
        closed = true;

        // 1. Consolidate statistics
        shiftsCompleted++;
        totalOperations += operations.size();

        System.out.printf("  [session of %s closed] %d loans, %d returns, %.2f EUR%n",
                employeeId, loansMade, returnsMade, finesCollected);

        // 2. Release the lock, protected so as not to break a correct operation
        try {
            catalog.unlock(employeeId);
        } catch (RuntimeException e) {
            // Not propagated: closing must not turn a correct shift into a failure.
            // In 06-07 this will be logger.log(Level.WARNING, ..., e).
            System.err.println("  Warning: could not release the catalogue lock: "
                    + e.getMessage());
        }
    }

    public boolean isClosed() { return closed; }

    public static int getShiftsCompleted()  { return shiftsCompleted; }
    public static int getTotalOperations()  { return totalOperations; }
}

Now the exporter, which does touch a file and serves to show two nested resources:

package com.nexussoftware.bibliotech.service;

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.util.List;

import com.nexussoftware.bibliotech.domain.Material;

/**
 * Exports the catalogue to a text file.
 *
 * It implements Closeable (not AutoCloseable) because it IS an I/O resource and its
 * close() can legitimately throw IOException: on closing, the buffer is flushed
 * to disk, and if that fails, the data has NOT been written. That failure
 * MUST reach the caller.
 *
 * (The writing API is developed in module 7.)
 */
public class CatalogExporter implements java.io.Closeable {

    private final String path;
    private final BufferedWriter writer;

    private int materialsWritten = 0;
    private boolean closed = false;

    public CatalogExporter(String path) throws IOException {
        this.path = path;
        this.writer = new BufferedWriter(new FileWriter(path));
        writeHeader();
    }

    private void writeHeader() throws IOException {
        writer.write("# BiblioTech catalogue - Nexus Software");
        writer.newLine();
        writer.write("# reference;title;available");
        writer.newLine();
    }

    public void export(Material material) throws IOException {
        if (closed) {
            throw new IllegalStateException("The exporter for '" + path + "' is already closed");
        }
        writer.write(material.getReference() + ";" + material.getTitle() + ";"
                + material.isAvailable());
        writer.newLine();
        materialsWritten++;
    }

    public void exportAll(List<Material> materials) throws IOException {
        for (Material m : materials) {
            export(m);
        }
    }

    public int getMaterialsWritten() { return materialsWritten; }

    /**
     * Closes the file.
     *
     * HERE the IOException IS propagated: a writer's close() flushes the
     * buffer to disk. If it fails, the data is not saved and the caller
     * has to find out. It is the exception to the "close does not throw" rule.
     */
    @Override
    public void close() throws IOException {
        if (closed) {
            return;                              // idempotent
        }
        closed = true;
        try {
            writer.write("# Total: " + materialsWritten + " materials");
            writer.newLine();
        } finally {
            writer.close();                      // flushes the buffer and releases the descriptor
        }
    }
}

And the joint demonstration:

package com.nexussoftware.bibliotech.presentation;

import java.io.IOException;

import com.nexussoftware.bibliotech.domain.Book;
import com.nexussoftware.bibliotech.service.Catalog;
import com.nexussoftware.bibliotech.service.CatalogExporter;
import com.nexussoftware.bibliotech.service.LibrarySession;

public class SessionAndExporterDemo {

    public static void main(String[] args) {
        Catalog catalog = new Catalog();
        catalog.register(new Book("BK-0001", "Effective Java", "Bloch", 2018, "978-0000000001"));
        catalog.register(new Book("BK-0002", "Design Patterns", "GoF", 1994, "978-0000000002"));
        catalog.register(new Book("BK-0003", "Refactoring", "Fowler", 1999, "978-0000000003"));

        // === 1. A shift that goes well ===
        System.out.println("=== 1. Marta's correct shift ===");
        try (LibrarySession session = new LibrarySession("EMP-001", 10, catalog)) {
            session.recordLoan("BK-0001");
            session.recordLoan("BK-0002");
            session.recordReturn("BK-0003", 1.25);
            System.out.println("  Shift operations: " + session.getOperations());
        }
        System.out.println("  Catalogue locked: " + catalog.isLocked());

        // === 2. A shift that fails halfway ===
        System.out.println("\n=== 2. Diego's shift that fails ===");
        try (LibrarySession session = new LibrarySession("EMP-002", 11, catalog)) {
            session.recordLoan("BK-0001");
            session.recordReturn("BK-0002", -5.0);            // throws
            System.out.println("  this line does not run");
        } catch (IllegalArgumentException e) {
            System.out.println("  Failure caught: " + e.getMessage());
        }
        System.out.println("  Catalogue locked: " + catalog.isLocked());
        System.out.println("  (the session closed and consolidated all the same)");

        // === 3. Using the session after closing it ===
        System.out.println("\n=== 3. Use after closing ===");
        LibrarySession outside;
        try (LibrarySession session = new LibrarySession("EMP-003", 12, catalog)) {
            session.recordLoan("BK-0003");
            outside = session;                                // the reference is kept
        }
        try {
            outside.recordLoan("BK-0001");
        } catch (IllegalStateException e) {
            System.out.println("  " + e.getMessage());
        }
        System.out.println("  close() twice: ");
        outside.close();                                      // idempotent: does not fail
        System.out.println("  no errors");

        // === 4. Two nested resources ===
        System.out.println("\n=== 4. Session + exporter (two resources) ===");
        try (LibrarySession session = new LibrarySession("EMP-001", 13, catalog);
             CatalogExporter exporter = new CatalogExporter("catalog-export.txt")) {

            exporter.exportAll(catalog.list());
            session.recordLoan("BK-0002");
            System.out.println("  Exported: " + exporter.getMaterialsWritten());

        } catch (IOException e) {
            System.out.println("  Export failure: " + e.getMessage());
        }
        // Closing in REVERSE order: first the exporter, then the session

        System.out.println("\n=== Global statistics ===");
        System.out.println("  Shifts completed : " + LibrarySession.getShiftsCompleted());
        System.out.println("  Total operations : " + LibrarySession.getTotalOperations());
    }
}

Output:

=== 1. Marta's correct shift ===
  [session of EMP-001 opened on day 10]
  Shift operations: [LOAN BK-0001, LOAN BK-0002, RETURN BK-0003 (fine 1.25)]
  [session of EMP-001 closed] 2 loans, 1 returns, 1.25 EUR
  Catalogue locked: false

=== 2. Diego's shift that fails ===
  [session of EMP-002 opened on day 11]
  [session of EMP-002 closed] 1 loans, 0 returns, 0.00 EUR
  Failure caught: The fine cannot be negative: -5.0
  Catalogue locked: false
  (the session closed and consolidated all the same)

=== 3. Use after closing ===
  [session of EMP-003 opened on day 12]
  [session of EMP-003 closed] 1 loans, 0 returns, 0.00 EUR
  The session of EMP-003 is already closed
  close() twice:
  no errors

=== 4. Session + exporter (two resources) ===
  [session of EMP-001 opened on day 13]
  Exported: 3
  [session of EMP-001 closed] 1 loans, 0 returns, 0.00 EUR

=== Global statistics ===
  Shifts completed : 4
  Total operations : 5

What case 2 demonstrates is the heart of the lesson: Diego's shift failed halfway, and even so the session closed, the statistics were consolidated with what had actually been done, the lock was released and the exception reached the catch with its original message. Without a single line of finally written by hand.

And case 4 shows the reverse closing order: the exporter —declared second— is closed first, and the session afterwards.

  1. Summary table: manual versus automatic management

Aspect Manual try-finally try-with-resources
Variable declaration Outside the try, initialised to null Inside the parentheses
null check Manual, compulsory Automatic
Call to close() Manual, in the finally Automatic
close() that throws a checked one Another nested try Automatic
Order with several resources Manual, and you have to nest Automatic and reverse
Failure opening the second resource The first is left open if you do not nest The first is always closed
Body exception + closing exception The closing one erases the original The original is preserved; the closing one is suppressed
Lines for two resources ~20 2
Probability of getting it wrong High None
When finally is still needed For state compensation, not resources

The operational conclusion: in new code, always try-with-resources for anything that closes. try-finally is still needed, but for something else: restoring state and compensating half-done operations, as in 06-05.

Common Mistakes and Tips

Wrapping System.in in a try-with-resources. It closes standard input for the whole application, and the next Scanner will throw NoSuchElementException from a place that is not to blame. A single shared Scanner, created once, never closed.

Closing a resource passed to you as a parameter. You are not its owner. Whoever gave it to you probably wants to keep using it. Close what you open.

Returning a resource from inside the try-with-resources. It is closed before returning —the close runs before the return, just like the finally of 06-05— and whoever receives it will get Stream closed on the first operation.

Declaring throws Exception in your close(). You are imposing it on all your users in every try-with-resources. Narrow the throws to what you actually throw, or to nothing.

A non-idempotent close(). If somebody closes explicitly inside the block, the automatic closing will call it again. With the closed flag and an early return, solved.

Throwing from close() when the body went fine. You turn a correct operation into a failure. Log and do not propagate, except when close() is the moment the data is committed —a writer flushing its buffer—, because then the failure is real and must be reported.

Looking for the closing exception in getCause(). It is not there: it is in getSuppressed(). getCause() is A triggered B; getSuppressed() is A and B happened as well.

Reassigning the resource variable. It does not compile, and that is good news: if it could be done, you would leave the first resource open for ever.

Using the resource in the catch or the finally of the same try. It is not in scope —and even if it were, it is already closed.

Relying on finalize() to release resources. It has been deprecated since Java 9 and marked for removal, and it guarantees neither when nor whether it runs. AutoCloseable is deterministic.

Tip: any open/use/close life cycle deserves to be AutoCloseable. Sessions, shifts, locks, transactions, timers. The try block visually documents the resource's lifetime.

Tip: choose Closeable for I/O and AutoCloseable for everything else. And in AutoCloseable, narrow the throws.

Tip: check the state at the start of every usage method. A clear IllegalStateException is infinitely better than odd behaviour over an already closed resource.

Tip: if you have a try-finally whose finally only calls close(), convert it. You get correct handling of closing exceptions for free.

Exercises

Exercise 1: a reentrant, closeable CatalogLock

Write CatalogLock in com.nexussoftware.bibliotech.service: an exclusion lock over the catalogue acquired on construction and released on closing.

Requirements:

  • It implements AutoCloseable with close() without throws.
  • Fields: ownerId, acquisitionMoment (an incremental counter, not a LocalDate), closed.
  • A static field currentOwner (String, null if free) and locksGranted (a counter).
  • The constructor throws IllegalStateException if there is already another owner, with a message saying who has it.
  • It is reentrant: if the same owner asks for it again, it is granted and the depth is tracked; the lock is only really released when the outermost one is closed.
  • An idempotent close().
  • A method void checkOwnership(String id) throwing IllegalStateException if id is not the current owner.

In main, demonstrate: normal acquisition and release, reentrancy with two nested levels, an acquisition attempt by another employee that fails, the guaranteed release after an exception in the body, and the double close().

Exercise 2: cascading suppressed exceptions

Write FailureCascade with a class FailingResource implements AutoCloseable whose constructor takes the name and two flags: whether it must fail on use and whether it must fail on closing.

Write a main running five scenarios with three resources declared in the same try-with-resources, and for each one show the main exception, its cause and all its suppressed ones with getSuppressed():

  1. Nothing fails.
  2. Only the body fails.
  3. Only the closing of the middle resource fails.
  4. The body fails and the closing of all three resources.
  5. The opening of the third resource fails (it throws in the constructor).

For each scenario, answer in writing: which exception propagates? how many are suppressed? in what order do the suppressed ones appear and why? which resources actually got closed?

Add a method dump(Throwable t) printing the complete tree of causes and suppressed ones with indentation.

Exercise 3: CatalogImporter with real resources

Write CatalogImporter, which reads a text file with lines reference;title;author;year;isbn and registers the materials in the Catalog, with an import report.

Requirements:

  • Use try-with-resources with a BufferedReader over a FileReader. Document with a comment that the I/O API is module 7 and limit its use to opening, reading lines and closing.
  • The method ImportReport importFile(String path) throws IOException does not catch the opening IOException: it declares it so that the caller decides.
  • Each line is processed independently: failures in one do not prevent the others. Distinguish at least three rejection reasons, one of them catching DuplicateReferenceException from 06-04.
  • Before reading, the importer must create a sample file if it does not exist, using CatalogExporter or a BufferedWriter in its own try-with-resources.
  • Add a method importWithSession(String path, String employeeId, Catalog catalog) combining two resources: a LibrarySession and the BufferedReader, and demonstrating the reverse closing order.
  • The main must demonstrate: a correct import, an import of a non-existent file (FileNotFoundException propagated and handled above), and the final report.

Solutions

Solution 1

package com.nexussoftware.bibliotech.service;

import java.util.Objects;

/**
 * Exclusion lock over the catalogue, reentrant and closeable.
 *
 * Intended use:
 *   try (CatalogLock l = new CatalogLock("EMP-001")) {
 *       // exclusive operations
 *   }   // always released
 *
 * Note: this is NOT thread-safe; real locks between threads are the subject
 * of module 8. Here it is a logical single-threaded lock.
 */
public class CatalogLock implements AutoCloseable {

    private static String currentOwner = null;
    private static int depth = 0;
    private static int locksGranted = 0;
    private static int momentCounter = 0;

    private final String ownerId;
    private final int acquisitionMoment;
    private final boolean reentrant;

    private boolean closed = false;

    /**
     * Acquires the lock.
     *
     * @throws IllegalStateException if ANOTHER owner has it
     */
    public CatalogLock(String ownerId) {
        this.ownerId = Objects.requireNonNull(ownerId,
                "The owner identifier cannot be null");

        if (currentOwner != null && !currentOwner.equals(ownerId)) {
            throw new IllegalStateException(
                    "The catalogue is locked by " + currentOwner
                            + "; " + ownerId + " cannot acquire it");
        }

        this.reentrant = (currentOwner != null);          // same owner again
        currentOwner = ownerId;
        depth++;
        locksGranted++;
        this.acquisitionMoment = ++momentCounter;

        System.out.println("    [lock " + (reentrant ? "REENTRANT " : "")
                + "acquired by " + ownerId + " (depth " + depth
                + ", moment " + acquisitionMoment + ")]");
    }

    /**
     * Releases the lock. Only the outermost one REALLY releases it.
     * Idempotent: closing twice does nothing the second time.
     */
    @Override
    public void close() {
        if (closed) {
            return;                              // idempotent
        }
        closed = true;
        depth--;

        if (depth == 0) {
            currentOwner = null;
            System.out.println("    [lock RELEASED by " + ownerId + "]");
        } else {
            System.out.println("    [reentrant level closed; depth " + depth + "]");
        }
    }

    /**
     * Checks that whoever operates is the owner of the lock.
     *
     * @throws IllegalStateException if they are not, or if there is no lock
     */
    public static void checkOwnership(String id) {
        if (currentOwner == null) {
            throw new IllegalStateException(
                    "There is no active lock over the catalogue; " + id + " cannot operate");
        }
        if (!currentOwner.equals(id)) {
            throw new IllegalStateException(
                    "The catalogue is locked by " + currentOwner + ", not by " + id);
        }
    }

    public String getOwnerId()          { return ownerId; }
    public int getAcquisitionMoment()   { return acquisitionMoment; }
    public boolean isClosed()           { return closed; }

    public static String getCurrentOwner()  { return currentOwner; }
    public static int getDepth()            { return depth; }
    public static int getLocksGranted()     { return locksGranted; }

    // ------------------------------------------------------------------

    public static void main(String[] args) {

        System.out.println("=== 1. Normal acquisition and release ===");
        try (CatalogLock l = new CatalogLock("EMP-001")) {
            System.out.println("  operating as " + l.getOwnerId());
            checkOwnership("EMP-001");
        }
        System.out.println("  Owner after closing: " + getCurrentOwner());

        System.out.println("\n=== 2. Two-level reentrancy ===");
        try (CatalogLock outer = new CatalogLock("EMP-001")) {
            System.out.println("  outer level");

            try (CatalogLock inner = new CatalogLock("EMP-001")) {
                System.out.println("  inner level");
                System.out.println("  Depth inside: " + getDepth());
            }
            System.out.println("  After closing the inner one, depth: " + getDepth());
            System.out.println("  Owner is still: " + getCurrentOwner());
        }
        System.out.println("  After closing the outer one: " + getCurrentOwner());

        System.out.println("\n=== 3. Another employee tries to lock ===");
        try (CatalogLock l = new CatalogLock("EMP-001")) {
            try (CatalogLock other = new CatalogLock("EMP-002")) {
                System.out.println("  this should not happen");
            } catch (IllegalStateException e) {
                System.out.println("  Rejected: " + e.getMessage());
            }
            System.out.println("  EMP-001's lock is still alive: " + getCurrentOwner());
        }

        System.out.println("\n=== 4. Exception in the body ===");
        try (CatalogLock l = new CatalogLock("EMP-003")) {
            System.out.println("  operating...");
            throw new IllegalArgumentException("failure in the middle of the operation");
        } catch (IllegalArgumentException e) {
            System.out.println("  Exception received INTACT: " + e.getMessage());
        }
        System.out.println("  Owner after the failure: " + getCurrentOwner()
                + " (correctly released)");

        System.out.println("\n=== 5. Double close() ===");
        CatalogLock manual = new CatalogLock("EMP-001");
        manual.close();
        manual.close();
        System.out.println("  No errors. Depth: " + getDepth());

        System.out.println("\n=== 6. Operating with no lock ===");
        try {
            checkOwnership("EMP-002");
        } catch (IllegalStateException e) {
            System.out.println("  " + e.getMessage());
        }

        System.out.println("\nLocks granted in total: " + getLocksGranted());
    }
}

Output:

=== 1. Normal acquisition and release ===
    [lock acquired by EMP-001 (depth 1, moment 1)]
  operating as EMP-001
    [lock RELEASED by EMP-001]
  Owner after closing: null

=== 2. Two-level reentrancy ===
    [lock acquired by EMP-001 (depth 1, moment 2)]
  outer level
    [lock REENTRANT acquired by EMP-001 (depth 2, moment 3)]
  inner level
  Depth inside: 2
    [reentrant level closed; depth 1]
  After closing the inner one, depth: 1
  Owner is still: EMP-001
    [lock RELEASED by EMP-001]
  After closing the outer one: null

=== 3. Another employee tries to lock ===
    [lock acquired by EMP-001 (depth 1, moment 4)]
  Rejected: The catalogue is locked by EMP-001; EMP-002 cannot acquire it
  EMP-001's lock is still alive: EMP-001
    [lock RELEASED by EMP-001]

=== 4. Exception in the body ===
    [lock acquired by EMP-003 (depth 1, moment 5)]
  operating...
    [lock RELEASED by EMP-003]
  Exception received INTACT: failure in the middle of the operation
  Owner after the failure: null (correctly released)

=== 5. Double close() ===
    [lock acquired by EMP-001 (depth 1, moment 6)]
    [lock RELEASED by EMP-001]
  No errors. Depth: 0

=== 6. Operating with no lock ===
  There is no active lock over the catalogue; EMP-002 cannot operate

Locks granted in total: 6

Look at case 3: when acquiring the second lock fails in the constructor, the inner try-with-resources never has a resource to close, so the outer one's depth is untouched. If the constructor had incremented the counter before validating, that failure would have left the depth out of balance for ever. It is the fail-fast of 06-03: validate before modifying.

Solution 2

package com.nexussoftware.bibliotech.presentation;

/**
 * Explores the suppressed exception mechanism with three resources.
 */
public class FailureCascade {

    static class FailingResource implements AutoCloseable {
        private final String name;
        private final boolean failsOnUse;
        private final boolean failsOnClose;

        FailingResource(String name, boolean failsOnUse, boolean failsOnClose) {
            this(name, failsOnUse, failsOnClose, false);
        }

        FailingResource(String name, boolean failsOnUse, boolean failsOnClose,
                        boolean failsOnOpen) {
            if (failsOnOpen) {
                throw new IllegalStateException("OPENING of " + name + " failed");
            }
            this.name = name;
            this.failsOnUse = failsOnUse;
            this.failsOnClose = failsOnClose;
            System.out.println("    open  " + name);
        }

        void use() {
            System.out.println("    use   " + name);
            if (failsOnUse) {
                throw new IllegalStateException("USE of " + name + " failed");
            }
        }

        @Override
        public void close() {
            System.out.println("    close " + name);
            if (failsOnClose) {
                throw new IllegalStateException("CLOSING of " + name + " failed");
            }
        }
    }

    public static void main(String[] args) {

        scenario("1. Nothing fails", false, false, false, false, false, false);
        scenario("2. Only the body fails", true, false, false, false, false, false);
        scenario("3. Only the middle one's closing fails", false, false, false, false, true, false);
        scenario("4. The body AND all three closings fail", true, false, false, true, true, true);
        failedOpeningScenario();
    }

    /**
     * @param bodyFails       whether the body throws after using the resources
     * @param close1/2/3      whether each resource's closing fails
     */
    static void scenario(String title, boolean bodyFails,
                         boolean useA, boolean useB,
                         boolean close1, boolean close2, boolean close3) {
        System.out.println("\n=== " + title + " ===");
        try (FailingResource r1 = new FailingResource("R1", useA, close1);
             FailingResource r2 = new FailingResource("R2", useB, close2);
             FailingResource r3 = new FailingResource("R3", false, close3)) {

            r1.use();
            r2.use();
            r3.use();

            if (bodyFails) {
                throw new IllegalStateException("BODY failed");
            }
            System.out.println("  (no exception)");

        } catch (IllegalStateException e) {
            System.out.println("  --- Propagated exception ---");
            System.out.println(dump(e));
        }
    }

    static void failedOpeningScenario() {
        System.out.println("\n=== 5. The OPENING of the third resource fails ===");
        try (FailingResource r1 = new FailingResource("R1", false, false);
             FailingResource r2 = new FailingResource("R2", false, false);
             FailingResource r3 = new FailingResource("R3", false, false, true)) {

            r1.use();
            System.out.println("  this line does NOT run");

        } catch (IllegalStateException e) {
            System.out.println("  --- Propagated exception ---");
            System.out.println(dump(e));
        }
    }

    /** Dumps the tree of causes and suppressed ones with indentation. */
    static String dump(Throwable t) {
        StringBuilder sb = new StringBuilder();
        dump(t, sb, 1, "");
        return sb.toString();
    }

    private static void dump(Throwable t, StringBuilder sb, int level, String label) {
        if (t == null || level > 10) { return; }

        sb.append("  ".repeat(level)).append(label)
          .append(t.getClass().getSimpleName()).append(": ")
          .append(t.getMessage()).append('\n');

        for (Throwable s : t.getSuppressed()) {
            dump(s, sb, level + 1, "Suppressed: ");
        }
        if (t.getCause() != null && t.getCause() != t) {
            dump(t.getCause(), sb, level + 1, "Caused by: ");
        }
    }
}

Output (abbreviated) and analysis:

=== 1. Nothing fails ===
    open  R1
    open  R2
    open  R3
    use   R1
    use   R2
    use   R3
  (no exception)
    close R3
    close R2
    close R1

=== 2. Only the body fails ===
    open  R1 / R2 / R3 ... use R1 / R2 / R3
    close R3
    close R2
    close R1
  --- Propagated exception ---
    IllegalStateException: BODY failed

=== 3. Only the middle one's closing fails ===
    ... close R3
    close R2
  --- Propagated exception ---
    IllegalStateException: CLOSING of R2 failed

=== 4. The body AND all three closings fail ===
    close R3
    close R2
    close R1
  --- Propagated exception ---
    IllegalStateException: BODY failed
      Suppressed: IllegalStateException: CLOSING of R3 failed
      Suppressed: IllegalStateException: CLOSING of R2 failed
      Suppressed: IllegalStateException: CLOSING of R1 failed

=== 5. The OPENING of the third resource fails ===
    open  R1
    open  R2
    close R2
    close R1
  --- Propagated exception ---
    IllegalStateException: OPENING of R3 failed

Analysis of the five scenarios:

# Propagates Suppressed Resources closed
1 Nothing R3, R2, R1
2 The body's 0 R3, R2, R1
3 R2's closing one 0 R3, R2 (and R1 is attempted)
4 The body's 3 R3, R2, R1
5 R3's opening one 0 R2, R1

Answers to the questions:

  • Why does the body's one propagate in scenario 4? Because the mechanism gives priority to the body's failure: it is the one explaining what went wrong. Closing failures are secondary consequences, so they accumulate as suppressed instead of replacing it.
  • In what order do the suppressed ones appear? In the order in which the closings happen, which is the reverse of the opening: R3, R2, R1. Every failing close() adds its exception at the end of the main one's array.
  • Scenario 3, why are there no suppressed ones? Because there was no previous exception when R2's closing failed, so that one becomes the main one. And note a detail: R1 is also attempted afterwards —the mechanism does not abort the chain of closings—; if R1 had also failed, its exception would have been suppressed under R2's.
  • Scenario 5, the most instructive. When R3's constructor fails, R3 does not exist and there is nothing in it to close. But R1 and R2 were opened, and the mechanism closes them in reverse order before propagating the opening exception. The body never runs. This is precisely the case the manual pattern of 06-05 got wrong unless you nested a try per resource.

Solution 3

package com.nexussoftware.bibliotech.service;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import com.nexussoftware.bibliotech.domain.Book;
import com.nexussoftware.bibliotech.domain.DuplicateReferenceException;

/**
 * Imports materials from a text file into the catalogue.
 *
 * NOTE ON THE I/O API: here only FileReader, BufferedReader and
 * BufferedWriter are used, to open, read/write lines and close. Complete
 * input/output (streams, NIO.2, formats) is MODULE 7. This class's focus
 * is on the guaranteed CLOSING with try-with-resources.
 */
public class CatalogImporter {

    public record ImportReport(int linesRead, int accepted, int rejected,
                               List<String> reasons) {

        public String summary() {
            StringBuilder sb = new StringBuilder();
            sb.append("=== IMPORT REPORT ===\n");
            sb.append("Lines read : ").append(linesRead).append('\n');
            sb.append("Accepted   : ").append(accepted).append('\n');
            sb.append("Rejected   : ").append(rejected).append('\n');
            for (String r : reasons) {
                sb.append("  * ").append(r).append('\n');
            }
            return sb.toString();
        }
    }

    private final Catalog catalog;

    public CatalogImporter(Catalog catalog) {
        this.catalog = catalog;
    }

    /**
     * Imports a file into the catalogue.
     *
     * It does NOT catch the opening IOException: it declares it so that the
     * caller decides whether to start with an empty catalogue or abort (06-03).
     */
    public ImportReport importFile(String path) throws IOException {
        List<String> reasons = new ArrayList<>();
        int linesRead = 0;
        int accepted = 0;

        // try-with-resources: the reader is always closed, even if
        // the loop throws. With a manual try-finally 8 more lines would be needed.
        try (BufferedReader reader = new BufferedReader(new FileReader(path))) {

            String line;
            while ((line = reader.readLine()) != null) {
                linesRead++;

                if (line.isBlank() || line.startsWith("#")) {
                    continue;                    // blanks and comments: skipped quietly
                }

                // try INSIDE the loop: a bad line does not abort the import (06-02)
                try {
                    String[] c = line.split(";");
                    Book book = new Book(c[0].trim(), c[1].trim(), c[2].trim(),
                            Integer.parseInt(c[3].trim()), c[4].trim());
                    catalog.register(book);
                    accepted++;

                } catch (ArrayIndexOutOfBoundsException e) {
                    reasons.add("Line " + linesRead + ": missing fields -> '" + line + "'");

                } catch (NumberFormatException e) {
                    reasons.add("Line " + linesRead + ": non-numeric year -> " + e.getMessage());

                } catch (DuplicateReferenceException e) {
                    // DOMAIN exception (06-04): use its data, not its message
                    reasons.add("Line " + linesRead + ": " + e.getKind() + " duplicate "
                            + e.getDuplicateValue() + ", already used by '"
                            + e.getExistingTitle() + "'");

                } catch (IllegalArgumentException e) {
                    // Book's validations (reference format, year, ISBN)
                    reasons.add("Line " + linesRead + ": invalid data -> " + e.getMessage());
                }
            }
        }
        // The reader is already closed here, on success or on failure

        return new ImportReport(linesRead, accepted, linesRead - accepted, reasons);
    }

    /**
     * Imports inside a work session: TWO resources in the same
     * try-with-resources, closed in the reverse order of opening.
     */
    public ImportReport importWithSession(String path, String employeeId, int day)
            throws IOException {

        try (LibrarySession session = new LibrarySession(employeeId, day, catalog);
             BufferedReader reader = new BufferedReader(new FileReader(path))) {

            List<String> reasons = new ArrayList<>();
            int linesRead = 0;
            int accepted = 0;
            String line;

            while ((line = reader.readLine()) != null) {
                linesRead++;
                if (line.isBlank() || line.startsWith("#")) { continue; }

                try {
                    String[] c = line.split(";");
                    catalog.register(new Book(c[0].trim(), c[1].trim(), c[2].trim(),
                            Integer.parseInt(c[3].trim()), c[4].trim()));
                    accepted++;
                    session.recordLoan("REGISTER " + c[0].trim());   // recorded in the shift

                } catch (RuntimeException e) {
                    reasons.add("Line " + linesRead + ": " + e.getClass().getSimpleName()
                            + " - " + e.getMessage());
                }
            }
            return new ImportReport(linesRead, accepted, linesRead - accepted, reasons);
        }
        // REVERSE closing: first the reader, then the session (which consolidates
        // its statistics and releases the catalogue lock).
    }

    /** Creates a sample file if it does not exist. Another try-with-resources. */
    public static void createSampleFile(String path) throws IOException {
        File file = new File(path);
        if (file.exists()) {
            return;
        }
        try (BufferedWriter writer = new BufferedWriter(new FileWriter(path))) {
            writer.write("# BiblioTech sample catalogue");             writer.newLine();
            writer.write("# reference;title;author;year;isbn");        writer.newLine();
            writer.write("BK-0001;Effective Java;Bloch;2018;978-0000000001");     writer.newLine();
            writer.write("BK-0002;Design Patterns;GoF;1994;978-0000000002");      writer.newLine();
            writer.write("BK-0003;Refactoring;Fowler;1999;978-0000000003");       writer.newLine();
            writer.write("BK-0004;No author");                                    writer.newLine();
            writer.write("BK-0005;Bad year;Author;thousand;978-0000000005");      writer.newLine();
            writer.write("BK-0001;Duplicate;Other;2020;978-0000000006");          writer.newLine();
            writer.write("BK-0007;Absurd year;Author;1200;978-0000000007");       writer.newLine();
        }
        System.out.println("  (sample file created at " + path + ")");
    }

    // ------------------------------------------------------------------

    public static void main(String[] args) {
        String path = "catalog-input.txt";

        Catalog catalog = new Catalog();
        CatalogImporter importer = new CatalogImporter(catalog);

        System.out.println("=== 1. Preparation ===");
        try {
            createSampleFile(path);
        } catch (IOException e) {
            System.out.println("  Could not create the sample file: " + e.getMessage());
            return;
        }

        System.out.println("\n=== 2. Normal import ===");
        try {
            ImportReport report = importer.importFile(path);
            System.out.print(report.summary());
            System.out.println("Catalogue: " + catalog.size() + " materials");
        } catch (IOException e) {
            System.out.println("  I/O failure: " + e.getMessage());
        }

        System.out.println("\n=== 3. Non-existent file ===");
        try {
            importer.importFile("does-not-exist.txt");
        } catch (java.io.FileNotFoundException e) {
            // Subclass of IOException: it can be caught separately
            System.out.println("  The file does not exist. Continuing with the current catalogue.");
            System.out.println("  Detail: " + e.getMessage());
        } catch (IOException e) {
            System.out.println("  Another I/O failure: " + e.getMessage());
        }

        System.out.println("\n=== 4. Import inside a session (two resources) ===");
        Catalog other = new Catalog();
        CatalogImporter withSession = new CatalogImporter(other);
        try {
            ImportReport report = withSession.importWithSession(path, "EMP-001", 20);
            System.out.print(report.summary());
        } catch (IOException e) {
            System.out.println("  Failure: " + e.getMessage());
        }

        System.out.println("\n=== Final state ===");
        System.out.println("  Main catalogue: " + catalog.size() + " materials");
        System.out.println("  Shifts completed: " + LibrarySession.getShiftsCompleted());
    }
}

Output:

=== 1. Preparation ===
  (sample file created at catalog-input.txt)

=== 2. Normal import ===
=== IMPORT REPORT ===
Lines read : 10
Accepted   : 3
Rejected   : 7
  * Line 6: missing fields -> 'BK-0004;No author'
  * Line 7: non-numeric year -> For input string: "thousand"
  * Line 8: REFERENCE duplicate BK-0001, already used by 'Effective Java'
  * Line 9: invalid data -> The publication year must be between 1450 and 2100, and it was: 1200
Catalogue: 3 materials

=== 3. Non-existent file ===
  The file does not exist. Continuing with the current catalogue.
  Detail: does-not-exist.txt (No such file or directory)

=== 4. Import inside a session (two resources) ===
  [session of EMP-001 opened on day 20]
  [session of EMP-001 closed] 3 loans, 0 returns, 0.00 EUR
=== IMPORT REPORT ===
Lines read : 10
Accepted   : 3
Rejected   : 7
...

=== Final state ===
  Main catalogue: 3 materials
  Shifts completed: 1

Three points the exercise sums up:

  1. The opening IOException is declared, not caught. The importer does not know whether a missing file should abort the application or not; that decision belongs to main, which here chooses to carry on. It is the rule from 06-03 in action.
  2. Two levels of try with different purposes. The outer one, with resources, guarantees the closing. The inner one, inside the loop, tolerates faulty lines. Neither of the two could do the other's job.
  3. The reverse closing order is visible in case 4's output: the reader closes first (silently) and the session afterwards, printing its consolidation before the report is shown in main.

Conclusion

You have now mastered automatic resource management. You know that a try-with-resources declares its resources between parentheses and that the compiler generates the complete try-finally for you —with the null check, the protected close() and, above all, the addSuppressed() that preserves the original exception—, so that the twenty lines of manual bookkeeping from the pre-Java 7 pattern come down to zero and with better behaviour.

You know the two interfaces: Closeable (java.io, close() throws IOException, idempotence required) for I/O resources, and AutoCloseable (java.lang, close() throws Exception) for everything else; with the practical rule of narrowing the throws in your implementation, because every checked exception you declare in close() you impose on all your users in every block.

You have seen the closing order being the reverse of the opening order demonstrated with traces, and why it is essential when one resource wraps another. You know that the resources are closed before the catch and the finally, that if opening the second resource fails the first is closed all the same —the case the manual pattern almost always got wrong—, and that the resource variable is implicitly final and does not exist outside the block, both restrictions aimed at preventing leaks and uses over already closed resources. And you know the Java 9 form that accepts an already existing effectively final variable, with the ownership warning that comes with it: close what you open.

You understand the mechanism of suppressed exceptions, which is what solves the problem 06-05 left open: when the body and the closing fail, the body's one propagates —the one explaining what went wrong— and the closing one stays accessible in getSuppressed(), with several suppressed ones if several closings fail, in the reverse order in which they were closed. You know how to tell a cause from a suppressed one —A triggered B versus A and B happened as well— and how to read a complete stack trace with its nested Caused by: and Suppressed: blocks.

You know which resources must not be closed this way: System.in first of all —hence module 1's convention of a single shared Scanner that is never closed—, resources received as a parameter, and those the method returns to the caller, which would be closed before returning. And you know how to implement AutoCloseable in your classes with its four elements: a state flag, an idempotent close(), a check with IllegalStateException in every usage method and a minimal throws. With the good practices of close(): idempotent, fast, leaving the object unusable, not throwing if it can be avoided —except when the closing is the moment the data is committed, like a writer flushing its buffer— and never relying on the deprecated finalize().

BiblioTech has gained two closeable classes. LibrarySession models a work shift: it acquires the catalogue lock on construction and, on closing, consolidates the shift's statistics, releases the lock and becomes unusable, however the block is left. The demonstration proves it: when Diego's shift fails halfway because of a negative fine, the session closes all the same, the statistics are consolidated with what was actually done, the lock is released and the exception reaches the catch intact, without a single line of finally written by hand. And CatalogExporter implements Closeable with a close() that does propagate its IOException, because on closing the buffer is flushed to disk and that failure means the data has not been saved.

With this, the first four fragilities of module 5 are solved: invalid data no longer aborts the program, errors are not reported with mute null or false, operations do not leave the state half-done and resources are not left open. One thing remains in this module, and it is the one that turns all of the above into production software: deciding where each error is caught and stopping writing warnings with System.out.println.

That is the next and last lesson of the module, Error Handling Strategies and Logging: where to catch —the rule of catching where you can decide, not where it happens—, what each BiblioTech layer does with errors, the error boundary in main and the global handler with Thread.setDefaultUncaughtExceptionHandler; what information must reach the user and what only the log —never a stack trace in anybody's face—; recoverable versus unrecoverable errors, graceful degradation, bounded retries, prior validation versus exception, and the "result object" pattern. And the whole logging part: why System.out.println is no good in production, the table of levels and what to log at each one, java.util.logging in practice —a Logger per class, console and file Handlers, Formatter, configuration via logging.properties and logging an exception with log(Level.SEVERE, msg, e) instead of printStackTrace()—, what must never be logged, how a useful log message is written, and the real ecosystem with SLF4J and Logback that you will see in 11-07.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved