null

Java NIO

Люди часто спрашивают меня, знаю ли я Java NIO...

Ответ прост - нет, не знаю. И попытаюсь поделиться с вами своим незнанием.

Что это такое?

Java NIO (aka New IO aka Non-blocking IO) - часть стандратной библиотеки Java, предназначенная для работы (вы не поверите) с вводом-выводм. Возникает закономерный вопрос: а зачем она нужна, если уже есть прекрасно работающий Java IO? В Java NIO значительно отличаются подходы по чтению и записи данных. В каждой статье о NIO вы увидите следующие тезисы. Java NIO:

  1. Неблокирующий
  2. Асинхронный
  3. Буфер-ориентированный

Разберём же этот набор странных терминов.

Неблокирующий

При работе с обычными потоками ввода-вывода вы обычно вызываете метод read (write) для получения очередной порции данных. Что же происходит с вашей программой в этот момент? Она останавливается до тех пор, пока эти данные не появятся в потоке. Традиционно операции ввода-вывода считаются долгими (на порядок дольше исполнения машинных инструкций) по ряду причин: чтение с жёсткого диска медленное, пропускная способность сетевого канала ограничена и т.п., потому программа начинает неэффективно использовать процессорное время. В этот момент операционная система может активировать другой поток исполнения, что тоже отнимает время.

Как же работает NIO? Он не ожидает появления данных в канале (см. далее), а получает уже имеющиеся в нём данные, а если их нет - не получает ничего, после этого программа может перейти к исполнению следующих инструкций. Это может дать определённые преимущества: так, в серверном приложении один поток может получать данные от нескольких клиентов по мере их готовности, а когда они будут получены - отправить на дальнейшую обработку.

Но откуда же берутся данные в канале? Тут самое время поговорить о следующем тезисе:

Асинхронный

В IO read/write иницирует процесс получения данных: например, чтение из файла на жёстком диске (опустим различные оптимизации на уровне ОС, кэширование и буферизацию).

Reader reader = ...;
// Иницируем чтение из потока и ждём появления даннх
int read = reader.read();
// Выполнится только после того, как данные были получены
System.out.println("Read: " + read);

В NIO же данные в канал поступают независимо от программы - достаточно связать канал с источником данных, и при их появлении они будут "кэшироваться" в нём. Операция чтения из канала просто получит уже имеющиеся в нём данные.

Буфер-ориентированный

Если в IO работа сданными происходит побайтово/посимвольно (или с массивами байт/символов), то NIO работает с буферами, которые можно воспринимать как высокоуровневые обёртки над массивом байт. В отличии от буферизации в том же BufferedInputStream'е (и прочих Buffered*), программисту даётся полный контроль над буфером: существует большое число методов по модификации его содержимого, навигации и т.д. Вкупе с асинхронностью это накладывает определённый уровень ответственности на программиста - он сам должен следить за тем, считалось ли достаточно данных для дальнейшей обработки.

Основные концепции

Буфер

В стандратной библиотеке буфер представлен абстрактным классом Buffer и множеством его наследников. Основное отличие наследников - тип данных, который они будут хранить: byte, int, long и другие примитивные типы данных. Потому работа буфера будет рассмотрена на примере класса ByteBuffer - все его ключевые особенности будут справедливы и для остальных классов.

Буфер можно считать ограниченной в размерах последовательностью данных одного типа с набором методов по манипуляции его содержимым. Отличительной особенностью этих методов является то, что они являются относительными - запись и чтение в буфер производится не в конкретное место, а относительно некоторого указателя; положение этого указателя изменяется с каждой операцией чтения/записи. Данный указатель называется позицией (position). Кроме позиции, ключевыми понятиямя для буфера являются ёмкость (capacity) и лимит (limit). Ёмкость - это просто размер "массива", хранящего данные в буфере - больше этого количества положить в него никак не получится. Лимит - это "индекс" во внутреннем массиве, дальше которого не может "зайти" позиция при чтении или записи.

Должно быть, все эти понятия могли вас запутать - попытаемся же разобраться с ними сперва на простых примерах.

Первым делом создадим буфер - это можно сделать с помощью метода allocate (про другие методы и их отличия расскажу позднее), который создаёт буфер указанного размера и заполняет его нулями. Создадим, напрмер, буфер ёмкостью в 5 байт.

ByteBuffer buffer = ByteBuffer.allocate(5);

Посмотрим, какие значения будут у всех трёх меток. Сделать это можно с помощью методов capacity, position и limit (position и limit также могут принимать аргумент - при желании эти метки можно установить вручную). Кроме того, перегрузка метода toString у буфера также даёт информацию об этих значениях.

int capacity = buffer.capacity();
int limit = buffer.limit();
int position = buffer.position();
System.out.printf("Capacity: %d, position: %d, limit: %d\n", capacity, position, limit);
System.out.println("Buffer: " + buffer);
Capacity: 5, position: 0, limit: 5
Buffer: java.nio.HeapByteBuffer[pos=0 lim=5 cap=5]

Для чтения из буфера предназначен метод get, с помощью которого можно считать из буфера один байт из места, на которое указывает маркер позиции (маркер при этом сместится на единицу), и множество его "коллег" (для чтения массива, а также всех примитивов, вроде char, double и т.д.). Также у большинства из них есть перегрузки, принимающие аргумент типа int (напремер, этот метод) - эти методы принимают позицию в буфере в качестве аргумента и при чтении не меняют маркер позиции.

Для записи существует метод put, и ситуация с ним аналогична методу get: запись происходит в место, на которое указывает position; маркер при этом перемещается; существует ряд аналогичных методов для работы с различными примитивными типами данных; существуют методы, не затрагивающие маркер позиции.

Посмотрим, как это будет работать на практике.

Для создания буфера на этот раз используем метод wrap - он позволяет создать буфер из уже существующего массива байт, что позволит сразу его заполнить некоторыми данными.

Данная программа

byte arr[] = {10, 20, 30, 40, 50};
ByteBuffer buffer = ByteBuffer.wrap(arr);
System.out.println("Initial buffer state: " + buffer);
System.out.println("First read from buffer: " + buffer.get());
System.out.println("Buffer state: " + buffer);
System.out.println("Second read from buffer: " + buffer.get());
System.out.println("Buffer state: " + buffer);
System.out.println("Read double (4 bytes): " + buffer.getDouble());

Выведет следующее

Initial buffer state: java.nio.HeapByteBuffer[pos=0 lim=5 cap=5]
First read from buffer: 10
Buffer state: java.nio.HeapByteBuffer[pos=1 lim=5 cap=5]
Second read from buffer: 20
Buffer state: java.nio.HeapByteBuffer[pos=2 lim=5 cap=5]
Exception in thread "main" java.nio.BufferUnderflowException
	at java.nio.Buffer.nextGetIndex(Buffer.java:506)
	at java.nio.HeapByteBuffer.getDouble(HeapByteBuffer.java:514)
	at ru.ifmo.channels.BufferTest.main(BufferTest.java:16)

Рассмотрим по шагам.

Изначально буфер был создан в следующем состоянии: позиция указывает на первый элемент массива, лимит и ёмкость - на последний, массив заполнен данными.

Первый вызов get вернул элемент, на который указывал position, и переместил указатель вперёд.

Ещё один вызов - ещё одно перемещение маркера вперёд.

И последняя попытка чтения double'а завершится ошибкой, ведь между position и limit нет 4 байтов, необходимых для получения double.

Аналогично будет работать и put. Такой код (метод array возвращает массив, хранящий данные буфера)

ByteBuffer buffer = ByteBuffer.allocate(5);
System.out.println("Buffer state: " + buffer + ", content: " + Arrays.toString(buffer.array()));
byte item = 10;
System.out.println("Put: " + item);
buffer.put(item);

System.out.println("Buffer state: " + buffer + ", content: " + Arrays.toString(buffer.array()));
item = 20;
System.out.println("Put: " + item);
buffer.put(item);

System.out.println("Buffer state: " + buffer + ", content: " + Arrays.toString(buffer.array()));
int intItem = 1000;
System.out.println("Put: " + intItem);
buffer.putInt(intItem);

выведет следующее

Buffer state: java.nio.HeapByteBuffer[pos=0 lim=5 cap=5], content: [0, 0, 0, 0, 0]
Put: 10
Buffer state: java.nio.HeapByteBuffer[pos=1 lim=5 cap=5], content: [10, 0, 0, 0, 0]
Put: 20
Buffer state: java.nio.HeapByteBuffer[pos=2 lim=5 cap=5], content: [10, 20, 0, 0, 0]
Put: 1000
Exception in thread "main" java.nio.BufferOverflowException
	at java.nio.Buffer.nextPutIndex(Buffer.java:527)
	at java.nio.HeapByteBuffer.putInt(HeapByteBuffer.java:372)
	at ru.ifmo.channels.BufferTest.main(BufferTest.java:23)

До записи

После первой записи

После второй записи

На третью запись в буфере не хватает места (int занимает 4 байта), что приводит к возникновению исключения.

Теперь стало более-менее понятно, как же работают отдельно операции чтения и записи. Но как же они будут работать вместе? Допустим, вы считали из канала что-то в буфер - как это теперь получить для дальнейшей обработки? Вызов метода get после put приведёт к недоразумению - он считает не то, что было записано только что, а то, что лежит в буфере дальше... Для дальнейшей работы нам понадобятся методы clear, flip и compact.

Метод clear очень простой - он лишь сбрасывает все метки в начальное состояние: postion - на первый элемент, limit - на последний (capacity).

Состояние до вызова clear

Состояние после вызова clear

Метод flip тоже весьма прост по своей сути: он устанавливает маркер limit на текущее значение position, после чего перемещает position на начало массива.

System.out.println("Buffer before flip: " + buffer);
//Buffer before flip: java.nio.HeapByteBuffer[pos=2 lim=5 cap=5]
buffer.flip();
System.out.println("Buffer after flip: " + buffer);
//Buffer after flip: java.nio.HeapByteBuffer[pos=0 lim=3 cap=5]

До вызова метода flip состояние буфера было следующим:

После вызова:

При всей простоте этого метода его назначение вряд ли будет очевидно сразу, потому стоит пояснить на примере.

Допустим, необходимо отправить данные в канал - перед этим их нужно поместить в буфер. В изначально пустой буфер

ByteBuffer buffer = ByteBuffer.allocate(5);

записывается порция данных

buffer.put(new byte[] {1, 2, 3});
System.out.println("Buffer state: " + buffer);
//Buffer state: java.nio.HeapByteBuffer[pos=3 lim=5 cap=5]

И теперь их предстоит отправить в канал. Так как канал при получении данных из буфера тоже будет использовать относительные методы get/put, то текущее состояние не подходит - в нём в канал будут отправлены последние 2 байта буфера, а не те, что записали ранее. Очевидно, что метку position следует переместить в начало буфера, чтобы чтение началось с него

а метку limit - на предыдущее место position, чтобы избежать считываания последних байт, являющихся "мусором".

Разумеется, подобную операцию можно осуществить вручную с помощью методов position/limit:

int oldPosition = buffer.position();
buffer.position(0)
        .limit(oldPosition);
System.out.println("New state: " + buffer);

Но, если внимательно прочитать описание произведённых манипуляций, можно обнаружить, что это именно то, что делает метод flip! Таким образом, достаточно лишь вызвать его.

buffer.flip();
System.out.println("After flip: " + buffer);
//After flip: java.nio.HeapByteBuffer[pos=0 lim=3 cap=5]

Суммаризируя, передача данных через буфер состоит из нескольких этапов:

  1. Подготовка данных: буфер очищается, в него записываются данные
  2. Вызывается метод flip чтобы подготовить буфер к чтению из него
  3. Данные считываются из буфера (например, каналом)

Уже этого должно быть достаточно для работы с буфером, но остаётся нерасмотренным ещё один вышеназванный метод - compact. Он перемещает данные между текущими маркерами position и limit в начало буфера, а сами маркеры помещает за пределы этих данных.

До вызова метода:

После:

Зачем это нужно? Допустим, нам требуется перенаправить данные из одного канала в другой. В силу асинхронной и неблокирующей природы работы каналов, нет никаких гарантий, что при записи в канал он возьмёт все подготовленные в буфере данных - он может взять лишь какую-то их часть (как на картинках).

При этом в данный момент времени в втором канале (источнике) уже могут появиться новые данные, готовые для записи в буфер и последующей пересылке. Что же делать? Если попытаться считать данные в текущий буфер, то потеряется их часть, ещё не отправленная в канал-приёмник. При этом нежелательно давать источнику "простаивать" и ждать, пока приёмник считает все данные. А ещё в буфере начинает заканчиваться место из-за "фрагментации" - в начале буфера есть данные, которые уже не будут использоваться, но и в силу механик работы буфера эта область использоваться уже не может. Тут и поможет compact, который высвободит место под новые данные.

После вызова метода можно записать новые данные

И затем вызвать метод flip для пересылки в приёмник

Основные моменты работы с буферами рассмотрены, остаётся последняя, ранее упомянутая, тема - выделение буфера. Для этого существует несколько методов:

  1. allocate
  2. wrap
  3. allocateDirect

Первые методы немного похожи друг на друга - созданные с их помощью буферы будут основываться на обычных массивах байт. Разница лишь в том, что в первом случае этот массив будет создан вместе с буфером, а во втором массив будет явным образом передан "снаружи" (соответственно, его можно будет предварительно заполнить данными). Следует понимать, что метод wrap не копирует массив, а использует именно его, так что все изменения содержимого массива отразятся и на буфере. Кроме того, к массиву буфера, созаднного этими методами, можно получить доступ с помощью метода array, и его также можно свободно модифицировать.

ByteBuffer buf = ByteBuffer.allocate(3);
System.out.println("Internal array: " + Arrays.toString(buf.array()));
//Internal array: [0, 0, 0]
buf.array()[0] = 10;
System.out.println("New internal array: " + Arrays.toString(buf.array()));
//New internal array: [10, 0, 0]
System.out.println("Read from buffer: " + buf.get());
//Read from buffer: 10

Остаётся метод allocateDirect. Он попытается выделить особый, системный буфер, оптимизированный под работу с вводом-выводом. Как результат, у него не будет никакого внутреннего массива, к которому можно получить доступ с помощью метода array, но зато он (в теории) должен эффективнее работать с асинхронными каналами.

 

Неожиданно для меня, данная статься становится уже слишком большой. Потому на данном моменте я закончу, а в следующей части рассмотрю работу с каналами и селекторами.

Засим откланиваюсь, прощайте.