Files and NIO

Path, Files, buffered streams, charsets, and reading a large file without loading it — the API you actually use, not the 1998 one.

7 min read🧰 Exceptions, I/O and Reflection

Java has two file APIs. The 1996 one — java.io.File, FileInputStream, FileReader — still works and still appears in tutorials. The 2011 one — java.nio.file — is the one to use: Path instead of File, Files for every operation, and streams that are closed by try-with-resources. Backends read configuration, write exports, and process uploads; each of those is a place to leak a handle or load a gigabyte into memory by accident.

Path and Files

java
Path dir = Path.of("/var/data/exports");
Path file = dir.resolve("orders-2024-03.csv");        // /var/data/exports/orders-2024-03.csv
file.getFileName();                                   // orders-2024-03.csv
file.getParent();                                     // /var/data/exports
Files.exists(file); Files.isDirectory(dir); Files.size(file);
Files.createDirectories(dir);                         // mkdir -p
Files.delete(file); Files.deleteIfExists(file);
Files.move(src, dst, StandardCopyOption.ATOMIC_MOVE);
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);

A Path is a value — immutable, comparable, no I/O until you hand it to Files. resolve joins; relativize finds the difference; normalize removes .. segments. Never build paths from user input with string concatenation: dir.resolve(userInput).normalize().startsWith(dir) is the check that stops ../../etc/passwd.

Reading, small and large

For a file that fits comfortably in memory:

java
String text = Files.readString(file);                            // UTF-8 by default since 11
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
byte[] bytes = Files.readAllBytes(file);

For a file that might not — logs, exports, uploads — stream it:

java
try (Stream<String> lines = Files.lines(file)) {                 // lazy; holds a handle
    long errors = lines.filter(l -> l.contains("ERROR")).count();
}
 
try (BufferedReader r = Files.newBufferedReader(file)) {        // buffered, UTF-8
    String line;
    while ((line = r.readLine()) != null) process(line);
}
 
try (InputStream in = Files.newInputStream(file)) {              // bytes
    byte[] buf = new byte[8192];
    int n;
    while ((n = in.read(buf)) != -1) sink.write(buf, 0, n);
}

Files.lines is a stream backed by an open file — it must be in a try. The readAll* methods are not: they close before returning.

Writing

java
Files.writeString(file, text);                                   // create or truncate
Files.write(file, lines);
Files.writeString(file, text, StandardOpenOption.APPEND, StandardOpenOption.CREATE);
 
try (BufferedWriter w = Files.newBufferedWriter(file)) {
    for (Row row : rows) { w.write(row.toCsv()); w.newLine(); }
}

For an export that must not be seen half-written, write to a temporary file in the same directory and Files.move(tmp, target, ATOMIC_MOVE). Readers see the old file or the new one, never a partial.

Buffering and charsets

Raw InputStream/OutputStream calls hit the OS per call. Wrap them: BufferedInputStream, BufferedReader. The Files.newBuffered* methods do it for you. Readers and writers convert bytes to characters and need a charset; the Files methods default to UTF-8, the old FileReader constructor used the platform default until Java 18 — one more reason to avoid it.

The stream families

Every I/O class in java.io is one of four base types, and the names tell you which: InputStream/OutputStream move bytes, Reader/Writer move characters. Everything else is a wrapper around one of those, added one layer at a time — the "decorator" arrangement that the low-level design course names, seen here in its original home:

LayerBytesCharactersWhat it adds
Source or sinkFileInputStream, ByteArrayInputStream, a socket's streamFileReader, StringReaderwhere the data comes from
BufferingBufferedInputStreamBufferedReader (readLine)one OS call per 8 KB instead of per byte
Conversion—InputStreamReader(in, UTF_8)bytes → characters, with a charset you name
Typed dataDataInputStream (readInt, readUTF)—primitives in a fixed binary layout; the pair of DataOutputStream
ObjectsObjectInputStream—whole object graphs: Java serialization, below

Files.newBufferedReader(path) is new BufferedReader(new InputStreamReader(new FileInputStream(...), UTF_8)) with the layers chosen for you, which is why the Files methods are the ones to reach for. Closing the outermost stream closes the chain. Data streams are the right tool for a compact binary record format of your own — a fixed header, an int count, n longs — and the wrong tool for anything another language must read, because writeUTF is Java's own modified UTF-8 with a two-byte length prefix.

Java serialization: what it is, and why you will not use it

Serializable is an empty marker interface. Implement it and ObjectOutputStream.writeObject(x) walks the object graph reachable from x and writes every non-transient, non-static field of every class in it, with each class's name and a serialVersionUID; ObjectInputStream.readObject() rebuilds the graph without calling any constructor. Mark a field transient to leave it out (a cache, a lock, a connection, a password); it comes back as null or zero. Declare private static final long serialVersionUID = 1L; yourself: if you do not, the compiler derives one from the class's shape, so adding a method changes it, and a stream written by the old class fails with InvalidClassException on the new one. Externalizable is the escape hatch that hands you the format entirely — writeExternal/readExternal, and a public no-arg constructor that is called — which is more work and more control, and equally rarely worth it.

Know it because interviews ask and because frameworks still lean on it: HTTP session replication, HttpSession attributes, some caches and the JDK's own Throwable (which is why an exception with a non-serializable field breaks a distributed cache). Do not choose it for anything of your own, for three reasons that stack:

  • It is a remote-code-execution surface. readObject instantiates whatever classes the stream names, and gadget chains through common libraries have turned that into some of the worst vulnerabilities in the Java ecosystem. Since Java 9 (JEP 290) an ObjectInputFilter can allow-list classes, and Java 17 added a JVM-wide filter factory; if you must read a stream, filter it, and never read one from an untrusted source at all.
  • It bypasses your constructors. Invariants the constructor enforces are simply not checked on the way in; a record is the one exception, deserialised through its canonical constructor since Java 16.
  • It couples the wire format to your class layout. Every rename is a migration you cannot see, and a stream from a version you no longer run is unreadable.

For data that leaves the process, use a format with a schema you own: JSON through Jackson (the next lesson), or Protobuf or Avro when size and evolution rules matter. They are readable by other languages, versioned on purpose, and deserialise into classes you chose.

Directories

java
try (Stream<Path> entries = Files.list(dir)) { ... }                // one level
try (Stream<Path> all = Files.walk(dir)) {                          // recursive
    all.filter(p -> p.toString().endsWith(".log")).forEach(this::rotate);
}
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, "*.csv")) { ... }

All three hold handles; all three go in try.

Temporary files and resources on the classpath

Files.createTempFile("export-", ".csv") — delete it when done; deleteOnExit is unreliable in long-running servers. Files packaged in the jar are not Paths on the file system: read them as streams via getClass().getResourceAsStream("/templates/invoice.html") or Spring's ClassPathResource. A File reference into a jar does not work, and it breaks the first time the application runs from a fat jar.

Channels and memory mapping

FileChannel and MappedByteBuffer exist for very large files and zero-copy transfers (transferTo). Most services never need them; know that they are there for the day you process multi-gigabyte files and Files.lines is the bottleneck.

WatchService: reacting to a directory

WatchService registers a directory and blocks until something in it is created, modified or deleted:

java
try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
    dir.register(watcher, ENTRY_CREATE, ENTRY_MODIFY);
    while (true) {
        WatchKey key = watcher.take();                                    // blocks
        for (WatchEvent<?> event : key.pollEvents()) {
            Path changed = dir.resolve((Path) event.context());
            if (event.kind() == OVERFLOW) { rescan(dir); continue; }      // events were dropped; look for yourself
            handle(changed);
        }
        if (!key.reset()) break;                                          // the directory is gone
    }
}

It is the right tool for a configuration file to reload, an inbox directory a batch job picks up from, or a development-time watcher. Four things to know before relying on it. It is not recursive: register each subdirectory, and the ones created later. It is not portable in its timing: Linux uses inotify and fires immediately; macOS polls every few seconds unless a native library is added. It coalesces and drops: a burst produces OVERFLOW, and the only correct response is to rescan. And a MODIFY fires while the writer is still writing, so a consumer that opens the file on the first event reads half of it — wait for the write to settle, or, better, have the writer use the atomic move from the writing section so the file appears complete or not at all. Across machines, or at any scale, a queue is the honest replacement: the filesystem is not a message bus.

Progress is saved on this device and to your account when signed in.