Java Exceptions, try-with-resources and NIO File Handling
ProgrammingLesson 3 of 47 min read
What you'll learn
- Choose between checked and unchecked exceptions and wrap low-level failures without losing the cause
- Predict what
finallydoes, including the cases where it hides an exception - Use try-with-resources to close resources in the right order and read suppressed exceptions
- Read, stream, write and walk files with
PathandFiles, closing lazy streams correctly
On this page
A nightly job imports electricity-meter readings. At row 40,000 one line says N/A instead of a number. Version one of the importer crashes and the whole night's data is lost. Version two catches the exception, prints nothing, and carries on, so the report is quietly wrong for a week. Good exception handling is the narrow path between those two outcomes: fail loudly where the data is broken, keep going where it is safe to, and always leave a trail explaining what happened.
Decide who has to deal with the failure
Java splits throwables into three families. Checked exceptions (subclasses of Exception that are not RuntimeException) must be caught or declared, so the compiler forces callers to acknowledge them. Unchecked exceptions (RuntimeException and its subclasses) are not enforced and usually signal a bug, such as a null or an illegal argument. Errors (OutOfMemoryError and friends) describe a sick JVM and are almost never worth catching.
A practical rule: make an exception checked when a sensible caller can recover from it, and unchecked when the only realistic reaction is to fix the code. A malformed line in an input file is a recoverable data problem, so the parser below throws a checked BadReadingException. Notice the second constructor: when it translates NumberFormatException into a domain exception, it passes the original as the cause, so nothing is lost.
import java.util.List;
public class ReadingParser {
// Checked on purpose: callers must decide what to do about a bad line
static class BadReadingException extends Exception {
BadReadingException(String message) {
super(message);
}
BadReadingException(String message, Throwable cause) {
super(message, cause); // keep the original failure attached
}
}
record Reading(String meterId, double kwh) {}
static Reading parse(String line) throws BadReadingException {
String[] parts = line.split(",");
if (parts.length != 3) {
throw new BadReadingException(
"expected 3 fields but found " + parts.length + ": '" + line + "'");
}
try {
return new Reading(parts[0].trim(), Double.parseDouble(parts[2].trim()));
} catch (NumberFormatException e) {
throw new BadReadingException("kWh is not a number in: '" + line + "'", e);
}
}
public static void main(String[] args) {
List<String> lines = List.of(
"M-1001, 2024-03-01, 431.5",
"M-1002,2024-03-01",
"M-1003,2024-03-01,abc");
for (String line : lines) {
try {
System.out.println("OK " + parse(line));
} catch (BadReadingException e) {
Throwable cause = e.getCause();
String why = cause == null ? "" : " (cause: " + cause.getClass().getSimpleName()
+ ": " + cause.getMessage() + ")";
System.out.println("BAD " + e.getMessage() + why);
}
}
}
}Output
OK Reading[meterId=M-1001, kwh=431.5] BAD expected 3 fields but found 2: 'M-1002,2024-03-01' BAD kWh is not a number in: 'M-1003,2024-03-01,abc' (cause: NumberFormatException: For input string: "abc")
The loop in main shows the payoff. Each bad line is reported with a reason and the program moves on to the next, which is the behaviour version two of the importer was missing. Because the failure carries its cause, a log line can show both the business message and the underlying NumberFormatException.
finally: what it guarantees and where it bites
A finally block runs whether the try block completes normally, throws, or returns. That makes it the traditional place for cleanup, and it also makes two behaviours worth memorising. The return value is evaluated before finally runs, so reassigning a variable there does not change it. And a return inside finally replaces whatever was in flight, including an exception that was about to propagate.
public class FinallyTrap {
@SuppressWarnings("finally")
static int swallowed() {
try {
throw new IllegalStateException("meter offline");
} finally {
return 7; // discards the exception without a trace
}
}
static int order() {
int n = 1;
try {
return n; // the value 1 is fixed here...
} finally {
n = 99; // ...so this does not change what is returned
System.out.println("finally runs before the caller sees the value");
}
}
public static void main(String[] args) {
System.out.println(swallowed());
System.out.println(order());
}
}Output
7 finally runs before the caller sees the value 1
The first call prints 7: the IllegalStateException about the offline meter simply vanishes. This is why most style guides ban return, break and continue inside finally. The compiler will not stop you, although -Xlint:finally will warn.
try-with-resources: close in reverse, keep both failures
Hand-written cleanup in finally has a nasty flaw. If the body throws and then close() also throws, the second exception replaces the first and you debug the wrong problem. try-with-resources fixes this. Any object implementing AutoCloseable can be declared in the parentheses; the JVM closes resources in reverse order of declaration, after the body finishes and before any catch or finally runs. If both the body and a close() fail, the body's exception is the primary one and the close failure is attached as a suppressed exception.
public class ResourceOrder {
static class Gate implements AutoCloseable {
private final String name;
private final boolean failOnClose;
Gate(String name, boolean failOnClose) {
this.name = name;
this.failOnClose = failOnClose;
System.out.println("open " + name);
}
void work(boolean fail) {
System.out.println("work " + name);
if (fail) {
throw new IllegalStateException("work failed on " + name);
}
}
@Override
public void close() {
System.out.println("close " + name);
if (failOnClose) {
throw new IllegalStateException("close failed on " + name);
}
}
}
public static void main(String[] args) {
try (Gate a = new Gate("A", false); Gate b = new Gate("B", false)) {
b.work(false);
}
System.out.println("--- when both the body and close() fail");
try (Gate c = new Gate("C", true)) {
c.work(true);
} catch (IllegalStateException e) {
System.out.println("primary: " + e.getMessage());
for (Throwable hidden : e.getSuppressed()) {
System.out.println("suppressed: " + hidden.getMessage());
}
}
}
}Output
open A open B work B close B close A --- when both the body and close() fail open C work C close C primary: work failed on C suppressed: close failed on C
Gate B closes before gate A, which matches how you would unwind real dependencies: a statement before the connection it came from. In the second block, e.getMessage() gives the original failure and getSuppressed() exposes the cleanup failure that the old finally pattern would have lost. Since Java 9 you can also put an existing, effectively final variable in the parentheses instead of declaring a new one.
Files and Paths with NIO.2
The java.nio.file package (Path, Paths, Files) replaces most uses of the old java.io.File. A Path is an immutable location that may or may not exist, Files holds static methods that act on it, and failures surface as specific IOException subclasses such as NoSuchFileException. The importer below is a whole mini-pipeline run inside a temporary directory, so you can paste it and run it safely.
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class MeterImport {
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("meter-demo");
Path inbox = Files.createDirectories(root.resolve("inbox"));
Path csv = inbox.resolve("march.csv");
// Files.writeString always uses UTF-8 unless you pass a charset
Files.writeString(csv, """
M-1001,2024-03-01,431.5
M-1002,2024-03-01,12.25
M-1001,2024-03-02,18.0
M-1003,2024-03-01,oops
M-1002,2024-03-02,7.75
""");
// 1. Small file: read everything at once
List<String> allLines = Files.readAllLines(csv);
System.out.println("lines in file: " + allLines.size());
// 2. Larger file: stream lazily. The stream holds the file open, so close it.
Map<String, Double> totals = new TreeMap<>();
int skipped = 0;
try (Stream<String> lines = Files.lines(csv)) {
Iterator<String> it = lines.iterator();
while (it.hasNext()) {
String[] f = it.next().split(",");
try {
totals.merge(f[0], Double.parseDouble(f[2]), Double::sum);
} catch (NumberFormatException e) {
skipped++;
}
}
}
System.out.println("totals: " + totals + ", skipped: " + skipped);
// 3. Write a report next to the inbox
Path report = Files.createDirectories(root.resolve("reports")).resolve("march-report.txt");
List<String> reportLines = totals.entrySet().stream()
.map(e -> e.getKey() + " used " + e.getValue() + " kWh")
.collect(Collectors.toList());
Files.write(report, reportLines);
// 4. Walk the tree (also a stream that must be closed)
try (Stream<Path> walk = Files.walk(root)) {
walk.filter(Files::isRegularFile)
.map(p -> root.relativize(p).toString().replace('\\', '/'))
.sorted()
.forEach(p -> System.out.println("file: " + p));
}
// 5. A missing file is an IOException subclass with a useful type
try {
Files.readString(root.resolve("april.csv"));
} catch (NoSuchFileException e) {
System.out.println("missing: " + Path.of(e.getFile()).getFileName());
}
// 6. Clean up: delete children before parents
try (Stream<Path> walk = Files.walk(root)) {
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
try {
Files.delete(p);
} catch (IOException e) {
throw new UncheckedIOException(e); // lambdas cannot throw checked exceptions
}
});
}
System.out.println("cleaned up: " + !Files.exists(root));
}
}Output
lines in file: 5
totals: {M-1001=449.5, M-1002=20.0}, skipped: 1
file: inbox/march.csv
file: reports/march-report.txt
missing: april.csv
cleaned up: trueWalk through the numbered steps. readAllLines is fine for small files because it loads everything into memory. Files.lines streams lazily, which suits big files, but the stream holds the file open and must be closed, hence the try-with-resources. The same is true of Files.walk, Files.list and Files.find. Files.writeString, readString and readAllLines use UTF-8 unless you pass a charset. Notice root.resolve("inbox") instead of gluing strings together with slashes, relativize to print readable names, and UncheckedIOException to smuggle an IOException out of a lambda, which cannot throw checked exceptions.
| Task | Older style | NIO.2 style |
|---|---|---|
| Represent a location | new File("a/b.txt") | Path.of("a", "b.txt") |
| Read a small text file | FileReader plus BufferedReader loop | Files.readString or Files.readAllLines |
| Read a huge file line by line | BufferedReader.readLine() loop | Files.lines inside try-with-resources |
| Create nested directories | File.mkdirs() returns a boolean | Files.createDirectories throws on failure |
| Delete | File.delete() returns false on failure | Files.delete throws a specific exception |
Common mistakes
- An empty `catch` block. The import "succeeds" with missing rows. At minimum record the failure; better, count skipped rows and surface the count, as
MeterImportdoes. - `throw new RuntimeException(e.getMessage())`. It discards the original stack trace, so logs show a message with no "Caused by:" section. Pass
eas the cause. - Catching `Exception` or `Throwable` everywhere. You also swallow
InterruptedExceptionand, withThrowable,OutOfMemoryError. Catch the narrowest type you can handle. - Catch order. Putting
catch (IOException e)beforecatch (NoSuchFileException e)fails to compile withexception NoSuchFileException has already been caught. - Not closing `Files.lines` or `Files.walk`. On Linux you eventually see
java.nio.file.FileSystemException: Too many open files; on Windows deleting the file later fails with "The process cannot access the file because it is being used by another process". - Building paths by string concatenation.
"data" + "/" + namebreaks on separators and odd input. UsePath.resolve. - Using exceptions as control flow. Parsing 100,000 lines by catching
NumberFormatExceptionfor the normal case is slow and unclear; validate first when bad input is expected to be common. - `printStackTrace()` in server code. It writes to standard error, outside your logging framework. Log with the exception as the last argument instead.
How this shows up in real projects and interviews
In real services, exceptions are mostly translated at boundaries. A repository turns SQLException into a domain error, a web layer turns that into a 4xx or 5xx response, and a logging line keeps the chain of causes. Frameworks such as Spring prefer unchecked exceptions so that business code is not littered with throws clauses, which is a legitimate design choice and why you will see both styles in the wild. File handling at work is usually less about clever NIO and more about closing things reliably and not loading a two-gigabyte file with readAllLines.
Interview staples are checked versus unchecked, what finally does with a return, the order in which try-with-resources closes resources, and what a suppressed exception is. If you can explain why the earlier example prints 7, you have the core of the topic. The next lesson, JVM memory and modern Java, covers what happens when the failure is the JVM itself running out of memory.
Try it yourself
Extend MeterImport so that malformed lines are not just counted but written to a rejects.txt file in a reports directory, each with its line number and the reason from a BadReadingException. Do the whole read-and-write inside one try-with-resources that declares both a Stream<String> (or BufferedReader) and a BufferedWriter, and confirm that a failure in one does not prevent the other from closing.
Show a hint
Use Files.newBufferedWriter(path) for the writer and Files.newBufferedReader(csv) for the reader so both are AutoCloseable and can be listed in one try (...). Track the line number with a simple counter variable inside the loop.
Key takeaways
- Make an exception checked when callers can recover, unchecked for programming errors, and always pass the original exception as the cause when wrapping.
finallyalways runs, but areturninside it overrides a pending exception and hides the failure.- try-with-resources closes in reverse declaration order and records close failures as suppressed exceptions instead of losing the primary one.
- Use
PathandFilesfor file work, and close every lazy stream fromFiles.lines,walkorlist. - Handle errors where you can act on them; an empty catch block is almost always a bug.
Test yourself
A trick from this lesson's ride: Checked: the caller can recover, so the compiler nags. Unchecked: fix the code, so no nagging. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is the difference between checked and unchecked exceptions in Java?
Checked exceptions must be caught or declared with throws, and the compiler enforces it; they model failures a caller may reasonably recover from, like a missing file. Unchecked exceptions extend RuntimeException, need no declaration, and usually mean a bug such as a null pointer or invalid argument.
Does finally always execute in Java?
Almost always: it runs after a normal exit, an exception or a return. It does not run if the JVM exits first (for example System.exit or a crash). Avoid return or throw inside finally, because it overrides a pending exception or return value.
How do I read a file line by line in Java without running out of memory?
Use Files.lines(path) or Files.newBufferedReader(path) inside a try-with-resources block so lines are read lazily and the file is closed afterward. Files.readAllLines loads the entire file into a list, which is only appropriate for small files.