For five lessons you have been leaving comments of the kind "in 07-06 this is done properly". You have put them in the directory creation, in the atomic rename, in the backup rotation, in the listFiles() that can return null and in every mute boolean that said something had failed without saying why.

This is that lesson. NIO.2New I/O 2, introduced in Java 7 with JSR 203— is the complete replacement for java.io.File, and it is not a cosmetic touch-up: it is a redesign that corrects deep errors in a 1996 API. Its two central pieces are Path, which represents a path with all its arithmetic, and Files, a utility class with over fifty static methods doing everything you have written by hand so far.

By the end, BiblioTech will have its persistence layer fully migrated: it will create its data/ directory if it does not exist, write truly atomically in one line, keep rotating backups and locate all its reports by walking a folder tree.

With try-with-resources everywhere, as always. And with a new and very specific warning: Files.lines(), Files.walk() and Files.list() return streams that MUST be closed. It is the most frequent mistake with this API and it is explained in section 8.

Contents

  1. Why NIO.2 exists: the defects of File
  2. Path: building and querying paths
  3. Path arithmetic: resolve, relativize, normalize
  4. Files: existence, creation and deletion
  5. Copying and moving, with real atomic writing
  6. High-level reading and writing
  7. StandardOpenOption: saying what you mean
  8. Files.lines, list and walk: streams that get closed
  9. Walking directory trees
  10. File attributes and permissions
  11. Temporary files and WatchService
  12. Interoperability with File
  13. BiblioTech migrates its persistence
  14. Common Mistakes and Tips
  15. Exercises

  1. Why NIO.2 exists: the defects of File

java.io.File is from Java 1.0 and carries design decisions from an era when Java had to work on very different systems with a very small common denominator. Its problems are not stylistic: they are functional.

Defect 1: the mute booleans.

File f = new File("/data/catalog.txt");
if (!f.delete()) {
    // Why? Did it not exist? Do I not have permission? Was it open?
    // Was it a non-empty directory? The API does not say.
}

It is the anti-pattern module 6 spent seven lessons eradicating: reporting a failure without saying which one. NIO.2 throws specific exceptions.

Defect 2: null as a return value.

File[] children = directory.listFiles();
for (File c : children) {       // NullPointerException if the directory does not exist
    // ...
}

listFiles() returns null if the path is not a directory or if access fails. Another silent failure turned into an exception at the wrong moment.

Defect 3: it knows nothing about symbolic links. File does not tell a file from a link pointing at it. On Unix systems that is a serious limitation, and a source of infinite loops when walking trees.

Defect 4: poor metadata. Only lastModified(). There is no creation date, no last access date, no owner, no POSIX permissions, no extended attributes.

Defect 5: renameTo is not reliable. Its behaviour depends on the operating system: on Windows it fails if the target exists, on Unix it does not; it can fail across different file systems; and it returns a mute boolean.

Defect 6: it is not extensible. There is no way for File to work with a file system other than the operating system's. NIO.2 can: it can walk the inside of a .zip as if it were a directory.

Defect 7: there is no efficient tree walking. Walking a large directory with listFiles() loads the whole array into memory.

The complete comparison:

Aspect java.io.File Path + Files (NIO.2)
Errors Mute boolean Specific exceptions with a message
Listing a directory File[], may be null Stream<Path> or DirectoryStream
Symbolic links It does not know them It tells them apart and follows or skips them at will
Metadata Only lastModified Creation, access, owner, permissions, ACL
Copying a file Does not exist: a hand-written loop Files.copy
Moving atomically renameTo, unreliable Files.move with ATOMIC_MOVE
Creating directories mkdirs(), mute boolean Files.createDirectories
Walking a tree Hand-written recursion Files.walk, walkFileTree
Reading a whole file A hand-written loop Files.readString, readAllLines
Watching for changes Does not exist WatchService
Alternative file systems No Yes: ZIP, in memory, remote
Path arithmetic String concatenation resolve, relativize, normalize

A side-by-side example that sums up the change:

// java.io: copying a file
try (InputStream in = new BufferedInputStream(new FileInputStream(source));
     OutputStream out = new BufferedOutputStream(new FileOutputStream(target))) {
    byte[] block = new byte[8192];
    int read;
    while ((read = in.read(block)) != -1) {
        out.write(block, 0, read);
    }
}

// NIO.2: the same thing
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);

Eight lines against one, and the NIO.2 one uses the most efficient primitive the operating system offers, which in many cases does not even pass the data through your process.

Professional rule: in new code, Path and Files. Always. Know File in order to read old code and for the APIs that still ask for it; write it only when there is no alternative.

  1. Path: building and querying paths

Path represents a path, not a file. Like File, it can point at something that does not exist: it is a sequence of names, not a resource.

import java.nio.file.Path;
import java.nio.file.Paths;

public class BuildingPaths {

    public static void main(String[] args) {
        // Java 11+: the recommended form
        Path p1 = Path.of("data", "catalog.txt");

        // Java 7-10: equivalent, still very common in existing code
        Path p2 = Paths.get("data", "catalog.txt");

        // A complete path in one string
        Path p3 = Path.of("data/reports/2026/january.txt");

        // An absolute path
        Path p4 = Path.of("/home/marta/bibliotech/data/catalog.txt");

        // From a URI
        Path p5 = Path.of(java.net.URI.create("file:///home/marta/catalog.txt"));

        System.out.println(p1);              // data/catalog.txt
        System.out.println(p1.equals(p2));   // true
    }
}

Use Path.of in new code. Paths.get does exactly the same thing and still works; Path.of is from Java 11 and is the preferred form.

Notice a virtue of the multi-argument constructor: Path.of("data", "catalog.txt") uses the correct system separator automatically. That is the end of File.separator and the string concatenation of 07-01.

The query methods:

import java.nio.file.Path;

public class QueryingPaths {

    public static void main(String[] args) {
        Path p = Path.of("/home/marta/bibliotech/data/catalog.txt");

        System.out.println("Full path      : " + p);
        System.out.println("Name           : " + p.getFileName());     // catalog.txt
        System.out.println("Directory      : " + p.getParent());       // .../data
        System.out.println("Root           : " + p.getRoot());         // /
        System.out.println("Absolute?      : " + p.isAbsolute());      // true
        System.out.println("Segments       : " + p.getNameCount());    // 5

        // Iterating the segments: Path is Iterable<Path>
        System.out.println("  Walk-through:");
        for (Path segment : p) {
            System.out.println("    " + segment);
        }

        // Access by index
        System.out.println("Segment 0      : " + p.getName(0));        // home
        System.out.println("Segment 3      : " + p.getName(3));        // data

        // Subpath [start, end)
        System.out.println("Subpath 1..3   : " + p.subpath(1, 3));     // marta/bibliotech

        // Checks
        System.out.println("Starts with /home?  " + p.startsWith("/home"));   // true
        System.out.println("Ends with .txt?     "
                + p.getFileName().toString().endsWith(".txt"));               // true
    }
}
Method Returns Example with /home/marta/data/catalog.txt
getFileName() The last segment catalog.txt
getParent() The path without the last segment /home/marta/data
getRoot() The root, or null if relative /
getNameCount() Number of segments 4
getName(i) Segment i getName(0)home
subpath(a, b) The segments from a to b−1 subpath(0,2)home/marta
isAbsolute() Whether it starts from the root true
toAbsolutePath() The absolute version, against user.dir
toRealPath() Absolute, normalised and with links resolved. Throws IOException
startsWith / endsWith Comparison by segments, not by characters

An important detail people often get wrong: startsWith compares complete segments, not text prefixes:

Path p = Path.of("/home/marta/data");
p.startsWith("/home/mar");      // false: "mar" is not a complete segment
p.startsWith("/home/marta");    // true

It is the correct behaviour —it stops /home/martarodriguez looking as if it were inside /home/marta— and it is a security check that with strings is constantly done wrong.

  1. Path arithmetic: resolve, relativize, normalize

Here is one of Path's greatest gains: well-defined path operations, with no string concatenation.

resolve: combining paths

Path base = Path.of("/home/marta/bibliotech");

base.resolve("data/catalog.txt");
// /home/marta/bibliotech/data/catalog.txt

base.resolve("/etc/config");
// /etc/config   <-- if the argument is ABSOLUTE, it replaces the base

base.resolve("");
// /home/marta/bibliotech

The rule: if the argument is absolute, it is returned as is; if it is relative, it is appended. That behaviour is deliberate and avoids creating nonsensical paths such as /home/marta/etc/config.

resolveSibling: a sibling in the same directory

Path catalog = Path.of("/home/marta/bibliotech/data/catalog.txt");

catalog.resolveSibling("catalog.txt.tmp");
// /home/marta/bibliotech/data/catalog.txt.tmp

catalog.resolveSibling("catalog.bak");
// /home/marta/bibliotech/data/catalog.bak

It is equivalent to getParent().resolve(...) but clearer, and it works even when there is no parent. It is the correct way to build the temporary file for the atomic writing you wrote by hand in 07-02.

relativize: the path from A to B

Path base   = Path.of("/home/marta/bibliotech");
Path report = Path.of("/home/marta/bibliotech/data/reports/january.txt");

base.relativize(report);
// data/reports/january.txt

report.relativize(base);
// ../../..

Useful for showing short paths to the user or for storing portable paths in a configuration file. Its restriction: both paths must be either both absolute or both relative; otherwise it throws IllegalArgumentException.

normalize: cleaning up . and ..

Path ugly = Path.of("/home/marta/./bibliotech/../bibliotech/data/../data/catalog.txt");
ugly.normalize();
// /home/marta/bibliotech/data/catalog.txt

normalize is purely syntactic: it does not consult the file system. If there are symbolic links involved, the result may not be the real path. That is what toRealPath() is for, which does consult the disk and throws IOException if the path does not exist.

And a use of normalize that goes beyond tidiness, and is worth knowing:

/**
 * Checks that a path requested by the user stays INSIDE the data
 * directory.
 *
 * Without this check, a name like "../../etc/passwd" would allow escaping
 * from the permitted directory. It is the vulnerability known as "path
 * traversal", and it is one of the most frequent in applications that accept
 * file names from the user. Application security is covered in 12-07.
 */
public static Path resolveSafely(Path baseDirectory, String requestedName) {
    Path base = baseDirectory.toAbsolutePath().normalize();
    Path candidate = base.resolve(requestedName).normalize();

    if (!candidate.startsWith(base)) {
        throw new IllegalArgumentException(
                "Path outside the permitted directory: " + requestedName);
    }
    return candidate;
}

The two pieces are normalize() —which resolves the .. before comparing— and startsWith() —which compares complete segments. With string concatenation, this check is nearly always done wrong.

Summary table:

Operation What it does Consults the disk
resolve(other) Combines; if other is absolute, it returns it No
resolveSibling(name) A sibling in the same directory No
relativize(other) The relative path from this one to other No
normalize() Removes . and .. syntactically No
toAbsolutePath() Absolute against user.dir, without normalising No
toRealPath() Absolute, normalised and with links resolved Yes, throws IOException

  1. Files: existence, creation and deletion

Files is a utility class with static methods. Everything File did with instance methods is done here by passing the Path.

Existence

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.LinkOption;

Path p = Path.of("data/catalog.txt");

Files.exists(p);            // true if it exists and can be checked
Files.notExists(p);         // true if it does NOT exist and can be checked
Files.isRegularFile(p);     // an ordinary file (not a directory, not a link)
Files.isDirectory(p);
Files.isSymbolicLink(p);
Files.isReadable(p);
Files.isWritable(p);
Files.isExecutable(p);
Files.isHidden(p);          // throws IOException

// Without following symbolic links
Files.exists(p, LinkOption.NOFOLLOW_LINKS);

And here is the detail that baffles people most in the whole API:

exists() and notExists() are NOT opposites. Both can return false at the same time.

How that is possible: both return false when it cannot be determined whether the file exists, typically because of a lack of permissions on the directory containing it. There are three states, not two:

Situation exists() notExists()
The file exists and it can be checked true false
The file does not exist and it can be checked false true
It cannot be determined (no permission) false false

That is why !Files.exists(p) does not mean "it does not exist": it means "it does not exist, or I cannot tell". If the distinction matters —and in security code it does—, use notExists() explicitly.

And the warning from 06-07, which still holds here: checking existence before operating is a race condition (TOCTOU). Between the check and the operation, another process can create or delete the file. The prior check is an optimisation or a way of giving a better message; the exception is still compulsory.

Creation

// An empty file. Throws FileAlreadyExistsException if it already exists.
Files.createFile(Path.of("data/new.txt"));

// A directory. Throws NoSuchFileException if the parent is missing.
Files.createDirectory(Path.of("data"));

// A directory AND ALL ITS PARENTS. It does not fail if they already exist.
Files.createDirectories(Path.of("data/reports/2026/january"));

createDirectories is the one you will use nearly always, and it replaces the mkdirs() with a mute boolean that you wrote in 07-02:

// java.io (07-02)
File parent = target.getAbsoluteFile().getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
    throw new IOException("Could not create the directory " + parent.getAbsolutePath());
}

// NIO.2: one line, idempotent, and with an informative exception if it fails
Files.createDirectories(target.getParent());

Notice that it is idempotent: if the directory already exists, it does nothing and does not fail. That removes the prior check.

Deletion

// Deletes. Throws NoSuchFileException if it does not exist.
Files.delete(p);

// Deletes if it exists. Returns true if it deleted something. Does NOT throw if absent.
boolean deleted = Files.deleteIfExists(p);

The difference matters:

Method If it does not exist When to use it
delete NoSuchFileException When its absence is an error you need to know about
deleteIfExists Returns false, no exception Clean-up: it does not matter whether it was there

And the exceptions delete throws, which is where the gain over the mute boolean shows:

Exception Means
NoSuchFileException It does not exist
DirectoryNotEmptyException It is a directory with content
AccessDeniedException No permission
IOException Another failure, with the system message

Compare the two pieces of code:

// java.io: you know it failed, not why
if (!file.delete()) {
    System.err.println("Could not delete it");
}

// NIO.2: you know exactly what happened and can act accordingly
try {
    Files.delete(path);
} catch (NoSuchFileException e) {
    LOG.fine(() -> "It was already gone: " + path);       // not a problem
} catch (DirectoryNotEmptyException e) {
    LOG.warning(() -> "The directory has content: " + path);
} catch (AccessDeniedException e) {
    LOG.severe(() -> "No permission to delete " + path);  // this does need looking at
} catch (IOException e) {
    LOG.log(Level.SEVERE, "Failure deleting " + path, e);
}

This is exactly what 06-04 asked for: an exception must carry what whoever reads it needs in order to decide.

  1. Copying and moving, with real atomic writing

Files.copy

import java.nio.file.StandardCopyOption;

// Copies. Throws FileAlreadyExistsException if the target exists.
Files.copy(source, target);

// Overwrites the target
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);

// Keeps modification date, owner and permissions
Files.copy(source, target,
        StandardCopyOption.REPLACE_EXISTING,
        StandardCopyOption.COPY_ATTRIBUTES);

// From an InputStream to a file (07-03)
Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING);

// From a file to an OutputStream
Files.copy(source, output);

The last two overloads are very useful: they connect the stream world of 07-03 with NIO.2 without writing a single loop.

A warning: Files.copy on a directory copies only the directory, empty. It is not recursive. To copy a tree you have to walk it, and that is section 9.

Files.move and ATOMIC_MOVE

Here comes the moment promised in 07-02:

// Moves or renames
Files.move(source, target);

// Overwriting the target
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);

// ATOMIC: either it is complete or it is not there. Never half done.
Files.move(temp, target,
        StandardCopyOption.REPLACE_EXISTING,
        StandardCopyOption.ATOMIC_MOVE);

ATOMIC_MOVE is what was missing. Compare with the implementation from 07-02:

// 07-02, with java.io: TWO operations, with a dangerous gap between them
if (target.exists() && !target.delete()) {      // 1. delete
    return false;
}
return temp.renameTo(target);                   // 2. rename
// Between 1 and 2, the target file DOES NOT EXIST. If the process dies there,
// you are left with nothing. And another process reading at that instant does
// not find the file.

// NIO.2: ONE atomic operation. There is no gap.
Files.move(temp, target, REPLACE_EXISTING, ATOMIC_MOVE);

With ATOMIC_MOVE, there is no instant at which the target is not there. A concurrent reader sees the complete old version or the complete new one. That is what "atomic" really means.

Its two limits, which must be known:

  1. It only works within the same file system. Across different partitions or drives it throws AtomicMoveNotSupportedException, because moving there involves copying and deleting. That is why the temporary file must be created in the same directory as the target, not in /tmp.
  2. REPLACE_EXISTING with ATOMIC_MOVE is supported on the usual systems, but the exact combination depends on the file system.

And this is how the complete atomic writing looks, replacing the forty lines of AtomicWrite from 07-02:

package com.nexussoftware.bibliotech.infrastructure;

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.logging.Logger;

/**
 * Atomic writing with NIO.2.
 *
 * It replaces the 07-02 version written with java.io. Same guarantee
 * —the target is never left half done— in a fraction of the code, and
 * with REAL atomicity instead of the delete-then-rename sequence.
 */
public final class AtomicWrite {

    private static final Logger LOG = Logger.getLogger(AtomicWrite.class.getName());

    private AtomicWrite() { }

    @FunctionalInterface
    public interface Content {
        void writeTo(BufferedWriter output) throws IOException;
    }

    public static void write(Path target, Content content) throws IOException {
        // 1. Create the directory if it is missing. Idempotent.
        Path directory = target.toAbsolutePath().getParent();
        Files.createDirectories(directory);

        // 2. The temporary file, IN THE SAME DIRECTORY: ATOMIC_MOVE demands it.
        //    resolveSibling does exactly this (section 3).
        Path temp = target.resolveSibling(target.getFileName() + ".tmp");

        boolean completed = false;
        try {
            // 3. Write everything to the temporary file. BufferedWriter, not
            //    PrintWriter: its methods DO throw IOException (07-04).
            try (BufferedWriter output = Files.newBufferedWriter(
                    temp, StandardCharsets.UTF_8,
                    StandardOpenOption.CREATE,
                    StandardOpenOption.TRUNCATE_EXISTING,
                    StandardOpenOption.WRITE)) {

                content.writeTo(output);
            }

            // 4. Replace ATOMICALLY. A single operation.
            Files.move(temp, target,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE);

            completed = true;
            LOG.fine(() -> "Atomic write completed: " + target);

        } catch (AtomicMoveNotSupportedException e) {
            // It can happen if the temporary file and the target are on
            // different file systems. It degrades to a non-atomic move, warning.
            LOG.warning(() -> "The file system does not support ATOMIC_MOVE; "
                    + "replacing non-atomically: " + target);
            Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
            completed = true;

        } finally {
            // 5. Compensation (06-05): no rubbish if something failed.
            if (!completed) {
                Files.deleteIfExists(temp);           // does not throw if already gone
            }
        }
    }
}

Notice the finally: deleteIfExists does not throw if the temporary file is already gone, so the compensation is a single line with no checks. It is the difference between a well-designed API and one you have to defend yourself against.

  1. High-level reading and writing

Files includes methods that do in one line what in 07-01 and 07-02 took a loop.

Reading

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

Path p = Path.of("data/catalog.txt");

// Java 11+: the whole file as a String
String everything = Files.readString(p, StandardCharsets.UTF_8);

// All the lines in a list
List<String> lines = Files.readAllLines(p, StandardCharsets.UTF_8);

// All the bytes (for binary)
byte[] bytes = Files.readAllBytes(p);

// A ready-built BufferedReader (07-04)
try (java.io.BufferedReader reader =
             Files.newBufferedReader(p, StandardCharsets.UTF_8)) {
    String line;
    while ((line = reader.readLine()) != null) {
        process(line);
    }
}

// An InputStream (07-03)
try (java.io.InputStream input = Files.newInputStream(p)) {
    // ...
}

The warning from 07-01 and 07-04 still stands, with more reason because it is now easier to get wrong. readString, readAllLines and readAllBytes load the whole file into memory. With a 2 KB configuration file they are perfect; with the 2 GB loan history, OutOfMemoryError. That is what newBufferedReader or Files.lines() are for.

Writing

// Java 11+: writing a string
Files.writeString(p, "content", StandardCharsets.UTF_8);

// Appending at the end
Files.writeString(p, "one more line\n", StandardCharsets.UTF_8,
        StandardOpenOption.CREATE, StandardOpenOption.APPEND);

// Writing a collection of lines (it adds the break to each one)
Files.write(p, lines, StandardCharsets.UTF_8);

// Writing bytes
Files.write(p, bytes);

// A ready-built BufferedWriter
try (java.io.BufferedWriter output =
             Files.newBufferedWriter(p, StandardCharsets.UTF_8)) {
    output.write("...");
    output.newLine();
}

A comparison with what you were writing three lessons ago:

// 07-02, with java.io
try (PrintWriter output = new PrintWriter(
        new BufferedWriter(new FileWriter(path, StandardCharsets.UTF_8)))) {
    for (String line : lines) {
        output.println(line);
    }
    if (output.checkError()) {
        throw new IOException("Failed to write");
    }
}

// NIO.2
Files.write(Path.of(path), lines, StandardCharsets.UTF_8);

One line, with the explicit charset and with the IOExceptions propagating for real, without any checkError() to forget.

  1. StandardOpenOption: saying what you mean

Remember the worst mistake of 07-02: the boolean append that gets confused with a Charset and which, forgotten, deletes a whole file. NIO.2 replaces it with named options:

Option What it does
CREATE Creates the file if it does not exist
CREATE_NEW Creates the file and fails if it already exists
APPEND Writes at the end, keeping the content
TRUNCATE_EXISTING Empties the file on opening
WRITE Opens for writing
READ Opens for reading
DELETE_ON_CLOSE Deletes the file on closing. Useful for temporary files
SYNC Every write reaches the physical disk (07-02, section 5)
DSYNC Like SYNC, only the data, not the metadata

The three usual cases, written so that they cannot be confused:

// OVERWRITE: create if missing, empty if it exists
Files.newBufferedWriter(p, UTF_8,
        StandardOpenOption.CREATE,
        StandardOpenOption.TRUNCATE_EXISTING,
        StandardOpenOption.WRITE);

// APPEND: create if missing, write at the end
Files.newBufferedWriter(p, UTF_8,
        StandardOpenOption.CREATE,
        StandardOpenOption.APPEND);

// CREATE NEW: fail if it already exists. Useful as a lock (07-02)
Files.newBufferedWriter(p, UTF_8,
        StandardOpenOption.CREATE_NEW,
        StandardOpenOption.WRITE);

This is what fixes the most expensive mistake in the module. StandardOpenOption.APPEND is impossible to confuse with a charset, and impossible to forget without it showing, because without it you have to write TRUNCATE_EXISTING explicitly. And CREATE_NEW gives, at last, a correct file lock: check and create in a single atomic operation, with FileAlreadyExistsException if it was already there.

And a note about the defaults: if you pass no option, newBufferedWriter uses CREATE, TRUNCATE_EXISTING and WRITE. That is, it overwrites. Just like FileWriter, but at least here you can see it written down.

  1. Files.lines, list and walk: streams that get closed

These three methods return a Stream, and here we have to be precise about scope.

A note on scope. Stream is the Java 8 API for collections, and it is studied in full in 10-04. Here only the minimum for walking files is used: forEach to process each element and try-with-resources to close it. filter, map, collect and the rest of the API are not used. When you get to 10-04, everything you see here will feel very limited, and that will be fine: by then you will know how to compose it.

What does have to be understood now, because it is a real and frequent mistake:

These streams are lazy and keep the file open. They return elements as they are asked for, reading from the disk as they go. That is why they MUST be closed with try-with-resources. Not doing so leaves file descriptors open, and with enough of them the operating system limit is exhausted and the application can no longer open anything.

Files.lines: the lines of a file

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;

public class ReadWithLines {

    public static void show(Path file) throws IOException {
        // try-with-resources COMPULSORY: the stream keeps the file open
        try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {

            // forEach: processes each line. The complete API, in 10-04.
            lines.forEach(System.out::println);
        }
    }
}

Its advantage over readAllLines: it is lazy. It does not load the file into memory; it reads as you consume. A 10 GB file is walked with constant memory, just as with BufferedReader.

Comparison:

readAllLines Files.lines newBufferedReader
Loaded into memory The whole file One line One line
Returns List<String> Stream<String> BufferedReader
Must be closed No Yes Yes
Can be walked several times Yes No: single use No
Large files No Yes Yes

With accumulators, which is what can be done without the 10-04 API:

public static void count(Path file) throws IOException {
    // The counters have to be effectively final to be used in a lambda
    // (04-05). A one-element array is the classic trick; in 10-04 you
    // will see better ways.
    int[] counters = new int[2];        // [0] = lines, [1] = with data

    try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
        lines.forEach(line -> {
            counters[0]++;
            if (!line.isBlank() && !line.startsWith("#")) {
                counters[1]++;
            }
        });
    }
    System.out.printf("%d lines, %d with data%n", counters[0], counters[1]);
}

An honest recommendation: until you have studied 10-04, Files.newBufferedReader with the canonical loop of 07-04 is clearer for this kind of task. Files.lines shines when you combine it with filter, map and collect, and that is 10-04. Know it now so you know it exists and so you do not forget to close it.

Files.list: the content of a directory

public static void list(Path directory) throws IOException {
    try (Stream<Path> children = Files.list(directory)) {
        children.forEach(child -> System.out.println("  " + child.getFileName()));
    }
}

It is the replacement for listFiles(), and it fixes its two defects: it does not return null —it throws NotDirectoryException or IOException with its reason— and it is lazy, so a directory with a million files does not load an array of a million elements.

It is not recursive: only the immediate level.

Files.walk: the complete tree

public static void walk(Path root) throws IOException {
    try (Stream<Path> tree = Files.walk(root)) {
        tree.forEach(p -> System.out.println("  " + p));
    }
}

/** Limiting the depth. */
public static void walkTo(Path root, int depth) throws IOException {
    try (Stream<Path> tree = Files.walk(root, depth)) {
        tree.forEach(System.out::println);
    }
}

Files.walk walks depth-first, starting with the root itself. Its important details:

  • It does not follow symbolic links by default. With FileVisitOption.FOLLOW_LINKS it does, but then it can fall into an infinite loop if there are circular links —it throws FileSystemLoopException when it detects one.
  • If a directory is not accessible, it throws IOException on reaching it, and that aborts the whole walk. To skip the inaccessible ones you have to use walkFileTree (section 9).
  • It includes directories and files. You have to tell them apart with Files.isRegularFile.

A realistic usage example for BiblioTech:

/** Locates all the reports in a directory tree. */
public static void locateReports(Path root) throws IOException {
    try (Stream<Path> tree = Files.walk(root)) {
        tree.forEach(p -> {
            if (Files.isRegularFile(p) && p.getFileName().toString().endsWith(".report")) {
                System.out.println("  " + root.relativize(p));
            }
        });
    }
}

In 10-04 this will be written with .filter(...) instead of the if inside the forEach, and it will look much better. Here it is correct and it is understandable.

  1. Walking directory trees

Files.walk is fine for the simple cases. For real control there is walkFileTree with FileVisitor, which is the Visitor pattern applied to the file system.

package com.nexussoftware.bibliotech.util;

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.logging.Logger;

/**
 * Tree walking with complete control, using walkFileTree.
 *
 * A decisive advantage over Files.walk: it can DECIDE what to do with each
 * element and each failure, instead of aborting the whole walk when a
 * single directory is not accessible.
 */
public class ControlledWalk {

    private static final Logger LOG = Logger.getLogger(ControlledWalk.class.getName());

    /** Computes the total size of a tree, skipping what is inaccessible. */
    public static Result calculateSize(Path root) throws IOException {
        Result result = new Result();

        Files.walkFileTree(root, new SimpleFileVisitor<Path>() {

            /** Before entering a directory. */
            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
                // Skip hidden and working directories without entering them
                String name = dir.getFileName().toString();
                if (name.startsWith(".") || name.equals("tmp")) {
                    return FileVisitResult.SKIP_SUBTREE;
                }
                result.directories++;
                return FileVisitResult.CONTINUE;
            }

            /** For each file. */
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                result.files++;
                result.bytes += attrs.size();

                if (attrs.size() > result.largestSize) {
                    result.largestSize = attrs.size();
                    result.largestFile = file;
                }
                return FileVisitResult.CONTINUE;
            }

            /**
             * When a file or directory CANNOT be visited.
             *
             * THIS is the method that makes walkFileTree superior: by
             * returning CONTINUE, the walk CARRIES ON. With Files.walk, a
             * single directory without permissions aborts everything.
             */
            @Override
            public FileVisitResult visitFileFailed(Path file, IOException e) {
                result.inaccessible++;
                LOG.fine(() -> "Not accessible: " + file + " (" + e.getMessage() + ")");
                return FileVisitResult.CONTINUE;
            }

            /** On leaving a directory, after visiting all its content. */
            @Override
            public FileVisitResult postVisitDirectory(Path dir, IOException e) {
                return FileVisitResult.CONTINUE;
            }
        });

        return result;
    }

    public static class Result {
        int directories = 0;
        int files = 0;
        int inaccessible = 0;
        long bytes = 0;
        long largestSize = 0;
        Path largestFile = null;

        public String report() {
            return String.format(
                    "%d directories, %d files, %.2f MB, %d inaccessible.%n"
                            + "  Largest: %s (%.2f MB)",
                    directories, files, bytes / 1024.0 / 1024.0, inaccessible,
                    largestFile == null ? "(none)" : largestFile.getFileName(),
                    largestSize / 1024.0 / 1024.0);
        }
    }
}

The four FileVisitor methods and the four return values:

Method When it is called
preVisitDirectory Before entering a directory
visitFile For each file
visitFileFailed When something cannot be visited
postVisitDirectory On leaving the directory
Return value Effect
CONTINUE Carry on normally
SKIP_SUBTREE Do not enter this directory (only in preVisitDirectory)
SKIP_SIBLINGS Skip the remaining siblings
TERMINATE Stop the whole walk

SimpleFileVisitor is an implementation with every method returning CONTINUE, so you only override the ones you care about. It is the Adapter pattern, and it is what saves you from having to implement all four every time.

A very practical use: deleting a whole tree, which does not exist as a method because Files.delete demands the directory be empty:

/** Deletes a directory and all its content. USE WITH GREAT CARE. */
public static void deleteTree(Path root) throws IOException {
    if (Files.notExists(root)) {
        return;
    }
    Files.walkFileTree(root, new SimpleFileVisitor<Path>() {

        @Override
        public FileVisitResult visitFile(Path f, BasicFileAttributes attrs)
                throws IOException {
            Files.delete(f);
            return FileVisitResult.CONTINUE;
        }

        /**
         * The directory is deleted AFTER its content: that is why it goes in
         * postVisitDirectory and not in pre. The other way round, Files.delete
         * would throw DirectoryNotEmptyException.
         */
        @Override
        public FileVisitResult postVisitDirectory(Path dir, IOException e)
                throws IOException {
            if (e != null) {
                throw e;                  // do not delete if the walk failed
            }
            Files.delete(dir);
            return FileVisitResult.CONTINUE;
        }
    });
}

The order is the key: the content first, the directory afterwards. It is a post-order traversal, and it is the only way it works.

  1. File attributes and permissions

File only gave you lastModified(). NIO.2 gives a complete model.

Basic attributes

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;

Path p = Path.of("data/catalog.txt");

// One at a time: each call queries the file system
long size = Files.size(p);
FileTime modified = Files.getLastModifiedTime(p);

// ALL AT ONCE: a single query. More efficient and CONSISTENT.
BasicFileAttributes attrs = Files.readAttributes(p, BasicFileAttributes.class);

System.out.println("Size          : " + attrs.size());
System.out.println("Created       : " + attrs.creationTime());
System.out.println("Modified      : " + attrs.lastModifiedTime());
System.out.println("Last access   : " + attrs.lastAccessTime());
System.out.println("File?         : " + attrs.isRegularFile());
System.out.println("Directory?    : " + attrs.isDirectory());
System.out.println("Link?         : " + attrs.isSymbolicLink());

Use readAttributes whenever you need more than one attribute. Every Files.size(), Files.getLastModifiedTime(), Files.isDirectory() is an independent query to the file system. When walking a tree of a hundred thousand files, the difference is enormous. And besides, readAttributes gives a consistent snapshot: the attributes all correspond to the same instant.

FileTime and dates

FileTime represents an instant:

FileTime moment = Files.getLastModifiedTime(p);

System.out.println(moment);               // 2026-08-05T14:23:11.482Z
System.out.println(moment.toMillis());    // 1785943391482

// Comparing two files
FileTime a = Files.getLastModifiedTime(pathA);
FileTime b = Files.getLastModifiedTime(pathB);
if (a.compareTo(b) > 0) {
    System.out.println("A is more recent than B");
}

// Age in days, with millisecond arithmetic
long ageInDays = (System.currentTimeMillis() - moment.toMillis())
        / (1000L * 60 * 60 * 24);

// Changing the modification date
Files.setLastModifiedTime(p, FileTime.fromMillis(System.currentTimeMillis()));

A note on scope: FileTime converts to Instant and to LocalDateTime with toInstant(), and there the java.time API begins, which is lesson 10-05. Here we stay with toString() and toMillis(), which is enough for comparing, sorting and computing ages. When you get to 10-05 you will format these dates properly.

POSIX permissions

On Linux and macOS:

import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Set;

try {
    Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(p);
    System.out.println(PosixFilePermissions.toString(permissions));   // rw-r--r--

    // Setting permissions: only the owner can read and write
    Files.setPosixFilePermissions(p,
            PosixFilePermissions.fromString("rw-------"));

} catch (UnsupportedOperationException e) {
    // On Windows the POSIX model does not exist: ACLs must be used
    LOG.fine("System without POSIX permissions");
}

Always handle UnsupportedOperationException, because on Windows this model does not exist. And watch out for a very important use: creating a file already with restricted permissions, instead of creating it open and closing it afterwards —between the two operations there is a window in which anybody can read it:

// Create the file ALREADY with restricted permissions, in one operation
Files.createFile(p, PosixFilePermissions.asFileAttribute(
        PosixFilePermissions.fromString("rw-------")));

This matters for configuration files with sensitive data, and it links to the warning in 07-07 about not storing credentials in the repository.

  1. Temporary files and WatchService

Temporary files and directories

// A temporary file in the system temporary directory
Path temp = Files.createTempFile("bibliotech-", ".tmp");

// In a particular directory: necessary if you are then going to ATOMIC_MOVE
Path localTemp = Files.createTempFile(Path.of("data"), "catalog-", ".tmp");

// A temporary directory
Path tempDir = Files.createTempDirectory("bibliotech-import-");

The name carries a random component, so there is no collision between processes. And a security virtue: on POSIX systems they are created with rw------- permissions, for the owner only.

With automatic deletion on closing:

Path temp = Files.createTempFile("bibliotech-", ".tmp");

try (var output = Files.newBufferedWriter(temp, StandardCharsets.UTF_8,
        StandardOpenOption.WRITE,
        StandardOpenOption.DELETE_ON_CLOSE)) {    // deleted on closing

    output.write("working data");
}
// Here the file NO LONGER EXISTS

DELETE_ON_CLOSE is more reliable than java.io's deleteOnExit(), which only acts when the JVM ends and does not run with kill -9.

A reminder from section 5: if you are going to ATOMIC_MOVE the temporary file to the target, create the temporary file in the same directory as the target. The system temporary directory is usually on a different file system.

WatchService: watching for changes

NIO.2 lets the application react to changes in a directory without polling:

import java.nio.file.*;

/**
 * Watching a directory.
 *
 * NOTE: this loop is BLOCKING. In a real application it runs in a separate
 * thread, and that is module 8. Here it stands as a demonstration of the
 * mechanism, not as code you would put in your main.
 */
public static void watch(Path directory) throws IOException, InterruptedException {

    try (WatchService watcher = FileSystems.getDefault().newWatchService()) {

        directory.register(watcher,
                StandardWatchEventKinds.ENTRY_CREATE,
                StandardWatchEventKinds.ENTRY_MODIFY,
                StandardWatchEventKinds.ENTRY_DELETE);

        System.out.println("Watching " + directory + "...");

        while (true) {
            WatchKey key = watcher.take();           // BLOCKS until something happens

            for (WatchEvent<?> event : key.pollEvents()) {
                System.out.printf("  %s: %s%n", event.kind().name(), event.context());
            }

            if (!key.reset()) {                      // the directory no longer exists
                break;
            }
        }
    }
}

Real uses: reloading the configuration when the file changes, automatically processing the files that appear in an input directory, invalidating a cache.

Its limitations: it is not recursive —every subdirectory has to be registered—, on macOS the implementation uses polling and has latency, and it can generate several events for a single modification, because many editors write in several phases.

  1. Interoperability with File

Converting between the two worlds is trivial, and that allows a gradual migration:

// From File to Path
File f = new File("data/catalog.txt");
Path p = f.toPath();

// From Path to File
Path p2 = Path.of("data/catalog.txt");
File f2 = p2.toFile();

The recommended migration strategy, which is the one BiblioTech follows:

  1. Use Path in all new code.
  2. Convert with toFile() only at the exact point where an old API demands it.
  3. Change public signatures from File to Path when you touch those classes.
  4. Do not rewrite code that works just to modernise it: migrate what you touch.

Direct equivalences:

java.io.File NIO.2
f.exists() Files.exists(p)
f.isFile() Files.isRegularFile(p)
f.isDirectory() Files.isDirectory(p)
f.length() Files.size(p)
f.delete() Files.delete(p) or Files.deleteIfExists(p)
f.mkdirs() Files.createDirectories(p)
f.renameTo(o) Files.move(p, o, ...)
f.listFiles() Files.list(p) or Files.newDirectoryStream(p)
f.lastModified() Files.getLastModifiedTime(p)
f.getAbsolutePath() p.toAbsolutePath()
f.getName() p.getFileName()
f.getParent() p.getParent()
f.canRead() Files.isReadable(p)

  1. BiblioTech migrates its persistence

Time to gather it all up. BiblioTechStore replaces the mixture of java.io classes from the previous lessons.

package com.nexussoftware.bibliotech.infrastructure;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Stream;

/**
 * BiblioTech's persistence layer on NIO.2.
 *
 * It replaces all the java.io code from 07-01 to 07-05:
 *   - directory creation      -> Files.createDirectories
 *   - atomic writing          -> Files.move with ATOMIC_MOVE
 *   - rotating backups        -> Files.move and Files.deleteIfExists
 *   - locating reports        -> Files.walk
 *   - failures                -> specific exceptions, not mute booleans
 *
 * The directory structure it manages:
 *   data/
 *     catalog.txt
 *     audit.txt
 *     backups/
 *       catalog.bak.1  (the most recent)
 *       catalog.bak.2
 *       catalog.bak.3
 *     reports/
 *       2026/
 *         01/ *.report
 *     sessions/
 */
public class BiblioTechStore {

    private static final Logger LOG = Logger.getLogger(BiblioTechStore.class.getName());

    private static final java.nio.charset.Charset CHARSET = StandardCharsets.UTF_8;

    private static final String CATALOG    = "catalog.txt";
    private static final String AUDIT      = "audit.txt";
    private static final String REPORT_EXT = ".report";

    private static final int BACKUPS_KEPT = 3;

    private final Path root;
    private final Path backups;
    private final Path reports;
    private final Path sessions;

    /**
     * Prepares the directory structure.
     *
     * createDirectories is IDEMPOTENT: if they exist, it does nothing and does
     * not fail. That is the end of "if (!exists) create" with its mute boolean.
     */
    public BiblioTechStore(String rootDirectory) throws IOException {
        this.root = Path.of(Objects.requireNonNull(rootDirectory,
                "The directory cannot be null")).toAbsolutePath().normalize();

        this.backups  = root.resolve("backups");
        this.reports  = root.resolve("reports");
        this.sessions = root.resolve("sessions");

        Files.createDirectories(backups);
        Files.createDirectories(reports);
        Files.createDirectories(sessions);

        LOG.config(() -> "BiblioTech store prepared at " + root);
    }

    // ---------------------- ATOMIC WRITING ----------------------

    @FunctionalInterface
    public interface Content {
        void writeTo(BufferedWriter output) throws IOException;
    }

    /**
     * Writes a file atomically, after making a backup.
     *
     * Sequence:
     *   1. rotate the existing backups
     *   2. copy the current file to backups/name.bak.1
     *   3. write the new content to a temporary SIBLING of the target
     *   4. move it over the target with ATOMIC_MOVE
     */
    public void writeWithBackup(String name, Content content) throws IOException {
        Path target = root.resolve(name);

        // 1 and 2: rotating backup
        if (Files.exists(target)) {
            rotateBackups(name);
            Files.copy(target, backups.resolve(name + ".bak.1"),
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.COPY_ATTRIBUTES);
        }

        // 3: a SIBLING temporary file. ATOMIC_MOVE demands the same file system.
        Path temp = target.resolveSibling(target.getFileName() + ".tmp");
        boolean completed = false;

        try {
            try (BufferedWriter output = Files.newBufferedWriter(temp, CHARSET,
                    StandardOpenOption.CREATE,
                    StandardOpenOption.TRUNCATE_EXISTING,
                    StandardOpenOption.WRITE)) {

                content.writeTo(output);
            }

            // 4: a genuinely atomic replacement
            Files.move(temp, target,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE);
            completed = true;

            LOG.info(() -> String.format("Written %s (%d bytes)",
                    target.getFileName(), safeSize(target)));

        } catch (AtomicMoveNotSupportedException e) {
            LOG.warning(() -> "No ATOMIC_MOVE support; non-atomic replacement");
            Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
            completed = true;

        } finally {
            if (!completed) {
                Files.deleteIfExists(temp);          // compensation (06-05)
            }
        }
    }

    /**
     * Backup rotation: .bak.2 becomes .bak.3, .bak.1 becomes .bak.2, and the
     * oldest one is lost.
     *
     * From HIGHEST to LOWEST, as in 07-02: the other way round, each rename
     * would crush the next backup before it had been shifted.
     */
    private void rotateBackups(String name) throws IOException {
        Files.deleteIfExists(backups.resolve(name + ".bak." + BACKUPS_KEPT));

        for (int i = BACKUPS_KEPT - 1; i >= 1; i--) {
            Path source = backups.resolve(name + ".bak." + i);
            Path target = backups.resolve(name + ".bak." + (i + 1));

            if (Files.exists(source)) {
                Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
            }
        }
    }

    // ---------------------- READING ----------------------

    /**
     * Reads the catalogue line by line, with constant memory.
     *
     * Files.newBufferedReader returns the BufferedReader of 07-04, with a
     * mandatory charset and a specific exception if the file is missing.
     */
    public List<String> readCatalog() throws IOException {
        Path file = root.resolve(CATALOG);
        List<String> lines = new ArrayList<>();

        if (Files.notExists(file)) {
            // GRACEFUL DEGRADATION (06-07, 07-01): the first run has no
            // catalogue, and that is normal, not a failure.
            LOG.warning(() -> "There is no catalogue at " + file
                    + "; starting with an empty catalogue");
            return lines;
        }

        try (BufferedReader reader = Files.newBufferedReader(file, CHARSET)) {
            String line;
            while ((line = reader.readLine()) != null) {       // canonical loop (07-04)
                String clean = line.trim();
                if (!clean.isEmpty() && !clean.startsWith("#")) {
                    lines.add(clean);
                }
            }
        }

        LOG.info(() -> "Catalogue read: " + lines.size() + " lines from " + file);
        return lines;
    }

    /** Appends a line to the audit log. Explicit APPEND: the file GROWS. */
    public void audit(String line) {
        Path file = root.resolve(AUDIT);

        try (BufferedWriter output = Files.newBufferedWriter(file, CHARSET,
                StandardOpenOption.CREATE,
                StandardOpenOption.APPEND)) {          // impossible to confuse

            output.write(line);
            output.write('\n');                        // data file: fixed '\n'

        } catch (IOException e) {
            // The audit does not bring down the business operation (07-02)
            LOG.log(Level.WARNING, "Could not audit: " + line, e);
        }
    }

    // ---------------------- REPORTS ----------------------

    /** Saves a report in reports/YYYY/MM/name.report. */
    public Path saveReport(String year, String month, String name, String content)
            throws IOException {

        Path directory = reports.resolve(year).resolve(month);
        Files.createDirectories(directory);

        Path file = directory.resolve(name + REPORT_EXT);
        Files.writeString(file, content, CHARSET,
                StandardOpenOption.CREATE,
                StandardOpenOption.TRUNCATE_EXISTING,
                StandardOpenOption.WRITE);

        LOG.info(() -> "Report saved: " + root.relativize(file));
        return file;
    }

    /**
     * Locates all the reports in the tree, sorted from the most recent to the
     * oldest.
     *
     * Files.walk returns a LAZY stream that MUST BE CLOSED: it keeps directory
     * descriptors open. Hence the try-with-resources.
     * Only forEach is used; the complete Streams API is 10-04.
     */
    public List<ReportInfo> locateReports() throws IOException {
        List<ReportInfo> found = new ArrayList<>();

        try (Stream<Path> tree = Files.walk(reports)) {
            tree.forEach(p -> {
                if (!Files.isRegularFile(p)
                        || !p.getFileName().toString().endsWith(REPORT_EXT)) {
                    return;
                }
                try {
                    // readAttributes: ONE query for all the attributes,
                    // instead of three independent calls (section 10)
                    BasicFileAttributes attrs =
                            Files.readAttributes(p, BasicFileAttributes.class);

                    found.add(new ReportInfo(
                            reports.relativize(p).toString(),
                            attrs.size(),
                            attrs.lastModifiedTime()));

                } catch (IOException e) {
                    LOG.fine(() -> "Could not read the attributes of " + p);
                }
            });
        }

        // Most recent first (05-09)
        found.sort(Comparator.comparing(ReportInfo::modified).reversed());
        return found;
    }

    /**
     * Information about a located report.
     *
     * FileTime is shown with toString() and compared with compareTo().
     * Converting it into readable dates is java.time, which is 10-05.
     */
    public record ReportInfo(String relativePath, long bytes, FileTime modified) {

        public String line() {
            return String.format("%-40s %8d bytes  %s",
                    relativePath, bytes, modified);
        }

        /** Age in days, with millisecond arithmetic (10-05 will do it better). */
        public long ageInDays() {
            return (System.currentTimeMillis() - modified.toMillis())
                    / (1000L * 60 * 60 * 24);
        }
    }

    // ---------------------- MAINTENANCE ----------------------

    /**
     * Deletes the reports older than N days.
     *
     * @return how many were deleted
     */
    public int cleanOldReports(int maxDays) throws IOException {
        int deleted = 0;

        for (ReportInfo info : locateReports()) {
            if (info.ageInDays() > maxDays) {
                Path file = reports.resolve(info.relativePath());
                if (Files.deleteIfExists(file)) {
                    deleted++;
                    LOG.fine(() -> "Old report deleted: " + info.relativePath());
                }
            }
        }

        final int total = deleted;
        LOG.info(() -> "Clean-up: " + total + " reports older than "
                + maxDays + " days");
        return deleted;
    }

    /** Summary of the space used, with walkFileTree so a failure does not abort it. */
    public String spaceReport() throws IOException {
        long[] data = new long[2];       // [0] bytes, [1] files

        Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult visitFile(Path f, BasicFileAttributes attrs) {
                data[0] += attrs.size();
                data[1]++;
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFileFailed(Path f, IOException e) {
                LOG.fine(() -> "Not accessible: " + f);
                return FileVisitResult.CONTINUE;      // does not abort the walk
            }
        });

        return String.format("%s: %d files, %.2f MB",
                root.getFileName(), data[1], data[0] / 1024.0 / 1024.0);
    }

    private long safeSize(Path p) {
        try {
            return Files.size(p);
        } catch (IOException e) {
            return -1;
        }
    }

    public Path getRoot()     { return root; }
    public Path getSessions() { return sessions; }
}

A complete demonstration:

package com.nexussoftware.bibliotech.presentation;

import java.io.IOException;
import com.nexussoftware.bibliotech.infrastructure.BiblioTechStore;

public class StoreDemo {

    public static void main(String[] args) throws IOException {
        BiblioTechStore store = new BiblioTechStore("data");

        // 1. Write the catalogue with a backup and atomicity
        store.writeWithBackup("catalog.txt", output -> {
            output.write("# BiblioTech catalogue - Nexus Software");
            output.newLine();
            output.write("BOOK;978-0000000001;Effective Java;Bloch;2018");
            output.newLine();
            output.write("BOOK;978-0000000002;Design Patterns;Gamma;1994");
            output.newLine();
            output.write("BOOK;978-0000000003;Refactoring;Fowler;1999");
            output.newLine();
        });

        // 2. Read
        System.out.println("=== CATALOGUE ===");
        for (String line : store.readCatalog()) {
            System.out.println("  " + line);
        }

        // 3. Audit (APPEND mode)
        store.audit("LOAN;LN-0001;978-0000000001;EMP-001");
        store.audit("RETURN;LN-0001;978-0000000001;EMP-001");

        // 4. Reports in a directory tree
        store.saveReport("2026", "01", "fines-january", "Total: 142.50 EUR");
        store.saveReport("2026", "02", "fines-february", "Total: 98.25 EUR");
        store.saveReport("2026", "08", "fines-august", "Total: 210.00 EUR");

        System.out.println();
        System.out.println("=== REPORTS LOCATED ===");
        for (BiblioTechStore.ReportInfo info : store.locateReports()) {
            System.out.println("  " + info.line());
        }

        // 5. Space used
        System.out.println();
        System.out.println("=== SPACE ===");
        System.out.println("  " + store.spaceReport());
    }
}

Output:

=== CATALOGUE ===
  BOOK;978-0000000001;Effective Java;Bloch;2018
  BOOK;978-0000000002;Design Patterns;Gamma;1994
  BOOK;978-0000000003;Refactoring;Fowler;1999

=== REPORTS LOCATED ===
  2026/08/fines-august.report                    17 bytes  2026-08-05T09:14:22.331Z
  2026/02/fines-february.report                  16 bytes  2026-08-05T09:14:22.329Z
  2026/01/fines-january.report                   17 bytes  2026-08-05T09:14:22.327Z

=== SPACE ===
  data: 9 files, 0.01 MB

The eight design decisions that sum up the migration:

  1. createDirectories in the constructor. Idempotent: that is the end of if (!exists) create with its mute boolean.
  2. The root is normalised and made absolute once, in the constructor. All the derived paths are consistent and the error messages say where the files really are.
  3. resolve instead of concatenating strings. No File.separator, no double slashes, no platform errors.
  4. The temporary file is a sibling of the target with resolveSibling, because ATOMIC_MOVE demands the same file system. Creating it in /tmp would have broken the atomicity just when it matters.
  5. ATOMIC_MOVE with explicit degradation. If the file system does not support it, a warning is issued and we carry on with a normal move, instead of failing. Graceful degradation from 06-07.
  6. Rotation from highest to lowest. The same detail as in 07-02, and it is still the one people get wrong most.
  7. readAttributes in a single call inside the walk, instead of three queries per file. With thousands of files, the difference shows.
  8. The Files.walk streams go in a try-with-resources. They keep descriptors open, and that is the most frequent mistake with this API.

Common Mistakes and Tips

  • Not closing the stream from Files.lines, list or walk. The most frequent NIO.2 mistake. It leaves descriptors open and ends up exhausting the system limit. try-with-resources always.
  • Believing that exists() and notExists() are opposites. Both are false when it cannot be determined. !exists() does not mean "it does not exist".
  • Checking existence and then operating. TOCTOU: between the two, the world changes. The check is a courtesy; the exception is compulsory.
  • Creating the temporary file in /tmp and doing ATOMIC_MOVE to the target. AtomicMoveNotSupportedException if they are on different file systems. The temporary file goes next to the target: resolveSibling.
  • Using createDirectory instead of createDirectories. The first fails if the parent does not exist. The second creates the whole chain and is idempotent.
  • Using delete where you meant deleteIfExists. NoSuchFileException in a clean-up that should have been harmless.
  • Calling size(), isDirectory() and getLastModifiedTime() separately. Three file system queries where readAttributes would have done. When walking large trees, it shows a great deal.
  • Concatenating strings to compose paths. resolve and resolveSibling exist precisely for that, and they get the separators right too.
  • Using String's startsWith to check path containment. /home/martarodriguez starts with /home/marta as text, but it is not inside it. Path.startsWith compares segments.
  • Checking containment without normalize() first. A ../.. in the requested path bypasses the check. That is path traversal, and it is a real vulnerability.
  • Expecting Files.copy to copy a directory with its content. It copies the directory empty. For the tree, walkFileTree.
  • Deleting a directory with Files.delete. DirectoryNotEmptyException if it has content. It must be walked in post-order.
  • Using Files.walk with FOLLOW_LINKS without thinking. A circular link causes FileSystemLoopException, or an endless walk.
  • Letting an inaccessible directory abort the whole walk. That is what Files.walk does. With walkFileTree and visitFileFailed returning CONTINUE, the walk carries on.
  • Loading a file of unknown size with readString or readAllLines. OutOfMemoryError. That is what newBufferedReader is for.
  • Using POSIX permissions without catching UnsupportedOperationException. On Windows that model does not exist.
  • Tip: Path.of in new code. Paths.get works and is the same thing; Path.of is the current form.
  • Tip: Path in public signatures, File only at the edges. Convert with toFile() exactly where an old API demands it, and not before.
  • Tip: use StandardOpenOption even though it is longer to write. APPEND is impossible to confuse with a charset, and TRUNCATE_EXISTING is impossible to add without noticing.
  • Tip: do not migrate for the sake of migrating. Code with File that works does not need rewriting. Migrate what you touch for another reason, and write NIO.2 for the new things.

Exercises

Exercise 1: path explorer

Write PathExplorer with a method analyse(String path) that shows a complete report of a path using NIO.2 only:

  1. The path as given, absolute, normalised and real (catching the exception if it does not exist).
  2. Name, parent, root, number of segments and the list of segments.
  3. If it exists: type, size, readable/writable permissions, and the four BasicFileAttributes times obtained in a single call.
  4. Age in days since the last modification, with millisecond arithmetic.
  5. A comparison of exists() and notExists(), explaining in the output what each combination means.

Test it with an existing path, a non-existent one, a directory and a path with .. unnormalised.

Exercise 2: directory synchroniser

Write DirectorySynchroniser to copy from a source directory to a target one only what is needed:

  1. Copy the files that do not exist in the target.
  2. Copy those that exist but are more recent in the source (compare FileTime with compareTo).
  3. Do not touch those that are the same.
  4. Report those that are only in the target (possible deletions), without deleting them.
  5. Create the missing directory structure in the target.
  6. Use walkFileTree so as not to abort if a file is inaccessible, and relativize to compute the equivalent path.
  7. Return a record with the counts and show a report.

Exercise 3: rotating backup manager

Write a complete BackupManager for BiblioTech:

  1. createBackup(Path file) saving a timestamped copy in backups/name.YYYYMMDD-HHMMSS.bak, using System.currentTimeMillis() for the name (without java.time, which is 10-05).
  2. Keep a maximum of N backups per file; delete the oldest by sorting on FileTime.
  3. restore(Path file, int index) recovering backup number index (0 = the most recent) atomically, taking a backup of the current state first.
  4. listBackups(Path file) returning the list sorted from the most recent to the oldest, with size and date.
  5. spaceUsed() summing the size of all the backups using walkFileTree.
  6. A main demonstrating the complete cycle: create five backups with a maximum of three, list, restore and check.

Solutions

Solution 1

package com.nexussoftware.bibliotech.util;

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.PosixFilePermissions;

/**
 * Complete report of a path using NIO.2 only.
 *
 * It replaces the PathDiagnostic of 07-01, which used java.io: here every
 * failure says WHY, instead of returning a mute boolean.
 */
public class PathExplorer {

    public static void analyse(String path) {
        Path p = Path.of(path);

        System.out.println("========================================");
        System.out.println("ANALYSIS OF: " + path);
        System.out.println("========================================");

        // ---- 1. Forms of the path ----
        System.out.println("  As given        : " + p);
        System.out.println("  Absolute        : " + p.toAbsolutePath());
        System.out.println("  Normalised      : " + p.toAbsolutePath().normalize());

        try {
            // toRealPath DOES consult the disk and resolves symbolic links
            System.out.println("  Real            : " + p.toRealPath());
        } catch (NoSuchFileException e) {
            System.out.println("  Real            : (does not exist, cannot be resolved)");
        } catch (IOException e) {
            System.out.println("  Real            : (error: " + e.getMessage() + ")");
        }

        // ---- 2. Structure ----
        System.out.println("  Name            : " + p.getFileName());
        System.out.println("  Parent          : " + p.getParent());
        System.out.println("  Root            : " + p.getRoot());
        System.out.println("  Absolute?       : " + p.isAbsolute());
        System.out.println("  Segments        : " + p.getNameCount());

        StringBuilder segments = new StringBuilder();
        for (Path s : p) {                       // Path is Iterable<Path>
            segments.append('[').append(s).append(']');
        }
        System.out.println("  Walk-through    : " + segments);

        // ---- 5. exists / notExists ----
        boolean exists = Files.exists(p);
        boolean notExists = Files.notExists(p);

        System.out.println("  ----");
        System.out.println("  exists()        : " + exists);
        System.out.println("  notExists()     : " + notExists);
        System.out.println("  Interpretation  : " + interpret(exists, notExists));

        if (!exists) {
            System.out.println();
            return;
        }

        // ---- 3. Attributes: a SINGLE query ----
        try {
            BasicFileAttributes attrs = Files.readAttributes(p, BasicFileAttributes.class);

            System.out.println("  ----");
            System.out.println("  Type            : " + type(attrs));
            System.out.println("  Size            : " + attrs.size() + " bytes");
            System.out.println("  Created         : " + attrs.creationTime());
            System.out.println("  Modified        : " + attrs.lastModifiedTime());
            System.out.println("  Last access     : " + attrs.lastAccessTime());
            System.out.println("  Readable?       : " + Files.isReadable(p));
            System.out.println("  Writable?       : " + Files.isWritable(p));

            // ---- 4. Age ----
            long days = (System.currentTimeMillis() - attrs.lastModifiedTime().toMillis())
                    / (1000L * 60 * 60 * 24);
            System.out.println("  Age             : " + days + " days");

            // POSIX permissions if the system has them
            try {
                System.out.println("  POSIX perms     : " + PosixFilePermissions.toString(
                        Files.getPosixFilePermissions(p)));
            } catch (UnsupportedOperationException e) {
                System.out.println("  POSIX perms     : (system without support, e.g. Windows)");
            }

        } catch (IOException e) {
            System.out.println("  ERROR reading attributes: " + e.getMessage());
        }

        System.out.println();
    }

    private static String type(BasicFileAttributes a) {
        if (a.isDirectory())     { return "directory"; }
        if (a.isRegularFile())   { return "file"; }
        if (a.isSymbolicLink())  { return "symbolic link"; }
        return "other";
    }

    /** The THREE possible states, not two. */
    private static String interpret(boolean exists, boolean notExists) {
        if (exists && !notExists) {
            return "EXISTS, and it could be checked";
        }
        if (!exists && notExists) {
            return "DOES NOT EXIST, and it could be checked";
        }
        if (!exists && !notExists) {
            return "CANNOT BE DETERMINED (probably no permission on the "
                    + "directory containing it). That is why !exists() does NOT "
                    + "mean 'it does not exist'.";
        }
        return "impossible state";
    }

    public static void main(String[] args) {
        analyse("data/catalog.txt");
        analyse("data");
        analyse("data/does-not-exist.txt");
        analyse("data/../data/./reports/../catalog.txt");   // unnormalised
    }
}

Output (a fragment of the last call):

========================================
ANALYSIS OF: data/../data/./reports/../catalog.txt
========================================
  As given        : data/../data/./reports/../catalog.txt
  Absolute        : /home/marta/bibliotech/data/../data/./reports/../catalog.txt
  Normalised      : /home/marta/bibliotech/data/catalog.txt
  Real            : /home/marta/bibliotech/data/catalog.txt
  Name            : catalog.txt
  Segments        : 7
  Walk-through    : [data][..][data][.][reports][..][catalog.txt]
  ----
  exists()        : true
  notExists()     : false
  Interpretation  : EXISTS, and it could be checked

The two key points: first, the difference between toAbsolutePath() —which only prepends the working directory, cleaning nothing up— and normalize()/toRealPath(), which do resolve the ... That distinction is exactly what makes the path traversal vulnerability of section 3 possible. And second, the interpretation of the three exists/notExists states, which is the subtlest trap in this API.

Solution 2

package com.nexussoftware.bibliotech.util;

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Logger;

/**
 * Synchronises a target directory with a source, copying only what is needed.
 *
 * It is the core of any incremental backup tool: not copying what is already
 * the same saves most of the work.
 */
public class DirectorySynchroniser {

    private static final Logger LOG =
            Logger.getLogger(DirectorySynchroniser.class.getName());

    public record Result(int copied, int updated, int unchanged,
                         int onlyInTarget, int errors,
                         List<String> onlyInTargetDetail) {

        public String report() {
            StringBuilder sb = new StringBuilder();
            sb.append("=== SYNCHRONISATION ===\n");
            sb.append(String.format("  Copied (new)      : %d%n", copied));
            sb.append(String.format("  Updated           : %d%n", updated));
            sb.append(String.format("  Unchanged         : %d%n", unchanged));
            sb.append(String.format("  Only in target    : %d%n", onlyInTarget));
            sb.append(String.format("  Errors            : %d%n", errors));

            if (!onlyInTargetDetail.isEmpty()) {
                sb.append("  --- Only in target (candidates for deletion) ---\n");
                int n = Math.min(10, onlyInTargetDetail.size());
                for (int i = 0; i < n; i++) {
                    sb.append("    ").append(onlyInTargetDetail.get(i)).append('\n');
                }
                if (onlyInTargetDetail.size() > n) {
                    sb.append(String.format("    ... and %d more%n",
                            onlyInTargetDetail.size() - n));
                }
            }
            return sb.toString();
        }
    }

    /** Synchronises source into target. It NEVER deletes anything in the target. */
    public Result synchronise(Path source, Path target) throws IOException {
        Path sourceRoot = source.toAbsolutePath().normalize();
        Path targetRoot = target.toAbsolutePath().normalize();

        if (!Files.isDirectory(sourceRoot)) {
            throw new NotDirectoryException(sourceRoot.toString());
        }
        Files.createDirectories(targetRoot);

        int[] counters = new int[4];          // copied, updated, same, errors
        Set<Path> seenInSource = new HashSet<>();

        // ---- PHASE 1: walk the source and synchronise ----
        Files.walkFileTree(sourceRoot, new SimpleFileVisitor<Path>() {

            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
                    throws IOException {

                // relativize gives the equivalent path in the target (section 3)
                Path equivalent = targetRoot.resolve(sourceRoot.relativize(dir));
                Files.createDirectories(equivalent);        // idempotent
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes sourceAttrs) {
                Path relative = sourceRoot.relativize(file);
                Path equivalent = targetRoot.resolve(relative);

                seenInSource.add(relative);

                try {
                    if (Files.notExists(equivalent)) {
                        // NEW: copy keeping the attributes
                        Files.copy(file, equivalent,
                                StandardCopyOption.COPY_ATTRIBUTES);
                        counters[0]++;
                        LOG.fine(() -> "New: " + relative);
                        return FileVisitResult.CONTINUE;
                    }

                    BasicFileAttributes targetAttrs =
                            Files.readAttributes(equivalent, BasicFileAttributes.class);

                    FileTime tSource = sourceAttrs.lastModifiedTime();
                    FileTime tTarget = targetAttrs.lastModifiedTime();

                    // compareTo > 0 means the source is MORE RECENT.
                    // The size is compared too: a file with the same date but a
                    // different size also has to be copied.
                    if (tSource.compareTo(tTarget) > 0
                            || sourceAttrs.size() != targetAttrs.size()) {

                        Files.copy(file, equivalent,
                                StandardCopyOption.REPLACE_EXISTING,
                                StandardCopyOption.COPY_ATTRIBUTES);
                        counters[1]++;
                        LOG.fine(() -> "Updated: " + relative);
                    } else {
                        counters[2]++;
                    }

                } catch (IOException e) {
                    counters[3]++;
                    LOG.warning(() -> "Error synchronising " + relative
                            + ": " + e.getMessage());
                }
                return FileVisitResult.CONTINUE;
            }

            /**
             * KEY: an inaccessible file does NOT abort the synchronisation.
             * With Files.walk this would not be possible.
             */
            @Override
            public FileVisitResult visitFileFailed(Path file, IOException e) {
                counters[3]++;
                LOG.warning(() -> "Not accessible in the source: " + file);
                return FileVisitResult.CONTINUE;
            }
        });

        // ---- PHASE 2: detect what is only in the target ----
        List<String> onlyTarget = new ArrayList<>();

        Files.walkFileTree(targetRoot, new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                Path relative = targetRoot.relativize(file);
                if (!seenInSource.contains(relative)) {
                    onlyTarget.add(relative.toString());
                }
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFileFailed(Path f, IOException e) {
                return FileVisitResult.CONTINUE;
            }
        });

        onlyTarget.sort(null);

        return new Result(counters[0], counters[1], counters[2],
                onlyTarget.size(), counters[3], onlyTarget);
    }

    public static void main(String[] args) throws IOException {
        Result r = new DirectorySynchroniser()
                .synchronise(Path.of("data"), Path.of("backup/data"));
        System.out.println(r.report());
    }
}

Output of a second run, after modifying a file:

=== SYNCHRONISATION ===
  Copied (new)      : 0
  Updated           : 1
  Unchanged         : 11
  Only in target    : 2
  Errors            : 0
  --- Only in target (candidates for deletion) ---
    old-catalog.txt
    reports/2025/12/fines-december.report

The four teaching points:

  1. relativize + resolve is the pair that makes this possible. sourceRoot.relativize(file) gives the relative path; targetRoot.resolve(relative) gives the equivalent. With string concatenation this goes wrong as soon as there are subdirectories.
  2. visitFileFailed returning CONTINUE is the reason for using walkFileTree. A single locked file would abort a whole Files.walk, and a backup tool that gives up at the first problem is useless.
  3. Both date and size are compared. Comparing only the date lets through files modified with the same timestamp, which happens more often than you would think with some tools. Comparing only the size lets through changes that do not alter it.
  4. It deletes nothing. It reports what is surplus and leaves the decision to whoever runs it. A tool that automatically deletes things in a backup directory is an efficient way of losing data.

Solution 3

package com.nexussoftware.bibliotech.infrastructure;

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.logging.Logger;
import java.util.stream.Stream;

/**
 * Rotating timestamped backups, on NIO.2.
 *
 * Each backup is named file.YYYYMMDD-HHMMSS.bak. The N most recent ones are
 * kept; the rest are deleted.
 *
 * NOTE: the dated name is composed with SimpleDateFormat because java.time is
 * lesson 10-05. With LocalDateTime and DateTimeFormatter this looks rather
 * better, and there you will see it.
 */
public class BackupManager {

    private static final Logger LOG = Logger.getLogger(BackupManager.class.getName());

    private static final String SUFFIX = ".bak";
    private static final int DEFAULT_MAX_BACKUPS = 3;

    private final Path backupDirectory;
    private final int maxBackups;

    public BackupManager(Path backupDirectory) throws IOException {
        this(backupDirectory, DEFAULT_MAX_BACKUPS);
    }

    public BackupManager(Path backupDirectory, int maxBackups) throws IOException {
        this.backupDirectory = Objects.requireNonNull(backupDirectory)
                .toAbsolutePath().normalize();

        if (maxBackups < 1) {
            throw new IllegalArgumentException(
                    "At least one backup must be kept, and the request was: " + maxBackups);
        }
        this.maxBackups = maxBackups;

        Files.createDirectories(this.backupDirectory);
    }

    /** Information about an existing backup. */
    public record BackupInfo(Path path, String name, long bytes, FileTime created) {

        public String line() {
            return String.format("%-45s %8d bytes  %s", name, bytes, created);
        }
    }

    // ---------------------- CREATING ----------------------

    /**
     * Creates a timestamped backup and applies the rotation.
     *
     * @return the path of the backup created
     */
    public Path createBackup(Path file) throws IOException {
        Objects.requireNonNull(file, "The file cannot be null");

        if (Files.notExists(file)) {
            throw new NoSuchFileException(file.toString(),
                    null, "A file that does not exist cannot be copied");
        }

        String baseName = file.getFileName().toString();
        String stamp = new java.text.SimpleDateFormat("yyyyMMdd-HHmmss")
                .format(new Date(System.currentTimeMillis()));

        Path backup = backupDirectory.resolve(baseName + "." + stamp + SUFFIX);

        // If another backup is requested in the same second, a suffix is added
        int attempt = 1;
        while (Files.exists(backup)) {
            backup = backupDirectory.resolve(
                    baseName + "." + stamp + "-" + attempt + SUFFIX);
            attempt++;
        }

        // COPY_ATTRIBUTES keeps the original date of the copied file
        Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES);

        final Path created = backup;
        LOG.info(() -> "Backup created: " + created.getFileName());

        applyRotation(baseName);
        return backup;
    }

    /** Deletes the surplus backups, starting with the oldest. */
    private void applyRotation(String baseName) throws IOException {
        List<BackupInfo> backups = listBackups(baseName);

        for (int i = maxBackups; i < backups.size(); i++) {
            BackupInfo surplus = backups.get(i);        // they come newest to oldest
            if (Files.deleteIfExists(surplus.path())) {
                LOG.fine(() -> "Old backup deleted: " + surplus.name());
            }
        }
    }

    // ---------------------- LISTING ----------------------

    /** Backups of a file, from the MOST RECENT to the oldest. */
    public List<BackupInfo> listBackups(String baseName) throws IOException {
        List<BackupInfo> found = new ArrayList<>();
        String prefix = baseName + ".";

        // Files.list returns a LAZY stream: try-with-resources compulsory
        try (Stream<Path> children = Files.list(backupDirectory)) {
            children.forEach(p -> {
                String name = p.getFileName().toString();
                if (!name.startsWith(prefix) || !name.endsWith(SUFFIX)) {
                    return;
                }
                try {
                    BasicFileAttributes attrs =
                            Files.readAttributes(p, BasicFileAttributes.class);
                    found.add(new BackupInfo(p, name, attrs.size(),
                            attrs.lastModifiedTime()));
                } catch (IOException e) {
                    LOG.fine(() -> "Could not read " + p);
                }
            });
        }

        // Most recent first (05-09)
        found.sort(Comparator.comparing(BackupInfo::created).reversed());
        return found;
    }

    public List<BackupInfo> listBackups(Path file) throws IOException {
        return listBackups(file.getFileName().toString());
    }

    // ---------------------- RESTORING ----------------------

    /**
     * Restores backup number 'index' (0 = the most recent).
     *
     * BEFORE restoring, it takes a backup of the CURRENT state: if the
     * restoration turns out to be a mistake, it can be undone. Restoring
     * without that net is a common way of losing the day's work.
     *
     * The replacement is ATOMIC: the file is never left half done.
     */
    public void restore(Path file, int index) throws IOException {
        List<BackupInfo> backups = listBackups(file);

        if (backups.isEmpty()) {
            throw new NoSuchFileException(file.toString(),
                    null, "There is no backup of this file");
        }
        if (index < 0 || index >= backups.size()) {
            throw new IllegalArgumentException(String.format(
                    "Index %d out of range: there are %d backups (0..%d)",
                    index, backups.size(), backups.size() - 1));
        }

        BackupInfo chosen = backups.get(index);

        // 1. Safety net: back up the current state before overwriting it
        if (Files.exists(file)) {
            createBackup(file);
        }

        // 2. A temporary SIBLING of the target: ATOMIC_MOVE demands it
        Path temp = file.resolveSibling(file.getFileName() + ".restoring");
        boolean completed = false;

        try {
            Files.copy(chosen.path(), temp,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.COPY_ATTRIBUTES);

            Files.move(temp, file,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE);
            completed = true;

            LOG.info(() -> "Restored " + file.getFileName()
                    + " from " + chosen.name());

        } catch (AtomicMoveNotSupportedException e) {
            LOG.warning(() -> "No ATOMIC_MOVE; non-atomic restoration");
            Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING);
            completed = true;

        } finally {
            if (!completed) {
                Files.deleteIfExists(temp);            // compensation (06-05)
            }
        }
    }

    // ---------------------- SPACE ----------------------

    /** Total space used by the backups. */
    public long spaceUsed() throws IOException {
        long[] total = new long[1];

        Files.walkFileTree(backupDirectory, new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult visitFile(Path f, BasicFileAttributes attrs) {
                if (f.getFileName().toString().endsWith(SUFFIX)) {
                    total[0] += attrs.size();
                }
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFileFailed(Path f, IOException e) {
                return FileVisitResult.CONTINUE;       // do not abort on a failure
            }
        });
        return total[0];
    }

    // ---------------------- DEMONSTRATION ----------------------

    public static void main(String[] args) throws IOException, InterruptedException {
        Path file = Path.of("data/catalog.txt");
        Files.createDirectories(file.getParent());

        BackupManager manager = new BackupManager(Path.of("data/backups"), 3);

        // 1. Five different versions, with a maximum of 3 backups
        System.out.println("=== CREATING 5 BACKUPS (maximum 3) ===");
        for (int v = 1; v <= 5; v++) {
            Files.writeString(file,
                    "# Catalogue version " + v + "\n"
                            + "BOOK;978-000000000" + v + ";Book " + v + ";Author;2020\n",
                    java.nio.charset.StandardCharsets.UTF_8,
                    StandardOpenOption.CREATE,
                    StandardOpenOption.TRUNCATE_EXISTING,
                    StandardOpenOption.WRITE);

            Path backup = manager.createBackup(file);
            System.out.println("  v" + v + " -> " + backup.getFileName());

            Thread.sleep(1100);        // so that the timestamp changes
        }

        // 2. List: only the last 3 should remain
        System.out.println();
        System.out.println("=== BACKUPS KEPT ===");
        List<BackupInfo> backups = manager.listBackups(file);
        for (int i = 0; i < backups.size(); i++) {
            System.out.printf("  [%d] %s%n", i, backups.get(i).line());
        }

        // 3. Current content
        System.out.println();
        System.out.println("=== CURRENT CONTENT ===");
        System.out.print(Files.readString(file));

        // 4. Restore backup [1] (the second-newest, that is, version 4)
        System.out.println();
        System.out.println("=== RESTORING BACKUP [1] ===");
        manager.restore(file, 1);
        System.out.print(Files.readString(file));

        // 5. Space
        System.out.println();
        System.out.printf("=== SPACE USED: %.2f KB in %d backups ===%n",
                manager.spaceUsed() / 1024.0,
                manager.listBackups(file).size());
    }
}

Output:

=== CREATING 5 BACKUPS (maximum 3) ===
  v1 -> catalog.txt.20260805-091422.bak
  v2 -> catalog.txt.20260805-091423.bak
  v3 -> catalog.txt.20260805-091424.bak
  v4 -> catalog.txt.20260805-091425.bak
  v5 -> catalog.txt.20260805-091426.bak

=== BACKUPS KEPT ===
  [0] catalog.txt.20260805-091426.bak                  61 bytes  2026-08-05T09:14:26.118Z
  [1] catalog.txt.20260805-091425.bak                  61 bytes  2026-08-05T09:14:25.012Z
  [2] catalog.txt.20260805-091424.bak                  61 bytes  2026-08-05T09:14:23.907Z

=== CURRENT CONTENT ===
# Catalogue version 5
BOOK;978-0000000005;Book 5;Author;2020

=== RESTORING BACKUP [1] ===
# Catalogue version 4
BOOK;978-0000000004;Book 4;Author;2020

=== SPACE USED: 0.18 KB in 3 backups ===

The five teaching points:

  1. The rotation is done by date, not by name. Sorting on FileTime with Comparator.comparing(...).reversed() is more robust than trusting the name to sort well —which here would work, because the YYYYMMDD-HHMMSS format sorts alphabetically, but that is a happy coincidence not worth taking for granted.
  2. Restoring first takes a backup of the current state. It is the difference between a backup tool and a trap: if you restore the wrong version, you can undo it. Notice that after the restoration there are 3 backups, not 4: the rotation was applied to the prior backup too.
  3. The restoration is atomic with the sibling temporary file and ATOMIC_MOVE, with explicit degradation if the file system does not support it.
  4. Files.list goes in a try-with-resources. If listBackups is called thousands of times without closing the stream, the process descriptors run out.
  5. The name-collision loop. Two backups in the same second would have the same name; the -1, -2 suffix avoids it. It is a small detail discovered in production when somebody presses the button twice.

Conclusion

All those "in 07-06 this is done properly" comments are resolved.

You know why NIO.2 exists and which seven defects of java.io.File it corrects: the mute booleans that say something failed without saying why, the listFiles() that returns null, the blindness to symbolic links, the poor metadata, the renameTo that behaves differently on every system, the impossibility of extending it and the absence of efficient tree walking. And you know that the professional rule admits no nuance: Path and Files in new code, File only for reading old code and for the APIs that still demand it, with toPath()/toFile() as the bridge at the edges.

You have mastered Path: Path.of as the current form, querying name, parent, root and segments, iteration —because Path is Iterable<Path>— and the startsWith that compares complete segments, not text prefixes. And you have mastered its arithmetic: resolve for combining, with the rule that an absolute argument replaces the base; resolveSibling for the sibling in the same directory, which is exactly what a temporary file needs; relativize for the path from A to B; and normalize for removing . and .. purely syntactically, as against toRealPath(), which does consult the disk. And you know the normalize + startsWith usage that prevents path traversal, a real vulnerability that with string concatenation is nearly always prevented badly.

You know Files from top to bottom. You know that exists() and notExists() are not opposites, because there is a third state —"it cannot be determined"— and that therefore !exists() does not mean "it does not exist". You know that createDirectories is idempotent and creates the whole chain of parents, that delete and deleteIfExists differ in whether the file's absence is an error, and that every failure arrives as a specific exception —NoSuchFileException, DirectoryNotEmptyException, AccessDeniedException— saying exactly what happened. That is what 06-04 asked of an exception.

And you have the piece you have been approximating for four lessons: Files.move with ATOMIC_MOVE, which replaces the delete-then-rename sequence with a single operation with no gap in between. With its two limits well understood: only within the same file system —hence the temporary file having to be a sibling of the target— and with AtomicMoveNotSupportedException as a foreseen degradation. The AtomicWrite of 07-02, with its forty lines of bookkeeping, now fits into fifteen and guarantees more.

You know how to read and write in one line with readString, writeString, readAllLines and write, with the usual warning —they load the whole file into memory— and with newBufferedReader/newBufferedWriter as the correct form for what grows. And you have StandardOpenOption, which fixes the most expensive mistake in the module: APPEND is impossible to confuse with a charset, and TRUNCATE_EXISTING is impossible to write by accident. CREATE_NEW, moreover, gives at last a correct file lock: check and create in one atomic operation.

You know Files.lines, list and walk, with the only thing that matters now: they return lazy streams that keep the file open and MUST be closed with try-with-resources, and here they are used with forEach because the complete Streams API is 10-04. You know that Files.list fixes the two defects of listFiles() and that Files.walk aborts the whole walk when it meets something inaccessible —which is why walkFileTree exists, with SimpleFileVisitor, its four methods and its four return values, and with the visitFileFailed returning CONTINUE that lets a walk carry on. And you know how to delete a tree in post-order, which is the only way it works.

You know the attributes with readAttributesone query for all of them, instead of one per attribute, which when walking large trees changes the performance—, FileTime with its toString(), its toMillis() and its compareTo for sorting and computing ages, deferring to 10-05 for dates proper; and POSIX permissions with their UnsupportedOperationException on Windows and their important use: creating a file already with restricted permissions, without the exposure window of creating it and protecting it afterwards. And you know temporary files with DELETE_ON_CLOSE and the WatchService with its limitations.

BiblioTech has its persistence layer migrated. BiblioTechStore prepares its directory structure with idempotent createDirectories, normalises its root once only, composes all its paths with resolve instead of concatenating, writes truly atomically with the sibling temporary file and ATOMIC_MOVE, keeps rotating backups from highest to lowest, appends to the audit log with an explicit APPEND, saves reports in a reports/YYYY/MM/ tree, locates them with a properly closed Files.walk and reading the attributes in one go, cleans out the old ones by age and computes the space with walkFileTree without aborting on an inaccessible file. Not one mute boolean, not one null return, not one existence check standing in for an exception.

And yet, what gives the whole module its point is still outstanding. BiblioTech's catalogue is still saved with a naive split(";") and a sanitise() that replaces the semicolons in the titles with commas and loses the data. You flagged it as a workaround in 07-02 and it still is one. And the four business constants —LOAN_DAYS, DAILY_RATE, MAX_FINE, MINOR_THRESHOLD— have been hard-coded for seven modules: changing the fine rate demands recompiling and redeploying the application.

In lesson 07-07, Interchange Formats: CSV and Properties, the module closes with both. You will see why a text format is superior to a binary one for interchange and what you pay in exchange; CSV properly, with everything the naive split gets wrong —commas inside quoted fields, escaped quotes, line breaks inside a field, empty fields versus nulls, the BOM— and a correct implementation with its escape/unescape and its table of edge cases, plus the decimal separator conflict that already showed up in 07-01; and Properties, with its key-value format, its load/store, its historical encoding problem, and the complete configuration hierarchy: defaults in the code, a bibliotech.properties file, system properties with -D and environment variables, with its precedence table. And then, at last, the four constants will stop being constants: BiblioTech will be configured without recompiling, and will remember everything it does between runs.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved