I/O and Files

Learning goals

By the end of this chapter you should be able to:

  • Use Path and Paths to represent file locations without hard-coding platform separators
  • Read and write text with Files helpers and understand when to use streams vs. whole-file APIs
  • Close resources safely with try-with-resources and know why leaked handles cause production bugs
  • Choose between InputStream/OutputStream, Reader/Writer, and NIO.2 Files for typical tasks

Why NIO.2 matters

Before Java 7, file code mixed java.io.File, manual streams, and easy-to-forget close() calls. NIO.2 (java.nio.file) gives you:

  • Path — immutable path value (replaces most File usage)
  • Files — static helpers for copy, move, read, write, walk trees
  • Better errorsIOException with clear messages instead of silent false returns

For new code on JDK 21, default to Path + Files. Drop down to streams only when you need fine-grained buffering or binary protocols.


Path basics

Path home = Path.of(System.getProperty("user.home"));
Path config = home.resolve(".myapp").resolve("config.properties");

Path absolute = config.toAbsolutePath();   // resolve against default file system
Path normalized = config.normalize();      // collapse . and ..

boolean exists = Files.exists(config);
boolean isDir = Files.isDirectory(config);

Path is not a file. It is a location string with helpers. Creating a Path does not touch disk until you call Files.*.

Common operations:

String fileName = config.getFileName().toString();  // "config.properties"
Path parent = config.getParent();                   // .../.myapp
Path sibling = config.resolveSibling("backup.properties");

Reading and writing text

Whole file (small, simple)

Since Java 11:

Path p = Path.of("notes.txt");
String content = Files.readString(p);                    // UTF-8 by default
Files.writeString(p, "updated\n", StandardOpenOption.TRUNCATE_EXISTING);

Specify charset when needed:

String latin = Files.readString(p, StandardCharsets.ISO_8859_1);

Line-by-line (large files)

try (Stream<String> lines = Files.lines(p)) {
    long count = lines.filter(line -> !line.isBlank()).count();
}

The stream must be closed — hence try-with-resources. Loading a 2 GB log with readString will blow the heap.

Binary data

byte[] bytes = Files.readAllBytes(p);
Files.write(p, bytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);

try-with-resources

Any type implementing AutoCloseable can sit in a try-with-resources header. The compiler inserts close() in a finally block — even if an exception is thrown.

public List<String> readLines(Path path) throws IOException {
    try (BufferedReader reader = Files.newBufferedReader(path)) {
        return reader.lines().toList();
    }
}

Multiple resources, declared in order, close in reverse order:

try (InputStream in = Files.newInputStream(src);
     OutputStream out = Files.newOutputStream(dst)) {
    in.transferTo(out);
}

If both in and out throw on close, the primary exception from the try block is preserved; suppressed exceptions attach to it.


Creating directories and copying

Files.createDirectories(Path.of("data/cache"));  // mkdir -p style

Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
Files.move(oldPath, newPath, StandardCopyOption.ATOMIC_MOVE);  // when supported

Use CREATE_NEW when you want “fail if exists” semantics for idempotent setup scripts.


Walking a directory tree

try (Stream<Path> walk = Files.walk(Path.of("src"), 2)) {
    walk.filter(Files::isRegularFile)
        .filter(p -> p.toString().endsWith(".java"))
        .forEach(System.out::println);
}

Depth limit 2 avoids scanning entire monorepos accidentally. For production file watchers, consider dedicated libraries — but Files.walk is fine for one-off tooling.


Common mistakes

  1. Using File in new code — prefer Path; File.toPath() only when bridging legacy APIs.
  2. Forgetting charsetreadString/writeString use UTF-8; Windows Notepad legacy files may need an explicit charset.
  3. Not closing streams — leaks file handles; on Windows you may be unable to delete or overwrite the file until GC runs.
  4. Checking Files.exists then acting — race condition between check and open; catch NoSuchFileException or use CREATE options instead.
  5. Mixing text and binary — never wrap a binary InputStream with a Reader unless you know the encoding.

Practice checkpoint

  1. Write a method long countNonBlankLines(Path p) using Files.lines. Answer: open Files.lines(p) in try-with-resources, filter !line.isBlank(), count.

  2. What happens if you write try (Files.lines(p)) without assigning to a variable? Answer: invalid — lines() returns a Stream you must bind; the try-with-resources variable must be the stream.

  3. Copy a.txt to b.txt only if b.txt does not exist. Answer: Files.copy(a, b, StandardCopyOption.COPY_ATTRIBUTES) without REPLACE_EXISTING; or check !Files.exists(b) knowing the TOCTOU race.

  4. Why prefer Path.of("foo", "bar") over string concatenation with /? Answer: correct separator on all platforms; avoids double slashes and drive-letter bugs on Windows.


Interview angles

  • NIO.2 vs legacy IO: Path/Files for filesystem operations; streams for custom protocols and socket IO.
  • try-with-resources vs finally: compiler-generated suppression list; fewer boilerplate bugs.
  • Memory: readAllBytes / readString load entire file — ask about size limits in production.
  • Atomic move: ATOMIC_MOVE for config swaps; falls back on some file systems — know your deployment OS.

Next: /java/learn/core/dates-and-time/