I/O ·
‹ Предыдущий Следующий ›
⏱ 5 минут чтения Обновлено: 2026-10-31

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), прекратить обход или выполнить действие после содержимого каталога — например, удалить его.

Комментарии

Зарегистрируйтесь или войдите, чтобы иметь возможность оставить комментарий.