Programación en Java

Java I/O y NIO.2: streams, Readers, Path, Files, channels y buffers

Intermedio9 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Java tiene dos API de archivos que conviven: los streams y readers originales de java.io y la API NIO.2 de Path/Files que sustituyó a java.io.File. Esta guía explica el modelo de streams de bytes y de caracteres y sus decoradores, cómo leer y escribir archivos de forma idiomática con Files, cómo recorrer, vigilar y copiar directorios, cuándo compensan los channels, los buffers o los archivos mapeados en memoria, y dónde fallan las codificaciones y la gestión de recursos. Sabrás elegir la API adecuada y usarla sin dejar descriptores abiertos ni corromper texto.

Lo que aprenderás

  • Explicar la diferencia entre streams de bytes, streams de caracteres y decoradores de buffering
  • Leer, escribir, copiar, mover y borrar archivos con java.nio.file.Files y Path
  • Recorrer árboles de directorios y reaccionar a cambios en el sistema de archivos con Files.walk y WatchService
  • Gestionar las codificaciones de caracteres de forma explícita y evitar sorpresas con el charset por defecto de la plataforma
  • Decidir cuándo los channels, ByteBuffer y los archivos mapeados en memoria justifican su complejidad

Leer y escribir archivos es el punto en el que un programa se encuentra con el sistema operativo, y el JDK expone esa frontera mediante dos generaciones de API. java.io (1996) te da streams y readers: componibles, sencillos y todavía la base de cualquier biblioteca de red o de compresión. java.nio.file, conocido como NIO.2 (Java 7), te da Path y Files, un modelo real del sistema de archivos con atributos, enlaces, recorrido de directorios y notificación de cambios. El código moderno usa Files para los archivos y las abstracciones de stream para todo lo que fluye. Esta guía cubre ambas, junto con las reglas de codificación y de gestión de recursos que separan una E/S robusta de un código que funciona hasta que se topa con un sistema de archivos real. El código está escrito para Java 21.

El modelo de streams de java.io

java.io tiene dos jerarquías paralelas. Los streams de bytes (InputStream, OutputStream) mueven bytes en bruto y son la herramienta adecuada para datos binarios. Los streams de caracteres (Reader, Writer) mueven texto decodificado. El puente entre ambos es un charset: InputStreamReader decodifica bytes en caracteres y OutputStreamWriter codifica en sentido contrario.

Cada jerarquía tiene orígenes y destinos (FileInputStream, ByteArrayOutputStream, Socket.getInputStream()) y decoradores que envuelven otro stream para añadirle comportamiento. Es el patrón Decorator en su hábitat natural:

DecoradorAñade
BufferedInputStream / BufferedReaderUn buffer en memoria, de modo que se llama al sistema operativo una vez por bloque en lugar de una vez por byte; BufferedReader añade readLine()
DataInputStream / DataOutputStreamTipos primitivos en un formato binario portable
ObjectInputStream / ObjectOutputStreamSerialización Java (evítala para todo lo que cruce una frontera de confianza)
GZIPInputStream / InflaterInputStreamDescompresión
PrintWriter / PrintStreamSalida de texto con formato mediante println y printf
try (InputStream raw = new FileInputStream("archive.gz");
     InputStream gunzip = new GZIPInputStream(raw);
     Reader decoded = new InputStreamReader(gunzip, StandardCharsets.UTF_8);
     BufferedReader lines = new BufferedReader(decoded)) {
    String line;
    while ((line = lines.readLine()) != null) {
        process(line);
    }
}

Leído de derecha a izquierda: un archivo, descomprimido, decodificado como UTF-8, con buffer y entregado línea a línea. Cerrar el stream más externo cierra toda la cadena. Leer un FileInputStream byte a byte sin buffer es una de las formas clásicas de hacer que Java parezca lento; un buffer de 8 KB (el valor por defecto) lo soluciona.

El método read(byte[]) devuelve el número de bytes realmente leídos, que puede ser menor que el solicitado, y -1 al final del stream. El código que ignora ese valor de retorno es incorrecto; en la práctica deberías usar readAllBytes(), readNBytes(n) o transferTo(out) (Java 9), que se encargan del bucle por ti.

Path: una dirección inmutable en el sistema de archivos

Un Path es un valor que describe una ubicación, y esa ubicación no tiene por qué existir. Se crea con Path.of("data", "in.csv") (Java 11) o Paths.get(...), y se manipula con métodos que devuelven rutas nuevas:

Path base = Path.of("/var/app");
Path config = base.resolve("conf/app.yml");          // /var/app/conf/app.yml
Path parent = config.getParent();                    // /var/app/conf
Path name = config.getFileName();                    // app.yml
Path rel = base.relativize(config);                  // conf/app.yml
Path clean = Path.of("/var/app/../log/./x").normalize();   // /var/log/x
Path abs = Path.of("x").toAbsolutePath();            // resolved against the working directory
Path real = config.toRealPath();                     // follows symlinks; throws if it does not exist

resolve une rutas; relativize es su inversa; normalize elimina . y .. de forma léxica (sin tocar el disco); toRealPath pregunta al sistema de archivos. Path implementa Comparable e Iterable<Path> sobre sus elementos de nombre. De los separadores se encarga el FileSystem al que pertenece la ruta, así que el mismo código funciona en Windows y en Linux: Path.of("a", "b") es la forma portable de escribirlo.

Nunca construyas rutas concatenando cadenas a partir de datos del usuario. base.resolve(userInput).normalize(), seguido de una comprobación de que el resultado sigue cumpliendo startsWith(base), es la defensa mínima contra un path traversal del tipo ../../etc/passwd.

Files: las operaciones del día a día

Files es una clase de métodos estáticos que trabajan sobre Path. Lanza IOException (normalmente un subtipo concreto: NoSuchFileException, AccessDeniedException, FileAlreadyExistsException) en lugar de devolver false, y cubre casi todo lo que el antiguo File no sabía hacer.

Path file = Path.of("report.txt");

// Whole-file text (Java 11); always UTF-8 unless you pass another charset
String text = Files.readString(file);
Files.writeString(file, "hello\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);

// Whole-file lines and bytes
List<String> lines = Files.readAllLines(file);
byte[] bytes = Files.readAllBytes(file);
Files.write(file, List.of("a", "b"));

// Streaming, for large files: closes the file only when the stream is closed
try (Stream<String> stream = Files.lines(file)) {
    long errors = stream.filter(l -> l.contains("ERROR")).count();
}

// Buffered reader/writer, when you want the classic loop
try (BufferedWriter w = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
    w.write("first line");
    w.newLine();
}

// Metadata and existence
boolean exists = Files.exists(file);
long size = Files.size(file);
Instant modified = Files.getLastModifiedTime(file).toInstant();
boolean dir = Files.isDirectory(file);

// Structure
Files.createDirectories(Path.of("out/reports"));          // mkdir -p, no error if present
Files.copy(file, Path.of("backup.txt"), StandardCopyOption.REPLACE_EXISTING);
Files.move(file, Path.of("archive/report.txt"), StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(Path.of("tmp.txt"));
Path temp = Files.createTempFile("upload-", ".bin");

StandardOpenOption controla cómo se abre un archivo: CREATE, CREATE_NEW (falla si ya existe), APPEND, TRUNCATE_EXISTING, WRITE. Por defecto, los métodos de escritura crean el archivo o lo truncan. ATOMIC_MOVE garantiza que el renombrado sea todo o nada dentro del mismo sistema de archivos, y así es como se publica un archivo de forma segura: se escribe con un nombre temporal y después se mueve.

Prefiere los métodos de Files a construir FileInputStream directamente. Eligen tamaños de buffer razonables, tratan los charsets de manera uniforme, y newInputStream/newOutputStream están ahí cuando otra API necesita un stream en bruto.

Atributos y enlaces

Files.readAttributes(path, BasicFileAttributes.class) devuelve el tamaño, las marcas de tiempo y el tipo en una sola llamada al sistema, algo que importa cuando recorres miles de archivos. PosixFileAttributes añade propietario, grupo y bits de permisos en Unix; Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-r-----")) los modifica. Los enlaces simbólicos se siguen por defecto; pasa LinkOption.NOFOLLOW_LINKS para inspeccionar el propio enlace, y usa Files.createSymbolicLink y readSymbolicLink para gestionarlos.

Directorios: listar, recorrer, vigilar

Files.list(dir) devuelve un stream con los hijos directos; Files.walk(dir) recorre el árbol completo en profundidad; Files.find(dir, depth, matcher) filtra teniendo ya a mano los atributos. Los tres devuelven un Stream<Path> respaldado por un descriptor de directorio abierto, así que envuélvelos en un try-with-resources.

try (Stream<Path> paths = Files.walk(Path.of("src"))) {
    List<Path> javaFiles = paths
            .filter(Files::isRegularFile)
            .filter(p -> p.toString().endsWith(".java"))
            .toList();
}

// Glob matching, the shell way
PathMatcher logs = FileSystems.getDefault().getPathMatcher("glob:**/*.log");

Files.walkFileTree(start, visitor) es la variante más antigua y más controlable: un FileVisitor recibe callbacks antes y después de cada directorio, por cada archivo y por cada fallo, puede saltarse subárboles, y es la herramienta adecuada para un borrado o una copia recursivos en los que los errores deben tratarse archivo a archivo.

WatchService emite eventos cuando se crean, modifican o borran entradas de un directorio registrado. Es la base del «recargar la configuración cuando cambia el archivo» y de las integraciones basadas en dejar archivos en una carpeta:

try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
    Path inbox = Path.of("inbox");
    inbox.register(watcher, StandardWatchEventKinds.ENTRY_CREATE);
    while (true) {
        WatchKey key = watcher.take();                          // blocks until an event
        for (WatchEvent<?> event : key.pollEvents()) {
            Path created = inbox.resolve((Path) event.context());
            handleNewFile(created);
        }
        if (!key.reset()) break;                                // directory no longer accessible
    }
}

La especificación advierte de dos cosas: el registro es por directorio, no recursivo (recorre el árbol y registra cada subdirectorio), y la implementación de macOS históricamente ha hecho polling en lugar de usar eventos nativos, así que allí la latencia se mide en segundos y no en milisegundos. Además, un evento ENTRY_CREATE puede llegar antes de que quien escribe haya terminado; espera a un ENTRY_MODIFY asociado o a que el tamaño deje de cambiar antes de procesar el archivo.

Codificaciones de caracteres

El texto son bytes más un charset, y equivocarse de charset produce mojibake o una excepción muy lejos de la causa. Hasta Java 17, cualquier API a la que se llamara sin charset usaba el charset por defecto de la plataforma: UTF-8 en Linux, Windows-1252 en muchas máquinas Windows. Así, el mismo JAR se comportaba de forma distinta en servidores distintos. La JEP 400 hizo de UTF-8 el valor por defecto en Java 18 para new FileReader, new InputStreamReader(in), String.getBytes(), Scanner y el resto de API sin charset (los métodos de texto de Files siempre habían usado UTF-8 por defecto). Quedan dos excepciones: System.out/System.err y Console usan la codificación del terminal, y todavía se puede pasar -Dfile.encoding=COMPAT para recuperar el comportamiento antiguo.

Aun así, sé explícito. Escribir StandardCharsets.UTF_8 no cuesta nada y deja clara la intención a quien lee el código. Para los archivos que llegan en otras codificaciones, nómbralas: Files.newBufferedReader(path, Charset.forName("windows-1252")). Cuando decodificas bytes que no son válidos en el charset, por defecto se sustituyen por U+FFFD; configura un CharsetDecoder con CodingErrorAction.REPORT si prefieres que falle.

Un detalle relacionado: String.length() cuenta unidades de código UTF-16, no caracteres, así que un emoji tiene longitud 2. Usa codePointCount cuando la diferencia importe, y la herramienta Base64 o HexFormat (Java 17) cuando necesites ver bytes en bruto mientras depuras.

Channels, buffers y archivos mapeados en memoria

java.nio (Java 1.4) introdujo los channels, conexiones bidireccionales con un archivo o un socket, y los buffers, contenedores de tamaño fijo con posición, límite y capacidad. Un FileChannel permite lo que los streams no pueden: leer y escribir en una posición arbitraria, bloquear regiones, transferir directamente a otro channel y mapear el archivo en memoria.

try (FileChannel channel = FileChannel.open(Path.of("data.bin"), StandardOpenOption.READ)) {
    ByteBuffer header = ByteBuffer.allocate(16);
    channel.read(header, 0);                     // positional read, does not move the channel position
    header.flip();                               // switch from writing into the buffer to reading from it
    int magic = header.getInt();
    long recordCount = header.getLong();

    // Map the rest for random access without copying into the heap
    MappedByteBuffer data = channel.map(FileChannel.MapMode.READ_ONLY, 16, channel.size() - 16);
    byte first = data.get(0);
}

// Zero-copy file to socket or file to file
try (FileChannel in = FileChannel.open(src); FileChannel out = FileChannel.open(dst, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
    long position = 0, size = in.size();
    while (position < size) position += in.transferTo(position, size - position, out);
}

El baile de flip()/clear()/compact() en ByteBuffer es donde viven los bugs: olvidar flip() después de llenar un buffer hace que se lea basura del final. Los buffers directos (allocateDirect) viven fuera del heap y se ahorran una copia en la E/S, a cambio de una asignación más lenta; úsalos para buffers de larga duración, no uno por petición. Los archivos mapeados en memoria son excelentes para conjuntos de datos grandes que se leen mucho más de lo que se escriben y pésimos para archivos pequeños (domina el coste del mapeo), y no existe una forma portable de desmapear uno antes de que el garbage collector recoja el buffer.

Los sockets no bloqueantes con Selector también forman parte de java.nio, pero el código de aplicación rara vez los toca: Netty, el HttpClient del JDK y cualquier framework de servidor se ocupan de esa capa. Para archivos existe AsynchronousFileChannel, pero aporta poco frente a un pool de threads; y con los virtual threads, la E/S de archivos bloqueante de toda la vida sobre un virtual thread es la forma más sencilla de obtener concurrencia.

Gestión de recursos y errores

Cada archivo, channel, stream de directorio o stream de Files.lines abierto retiene un descriptor del sistema operativo. Los procesos tienen un límite (a menudo 1024 o 65536 en Linux), y una fuga de un descriptor por petición se convierte en IOException: Too many open files unas horas después del despliegue. La regla es mecánica: todo lo que implemente AutoCloseable va en un try-with-resources, como explica la guía de excepciones. Las herramientas de análisis estático detectan el olvido; activa esa regla.

IOException es checked, y la mayoría de quienes la reciben no pueden recuperarse. Tradúcela en tu frontera a UncheckedIOException con la ruta en el mensaje, new UncheckedIOException("Cannot read " + path, e), para que el log diga qué archivo falló. Files ya lanza subtipos con la ruta en el mensaje; consérvala al envolver la excepción.

Las escrituras parciales son el otro modo de fallo. Un crash a mitad de escritura deja un archivo truncado que la siguiente ejecución lee tan tranquila. El patrón para escrituras duraderas: escribir en un archivo temporal del mismo directorio, hacer force(true) sobre el channel o un fsync, y después Files.move con ATOMIC_MOVE. Los lectores ven o bien el archivo antiguo o bien el nuevo completo.

Errores comunes

Lecturas byte a byte sin buffer. FileInputStream.read() en un bucle hace una llamada al sistema por byte. Envuélvelo en un buffer o usa readAllBytes.

Ignorar el valor de retorno de read(byte[]). Puede llenar solo una parte del array. Haz un bucle, o usa readNBytes/transferTo.

Files.lines, list o walk sin try-with-resources. Mantienen el directorio o el archivo abierto hasta que se cierran; en un servicio que se ejecuta durante mucho tiempo, eso es una fuga de descriptores.

Charsets por defecto. En Java 17 y anteriores, new FileReader(f) usa lo que diga el sistema operativo. Pasa StandardCharsets.UTF_8 en todas partes y define -Dfile.encoding=UTF-8 en las JVM antiguas.

File.delete() devolviendo false en silencio. Usa Files.delete, que lanza una excepción con el motivo.

Rutas construidas con cadenas. dir + "/" + name falla en Windows y abre la puerta al path traversal. Usa resolve y valida contra la ruta base.

Leer de una vez un archivo que puede ser enorme. readAllBytes sobre un log de 4 GB es un OutOfMemoryError. Procésalo como stream.

Serialización Java para persistencia o transporte. Frágil entre versiones y un vector conocido de ejecución remota de código. Usa JSON, Protobuf o una base de datos.

Puntos clave

  • Los streams de bytes transportan datos binarios, los de caracteres texto decodificado, y decoradores como BufferedReader o GZIPInputStream se componen a su alrededor; cierra el más externo en un try-with-resources.
  • Usa Path y Files para todo el trabajo con el sistema de archivos; java.io.File es legado y sus fallos no explican nada.
  • Files.readString, writeString, lines, walk, copy, move y createDirectories cubren la mayoría de las necesidades; un ATOMIC_MOVE después de escribir un archivo temporal da actualizaciones a prueba de crashes.
  • Los streams que devuelven Files.lines, list y walk retienen descriptores: ciérralos siempre.
  • Indica el charset de forma explícita, aunque UTF-8 sea el valor por defecto desde Java 18.
  • Los channels y ByteBuffer justifican su complejidad para el acceso posicional, el bloqueo, el mapeo en memoria y la transferencia zero-copy; para todo lo demás, los métodos de Files son más sencillos y lo bastante rápidos.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Debo usar java.io.File o java.nio.file.Path?
Path y Files, para todo código nuevo. Los métodos de File devuelven false cuando fallan sin explicar por qué, no manejan bien los enlaces simbólicos ni los atributos de archivo, y no permiten recorrer ni vigilar directorios. Path es un valor inmutable que conoce su sistema de archivos, y Files lanza IOException descriptivas. Convierte con file.toPath() cuando una biblioteca todavía te entregue un File.
¿Qué diferencia hay entre un InputStream y un Reader?
Un InputStream entrega bytes en bruto; un Reader entrega caracteres ya decodificados. El texto hay que decodificarlo con un charset, que es justo lo que hace InputStreamReader entre ambos. Usa streams para datos binarios como imágenes, archivos comprimidos o payloads de protocolos, y readers para texto, indicando siempre el charset.
¿Files.lines es lo mismo que Files.readAllLines?
No. readAllLines carga el archivo entero en una List<String>; lines devuelve un Stream<String> perezoso que lee bajo demanda y que hay que cerrar con try-with-resources, porque mantiene el archivo abierto. Usa lines para archivos grandes o cuando vayas a parar antes del final, y readAllLines para archivos pequeños que de todos modos necesitas en memoria.
¿Cuándo necesito channels y ByteBuffer en lugar de streams?
Rara vez. Los channels ofrecen acceso aleatorio, bloqueo de archivos, mapeo en memoria, scatter/gather y transferTo, y son la base de la E/S de red no bloqueante. Para leer y escribir archivos de forma corriente, los métodos de conveniencia de Files y los streams con buffer son más sencillos e igual de rápidos. Recurre a FileChannel cuando necesites lecturas posicionales, hacer mmap de un archivo grande o una transferencia zero-copy entre channels.