Las dos lecciones anteriores dejaron una pregunta sin responder. Has escrito new PrintWriter(new FileWriter(ruta, UTF_8)) y has aceptado que funciona, pero ¿por qué PrintWriter envuelve a FileWriter en lugar de sustituirlo? ¿Por qué existen clases terminadas en Reader y Writer y otras terminadas en InputStream y OutputStream? ¿Y cómo se copia la imagen de portada de un libro, que no es texto y que ningún FileReader puede leer sin destrozarla?

Esta lección responde a las tres. java.io no es una colección de clases sueltas que hay que memorizar: es un diseño con una estructura muy deliberada, basada en dos ideas —el flujo como abstracción y el decorador como forma de componer— que, una vez entendidas, convierten cincuenta clases en un puñado de reglas.

Al terminar sabrás mirar cualquier expresión como new DataOutputStream(new BufferedOutputStream(new FileOutputStream(f))) y leer exactamente qué hace cada paréntesis, en qué orden fluyen los bytes y qué pasa si quitas una capa. Y BiblioTech podrá manejar las portadas de sus libros, que son ficheros binarios.

try-with-resources en todos los ejemplos, como siempre desde 06-06. Aquí adquiere un matiz nuevo que conviene tener presente: cerrar el decorador exterior cierra toda la cadena, y eso es exactamente lo que quieres.

Contenido

  1. Qué es un flujo: el modelo conceptual
  2. Las dos jerarquías y por qué son dos
  3. El mapa de las clases principales
  4. Clases de nodo y clases de filtro
  5. El patrón Decorador en java.io
  6. Los puentes: InputStreamReader y OutputStreamWriter
  7. FileInputStream y FileOutputStream: byte a byte
  8. Lectura por bloques y el contrato de read(byte[])
  9. Copiar un fichero binario: la portada de un libro
  10. DataInputStream y DataOutputStream: tipos primitivos
  11. Flujos en memoria: ByteArrayInputStream y ByteArrayOutputStream
  12. transferTo: la forma moderna de copiar
  13. Tamaño de bloque y rendimiento
  14. Qué flujo elijo para qué
  15. Errores Comunes y Consejos
  16. Ejercicios

  1. Qué es un flujo: el modelo conceptual

Un flujo (stream) es una secuencia ordenada de datos que se recorre de principio a fin y en un solo sentido.

La metáfora que le da nombre es la de una tubería de agua. El agua entra por un extremo y sale por otro; puedes tomar lo que va pasando, pero no puedes retroceder a ver lo que pasó hace un rato, ni adelantarte a lo que vendrá.

El vocabulario que hay que fijar:

Término Significado
Flujo La secuencia de datos y la clase que la gestiona
Fuente (source) De dónde salen los datos: fichero, red, memoria, teclado
Destino (sink) Adónde van: fichero, red, memoria, pantalla
Dirección Entrada (leer) o salida (escribir). Nunca las dos
Posición Cuánto se ha consumido. Avanza sola y no retrocede

Las tres propiedades que definen un flujo y que hay que interiorizar:

1. Es unidireccional. No existe una clase que lea y escriba. InputStream lee, OutputStream escribe. Si necesitas ambas cosas sobre el mismo fichero, abres dos flujos —o usas RandomAccessFile, que no es un flujo y que esta lección no cubre—.

2. Es secuencial. No hay acceso aleatorio: para llegar al byte 1000 tienes que consumir los 999 anteriores. Esta limitación es lo que permite que la misma abstracción sirva para un fichero local, una conexión de red o un dato que se está generando sobre la marcha.

3. Es agnóstico respecto a la fuente. Y esta es la propiedad que lo hace valioso. Un método que recibe un InputStream funciona con un fichero, con un socket del módulo 9, con un array de bytes en memoria o con la salida de otro proceso, sin cambiar una línea:

/**
 * Cuenta bytes de CUALQUIER flujo de entrada.
 *
 * No sabe ni le importa si viene de un fichero, de la red, de la memoria
 * o de un array. Eso es exactamente lo que aporta la abstraccion.
 */
public static long contarBytes(InputStream entrada) throws IOException {
    long total = 0;
    int leidos;
    byte[] bloque = new byte[8192];
    while ((leidos = entrada.read(bloque)) != -1) {
        total += leidos;
    }
    return total;
}

Ese método se puede llamar con un fichero, con una respuesta HTTP (módulo 9) o con datos generados en memoria. Es polimorfismo del módulo 3 aplicado a la E/S, y es la razón de que la jerarquía exista.

Un aviso terminológico importante para evitar una confusión muy extendida:

Estos flujos no tienen nada que ver con la API de Streams de colecciones (stream(), filter, map, collect), que es de Java 8 y se estudia en 10-04. Comparten el nombre en inglés y nada más. Los de esta lección son de Java 1.0, están en java.io y transportan bytes o caracteres; los de 10-04 están en java.util.stream y transportan elementos de una colección. El único punto de contacto lo verás en 07-06, con Files.lines().

  1. Las dos jerarquías y por qué son dos

Java tiene dos familias completas y paralelas de clases de flujo:

Familia de bytes Familia de caracteres
Clases base InputStream / OutputStream Reader / Writer
Unidad byte (8 bits, −128 a 127) char (16 bits, Unicode)
Desde Java 1.0 Java 1.1
Para Datos binarios: imágenes, PDF, ZIP, ejecutables Texto
Codificación No la conoce ni la necesita Imprescindible: traduce bytes a caracteres
Método de lectura read() devuelve 0..255 o −1 read() devuelve 0..65535 o −1
Ejemplos de nodo FileInputStream, FileOutputStream FileReader, FileWriter

Por qué no bastaba con una

Java 1.0 solo tenía la familia de bytes, y se descubrió pronto que era insuficiente. El motivo es el que ya conoces de 07-01: un carácter no es un byte.

En ASCII, la correspondencia es 1 a 1 y la distinción no importa. Pero en cuanto entra Unicode:

  • ñ en UTF-8 son dos bytes.
  • son tres.
  • Un emoji, cuatro.

Si solo tienes flujos de bytes y quieres leer texto, tienes que hacer la decodificación tú: acumular bytes, saber cuántos forman el siguiente carácter, gestionar los caracteres partidos entre dos lecturas. Eso es complejo, propenso a errores y siempre igual. La familia Reader/Writer lo hace por ti.

Y hay una segunda razón, más sutil: los caracteres pueden partirse entre dos bloques. Si lees 8192 bytes y el último es el primero de una ñ, el segundo byte llega en la lectura siguiente. Un Reader mantiene ese estado interno y te entrega la ñ completa. Manejarlo a mano es exactamente el tipo de error que aparece solo con ficheros grandes y solo a veces.

La regla de decisión es simple y no admite excusas:

¿El fichero es texto que una persona podría leer? Usa Reader/Writer. ¿Son datos binarios? Usa InputStream/OutputStream. ¿No estás seguro? Es binario. Trátalo como bytes y no lo estropearás.

Qué pasa si te equivocas: si lees un JPEG con un FileReader, el decodificador intenta interpretar bytes arbitrarios como caracteres UTF-8. La mayoría no forman secuencias válidas y se sustituyen por . Los datos se corrompen de forma irreversible. Si luego los escribes, la imagen ya no se abre. Es la corrupción silenciosa de 07-01 llevada al extremo.

  1. El mapa de las clases principales

La jerarquía de bytes:

flowchart TD
    IS["InputStream<br/>(abstracta)"]
    IS --> FIS["FileInputStream<br/>NODO: fichero"]
    IS --> BAIS["ByteArrayInputStream<br/>NODO: memoria"]
    IS --> OTROS["Otros nodos:<br/>socket, proceso, recurso"]
    IS --> FILT["FilterInputStream<br/>(decorador base)"]
    FILT --> BIS["BufferedInputStream<br/>anade buffer"]
    FILT --> DIS["DataInputStream<br/>anade tipos primitivos"]
    IS --> OIS["ObjectInputStream<br/>objetos serializados (07-05)"]

    style IS fill:#e3f2fd
    style FILT fill:#fff3e0
    style FIS fill:#e8f5e9
    style BAIS fill:#e8f5e9

La jerarquía de caracteres:

flowchart TD
    R["Reader<br/>(abstracta)"]
    R --> ISR["InputStreamReader<br/>PUENTE bytes a caracteres"]
    ISR --> FR["FileReader<br/>NODO: fichero de texto"]
    R --> SR["StringReader<br/>NODO: memoria"]
    R --> CAR["CharArrayReader<br/>NODO: memoria"]
    R --> FIR["FilterReader<br/>(decorador base)"]
    R --> BR["BufferedReader<br/>anade buffer y readLine"]

    style R fill:#e3f2fd
    style ISR fill:#f3e5f5
    style BR fill:#fff3e0
    style FR fill:#e8f5e9

Y la de escritura, que es simétrica:

flowchart TD
    W["Writer<br/>(abstracta)"]
    W --> OSW["OutputStreamWriter<br/>PUENTE caracteres a bytes"]
    OSW --> FW["FileWriter<br/>NODO: fichero de texto"]
    W --> SW["StringWriter<br/>NODO: memoria"]
    W --> BW["BufferedWriter<br/>anade buffer y newLine"]
    W --> PW["PrintWriter<br/>anade println y printf"]

    style W fill:#e3f2fd
    style OSW fill:#f3e5f5
    style BW fill:#fff3e0
    style PW fill:#fff3e0
    style FW fill:#e8f5e9

Fíjate en un detalle revelador de los dos últimos diagramas: FileReader extiende InputStreamReader y FileWriter extiende OutputStreamWriter. No son clases independientes: son atajos. FileReader es un puente al que ya le han conectado un FileInputStream por dentro. Esto explica de golpe por qué acepta un Charset en el constructor y por qué en 07-01 la alternativa new InputStreamReader(new FileInputStream(ruta), UTF_8) hacía exactamente lo mismo.

El contrato mínimo de cada clase base, que es lo único que hay que memorizar:

// InputStream
public abstract int read() throws IOException;            // 0..255, o -1 al final
public int read(byte[] b) throws IOException;             // cuantos leyo, o -1
public int read(byte[] b, int off, int len);
public void close() throws IOException;
public long transferTo(OutputStream destino);             // Java 9

// OutputStream
public abstract void write(int b) throws IOException;     // el byte menos significativo
public void write(byte[] b);
public void write(byte[] b, int off, int len);
public void flush() throws IOException;
public void close() throws IOException;

// Reader
public int read() throws IOException;                     // 0..65535, o -1
public int read(char[] c);
public void close() throws IOException;

// Writer
public void write(int c);
public void write(String s);
public void write(char[] c);
public void flush();
public void close();

Todo lo demás son añadidos. readLine() es de BufferedReader, println() es de PrintWriter, readInt() es de DataInputStream. Si conoces las cuatro clases base, sabes leer cualquier código de E/S de Java.

  1. Clases de nodo y clases de filtro

Esta es la distinción que organiza todo:

Clase de nodo (o de conexión) Clase de filtro (o decorador)
De dónde saca los datos De un recurso real: fichero, red, memoria De otro flujo
Qué añade El acceso al recurso Una capacidad: búfer, tipos, formato
Se puede usar sola No: necesita algo que envolver
Ejemplos de entrada FileInputStream, ByteArrayInputStream BufferedInputStream, DataInputStream
Ejemplos de salida FileOutputStream, ByteArrayOutputStream BufferedOutputStream, PrintWriter

Se reconocen a simple vista por el constructor:

// NODO: recibe un recurso (una ruta, un File, un array)
new FileInputStream("catalogo.txt")
new ByteArrayInputStream(datos)

// FILTRO: recibe OTRO FLUJO
new BufferedInputStream(otroFlujo)
new DataInputStream(otroFlujo)
new PrintWriter(otroWriter)

Y de ahí la regla que explica todas las expresiones anidadas que has visto:

La capa más interna siempre es un nodo. Todas las de fuera son filtros.

new DataInputStream(          // filtro: anade readInt, readDouble...
    new BufferedInputStream(  // filtro: anade buffer
        new FileInputStream(  // NODO: conecta con el fichero
            "datos/prestamos.bin")))

Se lee de dentro afuera: "abro el fichero, le pongo un búfer, y encima leo tipos primitivos".

  1. El patrón Decorador en java.io

Lo que acabas de ver tiene nombre propio en el catálogo de patrones de diseño: es el Decorador, y java.io es su ejemplo canónico —el que aparece en todos los libros—.

La idea: en lugar de crear una clase para cada combinación de capacidades, se crean capas que se envuelven unas a otras. Cada decorador implementa la misma interfaz que decora y delega en el envuelto, añadiendo su parte.

Imagina la alternativa sin decorador. Si quisieras cubrir todas las combinaciones de fuente (fichero, memoria, red) por capacidad (con búfer, con tipos, con ambos), necesitarías una clase por combinación:

Simple Con búfer Con tipos Con búfer y tipos
Fichero FileInput BufferedFileInput DataFileInput BufferedDataFileInput
Memoria MemoryInput BufferedMemoryInput DataMemoryInput BufferedDataMemoryInput
Red SocketInput BufferedSocketInput DataSocketInput BufferedDataSocketInput

Doce clases para tres fuentes y dos capacidades. Y cada capacidad nueva multiplica el número: con una tercera capacidad serían veinticuatro. Es la explosión combinatoria que el decorador evita: 3 nodos + 2 filtros = 5 clases, con las doce combinaciones disponibles por composición.

Cómo fluyen los datos por la cadena:

flowchart LR
    APP["Tu codigo"] -->|"readInt()"| D["DataInputStream"]
    D -->|"read(byte[4])"| B["BufferedInputStream"]
    B -->|"read(byte[8192])<br/>solo si el buffer esta vacio"| F["FileInputStream"]
    F -->|"llamada al sistema"| SO["Sistema operativo"]
    SO --> DISCO["Disco"]

    style D fill:#fff3e0
    style B fill:#fff3e0
    style F fill:#e8f5e9

Sigue una llamada a readInt() por esa cadena:

  1. DataInputStream.readInt() necesita 4 bytes y se los pide al BufferedInputStream.
  2. BufferedInputStream mira su búfer interno. Si tiene 4 bytes, los devuelve sin tocar el disco.
  3. Si está vacío, pide 8192 bytes de golpe al FileInputStream, los guarda y devuelve los 4 pedidos.
  4. FileInputStream hace una llamada al sistema.
  5. DataInputStream combina los 4 bytes en un int y lo devuelve.

Dos mil llamadas a readInt() producen una sola llamada al sistema. Ese es el valor del búfer, y es el tema completo de 07-04.

Las tres consecuencias prácticas del decorador, que hay que tener siempre presentes:

1. El orden importa. Estas dos cadenas no son equivalentes:

// CORRECTO: el buffer esta entre el fichero y los tipos
new DataInputStream(new BufferedInputStream(new FileInputStream(f)))

// INUTIL: el buffer esta fuera. DataInputStream lee directo del fichero,
// 4 bytes cada vez, con una llamada al sistema por cada uno.
new BufferedInputStream(new DataInputStream(new FileInputStream(f)))

La regla general: el búfer va lo más cerca posible del nodo, para que sea él quien absorba las llamadas al sistema.

2. Cerrar el exterior cierra todo. El close() de cada decorador llama al close() del envuelto, en cascada. Por eso basta con declarar la capa exterior en el try-with-resources:

// Basta con esto: el close() de PrintWriter cierra BufferedWriter,
// que cierra FileWriter, que cierra el fichero.
try (PrintWriter salida = new PrintWriter(
        new BufferedWriter(
            new FileWriter("informe.txt", StandardCharsets.UTF_8)))) {
    salida.println("...");
}

Y esto tiene un corolario incómodo que conviene conocer: si el constructor de una capa intermedia falla, las capas ya construidas quedan sin cerrar. Con la cadena de arriba, si new BufferedWriter(...) lanzara una excepción, el FileWriter ya construido quedaría abierto y sin referencia. Es un caso raro pero real; la forma robusta es declarar cada capa por separado en el try-with-resources:

try (FileWriter fw = new FileWriter("informe.txt", StandardCharsets.UTF_8);
     BufferedWriter bw = new BufferedWriter(fw);
     PrintWriter salida = new PrintWriter(bw)) {
    salida.println("...");
}
// Cada recurso declarado por separado: todos se cierran, en orden inverso (06-06)

Más verboso, pero sin ese hueco. Para código normal, la forma anidada es aceptable; para código de infraestructura crítica, esta.

3. La abstracción se conserva. Un método que recibe InputStream acepta cualquier cadena de decoradores, porque todos son InputStream.

Este patrón se formaliza, con su estructura general y sus otros usos, en la lección 12-02. Aquí lo has visto funcionando en el sitio donde mejor se aprecia.

  1. Los puentes: InputStreamReader y OutputStreamWriter

Las dos jerarquías no están aisladas: hay dos clases que las conectan, y son exactamente el punto donde se decide la codificación que llevas dos lecciones especificando.

Clase Convierte Es un
InputStreamReader Bytes → caracteres Reader que envuelve un InputStream
OutputStreamWriter Caracteres → bytes Writer que envuelve un OutputStream
flowchart LR
    subgraph BYTES["Mundo de BYTES"]
        FIS["FileInputStream"]
    end
    subgraph PUENTE["EL PUENTE"]
        ISR["InputStreamReader<br/>charset UTF-8"]
    end
    subgraph CARS["Mundo de CARACTERES"]
        BR["BufferedReader"]
        APP["Tu codigo:<br/>String"]
    end

    FIS -->|"bytes 0xC3 0xB1"| ISR
    ISR -->|"caracter 'n con tilde'"| BR
    BR --> APP

    style ISR fill:#f3e5f5

Ese diagrama contiene la idea completa: la traducción ocurre en un único punto, y ese punto es donde se especifica el charset. Todo lo que está a la izquierda son bytes sin significado; todo lo que está a la derecha son caracteres.

import java.io.*;
import java.nio.charset.StandardCharsets;

public class UsoDelPuente {

    /** Lectura de texto con la cadena completa, explicita. */
    public static void leer(String ruta) throws IOException {
        try (BufferedReader lector = new BufferedReader(       // 3. buffer y readLine
                new InputStreamReader(                          // 2. PUENTE: aqui el charset
                        new FileInputStream(ruta),              // 1. NODO: bytes del fichero
                        StandardCharsets.UTF_8))) {

            String linea;
            while ((linea = lector.readLine()) != null) {
                System.out.println(linea);
            }
        }
    }

    /** Escritura simetrica. */
    public static void escribir(String ruta, String texto) throws IOException {
        try (BufferedWriter escritor = new BufferedWriter(
                new OutputStreamWriter(
                        new FileOutputStream(ruta),
                        StandardCharsets.UTF_8))) {
            escritor.write(texto);
        }
    }
}

Ahora ya puedes ver la equivalencia exacta con lo que usaste en 07-01 y 07-02:

// Estas dos lineas son practicamente lo mismo:
new FileReader(ruta, StandardCharsets.UTF_8)
new InputStreamReader(new FileInputStream(ruta), StandardCharsets.UTF_8)

// Y estas dos tambien:
new FileWriter(ruta, StandardCharsets.UTF_8)
new OutputStreamWriter(new FileOutputStream(ruta), StandardCharsets.UTF_8)

FileReader es literalmente una subclase de InputStreamReader con el FileInputStream ya conectado. La forma corta es cómoda para ficheros; la larga es necesaria en cuanto la fuente no es un fichero:

// Leer texto de la ENTRADA ESTANDAR: no hay un "FileReader de System.in"
new InputStreamReader(System.in, StandardCharsets.UTF_8)

// Leer texto de una conexion de red (modulo 9)
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)

// Leer texto de un recurso dentro del .jar
new InputStreamReader(getClass().getResourceAsStream("/config.txt"),
                      StandardCharsets.UTF_8)

Ninguno de esos tres casos se puede resolver con FileReader, porque la fuente no es un fichero. El puente es lo que hace que el charset sea especificable en cualquier fuente de texto.

Y un caso especial que merece un aviso: System.out es un PrintStream, un flujo de bytes con métodos de texto. Es una rareza histórica de Java 1.0, anterior a la existencia de Writer. Por eso System.out.println("Diseño") puede mostrar mal las tildes en una consola de Windows: la conversión la hace PrintStream con la codificación de la consola, que no tiene por qué ser UTF-8. Si necesitas control:

PrintWriter consola = new PrintWriter(
        new OutputStreamWriter(System.out, StandardCharsets.UTF_8), true);
consola.println("Patrones de Diseño");   // charset controlado

El true del segundo parámetro activa el vaciado automático en cada println, que en consola es lo que quieres.

  1. FileInputStream y FileOutputStream: byte a byte

Estas son las clases de nodo para datos binarios. Su API es la de InputStream/OutputStream sin añadidos.

import java.io.FileInputStream;
import java.io.IOException;

public class LecturaBinariaSimple {

    /** Muestra los primeros bytes de un fichero en hexadecimal. */
    public static void volcarCabecera(String ruta, int cuantos) throws IOException {
        try (FileInputStream entrada = new FileInputStream(ruta)) {

            for (int i = 0; i < cuantos; i++) {
                int b = entrada.read();          // 0..255, o -1 al final
                if (b == -1) {
                    System.out.println("\n(fin del fichero)");
                    break;
                }
                System.out.printf("%02X ", b);
                if ((i + 1) % 16 == 0) {
                    System.out.println();
                }
            }
            System.out.println();
        }
    }

    public static void main(String[] args) throws IOException {
        volcarCabecera("portadas/java-efectivo.jpg", 32);
    }
}

Salida con un JPEG:

FF D8 FF E0 00 10 4A 46 49 46 00 01 01 00 00 48
00 48 00 00 FF DB 00 43 00 08 06 06 07 06 05 08

Esos primeros bytes son la firma del formato: FF D8 FF identifica un JPEG, igual que 89 50 4E 47 identifica un PNG y 25 50 44 46 identifica un PDF. Es el tipo de dato que solo se puede leer con flujos de bytes.

El detalle técnico que confunde a todo el mundo:

read() devuelve un int entre 0 y 255, pero el tipo byte de Java va de −128 a 127. El método hace la conversión sin signo por ti. Si conviertes a byte, el valor 200 se convierte en −56. Para volver a 0..255 hay que enmascarar: b & 0xFF.

byte b = -56;                    // asi lo guarda un byte[]
int sinSigno = b & 0xFF;         // 200: el valor real
System.out.printf("%02X%n", sinSigno);   // C8

Este detalle causa fallos reales en checksums, comparaciones y volcados hexadecimales. Cuando trabajes con bytes, enmascara con & 0xFF siempre que necesites el valor numérico.

En escritura, la asimetría es simétrica:

try (FileOutputStream salida = new FileOutputStream("prueba.bin")) {
    salida.write(255);     // escribe el byte 0xFF
    salida.write(65);      // escribe el byte 0x41, que es la 'A' en ASCII
    salida.write(300);     // escribe 0x2C: se queda con los 8 bits BAJOS, sin avisar
}

write(int) descarta los bits altos en silencio. Escribir 300 escribe 44. Es una fuente de errores en código que calcula valores y los escribe sin comprobar el rango.

Y como en 07-02, FileOutputStream tiene su parámetro append:

new FileOutputStream("datos.bin");         // SOBRESCRIBE, trunca a cero
new FileOutputStream("datos.bin", true);   // ANADE al final

Con las mismas consecuencias y el mismo riesgo. La advertencia de 07-02 se aplica idéntica.

  1. Lectura por bloques y el contrato de read(byte[])

Leer byte a byte tiene el mismo problema de rendimiento que leer carácter a carácter, por la misma razón. La solución inmediata es leer por bloques:

public int read(byte[] buffer) throws IOException

Su contrato tiene tres puntos que hay que conocer con precisión, porque los tres se fallan:

1. Devuelve cuántos bytes ha leído, no cuántos caben. Puede devolver menos que el tamaño del array aunque queden datos por leer. Con un fichero local esto ocurre solo al final; con un socket de red (módulo 9) ocurre constantemente, porque los datos llegan a trozos.

2. Devuelve −1 solo cuando no hay absolutamente nada más. Un 0 es posible con un array de longitud cero, pero nunca significa fin de fichero.

3. No garantiza llenar el array. No existe ninguna promesa de que lo haga. Suponerlo es el error clásico.

El bucle correcto:

import java.io.FileInputStream;
import java.io.IOException;

public class LecturaPorBloques {

    private static final int TAMANO_BLOQUE = 8192;

    public static long contarBytes(String ruta) throws IOException {
        long total = 0;

        try (FileInputStream entrada = new FileInputStream(ruta)) {
            byte[] bloque = new byte[TAMANO_BLOQUE];
            int leidos;

            // 'leidos' es CUANTOS bytes hay de verdad en el array.
            // Solo los primeros 'leidos' son datos nuevos; el resto es
            // basura de la iteracion anterior.
            while ((leidos = entrada.read(bloque)) != -1) {
                total += leidos;
                procesar(bloque, leidos);          // SIEMPRE con el limite
            }
        }
        return total;
    }

    private static void procesar(byte[] bloque, int cuantos) {
        for (int i = 0; i < cuantos; i++) {        // hasta 'cuantos', no hasta length
            // ...
        }
    }
}

El error, en su forma más habitual y más destructiva:

// MAL: copia SIEMPRE el array entero
while ((leidos = entrada.read(bloque)) != -1) {
    salida.write(bloque);                     // <-- deberia ser write(bloque, 0, leidos)
}

Con un fichero de 10 000 bytes y bloques de 8192: la primera lectura trae 8192, la segunda trae 1808. Pero write(bloque) escribe 8192 en las dos, así que el fichero copiado tiene 16 384 bytes en vez de 10 000, y los últimos 6 384 son basura de la primera lectura. Con una imagen, el resultado no se abre. Con un ZIP, está corrupto.

Escribe siempre write(bloque, 0, leidos). Es la misma trampa del append(bloque, 0, leidos) de 07-01, en su versión binaria y más grave.

Y como aviso: si necesitas de verdad llenar el array —porque el formato exige leer exactamente N bytes—, hay que insistir en bucle. Java lo tiene resuelto:

// Java 9+: lee EXACTAMENTE n bytes o lanza EOFException
byte[] cabecera = entrada.readNBytes(16);

// Java 9+: lee TODO lo que queda. Cuidado con ficheros grandes (07-01)
byte[] todo = entrada.readAllBytes();

  1. Copiar un fichero binario: la portada de un libro

BiblioTech guarda la imagen de portada de cada libro. Copiarlas requiere flujos de bytes, sí o sí.

package com.nexussoftware.bibliotech.servicio;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.logging.Logger;

/**
 * Gestion de las imagenes de portada del catalogo de BiblioTech.
 *
 * Las portadas son JPEG o PNG: datos BINARIOS. Usar Reader/Writer sobre
 * ellas las destruiria, porque el decodificador de caracteres sustituye
 * los bytes que no forman secuencias validas.
 */
public class GestorPortadas {

    private static final Logger LOG = Logger.getLogger(GestorPortadas.class.getName());

    private static final int TAMANO_BLOQUE = 8192;
    private static final long TAMANO_MAXIMO = 5 * 1024 * 1024;   // 5 MB

    private final File directorio;

    public GestorPortadas(String directorioPortadas) {
        this.directorio = new File(directorioPortadas);
        if (!directorio.exists() && !directorio.mkdirs()) {
            LOG.warning("No se pudo crear " + directorio.getAbsolutePath());
        }
    }

    /**
     * Copia la portada de un libro al almacen, nombrandola por su ISBN.
     *
     * @return bytes copiados
     * @throws IOException si falla la copia
     */
    public long importarPortada(String isbn, File origen) throws IOException {
        if (!origen.isFile()) {
            throw new IOException("No es un fichero legible: " + origen.getAbsolutePath());
        }
        if (origen.length() > TAMANO_MAXIMO) {
            throw new IOException("La portada supera el maximo de "
                    + (TAMANO_MAXIMO / 1024) + " KB: " + origen.length() + " bytes");
        }

        File destino = new File(directorio, isbn + extension(origen.getName()));
        long copiados = copiar(origen, destino);

        LOG.info(() -> String.format("Portada de %s importada: %d bytes en %s",
                isbn, copiados, destino.getName()));
        return copiados;
    }

    /**
     * Copia binaria byte a byte, con buffer en ambos extremos.
     *
     * La cadena de decoradores:
     *   FileInputStream  -> NODO: lee del fichero
     *   BufferedInputStream -> FILTRO: agrupa las lecturas
     *   (y lo mismo en el lado de escritura)
     */
    public long copiar(File origen, File destino) throws IOException {
        long total = 0;

        try (BufferedInputStream entrada =
                     new BufferedInputStream(new FileInputStream(origen), TAMANO_BLOQUE);
             BufferedOutputStream salida =
                     new BufferedOutputStream(new FileOutputStream(destino), TAMANO_BLOQUE)) {

            byte[] bloque = new byte[TAMANO_BLOQUE];
            int leidos;

            while ((leidos = entrada.read(bloque)) != -1) {
                // CLAVE: (bloque, 0, leidos), NUNCA (bloque) a secas.
                // Con (bloque) se copiaria basura al final y la imagen no abriria.
                salida.write(bloque, 0, leidos);
                total += leidos;
            }
            // El close() del try-with-resources hace flush: sin el, los ultimos
            // bytes del buffer no llegarian al fichero (07-02).
        }
        return total;
    }

    /** Detecta el formato leyendo la FIRMA del fichero, no su extension. */
    public String detectarFormato(File fichero) throws IOException {
        try (FileInputStream entrada = new FileInputStream(fichero)) {
            byte[] firma = entrada.readNBytes(8);      // Java 9+

            if (firma.length >= 3
                    && (firma[0] & 0xFF) == 0xFF
                    && (firma[1] & 0xFF) == 0xD8
                    && (firma[2] & 0xFF) == 0xFF) {
                return "JPEG";
            }
            if (firma.length >= 8
                    && (firma[0] & 0xFF) == 0x89
                    && firma[1] == 'P' && firma[2] == 'N' && firma[3] == 'G') {
                return "PNG";
            }
            if (firma.length >= 4 && firma[0] == 'G' && firma[1] == 'I' && firma[2] == 'F') {
                return "GIF";
            }
            return "DESCONOCIDO";
        }
    }

    private String extension(String nombre) {
        int punto = nombre.lastIndexOf('.');
        return (punto > 0) ? nombre.substring(punto).toLowerCase() : ".bin";
    }
}

Dos cosas que enseña esta clase más allá de la copia:

  • El & 0xFF de detectarFormato. Sin él, la comparación firma[0] == 0xFF sería siempre falsa: firma[0] vale −1 como byte, y 0xFF vale 255 como int. Este es exactamente el fallo del apartado 7, y aquí se ve por qué importa.
  • Detectar el formato por la firma, no por la extensión. La extensión es una convención que cualquiera puede cambiar; los primeros bytes son el formato real. Es una comprobación de seguridad elemental cuando aceptas ficheros de terceros.

  1. DataInputStream y DataOutputStream: tipos primitivos

Estos dos filtros añaden métodos para leer y escribir tipos primitivos de Java en un formato binario definido y portable.

import java.io.*;

public class DatosBinarios {

    public static void escribir(String ruta) throws IOException {
        try (DataOutputStream salida = new DataOutputStream(
                new BufferedOutputStream(new FileOutputStream(ruta)))) {

            salida.writeUTF("PR-0001");        // cadena: 2 bytes de longitud + UTF-8 modificado
            salida.writeInt(15);               // 4 bytes
            salida.writeDouble(0.25);          // 8 bytes
            salida.writeBoolean(true);         // 1 byte
            salida.writeLong(1_700_000_000L);  // 8 bytes
        }
    }

    public static void leer(String ruta) throws IOException {
        try (DataInputStream entrada = new DataInputStream(
                new BufferedInputStream(new FileInputStream(ruta)))) {

            // El ORDEN de lectura debe ser EXACTAMENTE el de escritura.
            // El fichero no lleva ninguna descripcion de su contenido.
            String referencia = entrada.readUTF();
            int    dias       = entrada.readInt();
            double tarifa     = entrada.readDouble();
            boolean devuelto  = entrada.readBoolean();
            long   marca      = entrada.readLong();

            System.out.printf("%s: %d dias, %.2f EUR/dia, devuelto=%b, marca=%d%n",
                    referencia, dias, tarifa, devuelto, marca);
        }
    }
}

El formato es fijo y documentado:

Método Bytes Formato
writeByte 1 El byte
writeShort 2 Big-endian
writeInt 4 Big-endian
writeLong 8 Big-endian
writeFloat 4 IEEE 754
writeDouble 8 IEEE 754
writeBoolean 1 0 o 1
writeChar 2 UTF-16
writeUTF 2 + n Longitud de 2 bytes + UTF-8 modificado

Las tres propiedades que hacen útil este formato:

  1. Es portable. Siempre big-endian, independientemente de la arquitectura de la máquina. Un fichero escrito en un servidor ARM se lee igual en un x86.
  2. Es compacto. Un int ocupa 4 bytes siempre. En texto, 1234567890 ocuparía 10.
  3. Es rápido. No hay parseo: los bytes se combinan directamente en el valor.

Y sus tres limitaciones, que hay que aceptar antes de elegirlo:

  1. No es autodescriptivo. El fichero no dice qué contiene. Si lees en distinto orden del que escribiste, obtienes basura sin ningún error: un readDouble sobre lo que era un int y un float devuelve un número perfectamente válido y completamente falso.
  2. No es legible. No se puede abrir con un editor ni comparar con diff.
  3. Es frágil ante cambios. Añadir un campo rompe todos los ficheros existentes.

Un aviso concreto sobre writeUTF: no escribe UTF-8 estándar. Usa el llamado UTF-8 modificado, que codifica el carácter nulo de forma distinta y las cadenas están limitadas a 65 535 bytes. Es un formato interno de Java: úsalo solo para ficheros que va a leer Java. Para intercambio con otros lenguajes, texto.

Cuándo usar DataStream:

Situación ¿DataStream?
Caché binaria interna de la aplicación Sí, encaja bien
Formato propio compacto y controlado
Protocolo binario de red (módulo 9) Sí, es su caso natural
Datos que otro lenguaje debe leer No: texto o un formato estándar
Datos que alguien va a inspeccionar No: no es legible
Datos con estructura de objetos y referencias No: eso es serialización (07-05)

BiblioTech no lo usará para su catálogo —quiere ficheros legibles y versionables, y eso es 07-07—, pero conocerlo es imprescindible para el módulo 9 y para entender que la serialización de 07-05 está construida sobre estas mismas primitivas.

  1. Flujos en memoria: ByteArrayInputStream y ByteArrayOutputStream

Estos dos nodos no tocan el disco: su "fichero" es un array de bytes en memoria.

import java.io.*;
import java.nio.charset.StandardCharsets;

public class FlujosEnMemoria {

    /** ByteArrayOutputStream: acumula en memoria lo que se le escribe. */
    public static byte[] generarEnMemoria() throws IOException {
        ByteArrayOutputStream memoria = new ByteArrayOutputStream();

        try (DataOutputStream salida = new DataOutputStream(memoria)) {
            salida.writeUTF("PR-0001");
            salida.writeInt(15);
            salida.writeDouble(0.25);
        }
        // toByteArray() devuelve una COPIA de lo acumulado
        return memoria.toByteArray();
    }

    /** ByteArrayInputStream: lee de un array como si fuera un fichero. */
    public static void leerDeMemoria(byte[] datos) throws IOException {
        try (DataInputStream entrada = new DataInputStream(
                new ByteArrayInputStream(datos))) {

            System.out.println(entrada.readUTF());
            System.out.println(entrada.readInt());
            System.out.println(entrada.readDouble());
        }
    }

    public static void main(String[] args) throws IOException {
        byte[] datos = generarEnMemoria();
        System.out.println("Generados " + datos.length + " bytes sin tocar el disco");
        leerDeMemoria(datos);
    }
}

Para qué sirven de verdad:

Uso Explicación
Pruebas Probar un método que recibe InputStream sin crear ficheros. Es su uso más valioso
Búfer completo Acumular una salida y decidir después qué hacer con ella
Conversión Pasar entre byte[] y flujo cuando una API exige uno y tienes el otro
Enviar por red Preparar el contenido en memoria y mandarlo de una vez (módulo 9)
Copia de seguridad en memoria Guardar el estado antes de una operación arriesgada

El uso en pruebas merece un ejemplo, porque cambia la forma de escribir código verificable:

/**
 * Metodo de produccion: recibe un InputStream, no una ruta.
 * Esa decision de diseno es lo que lo hace probable sin ficheros.
 */
public int contarLineas(InputStream entrada) throws IOException {
    int lineas = 0;
    try (BufferedReader lector = new BufferedReader(
            new InputStreamReader(entrada, StandardCharsets.UTF_8))) {
        while (lector.readLine() != null) {
            lineas++;
        }
    }
    return lineas;
}

// PRUEBA: sin fichero, sin disco, sin limpieza, sin dependencias del entorno
public void pruebaContarLineas() {
    String contenido = "LIBRO;978-0000000001;Java Efectivo\n"
                     + "LIBRO;978-0000000002;Patrones de Diseno\n";

    InputStream simulado =
            new ByteArrayInputStream(contenido.getBytes(StandardCharsets.UTF_8));

    int resultado = contarLineas(simulado);
    // se esperan 2
}

Lección de diseño que conviene grabarse: un método que recibe InputStream se puede probar con un array en memoria; uno que recibe String ruta obliga a crear ficheros de prueba, limpiarlos y depender del sistema de ficheros. Acepta la abstracción más general que te sirva. Esto se retomará con JUnit en 11-04.

Y una nota práctica: ByteArrayOutputStream no necesita cerrarse —su close() no hace nada— y por eso en el ejemplo está fuera del try-with-resources. Si estuviera dentro, quedaría cerrado antes de poder llamar a toByteArray(). Es una de las poquísimas excepciones legítimas a la regla de cerrarlo todo, y de las que 06-06 ya advertía.

Sus equivalentes en la familia de caracteres son StringWriter y StringReader, útiles cuando lo que acumulas es texto:

StringWriter memoria = new StringWriter();
try (PrintWriter salida = new PrintWriter(memoria)) {
    salida.printf("%-25s %8.2f%n", "Java Efectivo", 3.75);
}
String resultado = memoria.toString();

  1. transferTo: la forma moderna de copiar

Java 9 añadió un método que reduce todo el bucle de copia a una línea:

public long transferTo(OutputStream destino) throws IOException
import java.io.*;

public class CopiaModerna {

    /** Copia completa. Java 9+. */
    public static long copiar(File origen, File destino) throws IOException {
        try (InputStream entrada = new BufferedInputStream(new FileInputStream(origen));
             OutputStream salida = new BufferedOutputStream(new FileOutputStream(destino))) {

            return entrada.transferTo(salida);      // todo el bucle, en una linea
        }
    }
}

Comparación con el bucle manual:

Bucle manual transferTo
Líneas de código 6-8 1
Riesgo de olvidar (bloque, 0, leidos) Alto Ninguno
Tamaño de bloque Lo eliges tú Interno (8 KB)
Progreso durante la copia Se puede informar No se puede
Transformar los datos al vuelo No
Disponible desde Siempre Java 9

Usa transferTo cuando solo quieras copiar. Usa el bucle cuando necesites informar del progreso, transformar o detenerte a mitad:

/** Copia informando del progreso: aqui el bucle sigue siendo necesario. */
public long copiarConProgreso(File origen, File destino) throws IOException {
    long tamano = origen.length();
    long copiados = 0;
    int ultimoPorcentaje = -1;

    try (InputStream entrada = new BufferedInputStream(new FileInputStream(origen));
         OutputStream salida = new BufferedOutputStream(new FileOutputStream(destino))) {

        byte[] bloque = new byte[8192];
        int leidos;
        while ((leidos = entrada.read(bloque)) != -1) {
            salida.write(bloque, 0, leidos);
            copiados += leidos;

            int porcentaje = (int) (copiados * 100 / Math.max(1, tamano));
            if (porcentaje != ultimoPorcentaje && porcentaje % 10 == 0) {
                System.out.printf("  %d%% (%d de %d bytes)%n", porcentaje, copiados, tamano);
                ultimoPorcentaje = porcentaje;
            }
        }
    }
    return copiados;
}

Y en 07-06 verás la forma más alta de todas, que ni siquiera abre flujos:

Files.copy(origen, destino, StandardCopyOption.REPLACE_EXISTING);

  1. Tamaño de bloque y rendimiento

¿Cuánto importa el tamaño del bloque? Estas cifras, ilustrativas y medidas sobre un fichero de 100 MB en un SSD corriente, dan la forma de la curva:

Técnica Tiempo orientativo Relativo
read() byte a byte, sin búfer ~95 000 ms 1x (referencia)
read() byte a byte, con BufferedInputStream ~1 400 ms 68x
Bloques de 512 bytes ~420 ms 226x
Bloques de 8 KB ~180 ms 528x
Bloques de 64 KB ~165 ms 576x
Bloques de 1 MB ~170 ms 559x
transferTo ~150 ms 633x
Files.copy (07-06) ~130 ms 731x

Las tres conclusiones:

1. El salto que importa es el primero. De byte a byte sin búfer a cualquier cosa con búfer hay un factor de 70. De 8 KB a 64 KB hay un 8%. Si solo vas a hacer una optimización, es esa.

2. 8 KB es el punto de equilibrio. No es casualidad: coincide con el tamaño típico de bloque del sistema de ficheros, y es el valor por defecto de BufferedInputStream y de transferTo. Subir más da rendimientos decrecientes y consume memoria: cien copias concurrentes con búferes de 1 MB son 100 MB de heap.

3. Un búfer demasiado grande puede ser peor. Deja de caber en la caché del procesador y aumenta la presión sobre el recolector de basura. Más no es mejor a partir de cierto punto.

Regla práctica: 8 KB salvo que hayas medido y tengas un motivo. Y antes de tocar el tamaño del bloque, asegúrate de que hay un búfer: ahí está el 99% de la mejora posible.

  1. Qué flujo elijo para qué

La tabla de decisión completa del módulo:

Necesito... Clase Familia
Leer texto de un fichero BufferedReader sobre FileReader Caracteres
Escribir texto en un fichero PrintWriter o BufferedWriter sobre FileWriter Caracteres
Leer texto de la consola BufferedReader sobre InputStreamReader(System.in) Caracteres
Leer texto de la red BufferedReader sobre InputStreamReader(socket) Caracteres
Leer un fichero binario BufferedInputStream sobre FileInputStream Bytes
Escribir un fichero binario BufferedOutputStream sobre FileOutputStream Bytes
Copiar un fichero transferTo, o Files.copy (07-06) Bytes
Leer/escribir primitivos con formato fijo DataInputStream / DataOutputStream Bytes
Leer/escribir objetos completos ObjectInputStream / ObjectOutputStream (07-05) Bytes
Trabajar con datos en memoria ByteArrayInputStream / ByteArrayOutputStream Bytes
Acumular texto en memoria StringWriter / StringReader Caracteres
Convertir bytes en caracteres InputStreamReader con charset Puente
Convertir caracteres en bytes OutputStreamWriter con charset Puente

Y las cuatro reglas de composición que resumen la lección:

  1. El nodo va dentro; los filtros, fuera.
  2. El búfer va lo más cerca posible del nodo.
  3. El charset se especifica en el puente, que es el único sitio donde se traduce.
  4. Cierra la capa exterior: el cierre se propaga en cascada.

Errores Comunes y Consejos

  • Usar Reader/Writer con datos binarios. Destruye el fichero de forma irreversible: los bytes que no forman caracteres válidos se sustituyen por el carácter de reemplazo. La imagen ya no se abre.
  • Usar InputStream/OutputStream con texto sin puente. Funciona hasta que aparece una ñ, y entonces se parte a mitad de bloque.
  • Poner el búfer en el sitio equivocado. BufferedInputStream(DataInputStream(...)) no aporta nada: el búfer debe quedar entre el nodo y los filtros de formato.
  • write(bloque) en vez de write(bloque, 0, leidos). El error más destructivo de la lección: el fichero copiado sale más grande y con basura al final.
  • Suponer que read(byte[]) llena el array. No lo promete nunca. Con sockets falla constantemente.
  • Comparar bytes sin & 0xFF. firma[0] == 0xFF es siempre falso porque byte tiene signo. Enmascara cuando necesites el valor numérico.
  • Creer que write(300) escribe 300. Escribe 44: se queda con los 8 bits bajos, sin avisar.
  • Leer DataInputStream en distinto orden del escrito. No da error: devuelve valores perfectamente válidos y completamente falsos.
  • Usar writeUTF para intercambiar con otros lenguajes. No es UTF-8 estándar, es el UTF-8 modificado de Java.
  • Cerrar ByteArrayOutputStream antes de toByteArray(). No es fatal —su close() no hace nada— pero delata que no se ha entendido para qué sirve.
  • Olvidar que System.out es un PrintStream de bytes. Por eso las tildes salen mal en algunas consolas. Envuélvelo en un OutputStreamWriter con charset si necesitas control.
  • Construir cadenas anidadas donde una capa intermedia puede fallar. Si el constructor intermedio lanza, la capa ya construida queda sin cerrar. Para código crítico, declara cada capa por separado en el try-with-resources.
  • Buscar rendimiento en el tamaño del bloque antes de poner un búfer. El búfer da un factor de 70; el tamaño del bloque, un 8%.
  • Consejo: lee las cadenas de dentro afuera. new DataInputStream(new BufferedInputStream(new FileInputStream(f))) es "abro el fichero, le pongo búfer, y leo tipos". Con esa lectura, ninguna expresión de java.io vuelve a ser intimidante.
  • Consejo: acepta InputStream, no String ruta. Es la decisión de diseño que hace tu código probable sin ficheros y reutilizable con red y memoria.
  • Consejo: comprueba el formato por la firma, no por la extensión. Los primeros bytes no mienten; el nombre del fichero sí.
  • Consejo: si dudas entre texto y binario, elige binario. Tratar texto como bytes lo conserva intacto. Al revés, lo destruye.

Ejercicios

Ejercicio 1: visor hexadecimal

Escribe VisorHexadecimal con un método public static void volcar(String ruta, int maxBytes) que muestre el contenido de un fichero en el formato clásico de tres columnas:

00000000  FF D8 FF E0 00 10 4A 46  49 46 00 01 01 00 00 48  |......JFIF.....H|
00000010  00 48 00 00 FF DB 00 43  00 08 06 06 07 06 05 08  |.H.....C........|

Requisitos:

  1. Offset en hexadecimal de 8 dígitos.
  2. 16 bytes por línea, agrupados de 8 en 8.
  3. Columna ASCII: los bytes imprimibles (32 a 126) como carácter, el resto como ..
  4. Lectura por bloques, nunca byte a byte.
  5. Un main que detecte además el formato por la firma (JPEG, PNG, GIF, PDF, ZIP, clase de Java).

Ejercicio 2: comparador de rendimiento

Escribe ComparadorRendimiento que mida cinco formas de copiar el mismo fichero:

  1. FileInputStream.read() byte a byte sin búfer.
  2. Con BufferedInputStream/BufferedOutputStream, byte a byte.
  3. Bloques de 512 bytes.
  4. Bloques de 8192 bytes.
  5. transferTo.

Requisitos:

  1. Genera primero un fichero de prueba de 5 MB con datos pseudoaleatorios.
  2. Verifica en cada caso que la copia tiene el mismo tamaño que el original.
  3. Muestra una tabla con el tiempo y la mejora relativa respecto al primer método.
  4. Añade un comentario explicando por qué el salto grande está entre los métodos 1 y 2.

Advierte en un comentario que esto no es un banco de pruebas riguroso: la caché del sistema operativo y el calentamiento de la JVM distorsionan las medidas, y para medir en serio se usa JMH (mencionado en 10-07).

Ejercicio 3: almacén binario de préstamos

Escribe AlmacenPrestamos que guarde y recupere los préstamos de BiblioTech en formato binario propio usando DataOutputStream/DataInputStream:

  1. Una cabecera con un número mágico (0x424C4942, que es BLIB), la versión del formato (1) y el número de registros.
  2. Por cada préstamo: referencia (writeUTF), referencia del material, identificador del empleado, día de inicio, día de devolución (−1 si no devuelto) y multa acumulada.
  3. Al leer, valide el número mágico y lance IOException si no coincide, para no interpretar un fichero ajeno como propio.
  4. Valide también la versión, y rechace las versiones futuras con un mensaje claro.
  5. Use BufferedInputStream/BufferedOutputStream y escritura sobre fichero temporal con renombrado (07-02).
  6. Un main que guarde tres préstamos, los recupere y los muestre, y que después intente leer un fichero con número mágico incorrecto para comprobar el rechazo.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.util;

import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

/**
 * Visor hexadecimal de ficheros, en el formato clasico de tres columnas.
 *
 * Trabaja con FLUJOS DE BYTES porque el contenido puede ser cualquier cosa:
 * usar un Reader corromperia los datos antes de poder mostrarlos.
 */
public class VisorHexadecimal {

    private static final int BYTES_POR_LINEA = 16;
    private static final int PRIMER_IMPRIMIBLE = 32;
    private static final int ULTIMO_IMPRIMIBLE = 126;

    public static void volcar(String ruta, int maxBytes) throws IOException {
        try (InputStream entrada = new BufferedInputStream(new FileInputStream(ruta))) {

            byte[] linea = new byte[BYTES_POR_LINEA];
            int offset = 0;
            int leidos;

            // readNBytes (Java 9+) insiste hasta llenar el array o llegar al
            // final. Con read() a secas podriamos recibir menos de 16 bytes
            // en medio del fichero y el volcado quedaria desalineado.
            while (offset < maxBytes
                    && (leidos = entrada.readNBytes(linea, 0, BYTES_POR_LINEA)) > 0) {

                imprimirLinea(offset, linea, leidos);
                offset += leidos;
            }
        }
    }

    private static void imprimirLinea(int offset, byte[] datos, int cuantos) {
        StringBuilder hex   = new StringBuilder();
        StringBuilder ascii = new StringBuilder();

        for (int i = 0; i < BYTES_POR_LINEA; i++) {
            if (i == 8) {
                hex.append(' ');                    // separacion de los dos grupos
            }
            if (i < cuantos) {
                // & 0xFF: sin esto, un byte negativo se imprimiria como FFFFFFxx
                int b = datos[i] & 0xFF;
                hex.append(String.format("%02X ", b));
                ascii.append(esImprimible(b) ? (char) b : '.');
            } else {
                hex.append("   ");                  // relleno de la ultima linea
                ascii.append(' ');
            }
        }

        System.out.printf("%08X  %s |%s|%n", offset, hex, ascii);
    }

    private static boolean esImprimible(int b) {
        return b >= PRIMER_IMPRIMIBLE && b <= ULTIMO_IMPRIMIBLE;
    }

    /** Detecta el formato por su FIRMA. La extension no es fiable. */
    public static String detectarFormato(String ruta) throws IOException {
        try (InputStream entrada = new BufferedInputStream(new FileInputStream(ruta))) {
            byte[] f = entrada.readNBytes(8);

            if (empieza(f, 0xFF, 0xD8, 0xFF))                 { return "JPEG"; }
            if (empieza(f, 0x89, 0x50, 0x4E, 0x47))           { return "PNG"; }
            if (empieza(f, 0x47, 0x49, 0x46, 0x38))           { return "GIF"; }
            if (empieza(f, 0x25, 0x50, 0x44, 0x46))           { return "PDF"; }
            if (empieza(f, 0x50, 0x4B, 0x03, 0x04))           { return "ZIP (o JAR, DOCX...)"; }
            if (empieza(f, 0xCA, 0xFE, 0xBA, 0xBE))           { return "CLASE DE JAVA"; }
            if (empieza(f, 0xAC, 0xED))                       { return "OBJETO SERIALIZADO (07-05)"; }
            return "DESCONOCIDO (posiblemente texto)";
        }
    }

    /** Compara los primeros bytes con la firma dada. */
    private static boolean empieza(byte[] datos, int... firma) {
        if (datos.length < firma.length) {
            return false;
        }
        for (int i = 0; i < firma.length; i++) {
            if ((datos[i] & 0xFF) != firma[i]) {      // el & 0xFF, otra vez imprescindible
                return false;
            }
        }
        return true;
    }

    public static void main(String[] args) throws IOException {
        String ruta = (args.length > 0) ? args[0] : "portadas/java-efectivo.jpg";

        System.out.println("Fichero : " + ruta);
        System.out.println("Formato : " + detectarFormato(ruta));
        System.out.println();
        volcar(ruta, 64);
    }
}

Salida con un JPEG:

Fichero : portadas/java-efectivo.jpg
Formato : JPEG

00000000  FF D8 FF E0 00 10 4A 46  49 46 00 01 01 00 00 48  |......JFIF.....H|
00000010  00 48 00 00 FF DB 00 43  00 08 06 06 07 06 05 08  |.H.....C........|
00000020  07 07 07 09 09 08 0A 0C  14 0D 0C 0B 0B 0C 19 12  |................|
00000030  13 0F 14 1D 1A 1F 1E 1D  1A 1C 1C 20 24 2E 27 20  |........... $.' |

Los dos puntos didácticos: el & 0xFF aparece tres veces en la solución y en las tres es imprescindible —sin él, un byte negativo se imprime como FFFFFFC3 y todas las comparaciones de firma fallan—. Y readNBytes(array, 0, n) en lugar de read(array) garantiza que cada línea tenga sus 16 bytes: con read() a secas, una lectura corta a mitad de fichero desalinearía todo el volcado.

Solución 2

package com.nexussoftware.bibliotech.util;

import java.io.*;
import java.util.Random;

/**
 * Comparativa de cinco tecnicas de copia binaria.
 *
 * AVISO: esto NO es un banco de pruebas riguroso. La cache de pagina del
 * sistema operativo, el compilador JIT y el recolector de basura distorsionan
 * las medidas; la primera ejecucion siempre sale peor. Sirve para ver los
 * ORDENES DE MAGNITUD, que es lo que importa aqui. Para medir en serio se
 * usa JMH, que se menciona en 10-07.
 */
public class ComparadorRendimiento {

    private static final String ORIGEN  = "prueba-rendimiento.bin";
    private static final String DESTINO = "copia-rendimiento.bin";
    private static final int TAMANO_MB  = 5;

    // ---------- Las cinco tecnicas ----------

    /** 1. Byte a byte SIN buffer: una llamada al sistema por byte. */
    static long byteABytesinBuffer(File o, File d) throws IOException {
        try (InputStream in = new FileInputStream(o);
             OutputStream out = new FileOutputStream(d)) {
            int b;
            long n = 0;
            while ((b = in.read()) != -1) {
                out.write(b);
                n++;
            }
            return n;
        }
    }

    /** 2. Byte a byte CON buffer: el buffer absorbe las llamadas al sistema. */
    static long byteAByteConBuffer(File o, File d) throws IOException {
        try (InputStream in = new BufferedInputStream(new FileInputStream(o));
             OutputStream out = new BufferedOutputStream(new FileOutputStream(d))) {
            int b;
            long n = 0;
            while ((b = in.read()) != -1) {
                out.write(b);
                n++;
            }
            return n;
        }
    }

    /** 3 y 4. Por bloques del tamano indicado. */
    static long porBloques(File o, File d, int tamano) throws IOException {
        try (InputStream in = new FileInputStream(o);
             OutputStream out = new FileOutputStream(d)) {
            byte[] bloque = new byte[tamano];
            int leidos;
            long n = 0;
            while ((leidos = in.read(bloque)) != -1) {
                out.write(bloque, 0, leidos);     // el limite, SIEMPRE
                n += leidos;
            }
            return n;
        }
    }

    /** 5. transferTo (Java 9+). */
    static long conTransferTo(File o, File d) throws IOException {
        try (InputStream in = new BufferedInputStream(new FileInputStream(o));
             OutputStream out = new BufferedOutputStream(new FileOutputStream(d))) {
            return in.transferTo(out);
        }
    }

    // ---------- Infraestructura de medida ----------

    interface Tecnica {
        long copiar(File origen, File destino) throws IOException;
    }

    static void generarFicheroPrueba(File f, int megas) throws IOException {
        if (f.exists() && f.length() == megas * 1024L * 1024L) {
            return;                                // ya esta generado
        }
        Random aleatorio = new Random(42);          // semilla fija: reproducible
        byte[] bloque = new byte[1024 * 1024];

        try (OutputStream out = new BufferedOutputStream(new FileOutputStream(f))) {
            for (int i = 0; i < megas; i++) {
                aleatorio.nextBytes(bloque);
                out.write(bloque);
            }
        }
    }

    public static void main(String[] args) throws IOException {
        File origen  = new File(ORIGEN);
        File destino = new File(DESTINO);

        System.out.println("Generando fichero de prueba de " + TAMANO_MB + " MB...");
        generarFicheroPrueba(origen, TAMANO_MB);
        System.out.println("Tamano: " + origen.length() + " bytes");
        System.out.println();

        String[] nombres = {
                "1. Byte a byte SIN buffer",
                "2. Byte a byte CON buffer",
                "3. Bloques de 512 B",
                "4. Bloques de 8 KB",
                "5. transferTo"
        };
        Tecnica[] tecnicas = {
                ComparadorRendimiento::byteABytesinBuffer,      // referencias a metodo (04-06)
                ComparadorRendimiento::byteAByteConBuffer,
                (o, d) -> porBloques(o, d, 512),
                (o, d) -> porBloques(o, d, 8192),
                ComparadorRendimiento::conTransferTo
        };

        long referencia = 0;

        System.out.printf("%-28s %10s %10s %12s%n", "TECNICA", "TIEMPO", "MB/s", "MEJORA");
        System.out.println("-".repeat(64));

        for (int i = 0; i < tecnicas.length; i++) {
            long inicio = System.nanoTime();
            long copiados = tecnicas[i].copiar(origen, destino);
            long ms = (System.nanoTime() - inicio) / 1_000_000;

            // VERIFICACION: una copia rapida pero incorrecta no vale nada
            if (copiados != origen.length() || destino.length() != origen.length()) {
                System.out.printf("%-28s  COPIA INCORRECTA: %d bytes%n",
                        nombres[i], destino.length());
                continue;
            }

            if (i == 0) {
                referencia = ms;
            }
            double mbs = (origen.length() / 1024.0 / 1024.0) / (ms / 1000.0);

            System.out.printf("%-28s %8d ms %10.1f %11.1fx%n",
                    nombres[i], ms, mbs, (double) referencia / Math.max(1, ms));
        }

        System.out.println();
        System.out.println("El salto grande esta entre 1 y 2, y la causa es una sola:");
        System.out.println("sin buffer hay UNA LLAMADA AL SISTEMA POR BYTE (5 millones);");
        System.out.println("con buffer, una cada 8192 bytes (unas 640). El resto de");
        System.out.println("mejoras son marginales en comparacion.");

        destino.delete();
    }
}

Salida típica:

Generando fichero de prueba de 5 MB...
Tamano: 5242880 bytes

TECNICA                          TIEMPO       MB/s       MEJORA
----------------------------------------------------------------
1. Byte a byte SIN buffer          4680 ms        1.1         1.0x
2. Byte a byte CON buffer            78 ms       64.2        60.0x
3. Bloques de 512 B                  26 ms      192.5       180.0x
4. Bloques de 8 KB                    9 ms      555.6       520.0x
5. transferTo                         7 ms      714.3       668.7x

El salto grande esta entre 1 y 2, y la causa es una sola:
sin buffer hay UNA LLAMADA AL SISTEMA POR BYTE (5 millones);
con buffer, una cada 8192 bytes (unas 640). El resto de
mejoras son marginales en comparacion.

Lo que hay que extraer: la mejora de 1 a 2 es de 60x y consiste en escribir dos palabras más (Buffered...). La mejora de 4 a 5 es de un 28%. Cuando alguien pregunte por dónde empezar a optimizar E/S, la respuesta es siempre la misma: poner un búfer. Fíjate también en la verificación del tamaño: una técnica rápida que produzca una copia incorrecta —el clásico write(bloque) sin límite— se detecta ahí y no se contabiliza.

Solución 3

package com.nexussoftware.bibliotech.servicio;

import java.io.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.logging.Logger;

/**
 * Almacen binario de prestamos de BiblioTech.
 *
 * Formato propio (BLIB v1):
 *   [4 bytes] numero magico 0x424C4942 = "BLIB"
 *   [4 bytes] version del formato
 *   [4 bytes] numero de registros
 *   por registro:
 *     UTF  referencia del prestamo
 *     UTF  referencia del material
 *     UTF  identificador del empleado
 *     INT  dia de inicio
 *     INT  dia de devolucion (-1 si no devuelto)
 *     DOUBLE multa acumulada
 *
 * Este formato es COMPACTO y RAPIDO, pero NO es legible ni versionable en
 * control de codigo. Para el catalogo, BiblioTech usara CSV (07-07); esto
 * queda como cache binaria interna.
 */
public class AlmacenPrestamos {

    private static final Logger LOG = Logger.getLogger(AlmacenPrestamos.class.getName());

    /** "BLIB" en ASCII. Identifica el fichero como nuestro. */
    private static final int NUMERO_MAGICO = 0x424C4942;
    private static final int VERSION_ACTUAL = 1;

    /** Registro plano de un prestamo, tal como se guarda. */
    public record RegistroPrestamo(String referencia, String referenciaMaterial,
                                   String idEmpleado, int diaInicio,
                                   int diaDevolucion, double multa) {

        public RegistroPrestamo {
            Objects.requireNonNull(referencia, "La referencia no puede ser nula");
            Objects.requireNonNull(referenciaMaterial, "El material no puede ser nulo");
            Objects.requireNonNull(idEmpleado, "El empleado no puede ser nulo");
            if (multa < 0) {
                throw new IllegalArgumentException("La multa no puede ser negativa: " + multa);
            }
        }

        public boolean estaDevuelto() { return diaDevolucion >= 0; }
    }

    private final File fichero;

    public AlmacenPrestamos(String ruta) {
        this.fichero = new File(Objects.requireNonNull(ruta, "La ruta no puede ser nula"));
    }

    // ---------------------------- ESCRITURA ----------------------------

    /**
     * Guarda todos los prestamos. Escritura ATOMICA (07-02): se escribe en
     * un temporal y solo se renombra al final.
     */
    public void guardar(List<RegistroPrestamo> prestamos) throws IOException {
        Objects.requireNonNull(prestamos, "La lista no puede ser nula");

        File temporal = new File(fichero.getAbsolutePath() + ".tmp");
        boolean completado = false;

        try {
            try (DataOutputStream salida = new DataOutputStream(
                    new BufferedOutputStream(new FileOutputStream(temporal)))) {

                // Cabecera
                salida.writeInt(NUMERO_MAGICO);
                salida.writeInt(VERSION_ACTUAL);
                salida.writeInt(prestamos.size());

                // Registros, en el MISMO orden en que se leeran
                for (RegistroPrestamo p : prestamos) {
                    salida.writeUTF(p.referencia());
                    salida.writeUTF(p.referenciaMaterial());
                    salida.writeUTF(p.idEmpleado());
                    salida.writeInt(p.diaInicio());
                    salida.writeInt(p.diaDevolucion());
                    salida.writeDouble(p.multa());
                }
            }   // close() -> flush garantizado

            if (fichero.exists() && !fichero.delete()) {
                throw new IOException("No se pudo sustituir " + fichero.getName());
            }
            if (!temporal.renameTo(fichero)) {
                throw new IOException("No se pudo renombrar " + temporal.getName());
            }
            completado = true;

            LOG.info(() -> String.format("Guardados %d prestamos en %s (%d bytes)",
                    prestamos.size(), fichero.getName(), fichero.length()));

        } finally {
            if (!completado && temporal.exists() && !temporal.delete()) {
                LOG.warning(() -> "Temporal sin borrar: " + temporal.getAbsolutePath());
            }
        }
    }

    // ---------------------------- LECTURA ----------------------------

    /**
     * Carga los prestamos guardados.
     *
     * @throws IOException si el fichero no es nuestro, la version no se
     *                     soporta o esta truncado
     */
    public List<RegistroPrestamo> cargar() throws IOException {
        if (!fichero.exists()) {
            LOG.warning(() -> "No hay almacen en " + fichero.getAbsolutePath()
                    + "; se devuelve lista vacia");
            return new ArrayList<>();               // degradacion elegante (07-01)
        }

        try (DataInputStream entrada = new DataInputStream(
                new BufferedInputStream(new FileInputStream(fichero)))) {

            // 1. VALIDAR EL NUMERO MAGICO.
            //    Sin esto, un fichero ajeno se interpretaria como nuestro y
            //    produciria datos absurdos SIN NINGUN ERROR. Es la diferencia
            //    entre fallar y corromper.
            int magico = entrada.readInt();
            if (magico != NUMERO_MAGICO) {
                throw new IOException(String.format(
                        "%s no es un almacen de BiblioTech "
                                + "(numero magico 0x%08X, se esperaba 0x%08X)",
                        fichero.getName(), magico, NUMERO_MAGICO));
            }

            // 2. VALIDAR LA VERSION
            int version = entrada.readInt();
            if (version > VERSION_ACTUAL) {
                throw new IOException(String.format(
                        "El almacen usa la version %d del formato y esta version "
                                + "de BiblioTech solo entiende hasta la %d. "
                                + "Actualiza la aplicacion.",
                        version, VERSION_ACTUAL));
            }

            // 3. Numero de registros, con cordura minima
            int cuantos = entrada.readInt();
            if (cuantos < 0) {
                throw new IOException("Numero de registros invalido: " + cuantos);
            }

            List<RegistroPrestamo> prestamos = new ArrayList<>(cuantos);

            for (int i = 0; i < cuantos; i++) {
                try {
                    prestamos.add(new RegistroPrestamo(
                            entrada.readUTF(),      // el ORDEN debe coincidir
                            entrada.readUTF(),      // exactamente con guardar()
                            entrada.readUTF(),
                            entrada.readInt(),
                            entrada.readInt(),
                            entrada.readDouble()));

                } catch (EOFException e) {
                    // Fichero TRUNCADO: se corto una escritura anterior.
                    // Se encadena la causa (06-03) y se dice cuantos habia.
                    throw new IOException(String.format(
                            "Almacen truncado: se esperaban %d registros y solo "
                                    + "hay %d completos", cuantos, i), e);
                }
            }

            LOG.info(() -> String.format("Cargados %d prestamos de %s (formato v%d)",
                    prestamos.size(), fichero.getName(), version));
            return prestamos;
        }
    }

    // ---------------------------- DEMOSTRACION ----------------------------

    public static void main(String[] args) throws IOException {
        AlmacenPrestamos almacen = new AlmacenPrestamos("datos/prestamos.blib");

        List<RegistroPrestamo> originales = List.of(
                new RegistroPrestamo("PR-0001", "978-0000000001", "E-001", 10, 22,  1.75),
                new RegistroPrestamo("PR-0002", "978-0000000002", "E-002", 12, -1,  0.00),
                new RegistroPrestamo("PR-0003", "978-0000000003", "E-003",  5, 45, 20.00));

        almacen.guardar(originales);

        System.out.println("=== RECUPERADOS ===");
        for (RegistroPrestamo p : almacen.cargar()) {
            System.out.printf("  %s  material=%s  empleado=%s  dias %d..%s  multa %.2f%n",
                    p.referencia(), p.referenciaMaterial(), p.idEmpleado(),
                    p.diaInicio(),
                    p.estaDevuelto() ? String.valueOf(p.diaDevolucion()) : "pendiente",
                    p.multa());
        }

        // --- Comprobacion del rechazo de un fichero ajeno ---
        File impostor = new File("datos/impostor.blib");
        try (DataOutputStream d = new DataOutputStream(new FileOutputStream(impostor))) {
            d.writeInt(0xDEADBEEF);      // numero magico incorrecto
            d.writeInt(1);
            d.writeInt(0);
        }

        System.out.println();
        System.out.println("=== FICHERO AJENO ===");
        try {
            new AlmacenPrestamos(impostor.getPath()).cargar();
            System.out.println("  ERROR: deberia haber sido rechazado");
        } catch (IOException e) {
            System.out.println("  Rechazado correctamente:");
            System.out.println("  " + e.getMessage());
        }
        impostor.delete();
    }
}

Salida:

=== RECUPERADOS ===
  PR-0001  material=978-0000000001  empleado=E-001  dias 10..22  multa 1.75
  PR-0002  material=978-0000000002  empleado=E-002  dias 12..pendiente  multa 0.00
  PR-0003  material=978-0000000003  empleado=E-003  dias 5..45  multa 20.00

=== FICHERO AJENO ===
  Rechazado correctamente:
  impostor.blib no es un almacen de BiblioTech (numero magico 0xDEADBEEF, se esperaba 0x424C4942)

Las cuatro ideas que hay que llevarse:

  1. El número mágico es imprescindible en cualquier formato binario propio. Sin él, readUTF() sobre un fichero ajeno lee dos bytes cualesquiera como longitud, intenta leer esa cantidad y devuelve basura —o lanza una EOFException incomprensible—. Con él, el mensaje dice exactamente qué pasa. Es la misma filosofía de 06-04: la excepción debe transportar lo que quien la lee necesita para decidir.
  2. La versión del formato se declara desde el primer día. Cuando en un año quieras añadir un campo, el fichero antiguo seguirá siendo legible porque sabrás qué versión tiene. Añadirla después es imposible: los ficheros ya escritos no la llevan.
  3. La EOFException se traduce. Su mensaje por defecto no dice nada. Traducida con el contexto —"se esperaban 3 registros y solo hay 2 completos"— apunta directamente a la causa: una escritura anterior que se cortó, exactamente lo que la escritura atómica de 07-02 existe para evitar.
  4. El orden de lectura y escritura debe ser idéntico. No hay forma de comprobarlo automáticamente en este formato, y ese es su punto débil frente al texto de 07-07. Un record con los campos en el mismo orden ayuda a que el error salte a la vista al leer el código.

Conclusión

Ya no usas java.io de memoria: entiendes su diseño.

Tienes el modelo del flujo: una secuencia ordenada, unidireccional, secuencial y agnóstica respecto a la fuente, con su vocabulario de fuente, destino y dirección. Esa tercera propiedad es la que da valor a toda la jerarquía: un método que acepta InputStream funciona con un fichero, con un socket del módulo 9 o con un array en memoria, sin cambiar una línea. Es el polimorfismo del módulo 3 aplicado a la E/S. Y sabes que estos flujos no tienen nada que ver con la API de Streams de colecciones de 10-04, más allá del nombre.

Sabes por qué hay dos jerarquías y no una: InputStream/OutputStream para bytes, Reader/Writer para caracteres, porque un carácter no es un byteñ son dos, son tres— y porque los caracteres pueden partirse entre dos bloques de lectura. Tienes la regla de decisión sin excusas: si es texto legible, caracteres; si no, bytes; y si dudas, bytes, porque tratar texto como bytes lo conserva y al revés lo destruye.

Distingues las clases de nodo —que se conectan a un recurso real y se reconocen porque su constructor recibe una ruta, un fichero o un array— de las clases de filtro —que envuelven otro flujo y se reconocen porque su constructor recibe un flujo—. Y de ahí sale la regla que descifra cualquier expresión anidada: el nodo va dentro, los filtros fuera, y se lee de dentro afuera.

Has visto el patrón Decorador en su ejemplo canónico, con el argumento que lo justifica: sin él harían falta doce clases para tres fuentes y dos capacidades, y con él bastan cinco. Conoces sus tres consecuencias prácticas: el orden importa —el búfer va lo más cerca posible del nodo—, cerrar el exterior cierra toda la cadena en cascada, y la abstracción se conserva porque todos los decoradores son el tipo que decoran. Sabes también que una capa intermedia que falle al construirse deja abierta la anterior, y cómo evitarlo declarando cada capa por separado. El patrón se formaliza en 12-02; aquí lo has visto donde mejor se aprecia.

Conoces los puentes InputStreamReader y OutputStreamWriter, y sabes que son el punto exacto donde se decide la codificación que llevas tres lecciones especificando: a un lado bytes sin significado, al otro caracteres. Entiendes ahora por qué FileReader extiende InputStreamReader —es un puente con el fichero ya conectado— y por qué el puente es imprescindible para leer texto de System.in, de un socket o de un recurso del .jar, donde no hay ningún FileReader posible.

Sabes leer y escribir datos binarios con FileInputStream/FileOutputStream, byte a byte y por bloques, con los tres puntos del contrato de read(byte[]) —devuelve cuántos leyó, no llena el array, y solo -1 es fin— y con la regla que evita el error más destructivo de la lección: write(bloque, 0, leidos), nunca write(bloque). Conoces la trampa del byte con signo y el & 0xFF que la resuelve, y sabes que write(300) escribe 44 sin avisar. Con eso, GestorPortadas copia las imágenes de BiblioTech sin corromperlas y detecta su formato por la firma, no por la extensión.

Conoces DataInputStream/DataOutputStream para tipos primitivos en formato fijo, portable y compacto, con sus tres virtudes y sus tres limitaciones —no es autodescriptivo, no es legible, es frágil ante cambios—, y con el aviso de que writeUTF no es UTF-8 estándar. Conoces ByteArrayInputStream/ByteArrayOutputStream y su mejor uso, que es probar código de E/S sin tocar el disco, con la lección de diseño que lo acompaña: acepta InputStream, no String ruta. Y conoces transferTo de Java 9, con el criterio para elegir entre él y el bucle manual: el bucle solo si necesitas progreso, transformación o parada.

Y tienes las cifras que ordenan las prioridades de rendimiento: el búfer da un factor de sesenta; el tamaño del bloque, un ocho por ciento. Ocho kilobytes salvo que hayas medido. Antes de tocar nada, pon el búfer.

Ese búfer —el que aparece en todas las cadenas, el que produce el salto de rendimiento, el del que llevas tres lecciones oyendo hablar— es el protagonista de la siguiente. En 07-04, BufferedReader y BufferedWriter, verás exactamente qué hace por dentro y por qué reduce tanto el trabajo, con el diagrama de las lecturas con y sin él; el contrato completo de readLine() —que devuelve null al final y no incluye el salto de línea— y el bucle canónico que lo usa, además de por qué ready() no sirve como condición de fin; newLine() y el vaciado en escritura; qué aporta exactamente cada capa del trío PrintWriter + BufferedWriter + FileWriter; cómo procesar un fichero de un gigabyte con memoria constante; y la comparación definitiva entre BufferedReader y el Scanner que usas desde el módulo 1. Y BiblioTech estrenará ImportadorCatalogo, capaz de leer miles de líneas, validarlas una a una y devolver un informe de lo cargado y lo descartado.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados