Обзор Java NIO
Введение в Java NIO и NIO.2 — каналы, буферы, селекторы и пакет java.nio.file.
Пятнадцать предыдущих глав были посвящены java.io — потокам, Reader/Writer, File, Serializable. Это оригинальный I/O-API Java, который по сей день широко используется. NIO — семейство API, добавленных в Java позже, чтобы покрыть то, чего не хватало в java.io. Оно состоит из двух частей, объединённых общим префиксом пакета, но мало чем ещё:
- NIO (Java 1.4, 2002) —
java.nio.*— каналы, буферы, селекторы. Другой подход к I/O: ориентированный на байтовые буферы, опционально неблокирующий, разработанный для высокопроизводительных серверов. - NIO.2 (Java 7, 2011) —
java.nio.file.*— классыPath,Files,FileSystemиWatchService. Более удобная заменаjava.io.Fileи место для возможностей файловой системы, которыхjava.ioникогда не имел: символические ссылки, расширенные атрибуты, асинхронный файловый I/O, слежение за директориями.
Вы уже используете элементы NIO.2 с самого начала этой части: Path, Files.newBufferedReader, Files.newInputStream — всё это java.nio.file. Эта глава делает шаг назад и показывает, как все эти части соотносятся друг с другом, и для чего предназначена остальная часть пакета.
Поток против канала: две разных формы
InputStream.read() возвращает один байт. OutputStream.write(int) записывает один байт. Концептуальная модель — поэтапная передача по одному байту. Буферизующие декораторы ускоряют её, но абстракция остаётся последовательной и однонаправленной.
Канал (java.nio.channels.Channel) — двунаправленный, ориентированный на байтовые буферы, и поддерживает операции, которые InputStream не умеет выражать:
- Чтение в
ByteBufferи запись из него — не изbyte[]. - Отображение в память области файла в RAM и чтение/запись её как буфера.
- Scatter-чтение в несколько буферов (заголовок — в один, полезная нагрузка — в другой).
- Gather-запись из нескольких буферов (один
write()создаёт непрерывный вывод). - Пометить канал неблокирующим и позволить
Selectorмультиплексировать тысячи каналов в одном потоке.
Плата — многословность. Код с каналами читает и пишет через ByteBuffer с явными вызовами flip() и position(); java.io скрывает всё это за read(byte[]). Для типичного чтения файлов предпочтительнее API java.io/Files. Опускайтесь до каналов только тогда, когда вам нужна одна из их эксклюзивных возможностей.
// channel-shaped read into a 1 KB buffer
try (FileChannel ch = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buf = ByteBuffer.allocate(1024);
int n = ch.read(buf); // fills the buffer; updates position
buf.flip(); // switch to "read what was just written"
while (buf.hasRemaining()) {
process(buf.get());
}
}Шаг flip() — это тот момент, когда люди узнают, что у ByteBuffer есть собственный небольшой конечный автомат.
ByteBuffer: position, limit, capacity
ByteBuffer — это byte[] фиксированного размера (или фрагмент внеградуированной памяти) плюс три индекса:
position— следующий байт для чтения или записи.limit— индекс за последним байтом, который разрешено трогать.capacity— фиксированный размер буфера; не меняется.
0 ─────── position ─────── limit ─────── capacity
(consumed) (active region) (untouchable / empty)По соглашению буфер находится в одном из двух режимов:
- Режим записи (по умолчанию): вы помещаете в него байты через
put(byte).positionпродвигается;limit == capacity. - Режим чтения: вы извлекаете байты через
get().positionпродвигается;limit— там, где вы остановили запись.
flip() переключает из режима записи в режим чтения: устанавливает limit = position (отмечает конец данных) и сбрасывает position = 0 (начинает чтение с начала). clear() переключает обратно в режим записи (position = 0, limit = capacity). Ошибки здесь — самый частый источник фрустрации «я прочитал ноль байт; почему?».
Внеградуированные буферы (ByteBuffer.allocateDirect(n)) обходят кучу JVM и позволяют ОС читать/писать их напрямую без лишнего копирования. Они медленнее при выделении, быстрее при I/O, и являются правильным выбором только для кода на горячем пути I/O.
Селекторы: один поток, много каналов
До виртуальных потоков (Java 21), обслуживание тысяч одновременных сетевых соединений в Java означало либо тысячи потоков ОС (по одному на соединение — дорого), либо один поток, мультиплексирующий с помощью Selector:
Selector sel = Selector.open();
serverChannel.register(sel, SelectionKey.OP_ACCEPT);
while (true) {
sel.select(); // blocks until any channel is ready
for (SelectionKey k : sel.selectedKeys()) {
if (k.isAcceptable()) accept(k);
if (k.isReadable()) read(k);
}
}ОС уведомляет JVM, когда любой зарегистрированный канал может продвинуться; JVM передаёт вам готовый набор; вы выполняете неблокирующее чтение или запись и возвращаетесь к select(). Фреймворковый код Netty, gRPC и Spring WebFlux имеет именно такую форму.
С виртуальными потоками (Thread.startVirtualThread(...)), более простой паттерн «один поток на запрос» масштабируется до такого же количества соединений без хореографии Selector — виртуальные потоки приостанавливаются на блокирующем I/O практически бесплатно. Для нового кода приложений на Java 21+, цикл селектора всё чаще становится заботой библиотеки; вы обычно не пишете его вручную. Для библиотечного кода и JVM до Loom это стандартный паттерн.
java.nio.file: современный API для работы с файлами
Это та половина NIO, с которой вы будете работать в повседневном коде. Она заменяет java.io.File и большинство связанных с файлами частей java.io:
java.io | java.nio.file | Причина замены |
|---|---|---|
File | Path | Неизменяемый, OS-агностичный, без встроенных методов I/O |
File.list() | Files.list(Path), Files.walk(Path) | Stream<Path>; закрываемый; учитывает символические ссылки |
new FileInputStream(...) | Files.newInputStream(path) | Варианты с учётом кодировки для текста; единый последовательный API открытия |
file.delete(), возвращающий false при сбое | Files.delete(path), бросающий IOException | Сбои видимы, а не молчаливы |
| нет эквивалента | Files.walkFileTree, WatchService, API символических ссылок, представления атрибутов файлов | Возможности, которых java.io никогда не имел |
Две следующие главы подробно рассматривают Path и Files. Практическое правило: для работы с файлами в Java 2024+ используйте java.nio.file. java.io.File всё ещё существует, потому что его использует старый код, но новый код должен по умолчанию выбирать Path.
Практический пример: круговой обход файла через канал и буфер
Программа ниже копирует небольшой текстовый файл с помощью каналов и буферов, чтобы сделать position/limit/flip конкретными. Она открывает источник как FileChannel, читает в ByteBuffer, вызывает flip, пишет в FileChannel-назначение и выводит состояние буфера на каждом шаге, чтобы вы могли видеть, как двигаются индексы.
Что можно вынести из запуска:
- Цикл печатал состояние буфера на каждом шаге. После
read(),positionбыл равен числу прочитанных байт, аlimitпо-прежнему равенcapacity— это «режим записи»: место в конце есть. Послеflip(),position = 0, аlimit = только что прочитанному числу— это «режим чтения»: байты находятся между 0 иlimit. Два индекса кодируют «где живут данные» без их копирования. - Буфер был 16 байт; файл — 44. Цикл выполнился три итерации: 16, 16, 12. Как только буфер опустел (после того как
writeслил его),clear()сбросил его обратно в «режим записи», чтобы следующийread()мог заново заполнить его. Это паттерн каналов в миниатюре: заполнить, перевернуть, слить, очистить, повторить. transferToвыполнил то же копирование в одну строку без какого-либоByteBuffer. На Linux это отображается в единственный системный вызовsendfile()— байты перемещаются между ядрами без пересечения JVM. Когда вы перемещаете данные между двумя каналами и не нужно их смотреть, это правильный инструмент.- Обратите внимание, что исходный файл был создан через
Files.writeString, а назначение прочитано обратно черезFiles.readString— оба являются однострочникамиjava.nio.file, которые полностью скрывают каналы и буферы. Подробный цикл с каналами посередине — это то, что вы пишете только тогда, когда вам нужен прямой доступ к буферу (пользовательский бинарный разбор, отображение памяти, scatter/gather). Для «копирования файла»transferToилиFiles.copyкороче и по меньшей мере так же быстро. - Конструктор
FileChannel.open(path, OPTION)является аналогомFiles.newInputStream(path). ПеречислениеStandardOpenOption(READ, WRITE, CREATE, APPEND, TRUNCATE_EXISTING, ...) управляет поведением открытия — есть только одно место для поиска. Это перечисление параметров открытия постоянно встречается в следующей главе.
Что дальше
Эта глава перечислила элементы — каналы, буферы, селекторы, java.nio.file. Следующая глава, Класс Path в Java, подробно рассматривает наиболее удобный из этих элементов — Path — и методы (resolve, relativize, normalize), которые вы будете использовать каждый раз при работе с путями файловой системы.