Topic 12.3
Path and Files (NIO.2)
In one line
Path is an object that names a file or folder, and Files is a class of static methods that act on the file system: read, write, copy, move, delete, list and inspect. Together (NIO.2, Java 7, with Files.readString/writeString and Path.of added in Java 11) they replace the old java.io.File for all new code.
Think of it like this
A home address written on an envelope. The address (Path) is just text that says where a house is: writing it down doesn't build the house, and the house might not exist. The postal service (Files) is what actually goes there to deliver, collect or check. In Java, creating a Path never touches the disk; only Files methods do.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Path
- An object holding the name of a file or folder, like
reports/2024/june.csv. It is only a name; the file may not exist. - Absolute path
- A path that starts from the top of the file system, like
/home/asha/notes.txtorC:\Users\asha\notes.txt. - Relative path
- A path with no root, like
data/orders.csv. Java resolves it against the working directory. - Working directory
- The folder the program was started from.
Path.of("").toAbsolutePath()shows it. - NIO.2
- The
java.nio.filepackage added in Java 7:Path,Filesand related classes. NIO stands for "New I/O". - Open option
- A flag that says how to open a file, such as
APPENDorCREATE_NEW. - Atomic move
- A rename that happens in one indivisible step, so nobody can ever see the file half-moved or half-written.
- TOCTOU
- "Time of check to time of use": a bug where something changes between checking a condition and acting on it.
- Path traversal
- An attack where input like
../../secret.txtmakes a program reach files outside the folder it meant to use.
Step by step
01A Path is just a name
Path.of("projects", "shop", "src", "Main.java") joins the parts with the platform separator (/ on Linux and macOS, \ on Windows). Nothing is checked on disk: you can make a Path for a file that doesn't exist yet, which is exactly what you need before creating it.
A Path is immutable. Methods like resolve and normalize return new paths. Convert between old and new with file.toPath() and path.toFile() when a legacy API needs a File.
Path p = Path.of("projects", "shop", "src", "Main.java");
p.getFileName(); // Main.java
p.getParent(); // projects/shop/src
p.getNameCount(); // 4
Path.of("projects/shop").resolve("docs/readme.md"); // projects/shop/docs/readme.md
Path.of("a/./b/../c").normalize(); // a/c02Path methods versus String methods
Path comparisons work on name elements, not characters. Path.of("projects/shopping").startsWith(Path.of("projects/shop")) is false, because shopping and shop are different names, while the String version is true. That difference is what makes startsWith safe for security checks.
normalize() is purely textual. It removes a/.. without checking whether a is a symbolic link. For the real location on disk, use toRealPath(), which resolves links and throws NoSuchFileException if the file doesn't exist.
03Reading and writing small files
Java 11's writeString and readString are one-liners that use UTF-8 by default. readString is strict: if the file isn't valid UTF-8 it throws MalformedInputException rather than guessing.
Passing StandardOpenOption.APPEND adds to the end. Without options, writeString replaces the whole file.
Path notes = Path.of("notes.txt");
Files.writeString(notes, "buy milk\n"); // create or replace
Files.writeString(notes, "call Ravi\n", StandardOpenOption.APPEND); // add to the end
String all = Files.readString(notes); // whole file, UTF-8
List<String> lines = Files.readAllLines(notes); // [buy milk, call Ravi]
long size = Files.size(notes); // 19 bytes04Streaming big files and directories
Files.lines, Files.list and Files.walk return streams that hold an open file or directory handle. A stream doesn't close itself when the terminal operation finishes; you must close it. Forget, and a server that does this per request eventually fails with Too many open files.
Files.walk(root) visits every file and folder under root, depth-first, without following symbolic links by default. Files.find adds a filter that can see file attributes cheaply.
try (Stream<String> lines = Files.lines(Path.of("access.log"))) {
long errors = lines.filter(l -> l.contains(" 500 ")).count();
}
try (Stream<Path> files = Files.walk(Path.of("src"))) {
List<Path> javaFiles = files.filter(f -> f.toString().endsWith(".java")).toList();
}05Create, copy, move, delete
Files.createDirectories(dir) creates every missing parent and doesn't complain if the folder exists; createDirectory creates exactly one and throws FileAlreadyExistsException if it exists.
copy and move fail with FileAlreadyExistsException if the target exists, unless you pass REPLACE_EXISTING. delete throws NoSuchFileException for a missing file and DirectoryNotEmptyException for a folder with content; to delete a tree, walk it in reverse order (deepest first).
Path dir = Files.createDirectories(Path.of("archive", "2024"));
Files.copy(Path.of("report.csv"), dir.resolve("report.csv"), StandardCopyOption.REPLACE_EXISTING);
Files.move(Path.of("draft.txt"), dir.resolve("final.txt")); // rename / move
boolean removed = Files.deleteIfExists(Path.of("old.tmp")); // false if it wasn't there06Atomic writes: never leave half a file
If a program crashes in the middle of Files.writeString(config, ...), the target can be left empty or cut off, because the default options truncate it first. Readers may also see a half-written file at any moment.
The fix: write to a temporary file in the same folder (so it's on the same file system), then Files.move(tmp, target, ATOMIC_MOVE, REPLACE_EXISTING). The rename is a single file-system operation: readers see either the complete old file or the complete new one. If the file system can't do it atomically, Java throws AtomicMoveNotSupportedException instead of quietly copying.
static void saveAtomically(Path target, String content) throws IOException {
Path tmp = Files.createTempFile(target.getParent(), "save-", ".tmp");
try {
Files.writeString(tmp, content);
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(tmp); // no-op if the move succeeded
}
}07Defend against path traversal
A download endpoint that does base.resolve(request.getParameter("name")) can be asked for ../../etc/passwd or an absolute path; resolve with an absolute argument simply returns that argument. Never trust a file name from a user.
Defence: resolve against an absolute, normalized base, normalize the result, and check startsWith(base) (the Path version, which compares whole names). Reject anything else. For extra safety also reject symbolic links (toRealPath() and compare again) and allow-list the file names or extensions you expect. The runnable example below shows the check.
Try it yourself
- 1
Predict normalize and relativize
In "Path anatomy", add
show(Path.of("a/b").relativize(Path.of("a/b/c/d")))andshow(Path.of("../x/./y/..").normalize()). Predict both results before you run. (A leading..can't be removed, because there's no name before it.) - 2
Attack your own guard
In "Block path traversal", add the inputs
"uploads/../uploads/ok.txt","./././x"and"..". Predict ALLOW or REJECT for each, then run it. - 3
Run the real-files example locally
Save "Everyday Files operations" as
Main.java, runjava Main.javatwice, and compare the output. Then delete theREPLACE_EXISTINGoption and run it again: which exception appears, and why only on the second run?
Code & diagrams
None of these files exist. Path methods work on names only; Files methods touch the disk.
Expected output
path: projects/shop/src/Main.java
file name: Main.java
parent: projects/shop/src
root: null
name count: 4
name(1): shop
subpath: shop/src
absolute? false
resolve: projects/shop/docs/readme.md
sibling: projects/blog
normalize: projects/shop/docs/readme.md
relativize: ../blog/post.md
Path startsWith: false
String startsWith: true
endsWith: trueresolve() with an absolute path returns that path unchanged, so the startsWith check is what stops "/etc/passwd". This is a defence; also allow-list expected names in real code.
Expected output
ALLOW cat.png -> uploads/cat.png
ALLOW 2024/report.pdf -> uploads/2024/report.pdf
ALLOW a/../dog.png -> uploads/dog.png
REJECT ../secrets.txt
REJECT a/../../etc/passwd
REJECT /etc/passwdnew String(...) silently replaces bad bytes with U+FFFD. Files.readString and Files.readAllLines decode strictly, like the second case, and throw MalformedInputException.
Expected output
new String: length 4, last char U+FFFD
strict decoder: java.nio.charset.MalformedInputException: Input length = 1
as ISO-8859-1: length 4, last char U+E9Run with java Main.java. Output on Linux or macOS: exists: true, size: 36 / lines: [INFO start, ERROR disk full, INFO stop] / errors: 1 / entry: app.log / entry: app.log.bak / already exists: demo/logs/app.log / deleted backup: true. On Windows the path prints with backslashes.
import java.io.IOException;
import java.nio.file.*;
import java.util.List;
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) throws IOException {
Path dir = Files.createDirectories(Path.of("demo", "logs"));
Path log = dir.resolve("app.log");
Files.writeString(log, "INFO start\nERROR disk full\n");
Files.writeString(log, "INFO stop\n", StandardOpenOption.APPEND);
System.out.println("exists: " + Files.exists(log) + ", size: " + Files.size(log));
List<String> lines = Files.readAllLines(log);
System.out.println("lines: " + lines);
try (Stream<String> s = Files.lines(log)) {
System.out.println("errors: " + s.filter(l -> l.startsWith("ERROR")).count());
}
Path copy = Files.copy(log, dir.resolve("app.log.bak"), StandardCopyOption.REPLACE_EXISTING);
try (Stream<Path> entries = Files.list(dir)) {
entries.map(Path::getFileName).sorted().forEach(n -> System.out.println("entry: " + n));
}
try {
Files.createFile(log); // CREATE_NEW semantics
} catch (FileAlreadyExistsException e) {
System.out.println("already exists: " + e.getFile());
}
System.out.println("deleted backup: " + Files.deleteIfExists(copy));
}
}// Legacy: errors are just 'false', no reason given
File f = new File("data/out.txt");
boolean made = f.getParentFile().mkdirs();
boolean gone = f.delete(); // false: missing? locked? no permission?
// NIO.2: the exception tells you what happened
Path p = Path.of("data/out.txt");
Files.createDirectories(p.getParent());
Files.delete(p); // NoSuchFileException: data/out.txt
// Converting between them
Path fromFile = f.toPath();
File fromPath = p.toFile();Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Read a file that isn't there
Call Files.readString(Path.of("notes.txt")) in a folder with no notes.txt.
Break #2
Forget to close Files.lines
In a request handler, write long n = Files.lines(path).count(); without try-with-resources, and call it thousands of times.
Break #3
Read a non-UTF-8 file with readString
Read a file saved in ISO-8859-1 (or Windows-1252) that contains café with Files.readString(path).
Myth vs fact
Myth
Path.of("x.txt") creates or opens the file.
Fact
It only builds a name. Nothing touches the disk until you call a Files method.
Myth
Check Files.exists first, then create or delete.
Fact
Another process can change the file between the check and the action. Let the operation decide (CREATE_NEW, createFile, deleteIfExists) and handle the specific exception.
Myth
Files.notExists(p) is the same as !Files.exists(p).
Fact
Both return false when Java can't tell (for example, no permission to look). The file system has three answers: exists, doesn't exist, unknown.
Myth
normalize() makes a path safe.
Fact
It only removes . and name/.. textually. Safety comes from checking the normalized result startsWith an allowed base, and from not following unexpected symbolic links.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Pathcomes from aFileSystem(FileSystems.getDefault()), and other providers exist: the ZIP file system lets you open a.jaror.zipwithFileSystems.newFileSystem(zipPath)and useFiles.copy,Files.walkand the rest inside it. Test libraries such as Google's Jimfs provide an in-memory file system, so file code can be unit-tested without touching the disk. - ▸
Files.readAllBytesandreadStringsize the array from the file's length up front. They throwOutOfMemoryError: Required array size too largefor files over about 2 GB, because Java arrays are indexed byint. Use streaming orFileChannelfor huge files;FileChannel.mapmemory-maps a file so the OS pages it in on demand. - ▸
Files.movewithATOMIC_MOVEmaps torename(2)on Linux andMoveFileExon Windows. Atomicity only holds within one file system, which is why the temp file must be in the target's folder. For durability after a crash, you'd alsoforcethe temp file's channel before the move and sync the directory, which databases do and most apps skip. - ▸
WatchService(Java 7) lets you register a directory and receiveENTRY_CREATE/ENTRY_MODIFY/ENTRY_DELETEevents. It uses inotify on Linux, but on macOS the JDK falls back to polling, so events can arrive seconds late. File attributes are read in bulk withFiles.readAttributes(p, BasicFileAttributes.class), which is one system call instead of several.
Remember this
- 1
Before Java 7 there was only
java.io.File, which mixed both jobs and had real problems: methods likedelete()andmkdir()returnedfalsewithout saying why, it had no support for symbolic links or file attributes, and listing a large directory loaded everything at once. NIO.2 (JSR 203, Java 7) addedjava.nio.file:Path,Paths,Files,FileSystem. Its methods throw specific exceptions such asNoSuchFileException,FileAlreadyExistsException,AccessDeniedExceptionandDirectoryNotEmptyException, so you learn exactly what went wrong. - 2
You create a
PathwithPath.of("data", "orders.csv")(Java 11; in Java 7 and 8 usePaths.get(...), which does the same). A path has a root (/orC:\, or none for a relative path) and a list of name elements. Pure path methods work on the text only:getFileName(),getParent(),resolve("child")(join),resolveSibling,normalize()(remove.andname/..),relativize(the path from one to another),startsWithandtoAbsolutePath(). A relative path is resolved against the current working directory, the folder the JVM was started in. - 3
For small files, read and write in one call:
Files.readString(path)andFiles.writeString(path, text)(Java 11),Files.readAllLines(path)andFiles.write(path, lines)(Java 7),Files.readAllBytes(path). They open, read or write, and close for you, and they use UTF-8 by default. They load the whole file into memory, so don't use them on a 5 GB log. - 4
For big files, stream:
Files.newBufferedReader(path)(Topic 12.2), orFiles.lines(path)(Java 8), a lazyStream<String>.Files.lineskeeps the file open until the stream is closed, so it must be used in try-with-resources:try (Stream<String> lines = Files.lines(path)) { ... }. The same is true forFiles.listandFiles.walk, which stream a directory's entries and a whole folder tree. - 5
Writes take open options (
StandardOpenOption). The default forwriteString/write/newBufferedWriterisCREATE, TRUNCATE_EXISTING, WRITE: create the file if needed and replace its content. UseAPPENDto add to the end andCREATE_NEWto fail withFileAlreadyExistsExceptionif it exists. CheckingFiles.exists(p)and then creating is a race: another process can create the file in between (a TOCTOU bug, time-of-check to time-of-use). Let the operation itself check, usingCREATE_NEWorFiles.createFile. - 6
Files.copy,Files.moveandFiles.deletethrow if something is in the way unless you pass options likeREPLACE_EXISTING;deleteIfExistsreturnsfalseinstead of throwing.Files.move(tmp, target, ATOMIC_MOVE)renames in one step, so readers see the old file or the new one but never half a file: the standard atomic write pattern is "write to a temp file in the same folder, then atomically move it over the target". Paths built from user input must be checked against path traversal (../../etc/passwd): resolve, normalize, then confirm the result stillstartsWithyour base folder.
Explain it without notes
What's the difference between Path and Files, and why did NIO.2 replace java.io.File?
Which Files methods load a whole file into memory, and which stream it? When would you use each?
Why must Files.lines, Files.list and Files.walk be closed, when Files.readAllLines doesn't need to be?
How do you write a file so that a crash or a concurrent reader never sees a half-written version?
How do you safely serve a file whose name comes from a user?
Practice
Write static String extension(Path p) that returns the file extension in lower case ("" if none), using only getFileName() and String methods. Test it with report.PDF, archive.tar.gz, README and .gitignore.
Given the base Path.of("site") and the paths site/index.html, site/blog/post.html and site/img/logo.png, print each one relative to the base, with / separators.
Write static Path backupName(Path original, int n) that turns docs/plan.txt into docs/plan.txt.1 style siblings for n = 1 and n = 2, using resolveSibling. Print both with / separators.
Trade-offs
- ↔
readString/readAllLinesare the simplest and fastest for small files but load everything into memory; streaming APIs scale to any size but need careful closing and more code. - ↔
Atomic write-then-rename protects readers and survives crashes, but costs an extra file, needs write permission on the folder, and changes the file's identity (inode), which breaks tools that hold the old file open, like
tail -fwithout-F. - ↔
Relative paths make programs portable between machines but depend on the working directory, which differs between your IDE, a terminal and a service manager. Production code usually derives paths from configuration.
Done when you can
Done when you can explain why creating a
Pathnever touches the disk and whichFilesmethods do.Done when you can use
resolve,normalize,relativizeandstartsWithcorrectly, including the difference fromString.startsWith.Done when you choose between whole-file methods and streaming methods based on file size.
Done when every
Files.lines/list/walkstream is in try-with-resources.Done when you can write a file atomically and block path traversal from user input.