A program can compile correctly and still fail when a file is missing, data is malformed, or a disk cannot accept a write. Exceptions carry that failure from the operation to code capable of responding. This Java 21 lesson builds a file reader that reports useful context and closes its resources on both success and failure.
Understand the failure contract
An exception interrupts the normal path. throw raises an exception; throws declares exceptions a method may pass to its caller. A try block runs the operation, and a matching catch block handles the failure. A finally block runs when control leaves the try/catch, including through a return or exception under normal JVM execution.
Checked exceptions, such as IOException, must be caught or declared. Unchecked exceptions, such as IllegalArgumentException and NullPointerException, extend RuntimeException and have no such compiler requirement. Error represents a separate family of serious conditions; catching Throwable as routine recovery usually hides problems you cannot safely repair.
Catch only failures you can handle. A file parser can add a line number; a command-line boundary can show an error and choose an exit status. Neither should silently convert corrupt input into a plausible total.
Read a file with reliable cleanup
Save as FileTotals.java. Run javac --release 21 FileTotals.java, followed by java FileTotals.
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class FileTotals {
static long total(Path path) throws IOException {
long result = 0;
int lineNumber = 0;
try (BufferedReader reader =
Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
lineNumber++;
try {
result = Math.addExact(result, Long.parseLong(line.strip()));
} catch (NumberFormatException | ArithmeticException e) {
throw new IOException("invalid total at line " + lineNumber, e);
}
}
}
return result;
}
public static void main(String[] args) throws IOException {
Path file = Files.createTempFile("java-totals-", ".txt");
try {
Files.writeString(file, "12\n8\n", StandardCharsets.UTF_8);
System.out.println("total: " + total(file));
Files.writeString(file, "12\noops\n", StandardCharsets.UTF_8);
try {
total(file);
} catch (IOException e) {
System.out.println(e.getMessage());
}
} finally {
Files.deleteIfExists(file);
}
}
}
Expected output:
total: 20
invalid total at line 2
The demonstration creates its own temporary input and deletes it afterward; it does not depend on a file from another lesson. Inside total, a blank line is invalid by design. Math.addExact detects overflow instead of allowing a wrapped total. The new IOException preserves the original exception as its cause, so a caller can inspect both the useful context and the technical reason.
Memory cleanup is not resource cleanup
The garbage collector reclaims unreachable object memory. It is not a reliable way to release an open file, socket or database connection promptly. Such resources often implement AutoCloseable.
Try-with-resources closes each successfully initialized resource when the block ends, including when reading or parsing throws. Multiple resources close in reverse declaration order. If the body and closing both fail, the body exception remains primary and closing failures appear as suppressed exceptions. This is safer than a hand-written close call that might replace the original failure.
Files.readString and writeString open and close internally. By contrast, Files.lines, Files.list and Files.walk return streams that hold resources and need closing, usually in try-with-resources. Streams from an in-memory list do not normally have this file-handle requirement.
Choose file operations consciously
Path represents a filesystem path; it does not itself read a file. A relative path is resolved against the process's working directory, which might differ from the source folder. Use resolve to combine paths rather than inserting operating-system separators manually.
Specify UTF-8 when reading and writing text that your application owns. Use byte APIs for binary content. readString loads the complete file into memory, while a buffered reader processes it incrementally. Neither automatically limits the size of one individual line.
The default writeString behavior creates a missing file or truncates an existing file. For valuable persisted state, consider writing a new temporary file and replacing the destination only after writing succeeds. Atomic replacement support depends on the filesystem and options; it does not solve concurrent writers or guarantee durability after a power failure.
Pitfalls, exercise and solution guidance
Do not catch Exception and return zero: that makes an unreadable file indistinguishable from a legitimate zero total. Avoid returning from finally, because it can override another return or hide an exception. Put specific catches before broader ones.
Extend total to skip blank lines but reject negative numbers. Test an empty file, whitespace-only lines, 0, -1, and a sum that exceeds Long.MAX_VALUE. Keep physical line numbers even when a line is skipped.
For the solution, strip the line, continue if it is blank, parse once, then validate nonnegativity before adding. Wrap malformed or out-of-range data with the line number and preserve the cause where one exists. A deliberately rejected negative value can produce a fresh descriptive IOException. Cleanup must still happen for every failing case.
Official references
Previous: Interfaces, Collections and Generics. Next: Lambdas and Streams.

