Introducción a Java NIO
Introducción a Java NIO y NIO.2: canales, buffers, selectores y el paquete java.nio.file.
Los quince capítulos anteriores a este trataron sobre java.io — streams, Reader/Writer, File, Serializable. Esa API fue la I/O original de Java y todavía se usa ampliamente. NIO es la familia de APIs que Java añadió posteriormente para cubrir lo que java.io no podía. Viene en dos partes que comparten un prefijo de paquete y poco más:
- NIO (Java 1.4, 2002) —
java.nio.*— canales, buffers, selectores. Una forma distinta de I/O: basada en byte-buffer, opcionalmente sin bloqueo, diseñada para servidores de alto rendimiento. - NIO.2 (Java 7, 2011) —
java.nio.file.*— las clasesPath,Files,FileSystemyWatchService. Un reemplazo más amigable parajava.io.Filey un lugar para características del sistema de archivos quejava.ionunca tuvo (enlaces simbólicos, atributos extendidos, I/O de archivos asíncrona, observación de directorios).
Has estado usando partes de NIO.2 desde el inicio de esta sección: Path, Files.newBufferedReader, Files.newInputStream son todos java.nio.file. Este capítulo amplía la perspectiva y muestra dónde encajan esas piezas y para qué sirve el resto del paquete.
Stream vs canal: dos formas diferentes
InputStream.read() devuelve un byte. OutputStream.write(int) escribe un byte. El modelo mental es una tubería de un byte a la vez. Los decoradores con buffer lo hacen rápido, pero la abstracción es secuencial y unidireccional.
Un canal (java.nio.channels.Channel) es bidireccional, orientado a byte-buffer y admite operaciones que InputStream no puede expresar:
- Leer hacia y escribir desde un
ByteBuffer— no unbyte[]. - Mapear en memoria una región de un archivo en RAM y leerla/escribirla como un buffer.
- Dispersar una lectura en múltiples buffers (cabecera → uno, carga útil → otro).
- Concentrar una escritura desde múltiples buffers (un solo
write()produce una salida contigua). - Marcar un canal como sin bloqueo y dejar que un
Selectormultipleque miles de ellos en un solo hilo.
El precio es la verbosidad. El código de canal lee y escribe a través de un ByteBuffer con llamadas explícitas a flip() y position(); java.io oculta todo eso detrás de read(byte[]). Para la lectura típica de archivos, prefiere las APIs de java.io/Files. Recurre a los canales cuando necesites una de las características exclusivas de los canales.
// 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());
}
}El paso flip() es el momento en que la gente aprende que ByteBuffer tiene su propia pequeña máquina de estados.
ByteBuffer: position, limit, capacity
Un ByteBuffer es un byte[] de tamaño fijo (o un trozo de memoria fuera del heap) más tres índices:
position— el siguiente byte que se va a leer o escribir.limit— el índice posterior al último byte que puedes tocar.capacity— el tamaño fijo del buffer; no puede cambiar.
0 ─────── position ─────── limit ─────── capacity
(consumed) (active region) (untouchable / empty)El buffer está en uno de dos modos por convención:
- Modo escritura (predeterminado): metes bytes con
put(byte).positionavanza;limit == capacity. - Modo lectura: sacas bytes con
get().positionavanza;limitestá donde dejaste de escribir.
flip() cambia de escritura a lectura: establece limit = position (marca dónde terminan los datos) y restablece position = 0 (empieza a leer desde el principio). clear() vuelve al modo escritura (position = 0, limit = capacity). Los errores aquí son la fuente más común de la frustración de "leí cero bytes; ¿por qué?".
Los buffers fuera del heap (ByteBuffer.allocateDirect(n)) evitan el heap de la JVM y permiten que el sistema operativo los lea/escriba directamente sin una copia adicional. Son más lentos de asignar, más rápidos para hacer I/O y son la elección correcta solo para código de I/O en la ruta crítica.
Selectores: un hilo, muchos canales
Antes de los hilos virtuales (Java 21), atender miles de conexiones de red concurrentes en Java implicaba miles de hilos del sistema operativo (uno por conexión — costoso) o un único hilo multiplexando con un 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);
}
}El sistema operativo notifica a la JVM cuando cualquier canal registrado puede progresar; la JVM te entrega el conjunto listo; haces una lectura o escritura sin bloqueo y vuelves a select(). El código de framework bajo Netty, gRPC y Spring WebFlux tiene esta forma.
Con los hilos virtuales (Thread.startVirtualThread(...)), el patrón más sencillo de "un hilo por solicitud" escala al mismo número de conexiones sin la coreografía del Selector — los hilos virtuales se estacionan en I/O bloqueante esencialmente sin costo. Para código de aplicación nuevo en Java 21+, el bucle de selector es cada vez más una preocupación de las bibliotecas; normalmente no lo escribes a mano. Para código de biblioteca y JVM anteriores a Loom, es el patrón estándar.
java.nio.file: la API moderna de archivos
Esta es la mitad de NIO que usarás en el código cotidiano. Reemplaza java.io.File y la mayoría de las partes relacionadas con archivos de java.io:
java.io | java.nio.file | Por qué el reemplazo |
|---|---|---|
File | Path | Inmutable, independiente del SO, sin métodos de I/O integrados |
File.list() | Files.list(Path), Files.walk(Path) | Stream<Path>; cerrable; respeta los enlaces simbólicos |
new FileInputStream(...) | Files.newInputStream(path) | Variantes con soporte de juego de caracteres para texto; una API de apertura consistente |
file.delete() devolviendo false al fallar | Files.delete(path) lanzando IOException | Los fallos son visibles, no silenciosos |
| sin equivalente | Files.walkFileTree, WatchService, API de enlace simbólico, vistas de atributos de archivo | Capacidades que java.io nunca tuvo |
Los dos próximos capítulos cubren Path y Files en profundidad. La regla general: para trabajo con archivos en Java 2024+, recurre a java.nio.file. java.io.File sigue existiendo porque el código antiguo lo usa, pero el código nuevo debería usar Path por defecto.
Un ejemplo práctico: ida y vuelta de un archivo mediante un canal y un buffer
El programa a continuación copia un pequeño archivo de texto de la manera canal-y-buffer para hacer concretos position/limit/flip. Abre el origen como un FileChannel, lee en un ByteBuffer, hace flip, escribe en un FileChannel de destino e imprime el estado del buffer en cada paso para que puedas ver cómo se mueven los índices.
Lo que hay que extraer de la ejecución:
- El bucle imprimió el estado del buffer en cada paso. Después de un
read(),positionera el número de bytes leídos ylimitseguía siendocapacity— eso es "modo escritura": hay espacio al final. Después deflip(),position = 0ylimit = el número recién leído— eso es "modo lectura": los bytes se encuentran entre 0 ylimit. Los dos índices codifican "dónde viven los datos" sin copiarlos. - El buffer tenía 16 bytes; el archivo tenía 44. El bucle se ejecutó tres iteraciones: 16, 16, 12. Una vez que el buffer estaba vacío (después de que
writelo había drenado),clear()lo restableció al "modo escritura" para que el siguienteread()pudiera rellenarlo. Este es el patrón de canal en miniatura: rellenar, flip, drenar, clear, repetir. transferTohizo la misma copia en una línea sin ningúnByteBufferimplicado. En Linux, eso se mapea a una única llamada al sistemasendfile()— los bytes viajan de kernel a kernel sin cruzar la JVM. Cuando mueves datos entre dos canales y no necesitas examinarlos, esta es la herramienta correcta.- Observa que el archivo fuente fue creado con
Files.writeStringy el destino se leyó de nuevo conFiles.readString— ambos son instrucciones de una línea dejava.nio.fileque ocultan completamente los canales y buffers. El bucle detallado de canal en el medio es lo que escribirías solo cuando necesitas acceso directo al buffer (análisis binario personalizado, mapeo de memoria, scatter/gather). Para "copiar un archivo",transferTooFiles.copyes más corto y al menos igual de rápido. - El constructor
FileChannel.open(path, OPTION)es el paralelo aFiles.newInputStream(path). El enumStandardOpenOption(READ, WRITE, CREATE, APPEND, TRUNCATE_EXISTING, ...) es lo que controla el comportamiento de apertura — hay un solo lugar donde mirar. Ese enum de opciones de apertura sigue reapareciendo en el próximo capítulo.
Qué sigue
Este capítulo nombró las piezas — canales, buffers, selectores, java.nio.file. El próximo capítulo, Clase Path de Java, profundiza en la más amigable de esas piezas — Path — y los métodos (resolve, relativize, normalize) que usarás cada vez que trabajes con una ruta del sistema de archivos.