1. Introducción a NIO Channels
En el Java IO clásico (java.io) todo funciona con el principio «un hilo — un archivo o recurso». En cuanto empieza la lectura o escritura, el hilo se bloquea y espera a que termine la operación. Para casos sencillos esto es cómodo, pero en sistemas con mucha carga este enfoque se convierte en un cuello de botella: si hay miles de conexiones, miles de hilos quedan ocupados esperando.
En NIO (New IO) el enfoque es distinto. Aquí la entrada/salida puede ser no bloqueante y el hilo no tiene por qué estar parado. Mientras unos datos siguen llegando, puede cambiar a otra tarea. Esto permite atender una enorme cantidad de conexiones con apenas unos pocos hilos.
La diferencia también se aprecia en los detalles. En el IO «antiguo» el trabajo se organiza alrededor de flujos que leen y escriben bytes o caracteres, pero siempre se bloquean durante las operaciones. En NIO los conceptos clave son los canales (Channels) y los búferes (Buffers). Permiten implementar E/S no bloqueante (importante para servidores) y aplicar zero-copy, cuando los datos se transfieren directamente evitando copias innecesarias en los búferes de la JVM.
Comparativa: flujos (Streams) vs canales (Channels)
Flujos (InputStream/OutputStream):
- Leen/escriben bytes de uno en uno o en arrays.
- Sin control directo de la posición en el archivo.
- No resultan eficientes con archivos muy grandes.
Canales (Channel):
- Leen/escriben datos mediante búferes (Buffer).
- Se puede gestionar la posición (incluido el acceso aleatorio).
- Admiten asincronía y modo no bloqueante.
- Permiten usar zero-copy para copias ultrarrápidas.
2. FileChannel y SeekableByteChannel
Lectura y escritura de datos con búferes
FileChannel es el canal principal para trabajar con archivos. Se puede obtener desde FileInputStream, FileOutputStream o mediante NIO.2 — Files.newByteChannel (devuelve SeekableByteChannel).
Ejemplo: lectura de un archivo con FileChannel y ByteBuffer
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class FileChannelReadExample {
public static void main(String[] args) throws Exception {
try (RandomAccessFile file = new RandomAccessFile("data.txt", "r");
FileChannel channel = file.getChannel()) {
ByteBuffer buffer = ByteBuffer.allocate(1024); // buffer de 1 KB
int bytesRead = channel.read(buffer); // leemos en el buffer
while (bytesRead != -1) {
buffer.flip(); // cambiamos el buffer al modo de lectura
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
buffer.clear(); // limpiamos el buffer para la siguiente lectura
bytesRead = channel.read(buffer);
}
}
}
}
Escritura en un archivo:
try (RandomAccessFile file = new RandomAccessFile("output.txt", "rw");
FileChannel channel = file.getChannel()) {
ByteBuffer buffer = ByteBuffer.wrap("Hello, NIO!\n".getBytes());
channel.write(buffer);
}
NIO.2: abrir un canal con Files.newByteChannel
import java.nio.file.*;
import java.nio.channels.SeekableByteChannel;
import static java.nio.file.StandardOpenOption.*;
Path path = Paths.get("data.txt");
try (SeekableByteChannel ch = Files.newByteChannel(path, READ)) {
ByteBuffer buf = ByteBuffer.allocate(256);
ch.read(buf);
}
Posicionamiento (position()) y cambio de tamaño (truncate())
- position() — permite consultar o establecer la posición actual en el archivo (análogo al «cursor»).
- truncate(long size) — recorta el archivo al tamaño indicado.
channel.position(100); // nos movemos al byte 100
channel.truncate(1024); // truncamos el archivo a 1 KB
Acceso directo y posicional a archivos
- Acceso directo: puedes leer/escribir en cualquier parte del archivo, no solo de forma secuencial.
- Acceso posicional: puedes leer/escribir datos en una posición concreta sin cambiar la posición actual del canal.
ByteBuffer buffer = ByteBuffer.allocate(4);
channel.read(buffer, 128); // leemos 4 bytes desde la posición 128 sin cambiar channel.position()
3. ByteBuffer: cómo funciona
Parámetros clave: capacity, limit, position, mark
- capacity — tamaño máximo del búfer (se define al crearlo).
- limit — límite hasta el que se puede leer/escribir (por defecto igual a capacity).
- position — posición actual (dónde escribimos/de dónde leemos).
- mark — «marca» que se puede establecer y a la que se puede volver.
Ciclo de vida del búfer:
- Escribimos datos en el búfer (por ejemplo, leemos del canal read()).
- flip() — cambiamos el búfer al modo de lectura (position = 0, limit = la position actual).
- Leemos datos del búfer (get()).
- clear() — limpiamos el búfer para la siguiente escritura (position = 0, limit = capacity).
Ejemplo:
ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 42);
buffer.flip(); // ahora se puede leer
byte value = buffer.get(); // 42
buffer.clear(); // listo para una nueva escritura
Creación de búferes: allocate() vs allocateDirect()
ByteBuffer tiene dos formas principales de crear un búfer, y la diferencia entre ellas se nota en el funcionamiento. El método allocate() coloca el búfer en el heap de la JVM: se crea rápido y sirve para la mayoría de tareas, pero con E/S nativa pueden producirse copias adicionales entre el heap y la memoria del SO.
El método allocateDirect() reserva memoria fuera del heap de la JVM (en «memoria nativa»). Este búfer es más caro de crear y más complejo de gestionar, pero al leer/escribir archivos grandes o en operaciones de red suele ser más rápido al evitar copias innecesarias.
La idea es sencilla: si te importa el rendimiento con grandes volúmenes, usa búferes «directos». Para operaciones pequeñas y frecuentes, la sobrecarga de crearlos puede superar el beneficio.
ByteBuffer directBuffer = ByteBuffer.allocateDirect(4096);
4. Operaciones de alto rendimiento: transferTo() y transferFrom()
Métodos transferTo() y transferFrom()
La clase FileChannel tiene dos métodos que permiten trabajar con «copia cero» — transferTo() y transferFrom(). La idea es que los datos se pueden transferir directamente entre canales de archivos o, por ejemplo, entre un archivo y la red. La JVM apenas participa: la operación la realiza el SO y los búferes dentro de Java no se tocan.
Como resultado, la copia de archivos grandes funciona notablemente más rápido: menos copias, menos pasos entre user space y kernel space y menor carga de CPU.
Ejemplo: copiar un archivo con zero-copy
import java.nio.channels.FileChannel;
import java.nio.file.*;
public class ZeroCopyExample {
public static void main(String[] args) throws Exception {
try (FileChannel src = FileChannel.open(Paths.get("input.bin"), StandardOpenOption.READ);
FileChannel dst = FileChannel.open(Paths.get("output.bin"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
long size = src.size();
long transferred = src.transferTo(0, size, dst);
System.out.println("Bytes copiados: " + transferred);
}
}
}
¿Cuándo funciona realmente zero-copy?
- Al copiar entre archivos en el mismo disco.
- Al enviar archivos por la red (p. ej., mediante SocketChannel).
- Cuando el SO lo admite (Linux, macOS, Windows — lo admiten).
Ventajas:
- Mínimas copias: los datos no pasan por los búferes de la JVM.
- Alta velocidad: menos conmutaciones y menor carga de CPU.
- Menos memoria: no hacen falta grandes búferes de usuario.
Ejemplo: copiar un archivo «en una sola línea»
Files.copy(Paths.get("input.bin"), Paths.get("output.bin"), StandardCopyOption.REPLACE_EXISTING);
// Internamente puede usar zero-copy si es posible
5. Errores típicos
Error n.º 1: olvidaste llamar a flip() antes de leer del búfer. Después de escribir en el búfer, llama siempre a flip(); de lo contrario, la lectura no funcionará como esperas: position/limit seguirán en «modo escritura».
Error n.º 2: usar allocateDirect() para operaciones pequeñas. Los búferes directos son buenos para grandes volúmenes, pero para peticiones pequeñas su creación es injustificadamente cara. Por defecto, elige allocate().
Error n.º 3: no cerrar el canal. Usa siempre try-with-resources con canales y flujos para evitar fugas de descriptores.
Error n.º 4: confundir position/limit/capacity. Antes de leer/escribir, asegúrate de en qué modo está el búfer: después de escribir necesitas flip(); después de leer, para una nueva escritura, clear() o compact().
Error n.º 5: esperar que zero-copy «siempre funcione». En algunas configuraciones (dispositivos distintos/sistemas de archivos diferentes/flags especiales) zero-copy puede no estar disponible; entonces se realizará una copia normal y el rendimiento será distinto.
GO TO FULL VERSION