NIO.2 в Java: работа с файлами через Path и Files
Программа ищет текстовые отчёты и пишет Files.walk(dir).filter(p -> p.endsWith(".txt")). Код компилируется, исключений нет, а найдено 0 файлов — даже когда рядом лежит report.txt:
Path file = Path.of("docs", "report.txt");
System.out.println(file.endsWith(".txt")); // false
System.out.println(file.endsWith("report.txt")); // true
System.out.println(file.toString().endsWith(".txt")); // true Метод Path.endsWith() сравнивает не символы строки, а целые элементы пути: последний элемент здесь — report.txt, а не .txt. Таких неочевидных мест в API путей и файлов немало, и все они разобраны ниже.
NIO.2 — это API пакета java.nio.file (Java 7) для работы с файловой системой: интерфейс Path описывает путь к файлу или каталогу, а утилитный класс Files выполняет операции над ним — проверяет существование, читает и записывает содержимое, создаёт, копирует, перемещает и удаляет файлы, обходит дерево каталогов. В отличие от java.io.File, методы Files при неудаче бросают исключение с причиной (NoSuchFileException, FileAlreadyExistsException), а не возвращают false. Сравнение File и Files по задачам — в разделе File, Path и Files: что выбрать.
Создание Path: Path.of и Paths.get
Объект Path создаётся статическим методом Path.of() (Java 11) или Paths.get() (Java 7) — оба принимают путь целиком или по частям и возвращают одинаковый результат. Создание Path не обращается к диску: файл может не существовать, проверяется только синтаксис пути.
| Способ | Пример | Комментарий |
|---|---|---|
Path.of(String first, String... more) | Path.of("data", "reports", "2026.txt") | Рекомендуемый способ (Java 11+); части соединяются системным разделителем |
Paths.get(String first, String... more) | Paths.get("data/reports/2026.txt") | То же для Java 7–10; в новых версиях внутри вызывает Path.of() |
Path.of(URI uri) | Path.of(URI.create("file:///tmp/a.txt")) | Из URI со схемой file:; для схемы без открытой файловой системы — FileSystemNotFoundException |
file.toPath() | new File("a.txt").toPath() | Переход от старого java.io.File; обратно — path.toFile() |
Символ / в строке пути Java понимает в любой ОС, а при выводе Path печатается с системным разделителем: data/reports/2026.txt в Linux и macOS, data\reports\2026.txt в Windows. Недопустимый для ОС путь (например, Path.of("a:b?.txt") в Windows) сразу выбрасывает InvalidPathException. Относительный путь, как и у File, отсчитывается от рабочего каталога JVM (System.getProperty("user.dir")).
Части пути: getFileName, getParent, getRoot
Path хранит путь как последовательность элементов-имён и даёт к ним доступ: getFileName() — последний элемент, getParent() — всё, кроме последнего, getRoot() — корень (/ или C:\), getNameCount() и getName(i) — количество элементов и элемент по индексу. Все они возвращают Path, а не String.
import java.nio.file.Path;
import java.nio.file.Paths;
public class PathParts {
public static void main(String[] args) {
Path p1 = Path.of("data", "reports", "2026.txt");
Path p2 = Paths.get("data/reports/2026.txt");
System.out.println(p1);
System.out.println("Равны: " + p1.equals(p2));
System.out.println("Имя: " + p1.getFileName());
System.out.println("Родитель: " + p1.getParent());
System.out.println("Корень: " + p1.getRoot());
System.out.println("Элементов: " + p1.getNameCount());
System.out.println("Первый элемент: " + p1.getName(0));
System.out.println("Подпуть 1..3: " + p1.subpath(1, 3));
System.out.println("Абсолютный: " + p1.isAbsolute());
System.out.println("Родитель a.txt: " + Path.of("a.txt").getParent());
}
} Вывод (Linux, macOS; в Windows разделитель — \):
data/reports/2026.txt
Равны: true
Имя: 2026.txt
Родитель: data/reports
Корень: null
Элементов: 3
Первый элемент: data
Подпуть 1..3: reports/2026.txt
Абсолютный: false
Родитель a.txt: null У относительного пути нет корня, поэтому getRoot() возвращает null; у пути из одного элемента null возвращает и getParent(). Перед вызовом методов у результата проверяйте его на null, иначе получите NullPointerException. Индекс за пределами 0..getNameCount()-1 в getName() даёт IllegalArgumentException.
resolve, relativize и normalize
Три метода работают с путями как с текстом, не обращаясь к диску: resolve() присоединяет путь к базовому, relativize() строит относительный путь от одного пути к другому, normalize() убирает из пути элементы . и ... Вместо склейки строк через + "/" + используйте resolve(): он сам подставит нужный разделитель.
import java.nio.file.Path;
public class ResolveDemo {
public static void main(String[] args) {
Path base = Path.of("project");
Path main = base.resolve("src/Main.java");
Path docs = Path.of("project/docs/guide.md");
System.out.println(main);
System.out.println(main.resolveSibling("Test.java"));
System.out.println(main.relativize(docs));
System.out.println(Path.of("project/src/../docs/./guide.md").normalize());
System.out.println(Path.of("../a/../../b").normalize());
try {
main.relativize(Path.of("/tmp").toAbsolutePath());
} catch (IllegalArgumentException e) {
System.out.println("relativize: IllegalArgumentException");
}
}
} Вывод (Linux, macOS; в Windows разделитель — \):
project/src/Main.java
project/src/Test.java
../../docs/guide.md
project/docs/guide.md
../../b
relativize: IllegalArgumentException Чтобы дойти от project/src/Main.java до project/docs/guide.md, нужно подняться на два уровня (../..) и спуститься в docs. Ведущие .. метод normalize() не убирает: выше начала относительного пути подниматься некуда. relativize() работает только между двумя относительными или двумя абсолютными путями — смешение даёт IllegalArgumentException.
Отдельный нюанс resolve(): если аргумент — абсолютный путь, метод просто возвращает аргумент. В Linux Path.of("project").resolve("/etc/hosts") даст /etc/hosts, а не project/etc/hosts. Поэтому имя файла, пришедшее от пользователя, нельзя без проверки присоединять к рабочему каталогу — как проверить, показано в FAQ.
toAbsolutePath и toRealPath
toAbsolutePath() просто приписывает к относительному пути рабочий каталог и к диску не обращается, поэтому работает и для несуществующих файлов, а . и .. оставляет как есть. toRealPath() обращается к файловой системе: возвращает канонический путь без .., с раскрытыми символическими ссылками и бросает NoSuchFileException, если файла нет.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
public class RealPathDemo {
public static void main(String[] args) throws IOException {
Files.createDirectories(Path.of("demo-dir"));
Files.writeString(Path.of("notes.txt"), "hello");
Path p = Path.of("demo-dir/../notes.txt");
System.out.println(p.toAbsolutePath());
System.out.println(p.toRealPath());
try {
Path.of("missing.txt").toRealPath();
} catch (NoSuchFileException e) {
System.out.println("toRealPath: NoSuchFileException");
}
System.out.println(Path.of("missing.txt").toAbsolutePath());
}
} Вывод (Linux, рабочий каталог /home/anna/project; в Windows будет, например, C:\projects\demo\demo-dir\..\notes.txt):
/home/anna/project/demo-dir/../notes.txt
/home/anna/project/notes.txt
toRealPath: NoSuchFileException
/home/anna/project/missing.txt У java.io.File те же роли играют getAbsolutePath() и getCanonicalPath() (разбор — в FAQ урока о классе File). Если нужен абсолютный путь без .., но файл может ещё не существовать, используйте p.toAbsolutePath().normalize().
Сравнение путей: equals, startsWith, isSameFile
equals(), startsWith() и endsWith() у Path сравнивают пути как последовательности элементов, не обращаясь к диску: a/../b.txt и b.txt для них разные пути. Узнать, указывают ли два пути на один и тот же файл, можно только через Files.isSameFile(), который читает файловую систему.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class PathCompare {
public static void main(String[] args) throws IOException {
Files.createDirectories(Path.of("a"));
Files.writeString(Path.of("b.txt"), "B");
Path p1 = Path.of("a/../b.txt");
Path p2 = Path.of("b.txt");
System.out.println("equals: " + p1.equals(p2));
System.out.println("normalize + equals: " + p1.normalize().equals(p2));
System.out.println("isSameFile: " + Files.isSameFile(p1, p2));
Path src = Path.of("projects/app/Main.java");
System.out.println("Path.startsWith: " + src.startsWith("project"));
System.out.println("String.startsWith: " + src.toString().startsWith("project"));
System.out.println("endsWith(\"Main.java\"): " + src.endsWith("Main.java"));
System.out.println("Регистр: " + Path.of("B.TXT").equals(Path.of("b.txt")));
}
} Вывод (Linux, macOS; в Windows последняя строка — Регистр: true):
equals: false
normalize + equals: true
isSameFile: true
Path.startsWith: false
String.startsWith: true
endsWith("Main.java"): true
Регистр: false Path.startsWith("project") вернул false, потому что первый элемент пути — projects, а не project; строковый startsWith этого различия не видит. По той же причине в примере из начала урока endsWith(".txt") искал элемент пути с именем .txt. Чтобы проверить расширение, сравнивайте строку имени: p.getFileName().toString().endsWith(".txt").
Регистр букв и ОС
В Windows Path.equals() и compareTo() не различают регистр, поэтому B.TXT и b.txt равны. В Linux и macOS сравнение побайтовое — пути разные, хотя файловая система macOS по умолчанию тоже нечувствительна к регистру и откроет один и тот же файл. Если путь используется как ключ HashMap или элемент Set, программа будет вести себя по-разному на разных ОС.
Проверки: exists, isDirectory, isRegularFile, size
Проверки выполняют статические методы Files: exists() и notExists() — есть ли файл, isDirectory() и isRegularFile() — каталог это или обычный файл, isReadable(), isWritable() — права доступа, size() — размер в байтах. Проверки возвращают boolean без исключений, а size() для отсутствующего файла бросает NoSuchFileException.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
public class ChecksDemo {
public static void main(String[] args) throws IOException {
Path file = Path.of("check-demo.txt");
Files.writeString(file, "Привет");
System.out.println("exists: " + Files.exists(file));
System.out.println("isRegularFile: " + Files.isRegularFile(file));
System.out.println("isDirectory: " + Files.isDirectory(file));
System.out.println("size: " + Files.size(file));
Path missing = Path.of("missing.txt");
System.out.println("exists: " + Files.exists(missing));
System.out.println("notExists: " + Files.notExists(missing));
try {
Files.size(missing);
} catch (NoSuchFileException e) {
System.out.println("size: NoSuchFileException " + e.getFile());
}
}
} exists: true
isRegularFile: true
isDirectory: false
size: 12
exists: false
notExists: true
size: NoSuchFileException missing.txt Размер — 12 байт, а не 6: Files.writeString() пишет в UTF-8, где каждая кириллическая буква занимает 2 байта. notExists() — не просто !exists(): если у программы нет прав прочитать каталог, существование файла определить нельзя, и оба метода вернут false.
Проверка exists() не страхует
Код if (Files.exists(p)) { Files.readString(p); } всё равно может выбросить NoSuchFileException: между проверкой и чтением файл может удалить другая программа (ошибка класса TOCTOU, time-of-check to time-of-use). Надёжнее сразу выполнять операцию и обрабатывать исключение, а для создания использовать атомарные методы — Files.createFile() или опцию CREATE_NEW.
Чтение и запись файла: readString, writeString, readAllLines
Чтобы прочитать файл в Java целиком, используйте Files.readString(path) (Java 11) — весь текст одной строкой, или Files.readAllLines(path) — список строк. Чтобы записать в файл, используйте Files.writeString(path, text) или Files.write(path, lines). Файл открывается и закрывается внутри метода, кодировка по умолчанию — UTF-8, существующий файл перезаписывается.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class ReadWriteDemo {
public static void main(String[] args) throws IOException {
Path file = Path.of("notes.txt");
Files.writeString(file, "первая строка\nвторая строка\n");
String text = Files.readString(file);
System.out.print(text);
List<String> lines = Files.readAllLines(file);
System.out.println(lines.size() + " " + lines);
Files.write(file, List.of("alpha", "beta")); // файл перезаписан
System.out.println(Files.readAllLines(file));
byte[] bytes = Files.readAllBytes(file);
System.out.println("Байт: " + bytes.length);
}
} Вывод (Linux, macOS; в Windows последняя строка — Байт: 13):
первая строка
вторая строка
2 [первая строка, вторая строка]
[alpha, beta]
Байт: 11 Files.write(path, lines) добавляет после каждой строки системный разделитель строк: \n в Linux и macOS, \r\n в Windows — отсюда 11 или 13 байт для alpha и beta. readAllLines() понимает все три варианта конца строки (\n, \r\n, \r) и в результат их не включает.
| Метод | Что делает | Граничный случай |
|---|---|---|
readString(path) (Java 11) | Весь файл в одну String, UTF-8 | Файл больше 2 ГБ — OutOfMemoryError; не UTF-8 — MalformedInputException |
readAllLines(path) | Все строки в List<String> | Весь файл в памяти; пустой файл — пустой список |
readAllBytes(path) | Всё содержимое в byte[] — для двоичных файлов | Кодировка не применяется; файл больше 2 ГБ не прочитать |
writeString(path, text, options...) (Java 11) | Пишет строку в UTF-8 | Без опций создаёт или перезаписывает файл; нет родительского каталога — NoSuchFileException |
write(path, lines, options...) | Пишет строки, после каждой — разделитель строк ОС | То же поведение по умолчанию, что у writeString |
write(path, bytes, options...) | Пишет массив байтов как есть | Пара к readAllBytes(): копия картинки или архива через память |
StandardOpenOption: дописать в файл, создать, перезаписать
Режим открытия файла задают константы перечисления StandardOpenOption, которые передаются последними аргументами в writeString(), write(), newBufferedWriter(). Без опций действует набор CREATE, TRUNCATE_EXISTING, WRITE — файл создаётся или обрезается до нуля. Чтобы дописать в конец файла, передайте APPEND вместе с CREATE.
| Опция | Что делает | Если файла нет / есть |
|---|---|---|
WRITE | Открыть для записи | Нет — NoSuchFileException (без CREATE); есть — запись с начала поверх старых байтов |
APPEND | Писать в конец файла | Нет — NoSuchFileException, если не указан CREATE |
CREATE | Создать, если файла нет | Есть — открыть существующий |
CREATE_NEW | Создать новый файл атомарно | Есть — FileAlreadyExistsException |
TRUNCATE_EXISTING | Обрезать существующий файл до 0 байт | Есть — содержимое удаляется; вместе с APPEND — IllegalArgumentException |
DELETE_ON_CLOSE | Удалить файл при закрытии потока | Удобно для временных файлов |
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import static java.nio.file.StandardOpenOption.APPEND;
import static java.nio.file.StandardOpenOption.CREATE;
public class AppendDemo {
public static void main(String[] args) throws IOException {
Path log = Path.of("app.log");
Files.writeString(log, "start\n"); // создать или перезаписать
Files.writeString(log, "step 1\n", APPEND);
Files.writeString(log, "step 2\n", CREATE, APPEND);
System.out.print(Files.readString(log));
Path other = Path.of("other.log");
Files.deleteIfExists(other);
try {
Files.writeString(other, "x\n", APPEND); // нет CREATE
} catch (NoSuchFileException e) {
System.out.println("NoSuchFileException: " + e.getFile());
}
}
} start
step 1
step 2
NoSuchFileException: other.log Как только передана хотя бы одна опция, набор по умолчанию перестаёт действовать. Поэтому для журнала, который должен создаваться при первом запуске и дописываться при следующих, нужна пара CREATE, APPEND, а не один APPEND.
Большие файлы: lines, newBufferedReader, newBufferedWriter
Для больших файлов не подходят readString() и readAllLines(): они загружают весь файл в память. Читайте построчно — через Files.lines(path) (Java 8), который возвращает ленивый Stream<String>, или через Files.newBufferedReader(path). Для записи большого объёма используйте Files.newBufferedWriter(path). Все три объекта нужно закрывать через try-with-resources.
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
public class LargeFileDemo {
public static void main(String[] args) throws IOException {
Path csv = Path.of("orders.csv");
Files.write(csv, List.of("id;sum", "1;250", "2;100", "3;400"));
try (Stream<String> lines = Files.lines(csv)) {
int total = lines.skip(1) // пропустить заголовок
.mapToInt(line -> Integer.parseInt(line.split(";")[1]))
.sum();
System.out.println("Итого: " + total);
}
Path report = Path.of("report.txt");
try (BufferedWriter writer = Files.newBufferedWriter(report)) {
writer.write("Заказов: 3");
writer.newLine();
}
try (BufferedReader reader = Files.newBufferedReader(report)) {
System.out.println(reader.readLine());
}
}
} Итого: 750
Заказов: 3 Files.lines() держит файл открытым, пока поток не закрыт, — к чему приводит забытое закрытие, показано в таблице урока try-with-resources. Операции над строками (skip, mapToInt, filter) — это обычный Stream API. newBufferedReader() и newBufferedWriter() возвращают обычные BufferedReader и BufferedWriter из java.io (их методы — в разделе BufferedReader и BufferedWriter), но сразу с UTF-8 и без цепочки обёрток. Для двоичных данных есть Files.newInputStream() и Files.newOutputStream().
Кодировка: UTF-8 по умолчанию и MalformedInputException
Все текстовые методы Files без параметра Charset используют UTF-8 на любой ОС и в любой версии Java — в отличие от FileReader, который до Java 17 включительно брал системную кодировку. Если файл записан в другой кодировке, readString() и readAllLines() не выдают «кракозябры», а бросают MalformedInputException.
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.MalformedInputException;
import java.nio.file.Files;
import java.nio.file.Path;
public class CharsetDemo {
public static void main(String[] args) throws IOException {
Charset cp1251 = Charset.forName("windows-1251");
Path file = Path.of("cp1251.txt");
Files.writeString(file, "Привет", cp1251);
try {
Files.readString(file); // ожидается UTF-8
} catch (MalformedInputException e) {
System.out.println("MalformedInputException: " + e.getMessage());
}
System.out.println(Files.readString(file, cp1251));
System.out.println("Байт: " + Files.size(file));
}
} MalformedInputException: Input length = 1
Привет
Байт: 6 Сообщение Input length = 1 значит, что один байт не образует допустимую последовательность UTF-8: буква «П» в windows-1251 — байт 0xCF, с которого в UTF-8 начинается двухбайтовый символ, а следующий байт ему не подходит. Решение — указать настоящую кодировку файла вторым аргументом. У Files.lines() та же ошибка всплывает позже, при обходе потока, и завёрнута в UncheckedIOException. Как кодировка по умолчанию менялась для классов java.io (JEP 400, Java 18), описано в уроке Потоки данных в Java.
Создание, копирование, перемещение и удаление
Файлы и каталоги создают методы Files.createFile(), createDirectory() и createDirectories(), копирует Files.copy(), переименовывает и перемещает Files.move(), удаляют Files.delete() и deleteIfExists(). Каждый метод при неудаче бросает конкретное исключение: файл уже есть, файла нет, каталог не пуст.
| Метод | Что делает | Исключение / граничный случай |
|---|---|---|
createFile(path) | Создаёт пустой файл | Файл есть — FileAlreadyExistsException; нет родителя — NoSuchFileException |
createDirectory(path) | Создаёт один каталог | Каталог есть — FileAlreadyExistsException; нет родителя — NoSuchFileException |
createDirectories(path) | Создаёт каталог со всеми недостающими родителями | Каталог уже есть — не ошибка; на месте каталога лежит файл — FileAlreadyExistsException |
copy(src, target, options...) | Копирует файл | Цель есть — FileAlreadyExistsException без REPLACE_EXISTING; каталог копируется пустым, без содержимого |
move(src, target, options...) | Переименовывает или перемещает файл или каталог | Цель есть — FileAlreadyExistsException; с ATOMIC_MOVE между разными дисками — AtomicMoveNotSupportedException |
delete(path) | Удаляет файл или пустой каталог | Нет файла — NoSuchFileException; каталог не пуст — DirectoryNotEmptyException |
deleteIfExists(path) | Удаляет, если есть | Возвращает false, если файла не было; для непустого каталога — тоже DirectoryNotEmptyException |
Опции копирования и перемещения задаёт StandardCopyOption: REPLACE_EXISTING — заменить существующий файл, COPY_ATTRIBUTES — скопировать время изменения и права, ATOMIC_MOVE — переместить одной атомарной операцией ОС (другой процесс увидит либо старый файл, либо новый, но не половину).
import java.io.IOException;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
public class CopyMoveDeleteDemo {
public static void main(String[] args) throws IOException {
Path in = Files.createDirectories(Path.of("sandbox/in"));
Path src = in.resolve("a.txt");
Files.writeString(src, "данные");
Path backup = Path.of("sandbox/a-backup.txt");
Files.deleteIfExists(backup);
Files.copy(src, backup);
try {
Files.copy(src, backup);
} catch (FileAlreadyExistsException e) {
System.out.println("Уже есть: " + e.getFile());
}
Files.copy(src, backup, REPLACE_EXISTING);
Path moved = Files.move(backup, Path.of("sandbox/archive.txt"), REPLACE_EXISTING);
System.out.println("Перемещён в " + moved);
try {
Files.delete(in);
} catch (DirectoryNotEmptyException e) {
System.out.println("Не пустой: " + e.getFile());
}
Path nothing = Path.of("sandbox/nothing.txt");
System.out.println("deleteIfExists: " + Files.deleteIfExists(nothing));
try {
Files.delete(nothing);
} catch (NoSuchFileException e) {
System.out.println("Нет файла: " + e.getFile());
}
}
} Вывод (Linux, macOS; в Windows разделитель — \):
Уже есть: sandbox/a-backup.txt
Перемещён в sandbox/archive.txt
Не пустой: sandbox/in
deleteIfExists: false
Нет файла: sandbox/nothing.txt Чтобы скопировать файл в Java, хватает одной строки Files.copy(src, target, REPLACE_EXISTING) — без ручного цикла чтения и записи, который разобран в уроке Типы байтовых потоков. У Files.copy() есть и перегрузки для потоков: copy(InputStream, Path) сохраняет поток в файл, copy(Path, OutputStream) — выводит файл в поток.
Обход каталога: list, walk, find, newDirectoryStream
Содержимое каталога возвращают четыре метода: Files.list() — только непосредственные элементы, Files.walk() — всё дерево рекурсивно, Files.find() — дерево с фильтром по пути и атрибутам, Files.newDirectoryStream() — элементы каталога с фильтром по glob-шаблону. Первые три возвращают Stream<Path>, их закрывают через try-with-resources.
| Метод | Глубина | Результат | Нюанс |
|---|---|---|---|
list(dir) | 1 уровень | Stream<Path> | Сам dir не входит; не каталог — NotDirectoryException |
walk(dir), walk(dir, maxDepth) | Всё дерево или до maxDepth | Stream<Path> | Первым элементом идёт сам dir; каталог всегда раньше своего содержимого |
find(dir, maxDepth, (path, attrs) -> ...) | До maxDepth | Stream<Path> | Атрибуты уже прочитаны — не нужен лишний вызов Files.isRegularFile() |
newDirectoryStream(dir, "*.java") | 1 уровень | DirectoryStream<Path> (Iterable) | Фильтр glob: *, ?, {txt,java}; перебирается циклом for-each |
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
public class WalkDemo {
public static void main(String[] args) throws IOException {
Path root = Path.of("tree");
Files.createDirectories(root.resolve("sub"));
Files.writeString(root.resolve("a.txt"), "A");
Files.writeString(root.resolve("sub/b.java"), "class B {}");
Files.writeString(root.resolve("sub/c.txt"), "C");
System.out.println("list:");
try (Stream<Path> s = Files.list(root)) {
s.sorted().forEach(System.out::println); // порядок list() не гарантирован
}
System.out.println("walk:");
try (Stream<Path> s = Files.walk(root)) {
s.sorted().forEach(System.out::println);
}
System.out.println("find *.txt:");
try (Stream<Path> s = Files.find(root, Integer.MAX_VALUE,
(p, attrs) -> attrs.isRegularFile() && p.getFileName().toString().endsWith(".txt"))) {
s.sorted().forEach(System.out::println);
}
try (Stream<Path> s = Files.walk(root)) {
System.out.println("endsWith(\".txt\"): " + s.filter(p -> p.endsWith(".txt")).count());
}
List<String> names = new ArrayList<>();
try (DirectoryStream<Path> ds = Files.newDirectoryStream(root.resolve("sub"), "*.{java,kt}")) {
for (Path p : ds) {
names.add(p.getFileName().toString());
}
}
System.out.println("glob: " + names);
}
} Вывод (Linux, macOS; в Windows разделитель — \):
list:
tree/a.txt
tree/sub
walk:
tree
tree/a.txt
tree/sub
tree/sub/b.java
tree/sub/c.txt
find *.txt:
tree/a.txt
tree/sub/c.txt
endsWith(".txt"): 0
glob: [b.java] Строка endsWith(".txt"): 0 — та самая ошибка из начала урока: find с проверкой имени через строку нашёл два файла, а фильтр с Path.endsWith() — ни одного. Если нужно только вывести содержимое одного каталога с пометкой «файл / каталог», хватает Files.list() и Files.isDirectory(p). По символическим ссылкам walk() и find() по умолчанию не переходят; чтобы переходили, передайте FileVisitOption.FOLLOW_LINKS. Отслеживание изменений в каталоге (WatchService), каналы и асинхронный ввод-вывод — отдельные темы NIO, в этом уроке они не разбираются.
Рекурсивное удаление каталога
Готового метода «удалить каталог со всем содержимым» в Files нет: Files.delete() удаляет только пустой каталог. Стандартный приём — получить всё дерево через Files.walk(), отсортировать пути в обратном порядке, чтобы вложенные элементы шли раньше своих каталогов, и удалить их по одному.
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.stream.Stream;
public class DeleteTreeDemo {
static void deleteTree(Path root, Comparator<Path> order) throws IOException {
try (Stream<Path> s = Files.walk(root)) {
s.sorted(order).forEach(p -> {
try {
Files.delete(p);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
}
static void createTree(Path root) throws IOException {
Files.createDirectories(root.resolve("sub/deep"));
Files.writeString(root.resolve("a.txt"), "A");
Files.writeString(root.resolve("sub/deep/b.txt"), "B");
}
public static void main(String[] args) throws IOException {
Path root = Path.of("trash");
createTree(root);
try {
deleteTree(root, Comparator.naturalOrder()); // каталог раньше содержимого
} catch (UncheckedIOException e) {
System.out.println("Ошибка: " + e.getCause().getClass().getSimpleName());
}
System.out.println("Каталог есть: " + Files.exists(root));
deleteTree(root, Comparator.reverseOrder()); // содержимое раньше каталога
System.out.println("Каталог есть: " + Files.exists(root));
}
} Ошибка: DirectoryNotEmptyException
Каталог есть: true
Каталог есть: false В естественном порядке первым идёт сам trash, и первая же попытка удаления падает. Comparator.reverseOrder() ставит trash/sub/deep/b.txt раньше trash/sub/deep, а его — раньше trash/sub, поэтому каждый каталог к моменту удаления уже пуст. Лямбда в forEach не может бросить проверяемое IOException, поэтому оно завёрнуто в UncheckedIOException. Для очень больших деревьев без сортировки в памяти используют Files.walkFileTree() с SimpleFileVisitor: каталог удаляется в методе postVisitDirectory(), после всех его файлов.
Атрибуты и временные файлы
Время последнего изменения возвращает Files.getLastModifiedTime(path) в виде FileTime, а все основные атрибуты за одно обращение к диску — Files.readAttributes(path, BasicFileAttributes.class): размер, время создания и изменения, тип. Временный файл создаёт Files.createTempFile(prefix, suffix) в системном каталоге java.io.tmpdir с уникальным именем.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
public class AttributesDemo {
public static void main(String[] args) throws IOException {
Path tmp = Files.createTempFile("report-", ".txt");
try {
Files.writeString(tmp, "12345");
String name = tmp.getFileName().toString();
System.out.println("Имя по шаблону: " + (name.startsWith("report-") && name.endsWith(".txt")));
BasicFileAttributes attrs = Files.readAttributes(tmp, BasicFileAttributes.class);
System.out.println("size: " + attrs.size());
System.out.println("isRegularFile: " + attrs.isRegularFile());
System.out.println("isDirectory: " + attrs.isDirectory());
Files.setLastModifiedTime(tmp, FileTime.fromMillis(0));
System.out.println("lastModified: " + Files.getLastModifiedTime(tmp));
} finally {
Files.deleteIfExists(tmp);
}
System.out.println("Удалён: " + Files.notExists(tmp));
}
} Имя по шаблону: true
size: 5
isRegularFile: true
isDirectory: false
lastModified: 1970-01-01T00:00:00Z
Удалён: true Полное имя временного файла случайное, например /tmp/report-8371264519023847.txt в Linux или C:\Users\anna\AppData\Local\Temp\report-8371264519023847.txt в Windows, поэтому программа печатает только проверку шаблона. Временный каталог создаёт Files.createTempDirectory(prefix). Время создания (attrs.creationTime()) хранят не все файловые системы: там, где его нет, возвращается время последнего изменения.
Временный файл не исчезнет сам
Слово «временный» относится только к месту и имени: createTempFile() не удаляет файл ни при выходе из метода, ни при завершении программы. Удаляйте его явно в finally, как в примере, открывайте с опцией DELETE_ON_CLOSE или вызовите tmp.toFile().deleteOnExit() — иначе сервер, создающий тысячи таких файлов, однажды заполнит каталог /tmp.
Типичные промахи с Path и Files
Большинство ошибок с NIO.2 возникают из-за того, что методы Path сравнивают элементы пути, а не строки, из-за опций открытия по умолчанию и из-за потоков Stream<Path>, которые нужно закрывать. Ниже — что происходит в каждом случае и как исправить.
| Ошибка | Что происходит | Как правильно |
|---|---|---|
path.endsWith(".txt") для проверки расширения | Всегда false: сравнивается последний элемент пути целиком | path.getFileName().toString().endsWith(".txt") |
Path.of(dir + "/" + name) | Лишние или двойные разделители, путь собирается вручную | dir.resolve(name) |
base.resolve(userInput) без проверки | Абсолютный путь или ../.. во вводе выводят за пределы base | normalize() и проверка startsWith(base), см. FAQ |
Files.writeString(p, s, APPEND) для нового файла | NoSuchFileException: без CREATE файл не создаётся | Files.writeString(p, s, CREATE, APPEND) |
Files.readAllLines() для лога на несколько гигабайт | OutOfMemoryError: весь файл загружается в память | Files.lines() или newBufferedReader() в try-with-resources |
Files.readString() для файла в windows-1251 | MalformedInputException: Input length = 1 | Указать кодировку: Files.readString(p, Charset.forName("windows-1251")) |
Files.copy(dir, target) для каталога с файлами | Создаётся пустой каталог, содержимое не копируется | Обойти дерево через Files.walk() и копировать каждый файл |
walk(root).sorted().forEach(...) с Files.delete() | DirectoryNotEmptyException на первом же каталоге | sorted(Comparator.reverseOrder()) |
Files.list(dir) или Files.walk(dir) без try-with-resources | Дескриптор каталога остаётся открытым до сборки мусора | try (Stream<Path> s = Files.list(dir)) { ... } |
p1.equals(p2) для проверки «один и тот же файл» | a/../b.txt и b.txt не равны; регистр букв учитывается по-разному в Windows и Linux | Files.isSameFile(p1, p2) или сравнение toRealPath() |
Часто задаваемые вопросы
Что использовать: Path.of() или Paths.get()?
В Java 11 и новее — Path.of(). Результат у методов одинаковый (Paths.get() внутри вызывает Path.of()), но в документации класса Paths прямо сказано, что он может быть объявлен устаревшим в будущих версиях. Paths.get() нужен только в проектах на Java 7–10 и встречается в старом коде и ответах на форумах.
Как не дать пользователю выйти за пределы каталога через имя файла (path traversal)?
Присоедините ввод к базовому каталогу, нормализуйте и проверьте префикс: Path base = Path.of("uploads").toAbsolutePath().normalize(); Path target = base.resolve(name).normalize(); if (!target.startsWith(base)) throw new SecurityException();. Для name = "../../etc/passwd" или абсолютного пути проверка не пройдёт. startsWith() здесь сравнивает элементы пути, поэтому каталог uploads2 не примет за uploads. Почему resolve() пропускает абсолютные пути — в разделе resolve, relativize и normalize.
Как узнать размер каталога в Java?
Files.size(dir) для каталога возвращает размер служебной записи каталога (например, 4096 байт в Linux), а не сумму файлов. Размер содержимого считают обходом дерева: Files.walk(dir) с фильтром обычных файлов и суммой их размеров, например через mapToLong(p -> p.toFile().length()). Поток walk() закрывайте через try-with-resources.
Почему Path.of(getClass().getResource("/data.txt").toURI()) падает после сборки в jar?
Внутри jar ресурс имеет URI со схемой jar:, и для неё по умолчанию нет открытой файловой системы — Path.of(uri) выбрасывает FileSystemNotFoundException. В IDE ресурс лежит обычным файлом, поэтому ошибка проявляется только после сборки. Ресурсы из classpath читайте как поток: getClass().getResourceAsStream("/data.txt"), а текст из него — через new String(in.readAllBytes(), StandardCharsets.UTF_8).
Чем Files.walk() отличается от Files.walkFileTree()?
Files.walk() (Java 8) возвращает ленивый Stream<Path> и удобен для поиска и фильтрации, но при ошибке доступа к любому подкаталогу весь обход обрывается с UncheckedIOException. Files.walkFileTree() (Java 7) вызывает методы FileVisitor до и после каждого каталога и для каждого файла: можно пропустить недоступный каталог (visitFileFailed), прекратить обход или выполнить действие после содержимого каталога — например, удалить его.
Комментарии