Java и файлы: чтение, запись и обход каталогов через Files и Path

Java и файлы: чтение, запись и обход каталогов через Files и Path Полезное

В современной Java с файлами работают через пакет java.nio.file: Path описывает путь, а Files — набор статических методов, которые по этому пути читают, пишут, копируют, удаляют и обходят файлы. Старый класс java.io.File остался для совместимости, но в новом коде его обычно заменяют на Path + Files.

Ниже — рабочий пример целиком, затем чтение и запись, try-with-resources, обход каталогов, типовые исключения и безопасная работа с путями. Все примеры проверены на Java 25 (Temurin 25.0.4, Linux) 24.09.2026 запуском java Имя.java; большинство методов доступно с Java 11.

Четыре термина, которые путают

Термин Что это Чего не делает
Path Путь: строка вида data/notes.txt, разобранная на части Не открывает файл и не проверяет, что он существует
Files Утилитный класс с операциями над путями Не хранит состояние, объекты не создаются
Поток (InputStream, BufferedReader) Открытый файл, из которого читают по частям Сам не закрывается — нужен close()
java.io.File Старый класс пути и операций (до Java 7) Часто сообщает об ошибке через false без причины

Главное различие: Path.of("x.txt") ничего не делает с диском. Диск трогают только методы Files и открытые потоки.

Минимальный рабочий пример

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;

public class FilesDemo {
    public static void main(String[] args) throws IOException {
        Path dir = Path.of("data");
        Files.createDirectories(dir);                 // не падает, если каталог уже есть
        Path file = dir.resolve("notes.txt");         // data/notes.txt

        Files.writeString(file, "первая строка\n", StandardCharsets.UTF_8);
        Files.writeString(file, "вторая строка\n", StandardCharsets.UTF_8,
                StandardOpenOption.APPEND);           // дописать в конец

        List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
        System.out.println("Строк: " + lines.size());
        System.out.println("Первая: " + lines.get(0));
        System.out.println("Размер, байт: " + Files.size(file));
        System.out.println("Полный путь: " + file.toAbsolutePath());
    }
}

Вывод (полный путь зависит от каталога запуска):

Строк: 2
Первая: первая строка
Размер, байт: 52
Полный путь: /w/data/notes.txt

Что здесь происходит:

  • resolve склеивает путь с учетом разделителя ОС, поэтому file.separator вручную подставлять не нужно.
  • Первый writeString без опций создает файл или перезаписывает его целиком (по умолчанию действуют CREATE, TRUNCATE_EXISTING, WRITE). Поэтому повторный запуск снова дает 2 строки, а не 4.
  • APPEND дописывает в конец. 52 байта — это 28 символов: 24 кириллические буквы по 2 байта в UTF-8 плюс 2 пробела и 2 перевода строки по 1 байту.
  • Относительный путь data отсчитывается от рабочего каталога процесса, а не от папки с .java-файлом.

Чтение: какой метод выбрать

Задача Метод Граница
Небольшой текст целиком Files.readString(path) (Java 11+) Весь файл в памяти
Список строк Files.readAllLines(path) Весь файл в памяти
Большой файл построчно Files.newBufferedReader(path) или Files.lines(path) Нужно закрыть через try-with-resources
Бинарные данные Files.readAllBytes(path) / Files.newInputStream(path) Первый вариант — все в памяти

Методы Files без явной кодировки читают и пишут в UTF-8 независимо от настроек системы. У FileReader/FileWriter без аргумента кодировки иначе: там берется кодировка по умолчанию, которая стала UTF-8 только с Java 18. Надежнее всегда передавать StandardCharsets.UTF_8 явно.

try-with-resources: зачем закрывать файлы

readString и writeString открывают и закрывают файл сами. А newBufferedReader, newBufferedWriter, Files.lines, Files.walk и Files.list возвращают объект, который держит файл открытым, пока вы его не закроете. Конструкция try (...) закрывает ресурс при любом выходе из блока, в том числе по исключению.

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;

public class Big {
    public static void main(String[] args) throws IOException {
        Path log = Path.of("app.log");
        try (BufferedWriter w = Files.newBufferedWriter(log, StandardCharsets.UTF_8)) {
            for (int i = 1; i <= 100_000; i++) {
                w.write(i % 1000 == 0 ? "ERROR request " + i : "INFO request " + i);
                w.newLine();
            }
        }
        int errors = 0;
        try (BufferedReader r = Files.newBufferedReader(log, StandardCharsets.UTF_8)) {
            String line;
            while ((line = r.readLine()) != null) {
                if (line.startsWith("ERROR")) errors++;
            }
        }
        System.out.println("ERROR-строк: " + errors);
        System.out.println("Размер: " + Files.size(log) + " байт");
    }
}
ERROR-строк: 100
Размер: 1888995 байт

Файл почти в 2 МБ читается по строке, в памяти одновременно одна строка. Тот же подсчет короче через поток: try (Stream<String> s = Files.lines(log)) { s.filter(l -> l.startsWith("ERROR")).count(); } — и здесь try тоже обязателен.

Без закрытия ошибка проявляется не сразу. При сотнях незакрытых файлов процесс упирается в лимит дескрипторов ОС, в Windows открытый файл обычно нельзя удалить или переместить, а у BufferedWriter без close() или flush() хвост данных может так и не попасть на диск.

Обход каталогов: list и walk

Files.list отдает только первый уровень каталога, Files.walk — все вложенные файлы и папки рекурсивно. Оба возвращают Stream<Path>, который нужно закрыть.

import java.io.IOException;
import java.nio.file.*;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;

public class WalkDemo {
    public static void main(String[] args) throws IOException {
        Path root = Path.of("project");
        Files.createDirectories(root.resolve("src/util"));
        Files.writeString(root.resolve("README.txt"), "readme");
        Files.writeString(root.resolve("src/Main.java"), "class Main {}");
        Files.writeString(root.resolve("src/util/Helper.java"), "class Helper {}");

        try (Stream<Path> top = Files.list(root)) {           // только первый уровень
            top.map(p -> p.getFileName().toString()).sorted()
               .forEach(n -> System.out.println("list: " + n));
        }

        try (Stream<Path> all = Files.walk(root)) {            // рекурсивно
            List<Path> javaFiles = all
                .filter(Files::isRegularFile)
                .filter(p -> p.toString().endsWith(".java"))
                .sorted()
                .toList();
            javaFiles.forEach(p -> System.out.println("walk: " + root.relativize(p)));
        }

        try (Stream<Path> all = Files.walk(root)) {            // удалить дерево: сначала файлы, потом каталоги
            for (Path p : all.sorted(Comparator.reverseOrder()).toList()) {
                Files.delete(p);
            }
        }
        System.out.println("exists: " + Files.exists(root));
    }
}
list: README.txt
list: src
walk: src/Main.java
walk: src/util/Helper.java
exists: false

Порядок элементов в list и walk файловая система не гарантирует, поэтому в примере стоит sorted(). Обратная сортировка при удалении ставит вложенные пути раньше родительских. Files.walk по умолчанию не заходит по символическим ссылкам в каталоги; с опцией FileVisitOption.FOLLOW_LINKS заходит. Метод toList() у потока появился в Java 16, на более старых версиях используйте collect(Collectors.toList()).

Копирование, перемещение, удаление

  • Files.copy(src, dst) падает, если dst существует; перезапись — опцией StandardCopyOption.REPLACE_EXISTING. Каталог копируется без содержимого, для дерева нужен обход.
  • Files.move(src, dst) переименовывает или переносит; опция ATOMIC_MOVE просит атомарную операцию и бросает AtomicMoveNotSupportedException, если ФС ее не умеет (например, перенос между разными дисками).
  • Files.delete бросает исключение, если файла нет или каталог не пуст; Files.deleteIfExists вернет false для отсутствующего файла.

Практический прием — атомарное сохранение: пишем во временный файл рядом с целью и заменяем цель одним move. Читатель файла видит либо старую, либо новую версию, но не наполовину записанную.

import java.io.IOException;
import java.nio.file.*;

public class AtomicSave {
    static void saveAtomically(Path target, String content) throws IOException {
        Path dir = target.toAbsolutePath().getParent();
        Path tmp = Files.createTempFile(dir, "save-", ".tmp"); // в том же каталоге, что и цель
        try {
            Files.writeString(tmp, content);
            Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE);
        } finally {
            Files.deleteIfExists(tmp);                        // если move не случился
        }
    }

    public static void main(String[] args) throws IOException {
        Path cfg = Path.of("settings.json");
        saveAtomically(cfg, "{\"theme\": \"dark\"}");
        System.out.println(Files.readString(cfg));             // {"theme": "dark"}
    }
}

Граница приема: при ATOMIC_MOVE поведение для уже существующей цели документация оставляет на усмотрение реализации. На Linux в одной ФС цель заменяется, на других системах это стоит проверить. createTempFile на POSIX-системах создает файл с правами rw------- (проверено в прогоне), то есть другие пользователи его не прочитают.

Типовые исключения: код -> результат -> исправление

В java.nio.file ошибки приходят как подклассы IOException с понятным именем. Прогон на Java 25:

Files.createFile(Path.of("once.txt"));   // второй вызов
Files.delete(Path.of("box"));            // в box есть подкаталог
Files.readString(Path.of("missing.txt"));
Files.readString(Path.of("win1251.txt")); // файл в кодировке windows-1251
java.nio.file.FileAlreadyExistsException: once.txt
java.nio.file.DirectoryNotEmptyException: box
java.nio.file.NoSuchFileException: missing.txt
java.nio.charset.MalformedInputException: Input length = 1

Исправления по порядку: для «создать файл, если его нет» — перехватить FileAlreadyExistsException (сам createFile атомарен) или писать через Files.writeString(path, text, StandardOpenOption.CREATE, StandardOpenOption.APPEND); проверка Files.exists перед созданием ненадежна, потому что между проверкой и созданием файл может появиться, а Files.createDirectories подходит только для каталогов и на существующий файл с тем же именем тоже бросает FileAlreadyExistsException; для каталога — удаление дерева обходом, как выше; для отсутствующего файла — вывести path.toAbsolutePath() и сверить рабочий каталог; для кодировки — указать ее явно: Files.readString(path, Charset.forName("windows-1251")) вернула в прогоне «Привет».

Если имя файла приходит от пользователя

Имя из формы или HTTP-запроса нельзя просто передать в resolve: строка ../../etc/passwd выведет путь за пределы рабочего каталога (path traversal). Минимальная проверка — нормализовать путь и убедиться, что он остался внутри базового каталога.

import java.io.IOException;
import java.nio.file.*;

public class SafePath {
    static Path resolveInside(Path base, String userName) throws IOException {
        Path root = base.toRealPath();                        // реальный путь базы без симлинков
        Path target = root.resolve(userName).normalize();     // убираем ./ и ../
        if (!target.startsWith(root)) {
            throw new SecurityException("Путь вне каталога загрузок: " + userName);
        }
        return target;
    }

    public static void main(String[] args) throws IOException {
        Path base = Files.createDirectories(Path.of("uploads"));
        System.out.println(base.toRealPath().relativize(resolveInside(base, "report.txt")));
        try {
            resolveInside(base, "../../etc/passwd");
        } catch (SecurityException e) {
            System.out.println(e.getMessage());
        }
    }
}
report.txt
Путь вне каталога загрузок: ../../etc/passwd

Это базовая проверка, а не полная защита. Пустое имя дает сам базовый каталог (он проходит проверку, поэтому пустую строку стоит отклонять отдельно), а символ NUL в имени приводит к InvalidPathException — его тоже нужно обрабатывать как отказ. Path.startsWith сравнивает элементы пути, а не строки, поэтому uploads2 не пройдет как «внутри uploads». Но симлинк, созданный внутри uploads и ведущий наружу, эта проверка не ловит: для уже существующих файлов дополнительно сравнивайте target.toRealPath() с базой. В рабочем сервисе надежнее вообще не использовать имя пользователя как имя файла: генерировать свое (например, UUID), хранить исходное имя в базе, ограничивать размер и держать каталог загрузок вне раздаваемых веб-сервером папок.

java.io.File и Files: что выбрать

Операция java.io.File java.nio.file.Files
Создать файл createNewFile() -> boolean createFile(path) -> исключение с причиной
Удалить delete() -> false без объяснения delete(path) -> NoSuchFileException, DirectoryNotEmptyException
Прочитать текст через FileReader + цикл readString, readAllLines, lines
Обход дерева рекурсия по listFiles() walk, find, walkFileTree
Атрибуты, права, симлинки почти нет getPosixFilePermissions, isSymbolicLink, readAttributes

Если библиотека требует File, переход в обе стороны — одна строка: file.toPath() и path.toFile().

Если не получилось

  • NoSuchFileException, хотя файл «рядом» — программа запущена из другого каталога. Напечатайте Path.of("").toAbsolutePath() и путь к файлу через toAbsolutePath().
  • Вместо текста «кракозябры» или MalformedInputException — файл не в UTF-8. Узнайте исходную кодировку и передайте ее в метод чтения.
  • Файл не удаляется на Windows — где-то остался открытый поток; оберните все new..., lines, walk, list в try-with-resources.
  • AccessDeniedException — нет прав у пользователя, от которого запущен процесс, или файл занят другим процессом.
  • Записанные данные пропали — вызван writeString/write без APPEND, и файл перезаписался.

Выводы

  • Path — только путь, Files — операции над ним; java.io.File нужен в основном для совместимости со старым кодом.
  • Для небольших файлов хватает readString/writeString, для больших — newBufferedReader/newBufferedWriter или Files.lines.
  • Все, что возвращает поток или Stream (lines, walk, list, new...), закрывайте через try-with-resources.
  • Кодировку передавайте явно, а режим записи выбирайте сознательно: по умолчанию файл перезаписывается.
  • Имя файла от пользователя нормализуйте и проверяйте, что путь остался внутри базового каталога.

Где применяется / связь с практикой

Освойте тему на практике

Работа с файлами встречается почти в любом Java-проекте: чтение конфигураций и CSV, логи, выгрузки отчетов, загрузка файлов на сервер, тестовые данные во временных каталогах. Эти задачи опираются на базу языка — исключения, коллекции, Stream API и работу с ресурсами. Системно пройти ее с практикой и ревью кода можно на курсе «Java разработчик. Базовый уровень», а отдельные темы разобрать на открытых уроках Otus.

FAQ

Чем Path.of отличается от Paths.get?
Результат одинаковый. Path.of появился в Java 11, Paths.get — более старый вариант с Java 7; в новом коде обычно пишут Path.of.

Как прочитать файл из ресурсов внутри jar?
Файл внутри jar — не файл на диске, поэтому Path.of к нему не подходит. Используйте getClass().getResourceAsStream("/name.txt") в try-with-resources.

Нужно ли проверять Files.exists перед чтением?
Обычно нет: между проверкой и чтением файл может исчезнуть. Надежнее сразу читать и обрабатывать NoSuchFileException.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)