I/O and Exception Handling

Every Java program touches files, sockets, or streams eventually, and every non-trivial program has to decide what to do when things go wrong. The pre-Java-7 way (streams closed in finally, checked exceptions everywhere, throws IOException metastasizing through your call graph) was painful enough that most working Java engineers learned it and then avoided it. Modern Java (NIO.2 + try-with-resources + a mature view on unchecked exceptions) is genuinely pleasant.

This file is short deliberately. I/O is a small API; exceptions are a small feature. The trick is knowing which of the 20 things Java offers you actually use.

1. NIO.2 — the API you should use

Since Java 7, java.nio.file (usually shortened to NIO.2) replaces java.io.File for filesystem work. File still exists and works, but the modern API is more consistent, works with symbolic links, exposes real filesystem attributes, and integrates with streams.

The three types you’ll use daily:

Type

Role

Typical use

Path

Immutable file/dir path

Path.of("logs", "2026-07.log")

Files

Static utility methods

Files.readString(path)

Paths (legacy)

Was the factory

Prefer Path.of(...)

Reading a file

Path p = Path.of("data.txt");

// Whole file into memory
String text     = Files.readString(p);
List<String> ls = Files.readAllLines(p);
byte[] bytes    = Files.readAllBytes(p);

// Streaming (crucial for large files)
try (Stream<String> lines = Files.lines(p)) {
    lines.filter(l -> l.contains("ERROR"))
         .forEach(System.out::println);
}   // stream closes the file

⚠️ What most people get wrong

Files.lines() returns a Stream that holds an OS file handle. You must use it inside a try-with-resources. If you forget, you leak file descriptors, and on Windows you cannot even delete the file until the JVM exits. readAllLines doesn’t have this problem because it reads eagerly and closes.

Writing a file

Files.writeString(p, "hello");
Files.write(p, listOfLines);        // one line per element
Files.write(p, bytes);

// Append mode
Files.writeString(p, "more\n", StandardOpenOption.APPEND);

// BufferedWriter for many small writes
try (BufferedWriter w = Files.newBufferedWriter(p)) {
    for (var s : bigList) { w.write(s); w.newLine(); }
}

Directory operations

Files.createDirectories(Path.of("a/b/c"));    // mkdir -p
Files.exists(p);
Files.isDirectory(p);
Files.size(p);
Files.delete(p);
Files.deleteIfExists(p);
Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING);
Files.copy(src, dst);

// Directory listing (streaming)
try (Stream<Path> entries = Files.list(dir))         { ... }   // one level
try (Stream<Path> entries = Files.walk(dir))         { ... }   // recursive
try (Stream<Path> entries = Files.walk(dir, 2))      { ... }   // depth limit

Again: Files.list / Files.walk return resource-holding streams. Always inside try-with-resources.


2. try-with-resources

Any expression whose type implements AutoCloseable can be declared in the resource list. The compiler generates the finally that calls close(), in reverse order, and correctly handles suppressed exceptions.

try (var in  = Files.newBufferedReader(inputPath);
     var out = Files.newBufferedWriter(outputPath)) {
    String line;
    while ((line = in.readLine()) != null) {
        out.write(line.toUpperCase());
        out.newLine();
    }
}

Since Java 9 you can reference an already-declared effectively-final resource:

var reader = Files.newBufferedReader(p);
try (reader) { ... }

⚠️ What most people get wrong

They still write manual finally { in.close(); }. In modern code, if you see a manual close in a finally block, it’s either legacy or a mistake. Also: the resources list closes in reverse order — useful when one resource depends on another (write buffer closes before file handle).


3. Character vs byte streams (mental model)

  • Byte streams: InputStream / OutputStream. Raw bytes. Use for binary formats, images, network protocols.

  • Character streams: Reader / Writer. Text with an explicit Charset. Use for text files, logs, JSON.

  • Bridging: InputStreamReader(in, StandardCharsets.UTF_8), OutputStreamWriter(out, UTF_8).

Always specify the charset. The default charset is JVM-dependent (used to be platform-default — which was UTF-8 on Linux/macOS and Windows-1252 on Windows; since Java 18, UTF-8 everywhere by JEP 400, but still be explicit). Files.readString and Files.newBufferedReader default to UTF-8. Explicit is safer.


4. Serialization — the short verdict

Java’s built-in Serializable mechanism is a security and versioning disaster. It was deprecated in mindset if not in API. Every CVE-of-the-decade paper on Java has been a readObject gadget chain. In new code:

  • JSON: Jackson (ObjectMapper) is the industry default.

  • Binary: Protobuf if you need cross-language, Kryo if Java-only and you need speed.

  • XML: JAXB if you must, but honestly avoid new XML formats.

Serializable still shows up in RMI, some session replication, and legacy code. Know it exists, understand serialVersionUID. Don’t design new systems around it.


5. Exceptions — the hierarchy

Throwable
 ├── Error                (JVM / unrecoverable: OutOfMemoryError, StackOverflowError)
 │    ... don't catch these
 └── Exception
      ├── RuntimeException (unchecked: NPE, IllegalArgumentException, IllegalStateException, ...)
      └── everything else (checked: IOException, SQLException, InterruptedException, ...)

Checked exceptions must be caught or declared with throws. Compiler enforces it. Unchecked exceptions (subclasses of RuntimeException and Error) don’t need declaring.


6. The honest verdict on checked vs unchecked

Here is the position modern Java has landed on, after 25 years of arguing:

Prefer unchecked exceptions in new code.

Reasons:

  1. Checked exceptions leak implementation details through the type signature. If your save() method declares throws SQLException, every caller either handles SQL-specific problems or propagates them — both bad.

  2. Streams, lambdas, and functional interfaces (Function, Supplier, etc.) don’t declare checked exceptions. Wrapping every checked call inside a stream is boilerplate. This is why libraries like Jackson, Spring, and Hibernate wrap IOException and SQLException in unchecked runtime versions.

  3. Kotlin and Scala have no checked exceptions and it hasn’t caused problems. This is the strongest empirical signal.

When checked exceptions are actually correct:

  • The exception is a legitimate alternative return value the caller should think about (e.g. parseInt throwing a checked exception on bad input would arguably be right — they made it unchecked and now every caller writes try/catch anyway).

  • You are writing library APIs where the caller should be forced to make a decision, not just propagate.

These cases are rare. Default to unchecked. Wrap checked APIs at the boundary.

The wrapping pattern

try {
    return Files.readString(p);
} catch (IOException e) {
    throw new UncheckedIOException(e);      // preserves cause
}

Or for arbitrary checked exceptions:

try {
    return riskyChecked();
} catch (Exception e) {
    throw new RuntimeException("context: " + input, e);
}

Never swallow: catch (Exception e) {} is nearly always a bug. At minimum log with cause.


7. Custom exceptions

A custom exception is worth defining when:

  1. Callers need to catch and handle this specific failure distinctly, and

  2. The domain has a name for the failure (InsufficientFundsException, UserNotFoundException).

Otherwise reuse: IllegalArgumentException (bad input to a method), IllegalStateException (object not in a valid state for the operation), UnsupportedOperationException (method deliberately not implemented for this subtype).

Template for a custom exception:

public class InsufficientFundsException extends RuntimeException {
    private final BigDecimal shortfall;
    public InsufficientFundsException(BigDecimal shortfall) {
        super("Short by " + shortfall);
        this.shortfall = shortfall;
    }
    public InsufficientFundsException(BigDecimal shortfall, Throwable cause) {
        super("Short by " + shortfall, cause);
        this.shortfall = shortfall;
    }
    public BigDecimal shortfall() { return shortfall; }
}

Always provide the (String, Throwable) constructor for exception chaining. Extend RuntimeException unless you have a strong reason not to.


8. Multi-catch and rethrow

try {
    stuff();
} catch (IOException | SQLException e) {   // multi-catch (Java 7+)
    log.error("boom", e);
    throw new ServiceException("failed", e);   // rethrow chained
}

Since Java 7 the compiler is smart enough that if you catch a general type and rethrow, it tracks the actual thrown subtypes:

public void run() throws IOException, SQLException {
    try { ... }
    catch (Exception e) {
        cleanup();
        throw e;   // compiler knows only IOException / SQLException can occur
    }
}

9. finally and its trap

finally runs whether the try block completes normally, throws, or returns. Two traps:

  1. return inside finally swallows the pending exception. Don’t ever return from a finally.

  2. Throwing from finally masks the original exception. Use try-with-resources instead; it uses suppressed exceptions correctly (Throwable.getSuppressed()).

// BAD
try {
    throw new RuntimeException("real problem");
} finally {
    return 0;   // real problem is silently discarded
}

10. Assertions and preconditions

  • assert cond : msg; — disabled by default at runtime unless JVM started with -ea. Use for internal invariants only in tests / debug builds, never for public API validation.

  • Objects.requireNonNull(x, "x must not be null") — always active, throws NPE. Use at the top of methods for parameter validation.

  • Guava’s Preconditions.checkArgument, checkState, checkNotNull — nice one-liners if Guava is on the classpath. Otherwise Objects.requireNonNull and manual if (x < 0) throw new IllegalArgumentException(...).


Practice: 20-minute drill

  1. Read a text file, count lines matching a regex, print the count. Use Files.lines inside try-with-resources.

  2. Write a method that copies a file and, if the destination exists, throws a custom FileAlreadyExistsExceptionEx (yours, unchecked). No File, only Path.

  3. Wrap Files.readAllLines such that IOException becomes an UncheckedIOException. One-liner.

  4. Write a BankAccount.withdraw(BigDecimal) that throws your InsufficientFundsException if amount > balance. Include the shortfall in the exception.

  5. Walk a directory recursively, sum the size of all .log files. Use Files.walk.

Each under 5 minutes.


Return to README.md · Next: 05_50_programs_challenge.md