Перейти к основному содержимому

Руководство по ByteBuffer

· 14 мин. чтения

1. Обзор

Классы Buffer — это основа, на которой построен Java NIO. Однако в этих классах наиболее предпочтительным является класс ByteBuffer . Это потому, что тип byte является наиболее универсальным. Например, мы можем использовать байты для составления других небулевых примитивных типов в JVM. Кроме того, мы можем использовать байты для передачи данных между JVM и внешними устройствами ввода-вывода.

В этом руководстве мы рассмотрим различные аспекты класса ByteBuffer .

2. Создание байтового буфера

ByteBuffer — это абстрактный класс, поэтому мы не можем создать новый экземпляр напрямую. Однако он предоставляет статические фабричные методы для облегчения создания экземпляра. Вкратце, есть два способа создать экземпляр ByteBuffer , путем выделения или переноса:

./a45398571745c05cfcfae07d6b40c538.png

2.1. Распределение

Распределение создаст экземпляр и выделит личное пространство с определенной емкостью . Если быть точным, класс ByteBuffer имеет два метода выделения: allocate и allocateDirect .

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

ByteBuffer buffer = ByteBuffer.allocate(10);

Когда мы используем метод allocateDirect , он создаст прямой буфер:

ByteBuffer buffer = ByteBuffer.allocateDirect(10);

Для простоты давайте сосредоточимся на непрямом буфере и оставим обсуждение прямого буфера на потом.

2.2. Оберточная бумага

Обертывание позволяет экземпляру повторно использовать существующий массив байтов :

byte[] bytes = new byte[10];
ByteBuffer buffer = ByteBuffer.wrap(bytes);

И приведенный выше код эквивалентен:

ByteBuffer buffer = ByteBuffer.wrap(bytes, 0, bytes.length);

Любые изменения, внесенные в элементы данных в существующем массиве байтов , будут отражены в экземпляре буфера, и наоборот.

2.3. Луковая модель

Теперь мы знаем, как получить экземпляр ByteBuffer . Далее давайте рассмотрим класс ByteBuffer как трехуровневую луковичную модель и рассмотрим ее слой за слоем изнутри:

  • Уровень данных и индексов
  • Передача уровня данных
  • Просмотр слоя

./2076b8575dd8122a6ff23d7588aa1fde.png

На самом внутреннем уровне мы рассматриваем класс ByteBuffer как контейнер для массива байтов с дополнительными индексами. На среднем уровне мы фокусируемся на использовании экземпляра ByteBuffer для передачи данных из/в другие типы данных. Мы проверяем одни и те же базовые данные с помощью разных представлений на основе буфера на самом внешнем уровне.

3. Индексы байтового буфера

Концептуально класс ByteBuffer представляет собой массив байтов , заключенный в объект. Он предоставляет множество удобных методов для облегчения операций чтения или записи из/в базовые данные. И эти методы сильно зависят от поддерживаемых индексов.

Теперь давайте намеренно упростим класс ByteBuffer до контейнера массива байтов с дополнительными индексами:

ByteBuffer = byte array + index

Имея в виду эту концепцию, мы можем классифицировать связанные с индексами методы на четыре категории:

  • Базовый
  • Отметить и сбросить
  • Очистить, перевернуть, перемотать и сжать
  • Оставаться

./e78a3a7111feccec0f7154524535e1fa.png

3.1. Четыре основных индекса

В классе Buffer определены четыре индекса . Эти индексы записывают состояние базовых элементов данных:

  • Емкость: максимальное количество элементов данных, которые может хранить буфер.
  • Ограничение: индекс для остановки чтения или записи
  • Позиция: текущий индекс для чтения или записи
  • Метка: запомненная позиция

Также между этими индексами существует инвариантная связь:

0 <= mark <= position <= limit <= capacity

И мы должны отметить, что все методы, связанные с индексами, вращаются вокруг этих четырех индексов .

Когда мы создаем новый экземпляр ByteBuffer , метка не определена, позиция равна 0, а предел равен емкости . Например, давайте выделим ByteBuffer с 10 элементами данных:

ByteBuffer buffer = ByteBuffer.allocate(10);

Или давайте обернем существующий массив байтов 10 элементами данных:

byte[] bytes = new byte[10];
ByteBuffer buffer = ByteBuffer.wrap(bytes);

В результате метка будет -1, позиция будет 0, а лимит и вместимость будут равны 10:

int position = buffer.position(); // 0
int limit = buffer.limit(); // 10
int capacity = buffer.capacity(); // 10

Емкость доступна только для чтения и не может быть изменена. Но мы можем использовать методы position(int) и limit(int) для изменения соответствующих position и limit :

buffer.position(2);
buffer.limit(5);

Тогда позиция будет 2, а лимит 5.

3.2. Отметить и сбросить

Методы mark() и reset() позволяют нам запомнить определенную позицию и вернуться к ней позже.

Когда мы впервые создаем экземпляр ByteBuffer , метка не определена. Затем мы можем вызвать метод mark() , и метка будет установлена в текущую позицию. После некоторых операций вызов метода reset() изменит позицию обратно на метку .

ByteBuffer buffer = ByteBuffer.allocate(10); // mark = -1, position = 0
buffer.position(2); // mark = -1, position = 2
buffer.mark(); // mark = 2, position = 2
buffer.position(5); // mark = 2, position = 5
buffer.reset(); // mark = 2, position = 2

Следует отметить одну вещь: если метка не определена, вызов метода reset() приведет к InvalidMarkException .

3.3. Очистить, перевернуть, перемотать и сжать

Методы clear() , flip() , rewind() и compact() имеют некоторые общие черты и небольшие различия:

./e6b51353ec4438d914e1d126a6fb25ef.png

Для сравнения этих методов подготовим фрагмент кода:

ByteBuffer buffer = ByteBuffer.allocate(10); // mark = -1, position = 0, limit = 10
buffer.position(2); // mark = -1, position = 2, limit = 10
buffer.mark(); // mark = 2, position = 2, limit = 10
buffer.position(5); // mark = 2, position = 5, limit = 10
buffer.limit(8); // mark = 2, position = 5, limit = 8

Метод clear() изменит ограничение на емкость , позицию на 0 и отметку на -1:

buffer.clear();                              // mark = -1, position = 0, limit = 10

Метод flip() изменит ограничение на position , position на 0 и mark на -1:

buffer.flip();                               // mark = -1, position = 0, limit = 5

Метод rewind() сохраняет лимит неизменным и изменяет позицию на 0, а метку на -1:

buffer.rewind();                             // mark = -1, position = 0, limit = 8

Метод compact() изменит ограничение на вместимость , позицию на оставшуюся ( limit – position ) и отметку на -1:

buffer.compact();                            // mark = -1, position = 3, limit = 10

Вышеупомянутые четыре метода имеют свои варианты использования:

  • Для повторного использования буфера удобен метод clear() . Он установит индексы в начальное состояние и будет готов к новым операциям записи.
  • После вызова метода flip() экземпляр буфера переключается из режима записи в режим чтения. Но нам следует избегать двойного вызова метода flip() . Это связано с тем, что второй вызов установит предел равным 0, и никакие элементы данных не смогут быть прочитаны.
  • Если мы хотим прочитать базовые данные более одного раза, нам пригодится метод rewind() .
  • Метод compact() подходит для частичного повторного использования буфера. Например, предположим, что мы хотим прочитать некоторые, но не все базовые данные, а затем хотим записать данные в буфер. Метод compact() скопирует непрочитанные данные в начало буфера и изменит индексы буфера, чтобы они были готовы к операциям записи.

3.4. Оставаться

Методы hasRemaining() и rest( ) вычисляют отношение лимита и позиции :

./3ddf4c68906d83d8519389df20330c45.png

Когда лимит больше позиции , функция hasRemaining() вернет true . Кроме того, метод rest() возвращает разницу между лимитом и позицией .

Например, если буфер имеет позицию 2 и предел 8, то его остаток будет равен 6:

ByteBuffer buffer = ByteBuffer.allocate(10); // mark = -1, position = 0, limit = 10
buffer.position(2); // mark = -1, position = 2, limit = 10
buffer.limit(8); // mark = -1, position = 2, limit = 8
boolean flag = buffer.hasRemaining(); // true
int remaining = buffer.remaining(); // 6

4. Передача данных

Второй уровень луковой модели связан с передачей данных. В частности, класс ByteBuffer предоставляет методы для передачи данных из/в другие типы данных ( byte , char , short , int , long , float и double ):

./d6f27f040427550f909a0e07cbbe05bc.png

4.1. Передача байтовых данных

Для передачи байтовых данных класс ByteBuffer предоставляет одиночные и массовые операции.

Мы можем читать или записывать один байт из/в базовые данные буфера за одну операцию. Эти операции включают в себя:

public abstract byte get();
public abstract ByteBuffer put(byte b);
public abstract byte get(int index);
public abstract ByteBuffer put(int index, byte b);

Мы можем заметить две версии методов get() / put() из приведенных выше методов: одна не имеет параметров, а другая принимает индекс . Итак, какая разница?

Операция без индекса — это относительная операция, которая работает с элементом данных в текущей позиции, а затем увеличивает позицию на 1. Однако операция с индексом — это целая операция, которая работает с элементами данных в индексе и не изменит положение .

Напротив, массовые операции могут считывать или записывать несколько байтов из/в базовые данные буфера. Эти операции включают в себя:

public ByteBuffer get(byte[] dst);
public ByteBuffer get(byte[] dst, int offset, int length);
public ByteBuffer put(byte[] src);
public ByteBuffer put(byte[] src, int offset, int length);

Все вышеперечисленные методы относятся к относительным операциям. То есть они будут читать или записывать из/в текущую позицию и изменять значение позиции соответственно.

Также есть еще один метод put() , который принимает параметр ByteBuffer :

public ByteBuffer put(ByteBuffer src);

4.2. Передача данных _

Помимо чтения или записи байтовых данных, класс ByteBuffer также поддерживает другие примитивные типы, кроме логического типа. Возьмем в качестве примера тип int . К родственным методам относятся:

public abstract int getInt();
public abstract ByteBuffer putInt(int value);
public abstract int getInt(int index);
public abstract ByteBuffer putInt(int index, int value);

Точно так же методы getInt() и putInt() с параметром index являются абсолютными операциями, в противном случае относительными операциями.

5. Различные взгляды

Третий уровень луковой модели предназначен для чтения одних и тех же базовых данных с разных точек зрения .

./6e754cb57785bcc62e8554a3fe96d216.png

Каждый метод на изображении выше будет генерировать новое представление, которое использует те же базовые данные, что и исходный буфер. Чтобы понять новый взгляд, нас должны волновать две проблемы:

  • Как новое представление будет анализировать базовые данные?
  • Как новое представление будет записывать свои индексы?

5.1. Представление байтового буфера

Чтобы прочитать экземпляр ByteBuffer как другое представление ByteBuffer , у него есть три метода: дубликат() , slice() и asReadOnlyBuffer() .

Давайте посмотрим на иллюстрацию этих различий:

ByteBuffer buffer = ByteBuffer.allocate(10); // mark = -1, position = 0, limit = 10, capacity = 10
buffer.position(2); // mark = -1, position = 2, limit = 10, capacity = 10
buffer.mark(); // mark = 2, position = 2, limit = 10, capacity = 10
buffer.position(5); // mark = 2, position = 5, limit = 10, capacity = 10
buffer.limit(8); // mark = 2, position = 5, limit = 8, capacity = 10

Метод дубликат() создает новый экземпляр ByteBuffer , точно такой же, как исходный. Но каждый из двух буферов будет иметь свой независимый limit , position и mark :

ByteBuffer view = buffer.duplicate();        // mark = 2,  position = 5, limit = 8,  capacity = 10

Метод slice() создает общее подпредставление базовых данных. Позиция представления будет равна 0, а его предел и емкость будут равны оставшейся части исходного буфера :

ByteBuffer view = buffer.slice();            // mark = -1, position = 0, limit = 3,  capacity = 3

По сравнению с методом дубликата() метод asReadOnlyBuffer() работает аналогично, но создает буфер только для чтения. Это означает, что мы не можем использовать это представление только для чтения для изменения базовых данных:

ByteBuffer view = buffer.asReadOnlyBuffer(); // mark = 2,  position = 5, limit = 8,  capacity = 10

5.2. Другой вид

ByteBuffer также предоставляет другие представления: asCharBuffer() , asShortBuffer() , asIntBuffer() , asLongBuffer() , asFloatBuffer() и asDoubleBuffer() . Эти методы аналогичны методу slice() , т. е. они обеспечивают представление в виде среза, соответствующее текущему положению и пределу базовых данных . Основное различие между ними заключается в интерпретации базовых данных в другие значения примитивного типа.

Вопросы, которые нас должны волновать:

  • Как интерпретировать базовые данные
  • С чего начать толкование
  • Сколько элементов будет представлено в новом сгенерированном представлении

Новое представление будет объединять несколько байтов в тип целевого примитива и запускать интерпретацию с текущей позиции исходного буфера. Новое представление будет иметь емкость, равную количеству оставшихся элементов в исходном буфере, деленному на количество байтов, составляющих примитивный тип представления. Любые оставшиеся байты в конце не будут видны в представлении.

Теперь возьмем asIntBuffer() в качестве примера:

byte[] bytes = new byte[]{
(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE, // CAFEBABE ---> cafebabe
(byte) 0xF0, (byte) 0x07, (byte) 0xBA, (byte) 0x11, // F007BA11 ---> football
(byte) 0x0F, (byte) 0xF1, (byte) 0xCE // 0FF1CE ---> office
};
ByteBuffer buffer = ByteBuffer.wrap(bytes);
IntBuffer intBuffer = buffer.asIntBuffer();
int capacity = intBuffer.capacity(); // 2

В приведенном выше фрагменте кода буфер имеет 11 элементов данных, а тип int занимает 4 байта. Таким образом, intBuffer будет иметь 2 элемента данных (11/4 = 2) и не включать лишние 3 байта (11% 4 = 3).

6. Прямой буфер

Что такое прямой буфер? Прямой буфер относится к базовым данным буфера, размещенным в области памяти, где функции ОС могут напрямую обращаться к ним. Непрямой буфер относится к буферу, базовые данные которого представляют собой массив байтов , выделенный в области кучи Java.

Тогда как мы можем создать прямой буфер? Прямой ByteBuffer создается путем вызова метода allocateDirect () с желаемой емкостью:

ByteBuffer buffer = ByteBuffer.allocateDirect(10);

Зачем нужен прямой буфер? Ответ прост: непрямой буфер всегда требует ненужных операций копирования. При отправке данных непрямого буфера на устройства ввода-вывода собственный код должен «заблокировать» базовый массив байтов , скопировать его за пределы кучи Java, а затем вызвать функцию ОС для сброса данных. Однако собственный код может напрямую обращаться к базовым данным и вызывать функции ОС для сброса данных без каких-либо дополнительных затрат с помощью прямого буфера.

В свете вышеизложенного, является ли прямой буфер идеальным? Нет. Основная проблема заключается в том, что выделять и освобождать прямой буфер дорого. Итак, в действительности всегда ли прямой буфер работает быстрее, чем непрямой буфер? Не обязательно. Это потому, что многие факторы в игре. И компромиссы производительности могут сильно различаться в зависимости от JVM, операционной системы и дизайна кода.

Наконец, существует практическое правило программного обеспечения, которому нужно следовать: сначала заставьте его работать, а затем сделайте его быстрым . Это означает, что давайте сначала сосредоточимся на правильности кода. Если код работает недостаточно быстро, то проведем соответствующую оптимизацию.

7. Разное

Класс ByteBuffer также предоставляет несколько вспомогательных методов:

./b2d4e80d48df6db6b09c66a760ef7e4d.png

7.1. Is-родственные методы

Метод isDirect() может сказать нам, является ли буфер прямым буфером или непрямым буфером. Обратите внимание, что обернутые буферы, созданные с помощью метода wrap() , всегда непрямые.

Все буферы доступны для чтения, но не все для записи. Метод isReadOnly() указывает, можем ли мы записывать базовые данные.

Чтобы сравнить эти два метода, метод isDirect() заботится о том, где находятся базовые данные, в куче Java или в области памяти . Однако метод isReadOnly() заботится о том, можно ли изменить базовые элементы данных .

Если исходный буфер является прямым или доступным только для чтения, новое сгенерированное представление унаследует эти атрибуты.

7.2. Методы, связанные с массивами

Если экземпляр ByteBuffer является прямым или доступным только для чтения, мы не можем получить его базовый массив байтов. Но если буфер не прямой и не предназначен только для чтения, это не обязательно означает, что его базовые данные доступны.

Чтобы быть точным, метод hasArray() может сказать нам, есть ли у буфера доступный резервный массив или нет . Если метод hasArray() возвращает true , то мы можем использовать методы array() и arrayOffset() для получения более релевантной информации.

7.3. Порядок байтов

По умолчанию порядок байтов класса ByteBuffer всегда равен ByteOrder.BIG_ENDIAN . И мы можем использовать методы order() и order(ByteOrder) для получения и установки текущего порядка байтов соответственно.

Порядок байтов влияет на то, как интерпретировать базовые данные. Например, предположим, что у нас есть экземпляр буфера :

byte[] bytes = new byte[]{(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE};
ByteBuffer buffer = ByteBuffer.wrap(bytes);

Используя ByteOrder.BIG_ENDIAN , значение будет -889275714 (0xCAFEBABE) :

buffer.order(ByteOrder.BIG_ENDIAN);
int val = buffer.getInt();

Однако при использовании ByteOrder.LITTLE_ENDIAN val будет равен -1095041334 (0xBEBAFECA):

buffer.order(ByteOrder.LITTLE_ENDIAN);
int val = buffer.getInt();

7.4. Сравнение

Класс ByteBuffer предоставляет методы equals() и compareTo() для сравнения двух экземпляров буфера. Оба этих метода выполняют сравнение на основе оставшихся элементов данных, которые находятся в диапазоне [position, limit) .

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

byte[] bytes1 = "World".getBytes(StandardCharsets.UTF_8);
byte[] bytes2 = "HelloWorld".getBytes(StandardCharsets.UTF_8);

ByteBuffer buffer1 = ByteBuffer.wrap(bytes1);
ByteBuffer buffer2 = ByteBuffer.wrap(bytes2);
buffer2.position(5);

boolean equal = buffer1.equals(buffer2); // true
int result = buffer1.compareTo(buffer2); // 0

8. Заключение

В этой статье мы попытались рассматривать класс ByteBuffer как луковую модель. Сначала мы упростили его до контейнера байтового массива с дополнительными индексами. Затем мы говорили о том, как использовать класс ByteBuffer для передачи данных из/в другие типы данных.

Затем мы рассмотрели одни и те же базовые данные с разных точек зрения. Наконец, мы обсудили прямой буфер и некоторые другие методы.

Как обычно, исходный код этого руководства можно найти на GitHub .