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 |
|---|---|---|
|
Immutable file/dir path |
|
|
Static utility methods |
|
|
Was the factory |
Prefer |
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 explicitCharset. 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:
Checked exceptions leak implementation details through the type signature. If your
save()method declaresthrows SQLException, every caller either handles SQL-specific problems or propagates them — both bad.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 wrapIOExceptionandSQLExceptionin unchecked runtime versions.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.
parseIntthrowing 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:
Callers need to catch and handle this specific failure distinctly, and
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:
returninsidefinallyswallows the pending exception. Don’t ever return from a finally.Throwing from
finallymasks 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. OtherwiseObjects.requireNonNulland manualif (x < 0) throw new IllegalArgumentException(...).
Practice: 20-minute drill¶
Read a text file, count lines matching a regex, print the count. Use
Files.linesinside try-with-resources.Write a method that copies a file and, if the destination exists, throws a custom
FileAlreadyExistsExceptionEx(yours, unchecked). NoFile, onlyPath.Wrap
Files.readAllLinessuch thatIOExceptionbecomes anUncheckedIOException. One-liner.Write a
BankAccount.withdraw(BigDecimal)that throws yourInsufficientFundsExceptionif amount > balance. Include the shortfall in the exception.Walk a directory recursively, sum the size of all
.logfiles. UseFiles.walk.
Each under 5 minutes.
Return to README.md · Next: 05_50_programs_challenge.md