Las clases de Buffer son la base sobre la que se construye Java NIO. Sin embargo, en estas clases, la clase ByteBuffer es la más preferida. Eso es porque el tipo de byte es el más versátil. Por ejemplo, podemos usar bytes para componer otros tipos primitivos no booleanos en JVM. Además, podemos usar bytes para transferir datos entre JVM y dispositivos de E/S externos.

Creación de un ByteBuffer

ByteBuffer es una clase abstracta, por lo que no podemos construir una nueva instancia directamente. Sin embargo, proporciona métodos de fábrica estáticos para facilitar la creación de instancias. Brevemente, hay dos formas de crear una instancia de ByteBuffer, ya sea mediante asignación (allocate) o ajuste (wrap):

  • Allocate:
allocate(int capacity)
  • wrap
wrap(byte[] array)
wrap(byte[] array, int offset, int length)

Allocate

La asignación creará una instancia y asignará un espacio privado con una capacidad específica. Para ser precisos, la clase ByteBuffer tiene dos métodos de asignación: allocate y allocateDirect.

Con el método de asignación, obtendremos un búfer no directo, es decir, una instancia de búfer con una matriz de bytes subyacente:

ByteBuffer buffer = ByteBuffer.allocate(10);

Cuando usamos el método allocateDirect, generará un búfer directo:

ByteBuffer buffer = ByteBuffer.allocateDirect(10);

Para simplificar, centrémonos en el búfer no directo y dejemos la discusión sobre el búfer directo para más adelante.

Wrapping

Wrap permite que una instancia reutilice una matriz de bytes existente:

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

Y el código anterior es equivalente a:

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

Cualquier cambio realizado en los elementos de datos en la matriz de bytes existente se reflejará en la instancia del búfer y viceversa.

Ahora sabemos cómo obtener una instancia de ByteBuffer. A continuación, tratemos la clase ByteBuffer como un modelo de cebolla de tres capas y entendámoslo capa por capa desde adentro hacia afuera:

En la capa más interna, consideramos la clase ByteBuffer como un contenedor para una matriz de bytes con índices adicionales. En la capa intermedia, nos enfocamos en usar una instancia de ByteBuffer para transferir datos desde/hacia otros tipos de datos. Inspeccionamos los mismos datos subyacentes con diferentes vistas basadas en búfer en la capa más externa.

Indices

Conceptualmente, la clase ByteBuffer es una matriz de bytes envuelta dentro de un objeto. Proporciona muchos métodos convenientes para facilitar las operaciones de lectura o escritura desde/hacia los datos subyacentes. Y, estos métodos son altamente dependientes de los índices mantenidos.

Ahora, simplifiquemos deliberadamente la clase ByteBuffer en un contenedor de matriz de bytes con índices adicionales:

ByteBuffer = byte array + index

Con este concepto en mente, podemos clasificar los métodos relacionados con índices en cuatro categorías:

Indices Básicos

Hay cuatro índices definidos en la clase Buffer. Estos índices registran el estado de los elementos de datos subyacentes:

  • Capacidad: el número máximo de elementos de datos que puede contener el búfer
  • Límite: un índice para dejar de leer o escribir
  • Posición: el índice actual para leer o escribir
  • Marca: una posición recordada

Además, existe una relación invariante entre estos índices:

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

Y debemos tener en cuenta que todos los métodos relacionados con índices giran en torno a estos cuatro índices.

Cuando creamos una nueva instancia de ByteBuffer, la marca no está definida, la posición es 0 y el límite es igual a la capacidad. Por ejemplo, asignemos un ByteBuffer con 10 elementos de datos:

ByteBuffer buffer = ByteBuffer.allocate(10);

O bien, envolvamos una matriz de bytes existente con 10 elementos de datos:

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

Como resultado, la marca será -1, la posición será 0 y tanto el límite como la capacidad serán 10:

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

La capacidad es de solo lectura y no se puede cambiar. Pero podemos usar los métodos position(int) y limit(int) para cambiar la posición y el límite correspondientes:

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

Entonces, la posición será 2 y el límite será 5.

Mark y Reset

Los métodos mark() y reset() nos permiten recordar una posición en particular y volver a ella más tarde.

Cuando creamos por primera vez una instancia de ByteBuffer, la marca no está definida. Luego, podemos llamar al método mark() y la marca se establece en la posición actual. Después de algunas operaciones, llamar al método reset() cambiará la posición a la marca.

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

Una cosa a tener en cuenta: si la marca no está definida, llamar al método reset() conducirá a InvalidMarkException.

Clear, Flip, Rewind, y Compact

Los métodos clear(), flip(), rewind() y compact() tienen algunas partes comunes y ligeras diferencias:

Para comparar estos métodos, preparemos un fragmento de código:

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

El método clear() cambiará el límite a la capacidad, la posición a 0 y la marca a -1:

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

El método flip() cambiará el límite a la posición, la posición a 0 y la marca a -1:

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

El método rewind() mantiene el límite sin cambios y cambia la posición a 0 y la marca a -1:

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

El método compact() cambiará el límite a la capacidad, la posición a restante (límite – posición) y la marca a -1:

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

Los cuatro métodos anteriores tienen sus propios casos de uso:

  • Para reutilizar un búfer, el método clear() es útil. Establecerá los índices en el estado inicial y estará listo para nuevas operaciones de escritura.
  • Después de llamar al método flip(), la instancia del búfer cambia del modo de escritura al modo de lectura. Pero debemos evitar llamar al método flip() dos veces. Esto se debe a que una segunda llamada establecerá el límite en 0 y no se podrán leer elementos de datos.
  • Si queremos leer los datos subyacentes más de una vez, el método rewind() resulta útil.
  • El método compact() es adecuado para la reutilización parcial de un búfer. Por ejemplo, supongamos que queremos leer algunos, pero no todos, los datos subyacentes y luego queremos escribir datos en el búfer. El método compact() copiará los datos no leídos al comienzo del búfer y cambiará los índices del búfer para que estén listos para las operaciones de escritura.

Remain

Los métodos hasRemaining() y restantes() calculan la relación del límite y la posición:

Cuando el límite es mayor que la posición, hasRemaining() devolverá verdadero. Además, el método restante() devuelve la diferencia entre el límite y la posición.

Por ejemplo, si un búfer tiene una posición de 2 y un límite de 8, entonces su resto será 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

Transferencia de Datos

La segunda capa del Modelo Cebolla se ocupa de la transferencia de datos. Específicamente, la clase ByteBuffer proporciona métodos para transferir datos desde/hacia otros tipos de datos (byte, char, short, int, long, float y double):

Para transferir datos de bytes, la clase ByteBuffer proporciona operaciones únicas y masivas.

Podemos leer o escribir un solo byte desde/hacia los datos subyacentes del búfer en operaciones individuales. Estas operaciones incluyen:

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

Podemos notar dos versiones de los métodos get()/put() de los métodos anteriores: uno no tiene parámetros y el otro acepta un índice. Entonces, ¿cuál es la diferencia?

La que no tiene índice es una operación relativa, que opera en el elemento de datos en la posición actual y luego incrementa la posición en 1. Sin embargo, la que tiene un índice es una operación completa, que opera en los elementos de datos en el índice y no cambiará la posición.

Por el contrario, las operaciones masivas pueden leer o escribir varios bytes desde/hacia los datos subyacentes del búfer. Estas operaciones incluyen:

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);

Todos los métodos anteriores pertenecen a operaciones relativas. Es decir, leerán o escribirán desde/hasta la posición actual y cambiarán el valor de la posición, respectivamente.

También hay otro método put(), que acepta un parámetro ByteBuffer:

public ByteBuffer put(ByteBuffer src);

Además de leer o escribir datos de bytes, la clase ByteBuffer también admite otros tipos primitivos, excepto el tipo booleano. Tomemos el tipo int como ejemplo. Los métodos relacionados incluyen:

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

De manera similar, los métodos getInt() y putInt() con un parámetro de índice son operaciones absolutas, de lo contrario, operaciones relativas.

Vistas

La tercera capa del Modelo Cebolla se trata de leer los mismos datos subyacentes con diferentes perspectivas.

Cada método en la imagen de arriba generará una nueva vista que comparte los mismos datos subyacentes con el búfer original. Para entender una nueva vista, debemos preocuparnos por dos problemas:

  • ¿Cómo analizará la nueva vista los datos subyacentes?
  • ¿Cómo registrará la nueva vista sus índices?

Para leer una instancia de ByteBuffer como otra vista de ByteBuffer, tiene tres métodos: duplicate(), slice() y asReadOnlyBuffer().

Echemos un vistazo a la ilustración de esas diferencias:

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

El método duplicate() crea una nueva instancia de ByteBuffer como la original. Pero, cada uno de los dos búferes tendrá su límite, posición y marca independientes:

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

El método slice() crea una subvista compartida de los datos subyacentes. La posición de la vista será 0, y su límite y capacidad será el resto del búfer original:

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

Comparado con el método duplicate(), el método asReadOnlyBuffer() funciona de manera similar pero produce un búfer de solo lectura. Eso significa que no podemos usar esta vista de solo lectura para cambiar los datos subyacentes:

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

ByteBuffer también proporciona otras vistas: asCharBuffer(), asShortBuffer(), asIntBuffer(), asLongBuffer(), asFloatBuffer() y asDoubleBuffer(). Estos métodos son similares al método slice(), es decir, proporcionan una vista dividida correspondiente a la posición y el límite actuales de los datos subyacentes. La principal diferencia entre ellos es interpretar los datos subyacentes en otros valores de tipo primitivo.

Las preguntas que nos deben preocupar son:

  • Cómo interpretar los datos subyacentes
  • Dónde comenzar la interpretación
  • Cuántos elementos se presentarán en la nueva vista generada

La nueva vista compondrá varios bytes en el tipo primitivo de destino y comenzará la interpretación desde la posición actual del búfer original. La nueva vista tendrá una capacidad igual al número de elementos restantes en el búfer original dividido por el número de bytes que comprende el tipo primitivo de la vista. Cualquier byte restante al final no será visible en la vista.

Ahora, tomemos asIntBuffer() como ejemplo:

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

En el fragmento de código anterior, el búfer tiene 11 elementos de datos y el tipo int ocupa 4 bytes. Entonces, intBuffer tendrá 2 elementos de datos (11/4 = 2) y omitirá los 3 bytes adicionales (11 % 4 = 3).

Buffer Directo

¿Qué es un búfer directo? Un búfer directo se refiere a los datos subyacentes de un búfer asignados en un área de memoria donde las funciones del sistema operativo pueden acceder directamente a ellos. Un búfer no directo hace referencia a un búfer cuyos datos subyacentes son una matriz de bytes que se asigna en el área de almacenamiento dinámico de Java.

Entonces, ¿cómo podemos crear un búfer directo? Se crea un ByteBuffer directo llamando al método allocateDirect() con la capacidad deseada:

ByteBuffer buffer = ByteBuffer.allocateDirect(10);

¿Por qué necesitamos un búfer directo? La respuesta es simple: un búfer no directo siempre incurre en operaciones de copia innecesarias. Al enviar datos de un búfer no directo a dispositivos de E/S, el código nativo tiene que “bloquear” la matriz de bytes subyacente, copiarla fuera del montón de Java y luego llamar a la función del sistema operativo para vaciar los datos. Sin embargo, el código nativo puede acceder a los datos subyacentes directamente y llamar a las funciones del sistema operativo para vaciar los datos sin sobrecarga adicional mediante el uso de un búfer directo.

A la luz de lo anterior, ¿es perfecto un búfer directo? No. El principal problema es que es costoso asignar y desasignar un búfer directo. Entonces, en realidad, ¿un búfer directo siempre se ejecuta más rápido que un búfer no directo? No necesariamente. Eso es porque muchos factores están en juego. Y las compensaciones de rendimiento pueden variar ampliamente según la JVM, el sistema operativo y el diseño del código.

Finalmente, hay una máxima práctica de software a seguir: Primero, haz que funcione, luego, hazlo rápido. Eso significa que primero concentrémonos en la corrección del código. Si el código no se ejecuta lo suficientemente rápido, hagamos la optimización correspondiente.

Métodos de relación

El método isDirect() puede decirnos si un búfer es un búfer directo o un búfer no directo. Tenga en cuenta que los búferes envueltos, los creados con el método wrap(), siempre son no directos.

Todos los búferes se pueden leer, pero no todos se pueden escribir. El método isReadOnly() indica si podemos escribir en los datos subyacentes.

Para comparar estos dos métodos, el método isDirect() se preocupa por dónde se encuentran los datos subyacentes, en el montón de Java o en el área de memoria. Sin embargo, el método isReadOnly() se preocupa por si los elementos de datos subyacentes se pueden cambiar.

Si un búfer original es directo o de solo lectura, la nueva vista generada heredará esos atributos.

Si una instancia de ByteBuffer es directa o de solo lectura, no podemos obtener su matriz de bytes subyacente. Pero, si un búfer no es directo y no es de solo lectura, eso no significa necesariamente que sus datos subyacentes sean accesibles.

Para ser precisos, el método hasArray() puede decirnos si un búfer tiene una matriz de respaldo accesible o no. Si el método hasArray() devuelve verdadero, entonces podemos usar los métodos array() y arrayOffset() para obtener información más relevante.

De forma predeterminada, el orden de bytes de la clase ByteBuffer siempre es ByteOrder.BIG_ENDIAN. Y podemos usar los métodos order() y order(ByteOrder) para obtener y establecer respectivamente el orden de bytes actual.

El orden de los bytes influye en cómo interpretar los datos subyacentes. Por ejemplo, supongamos que tenemos una instancia de búfer:

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

Usando ByteOrder.BIG_ENDIAN, el valor será -889275714 (0xCAFEBABE):

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

Sin embargo, usando ByteOrder.LITTLE_ENDIAN, el valor será -1095041334 (0xBEBAFECA):

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

Comparación

La clase ByteBuffer proporciona los métodos equals() y compareTo() para comparar dos instancias de búfer. Ambos métodos realizan la comparación en función de los elementos de datos restantes, que están en el rango de [posición, límite].

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

Categorized in: