Llevas tres lecciones oyendo hablar del búfer. En 07-01 explicaba por qué leer carácter a carácter es cien veces más lento de lo necesario. En 07-02 explicaba por qué un fichero recién escrito tiene cero bytes. En 07-03 aparecía en todas las cadenas de decoradores y en la tabla de rendimiento con un factor de sesenta.

Esta lección es la del búfer. Verás qué hace exactamente por dentro, por qué esa mejora es tan grande, y las dos clases que lo aportan al mundo del texto: BufferedReader, con el readLine() que es el método de E/S más usado de Java, y BufferedWriter, con su newLine() portable.

Y verás algo más importante que el rendimiento: el procesamiento en flujo. Un BufferedReader permite recorrer un fichero de un gigabyte con un consumo de memoria constante, línea a línea, sin cargar nada más que la línea actual. Esa capacidad es la que convierte a BiblioTech en un sistema capaz de importar un inventario completo, y es lo que construirás en el caso práctico final: ImportadorCatalogo, que lee miles de líneas, valida cada una, cuenta las buenas y las malas, registra los motivos y devuelve un informe.

try-with-resources en todo, como siempre. Con búferes de escritura hay un motivo añadido para no olvidarlo: el close() es lo que vacía el búfer al disco. Sin él, no has escrito nada.

Contenido

  1. Qué hace exactamente un búfer
  2. Sin búfer y con búfer: el diagrama
  3. Las cifras que justifican la diferencia
  4. Construcción por decoración y tamaño del búfer
  5. BufferedReader.readLine() y su contrato
  6. El bucle canónico de lectura
  7. ready() y por qué no sirve como condición de fin
  8. mark y reset: mirar sin consumir
  9. BufferedWriter: write, newLine y flush
  10. El trío PrintWriter + BufferedWriter + FileWriter
  11. Files.newBufferedReader y newBufferedWriter
  12. Procesar un fichero grande con memoria constante
  13. Leer de consola con BufferedReader frente a Scanner
  14. BiblioTech: ImportadorCatalogo con informe completo
  15. Errores Comunes y Consejos
  16. Ejercicios

  1. Qué hace exactamente un búfer

Un búfer es un array intermedio en memoria que agrupa las operaciones para reducir el número de llamadas al sistema operativo.

Recuerda el diagrama de 07-01: entre tu código y el disco hay una frontera cara, la llamada al sistema. Cada vez que la cruzas, el procesador cambia de contexto, salta al núcleo, hace su trabajo y vuelve. Ese viaje cuesta órdenes de magnitud más que ejecutar unas instrucciones en tu propio proceso.

Lo que hace un BufferedReader:

  1. Cuando le pides un carácter, mira su array interno.
  2. Si tiene datos, te devuelve uno sin salir de la memoria del proceso.
  3. Si está vacío, pide 8192 caracteres de golpe al flujo que envuelve, los guarda en su array y te devuelve el primero.

El resultado es que una lectura de cada 8192 llega al sistema operativo; las otras 8191 son accesos a un array.

El mismo principio, invertido, en escritura:

  1. Cuando le pides escribir, guarda en su array.
  2. Cuando el array se llena —o cuando alguien hace flush() o close()— lo vuelca de una sola vez.

De ahí sale la explicación completa de dos cosas que ya has visto y que ahora encajan:

  • Por qué FileReader.read() es tan lento: no tiene búfer. Cada llamada es una llamada al sistema.
  • Por qué un fichero recién escrito tiene 0 bytes (07-02): los datos están en el array del búfer, no en el disco.

  1. Sin búfer y con búfer: el diagrama

flowchart TD
    subgraph SIN["SIN buffer: FileReader.read()"]
        A1["read() numero 1"] --> S1["llamada al sistema"]
        A2["read() numero 2"] --> S2["llamada al sistema"]
        A3["read() numero 3"] --> S3["llamada al sistema"]
        A4["... 8192 veces"] --> S4["... 8192 llamadas"]
        S1 --> D1["DISCO"]
        S2 --> D1
        S3 --> D1
        S4 --> D1
    end

    style D1 fill:#ffcdd2
flowchart TD
    subgraph CON["CON buffer: BufferedReader.read()"]
        B1["read() numero 1"] --> BUF["array interno<br/>de 8192 caracteres"]
        B2["read() numero 2"] --> BUF
        B3["read() numero 3"] --> BUF
        B4["... 8192 veces"] --> BUF
        BUF -->|"UNA sola vez,<br/>cuando esta vacio"| SYS["llamada al sistema"]
        SYS --> D2["DISCO"]
    end

    style BUF fill:#c8e6c9
    style D2 fill:#c8e6c9

La comparación numérica del caso completo, con un fichero de 5 MB:

Sin búfer Con búfer de 8 KB
Llamadas a read() de tu código 5 242 880 5 242 880
Llamadas al sistema operativo 5 242 880 640
Memoria adicional usada 0 16 KB
Tiempo orientativo ~4 500 ms ~60 ms

El código hace el mismo número de llamadas; lo que cambia es cuántas cruzan la frontera. Ocho mil veces menos, a cambio de dieciséis kilobytes de memoria. Es probablemente el mejor intercambio de memoria por tiempo que existe en programación.

  1. Las cifras que justifican la diferencia

Un experimento que puedes ejecutar:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class MedirBuffer {

    /** Sin buffer: una llamada al sistema por caracter. */
    static long sinBuffer(String ruta) throws IOException {
        long n = 0;
        try (FileReader lector = new FileReader(ruta, StandardCharsets.UTF_8)) {
            while (lector.read() != -1) {
                n++;
            }
        }
        return n;
    }

    /** Con buffer, mismo bucle: el buffer absorbe las llamadas. */
    static long conBuffer(String ruta) throws IOException {
        long n = 0;
        try (BufferedReader lector = new BufferedReader(
                new FileReader(ruta, StandardCharsets.UTF_8))) {
            while (lector.read() != -1) {
                n++;
            }
        }
        return n;
    }

    /** Con buffer y por lineas: la forma idiomatica. */
    static long porLineas(String ruta) throws IOException {
        long n = 0;
        try (BufferedReader lector = new BufferedReader(
                new FileReader(ruta, StandardCharsets.UTF_8))) {
            String linea;
            while ((linea = lector.readLine()) != null) {
                n += linea.length();
            }
        }
        return n;
    }

    public static void main(String[] args) throws IOException {
        String ruta = "datos/catalogo-grande.txt";     // unos 5 MB

        medir("Sin buffer, caracter a caracter", () -> sinBuffer(ruta));
        medir("Con buffer, caracter a caracter", () -> conBuffer(ruta));
        medir("Con buffer, por lineas",          () -> porLineas(ruta));
    }

    interface Medible { long ejecutar() throws IOException; }

    static void medir(String nombre, Medible m) throws IOException {
        long inicio = System.nanoTime();
        long resultado = m.ejecutar();
        long ms = (System.nanoTime() - inicio) / 1_000_000;
        System.out.printf("%-35s %6d ms  (%d caracteres)%n", nombre, ms, resultado);
    }
}

Resultado orientativo:

Sin buffer, caracter a caracter       4512 ms  (5242880 caracteres)
Con buffer, caracter a caracter         68 ms  (5242880 caracteres)
Con buffer, por lineas                  41 ms  (5158400 caracteres)

Tres observaciones:

  1. La diferencia entre la primera y la segunda es una sola palabra en el código: new BufferedReader(...). Factor 66.
  2. La tercera es aún mejor porque readLine() trabaja directamente sobre el array del búfer, buscando el salto de línea, sin llamar a read() una vez por carácter.
  3. El recuento de la tercera es menor porque readLine() no incluye el salto de línea. Ese detalle es parte de su contrato y se explica en el apartado 5.

  1. Construcción por decoración y tamaño del búfer

BufferedReader y BufferedWriter son filtros en el sentido de 07-03: envuelven otro Reader o Writer.

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

// Lectura, tamano por defecto (8192 caracteres)
BufferedReader lector = new BufferedReader(
        new FileReader("datos/catalogo.txt", StandardCharsets.UTF_8));

// Lectura, tamano explicito
BufferedReader grande = new BufferedReader(
        new FileReader("datos/catalogo.txt", StandardCharsets.UTF_8), 65536);

// Escritura
BufferedWriter escritor = new BufferedWriter(
        new FileWriter("datos/informe.txt", StandardCharsets.UTF_8));

// Sobre un puente, cuando la fuente no es un fichero (07-03)
BufferedReader deConsola = new BufferedReader(
        new InputStreamReader(System.in, StandardCharsets.UTF_8));

Sobre el tamaño:

Tamaño Cuándo
8192 (por defecto) Casi siempre. Coincide con el bloque del sistema de ficheros
32768 – 65536 Ficheros muy grandes leídos secuencialmente, si has medido mejora
1024 o menos Nunca, salvo memoria muy restringida
Más de 1 MB Contraproducente: deja de caber en caché y presiona al recolector

Y la advertencia de 07-03, que aquí se aplica igual: si abres cien flujos a la vez con búferes de 1 MB, son 100 MB de heap. El tamaño por defecto está muy bien elegido; cámbialo solo con una medición delante.

Un detalle que conviene señalar: envolver algo que ya tiene búfer no aporta nada. new BufferedReader(new BufferedReader(...)) es redundante, y new BufferedInputStream(new ByteArrayInputStream(...)) también, porque un array en memoria no hace llamadas al sistema. El búfer sirve donde hay una frontera cara que cruzar.

  1. BufferedReader.readLine() y su contrato

readLine() es, con diferencia, el método de E/S más usado de Java. Su contrato tiene cuatro puntos, y los cuatro importan:

public String readLine() throws IOException

1. Devuelve la línea sin el terminador. Si el fichero tiene Java Efectivo\n, readLine() devuelve "Java Efectivo", de 13 caracteres. El \n se consume pero no se incluye. Por eso el recuento del experimento del apartado 3 salía menor.

2. Reconoce las tres convenciones. \n, \r\n y \r se tratan como fin de línea, independientemente del sistema. Un fichero escrito en Windows se lee correctamente en Linux sin hacer nada. Esta es la razón por la que el apartado 8 de 07-02 concluía que, para tu propio programa, el separador da igual.

3. Devuelve null al final del fichero. No "", no una excepción: null. Y esa distinción es esencial, porque una línea vacía devuelve "", que no es lo mismo:

Contenido restante readLine() devuelve
Java Efectivo\n "Java Efectivo"
\n (línea vacía) "" (cadena vacía, longitud 0)
Nada: fin de fichero null
Sin salto final (última línea sin \n) "Sin salto final"

4. Bloquea hasta tener una línea completa. Con un fichero es instantáneo. Con la consola o un socket, espera hasta que llegue un salto de línea o se cierre el flujo. Es lo que hace que la consola espere a que pulses Intro.

Un caso límite que conviene conocer: una línea sin terminador al final del fichero se devuelve igualmente. La siguiente llamada devuelve null. Es correcto y evita perder la última línea de ficheros generados por herramientas que no ponen salto final.

Y un aviso de dimensionamiento: readLine() carga la línea entera en memoria. Con un fichero cuyo contenido sea una única línea de 2 GB —existen: volcados de bases de datos, JSON en una línea—, readLine() intenta construir un String de 2 GB y provoca OutOfMemoryError. Es raro, pero cuando ocurre desconcierta, porque el código "procesa línea a línea" y aun así se queda sin memoria.

  1. El bucle canónico de lectura

Esta es la forma idiomática, y hay que escribirla siempre igual:

try (BufferedReader lector = new BufferedReader(
        new FileReader(ruta, StandardCharsets.UTF_8))) {

    String linea;
    while ((linea = lector.readLine()) != null) {
        procesar(linea);
    }
}

Desmenuzado:

  1. String linea; se declara fuera del bucle, porque la condición necesita verla.
  2. linea = lector.readLine() lee y asigna. La asignación es una expresión, como en 07-01.
  3. Los paréntesis internos son obligatorios: sin ellos, linea = lector.readLine() != null intentaría asignar un boolean a un String y no compila.
  4. != null es el fin. No !linea.isEmpty(), que se detendría en la primera línea en blanco.

Los tres errores que sustituyen a este bucle y todos fallan:

// MAL 1: se para en la primera linea vacia
while (!(linea = lector.readLine()).isEmpty()) { }
// Ademas: NullPointerException al llegar al final, porque readLine devuelve null

// MAL 2: lee cada linea DOS veces y se salta una de cada dos
while (lector.readLine() != null) {
    procesar(lector.readLine());     // esta es la SIGUIENTE linea
}

// MAL 3: ready() no significa "quedan datos". Apartado 7
while (lector.ready()) {
    procesar(lector.readLine());
}

El segundo es especialmente traicionero porque funciona a medias: procesa las líneas pares y se salta las impares, y con un fichero de prueba pequeño puede parecer correcto.

Con contador de líneas, que es lo habitual en código real:

int numeroLinea = 0;
String linea;
while ((linea = lector.readLine()) != null) {
    numeroLinea++;

    String limpia = linea.trim();
    if (limpia.isEmpty() || limpia.startsWith("#")) {
        continue;                        // vacias y comentarios: 02-04
    }
    procesar(limpia, numeroLinea);       // el numero, para poder informar del error
}

Ese numeroLinea no es decorativo: es lo que permite decir "línea 4 217: el año no es un número" en lugar de "error al importar". Es la aplicación directa de 06-04 —la excepción debe llevar el contexto que quien la lee necesita— a la importación de ficheros.

  1. ready() y por qué no sirve como condición de fin

ready() existe y su nombre invita al malentendido:

public boolean ready() throws IOException

Devuelve true si una lectura no se bloquearía, es decir, si hay datos disponibles en el búfer o listos en la fuente. Y eso no es lo mismo que "queda algo por leer".

// MAL: uso incorrecto de ready()
try (BufferedReader lector = new BufferedReader(new FileReader(ruta, UTF_8))) {
    while (lector.ready()) {              // <-- BUG
        System.out.println(lector.readLine());
    }
}

Por qué falla, en tres escenarios reales:

Escenario Qué pasa
Fichero local pequeño Suele funcionar. Por eso el bug sobrevive a las pruebas
Fichero grande, búfer vacío en ese instante ready() devuelve false y el bucle termina a mitad
Consola ready() es false mientras el usuario piensa: el bucle no llega a leer nada
Socket de red (módulo 9) ready() es false entre paquetes: se pierden datos
Fichero en unidad de red lenta Falla de forma intermitente e irreproducible

Es el peor tipo de bug: funciona en desarrollo y falla en producción con ficheros grandes o fuentes lentas, de forma no determinista.

La única condición de fin correcta es readLine() != null. ready() sirve para otra cosa: comprobar si puedes leer sin bloquearte, en un programa que tiene otro trabajo que hacer mientras espera. Ese caso pertenece al módulo 8.

  1. mark y reset: mirar sin consumir

Un flujo no permite retroceder... salvo que el búfer lo recuerde. BufferedReader ofrece esa capacidad limitada:

public void mark(int limiteDeLectura) throws IOException
public void reset() throws IOException
public boolean markSupported()

mark(n) marca la posición actual y promete poder volver a ella siempre que no se lean más de n caracteres. reset() vuelve a la marca.

Uso típico: inspeccionar el principio de un fichero para decidir cómo procesarlo, sin perder esa primera línea.

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class DeteccionDeCabecera {

    /**
     * Detecta si el fichero tiene cabecera; si no la tiene, la primera
     * linea es un dato y NO debe perderse.
     */
    public static void procesar(String ruta) throws IOException {
        try (BufferedReader lector = new BufferedReader(
                new FileReader(ruta, StandardCharsets.UTF_8))) {

            lector.mark(8192);                     // margen amplio
            String primera = lector.readLine();

            boolean tieneCabecera = primera != null
                    && (primera.startsWith("#") || primera.toLowerCase().contains("titulo"));

            if (!tieneCabecera) {
                lector.reset();                    // devolver la linea al flujo
                System.out.println("Sin cabecera: la primera linea es un dato");
            } else {
                System.out.println("Cabecera detectada: " + primera);
            }

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

Sus límites, que hay que respetar:

  • mark puede fallar si el readAheadLimit es demasiado grande. El búfer tiene que crecer hasta ese tamaño; pedir mark(Integer.MAX_VALUE) intenta reservar 2 GB.
  • Leer más de n caracteres invalida la marca y reset() lanza IOException.
  • No todos los Reader lo soportan. markSupported() lo dice. BufferedReader sí; FileReader a secas, no.

En la práctica se usa poco: casi siempre es más simple leer la primera línea y decidir qué hacer con ella en una variable. Merece la pena conocerlo porque aparece en código de análisis de formatos, y porque explica por qué existe el parámetro de tamaño del búfer.

  1. BufferedWriter: write, newLine y flush

La contraparte de escritura. Su API es corta:

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class EscrituraConBuffer {

    public static void escribir(String ruta) throws IOException {
        try (BufferedWriter escritor = new BufferedWriter(
                new FileWriter(ruta, StandardCharsets.UTF_8))) {

            escritor.write("# Catalogo de BiblioTech");
            escritor.newLine();                        // separador DEL SISTEMA

            escritor.write("LIBRO;978-0000000001;Java Efectivo");
            escritor.newLine();

            escritor.write("LIBRO;978-0000000002;Patrones de Diseno");
            escritor.newLine();
        }
        // close() -> flush() -> los datos llegan al disco (07-02)
    }
}
Método Qué hace
write(String) Escribe la cadena en el búfer
write(String, int, int) Escribe una subcadena
write(char[], int, int) Escribe parte de un array
write(int) Escribe un carácter
newLine() Escribe System.lineSeparator()
flush() Vuelca el búfer al flujo envuelto
close() flush() + cierre en cascada

Dos cosas a destacar:

newLine() frente a write("\n"). newLine() usa el separador del sistema; write("\n") escribe siempre LF. Es la decisión del apartado 8 de 07-02: newLine() para informes, "\n" fijo para ficheros de datos que se comparan o versionan.

A diferencia de PrintWriter, BufferedWriter sí lanza IOException. Todos sus métodos la declaran. Ese es un argumento serio a su favor en código donde el contenido del fichero importa: no hay ningún checkError() que puedas olvidar.

Comparación de las dos formas de escribir texto con búfer:

BufferedWriter PrintWriter sobre BufferedWriter
Errores de E/S Lanza IOException Los guarda: hay que llamar a checkError()
Formateo No tiene printf, format
Salto de línea newLine() explícito println() lo pone
Escribir objetos Solo String y char[] print(Object) usa toString()
Cuándo usarla Datos: el fallo debe detectarse Informes y salida legible

  1. El trío PrintWriter + BufferedWriter + FileWriter

Esta cadena de tres capas es la más habitual para escribir texto en Java, y ahora puedes explicar exactamente qué aporta cada una:

try (PrintWriter salida = new PrintWriter(          // 3. formateo
        new BufferedWriter(                          // 2. buffer
            new FileWriter(ruta,                     // 1. fichero + charset
                    StandardCharsets.UTF_8)))) {

    salida.printf("%-25s %8.2f%n", "Java Efectivo", 3.75);
}
Capa Tipo (07-03) Qué aporta Qué pasa si la quitas
FileWriter Nodo Conexión con el fichero y codificación No hay fichero. Es imprescindible
BufferedWriter Filtro Agrupa las escrituras; newLine() Funciona, pero mucho más lento
PrintWriter Filtro println, printf, print(Object) Funciona, pero hay que formatear a mano

Un matiz honesto que conviene saber: PrintWriter ya tiene su propio búfer interno, así que la capa intermedia aporta menos de lo que la gente cree. En muchos casos, new PrintWriter(new FileWriter(ruta, UTF_8)) rinde prácticamente igual. La cadena de tres se escribe por costumbre y porque es explícita, no porque la mejora sea grande.

Lo que cambia según la capa exterior es el manejo de errores, y esa es la decisión de verdad:

// Opcion A: PrintWriter. Comodo, pero los errores hay que preguntarlos.
try (PrintWriter salida = new PrintWriter(
        new BufferedWriter(new FileWriter(ruta, UTF_8)))) {
    salida.printf("%-25s %8.2f%n", titulo, multa);
    if (salida.checkError()) {
        throw new IOException("Fallo al escribir " + ruta);
    }
}

// Opcion B: BufferedWriter. Mas verboso, pero los errores se propagan solos.
try (BufferedWriter salida = new BufferedWriter(new FileWriter(ruta, UTF_8))) {
    salida.write(String.format("%-25s %8.2f", titulo, multa));
    salida.newLine();
}

La opción B usa String.format para conservar el formateo y BufferedWriter para conservar las excepciones. Es la que usaremos en BiblioTech para los ficheros de datos; la A queda para los informes de consulta.

  1. Files.newBufferedReader y newBufferedWriter

Desde Java 7 existe una forma más corta de construir estas cadenas, que es la que verás en código moderno:

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

// Lectura: equivale a new BufferedReader(new InputStreamReader(
//          new FileInputStream(...), UTF_8))
try (BufferedReader lector = Files.newBufferedReader(
        Path.of("datos/catalogo.txt"), StandardCharsets.UTF_8)) {

    String linea;
    while ((linea = lector.readLine()) != null) {
        procesar(linea);
    }
}

// Escritura, con opciones explicitas
try (BufferedWriter escritor = Files.newBufferedWriter(
        Path.of("datos/informe.txt"), StandardCharsets.UTF_8)) {
    escritor.write("...");
    escritor.newLine();
}

Ventajas sobre la construcción manual:

Manual Files.newBufferedReader
Longitud Tres constructores anidados Una llamada
Charset Se puede olvidar Parámetro obligatorio en la práctica
Excepción si no existe FileNotFoundException NoSuchFileException, más específica
Opciones de apertura Solo append StandardOpenOption completas

Esta es la forma recomendada en código nuevo. Path, Files y StandardOpenOption son la API NIO.2, que se desarrolla entera en 07-06. Aquí queda presentada porque devuelve exactamente el BufferedReader de esta lección: el objeto es el mismo, solo cambia cómo se construye.

  1. Procesar un fichero grande con memoria constante

Esta es la capacidad que hace importante a BufferedReader, más allá del rendimiento.

El problema de 07-01: cargar un fichero entero en memoria consume tanta memoria como el fichero, y con ficheros grandes provoca OutOfMemoryError. La solución: procesar en flujo.

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class ProcesamientoEnFlujo {

    /**
     * Recorre un fichero de CUALQUIER tamano con memoria constante.
     *
     * En cada instante hay en memoria: el buffer (8 KB), la linea actual
     * y los acumuladores. Da igual que el fichero tenga 1 MB o 50 GB.
     */
    public static Resumen analizar(String ruta) throws IOException {
        long lineas = 0;
        long caracteres = 0;
        long lineasVacias = 0;
        int  maxLongitud = 0;
        String lineaMasLarga = "";

        try (BufferedReader lector = new BufferedReader(
                new FileReader(ruta, StandardCharsets.UTF_8))) {

            String linea;
            while ((linea = lector.readLine()) != null) {
                lineas++;
                caracteres += linea.length();

                if (linea.isBlank()) {
                    lineasVacias++;
                }
                if (linea.length() > maxLongitud) {
                    maxLongitud = linea.length();
                    lineaMasLarga = linea;      // se guarda UNA, no todas
                }
                // 'linea' queda disponible para el recolector en la vuelta siguiente
            }
        }
        return new Resumen(lineas, caracteres, lineasVacias, maxLongitud, lineaMasLarga);
    }

    public record Resumen(long lineas, long caracteres, long lineasVacias,
                          int maxLongitud, String lineaMasLarga) { }
}

Comparación de memoria con un fichero de 2 GB:

Enfoque Memoria en el pico Resultado
Files.readString() > 4 GB OutOfMemoryError
Files.readAllLines() > 5 GB (lista de millones de String) OutOfMemoryError
BufferedReader línea a línea ~50 KB Funciona

La regla, que ya apuntaba 07-01 y ahora tiene su herramienta:

Si el fichero puede crecer con el uso, procésalo en flujo. Configuración, plantillas y ficheros de pocos KB pueden cargarse enteros. Datos, registros, exportaciones e importaciones, nunca.

Y el matiz importante: procesar en flujo obliga a diseñar el algoritmo de otra manera. Solo puedes hacer un recorrido y solo ves una línea cada vez. Contar, sumar, buscar el máximo o filtrar y escribir en otro fichero funcionan perfectamente. Ordenar el fichero entero, no: eso requiere una ordenación externa, que es otro problema.

  1. Leer de consola con BufferedReader frente a Scanner

Desde el módulo 1 usas Scanner para la consola. BufferedReader es la alternativa:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;

public class ConsolaConBufferedReader {

    public static void main(String[] args) throws IOException {
        // System.in es un InputStream (bytes): hace falta el PUENTE (07-03)
        BufferedReader consola = new BufferedReader(
                new InputStreamReader(System.in, StandardCharsets.UTF_8));

        System.out.print("Referencia del material: ");
        String referencia = consola.readLine();

        System.out.print("Dias de prestamo: ");
        String texto = consola.readLine();

        // BufferedReader NO parsea: hay que convertir a mano
        int dias;
        try {
            dias = Integer.parseInt(texto.trim());
        } catch (NumberFormatException e) {
            System.out.println("'" + texto + "' no es un numero valido");
            return;
        }

        System.out.printf("Prestamo de %s por %d dias%n", referencia, dias);

        // OJO: NO se cierra el BufferedReader. Cerrarlo cerraria System.in
        // para toda la aplicacion, y no habria forma de reabrirlo (06-06).
    }
}

La comparación completa:

Scanner BufferedReader
Parseo de tipos nextInt, nextDouble, nextBoolean Ninguno: Integer.parseInt a mano
Leer una línea nextLine() readLine()
Búfer 1024 caracteres 8192 caracteres
Velocidad Más lenta: usa expresiones regulares Notablemente más rápida
Excepciones No comprobadas (InputMismatchException) Comprobada (IOException)
Fin de entrada hasNextLine() devuelve false readLine() devuelve null
Sensible al Locale : coma o punto decimal No: no parsea
Trampa del salto de línea : nextInt() no lo consume No: readLine() consume la línea entera
Expresiones regulares useDelimiter, hasNext(patron) No tiene
Cerrar System.in Peligroso Peligroso

Cuándo usar cada uno:

  • Scanner para menús interactivos y entrada por consola, que es lo que hace MenuBiblioTech desde el módulo 2. Su parseo compensa y el rendimiento es irrelevante cuando esperas a que alguien teclee.
  • BufferedReader para ficheros, siempre. En un fichero de cien mil líneas, la diferencia de velocidad es de varios segundos.
  • BufferedReader para entrada masiva por tubería, cuando el programa recibe datos por stdin desde otro proceso.

Y la advertencia que ya conoces de 06-07, aplicable a los dos: nunca cierres System.in. Es un recurso global de la aplicación; cerrarlo deja el programa sin entrada para siempre. Es la excepción explícita a la regla de cerrarlo todo que 06-06 documentaba.

  1. BiblioTech: ImportadorCatalogo con informe completo

Hora del caso práctico grande de la lección, y la segunda deuda que se salda: en 06-06 declaraste ImportadorCatalogo como esbozo; aquí se escribe entero.

Los requisitos, que son los de una importación real:

  1. Leer un fichero de miles de líneas con memoria constante.
  2. Validar cada línea sin abortar la importación en la primera mala.
  3. Contar correctas y descartadas, con el motivo de cada descarte.
  4. Registrar los errores con el logger, no en consola.
  5. Devolver un informe que quien llama pueda mostrar o guardar.
  6. Ser transaccional en lo razonable: no dejar el catálogo a medias si falla el fichero entero.
package com.nexussoftware.bibliotech.servicio;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.dominio.Libro;
import com.nexussoftware.bibliotech.dominio.Material;
import com.nexussoftware.bibliotech.dominio.ReferenciaDuplicadaException;
import com.nexussoftware.bibliotech.infraestructura.FormatoFicheros;

/**
 * Importacion masiva del catalogo de BiblioTech desde un fichero de texto.
 *
 * Formato esperado (una linea por material):
 *   LIBRO;isbn;titulo;autor;anio
 *
 * Politica (06-07):
 *   - Una linea mala NO aborta la importacion: se descarta y se cuenta.
 *   - El fichero ausente o ilegible SI aborta: no hay nada que importar.
 *   - Los materiales se acumulan y se vuelcan al catalogo AL FINAL, para no
 *     dejarlo a medias si el fichero resulta estar corrupto por completo.
 *
 * Procesa en FLUJO: memoria constante sea cual sea el tamano del fichero.
 */
public class ImportadorCatalogo {

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

    private static final int CAMPOS_ESPERADOS = 5;
    private static final int ANIO_MINIMO = 1450;
    private static final int ANIO_MAXIMO = 2100;

    /** Si mas de esta fraccion de lineas falla, el fichero es sospechoso. */
    private static final double UMBRAL_FICHERO_CORRUPTO = 0.5;

    /** Errores detallados que se guardan; el resto solo se cuentan. */
    private static final int MAX_ERRORES_DETALLADOS = 100;

    private final File fichero;

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

    // ------------------------- EL INFORME -------------------------

    /** Motivo por el que se descarta una línea. Enum (04-07): sin cadenas magicas. */
    public enum MotivoDescarte {
        NUMERO_DE_CAMPOS("Numero de campos incorrecto"),
        TIPO_DESCONOCIDO("Tipo de material no reconocido"),
        CAMPO_VACIO("Campo obligatorio vacio"),
        ANIO_NO_NUMERICO("El anio no es un numero"),
        ANIO_FUERA_DE_RANGO("Anio fuera del rango admitido"),
        ISBN_INVALIDO("El ISBN no tiene el formato esperado"),
        DUPLICADO("Referencia o ISBN ya presentes");

        private final String descripcion;
        MotivoDescarte(String descripcion) { this.descripcion = descripcion; }
        public String getDescripcion() { return descripcion; }
    }

    /** Una linea descartada, con todo lo necesario para corregirla. */
    public record LineaDescartada(int numero, MotivoDescarte motivo, String detalle,
                                  String contenido) {
        @Override
        public String toString() {
            return String.format("Linea %d [%s]: %s | %s",
                    numero, motivo.name(), detalle, recortar(contenido));
        }
        private static String recortar(String s) {
            return (s.length() <= 60) ? s : s.substring(0, 57) + "...";
        }
    }

    /** Resultado completo de la importacion. */
    public static class Informe {
        private final List<Material> importados = new ArrayList<>();
        private final List<LineaDescartada> descartadas = new ArrayList<>();
        private final Map<MotivoDescarte, Integer> porMotivo = new LinkedHashMap<>();

        private int lineasLeidas = 0;
        private int lineasIgnoradas = 0;       // vacias y comentarios
        private int descartesTotales = 0;      // incluye los no detallados
        private long milisegundos = 0;
        private boolean abortada = false;
        private String motivoAborto = null;

        void contarLinea()      { lineasLeidas++; }
        void contarIgnorada()   { lineasIgnoradas++; }
        void anadir(Material m) { importados.add(m); }

        void descartar(LineaDescartada d) {
            descartesTotales++;
            porMotivo.merge(d.motivo(), 1, Integer::sum);      // 05-05
            if (descartadas.size() < MAX_ERRORES_DETALLADOS) {
                descartadas.add(d);
            }
        }

        void abortar(String motivo) {
            abortada = true;
            motivoAborto = motivo;
        }

        public List<Material> getImportados()            { return List.copyOf(importados); }
        public List<LineaDescartada> getDescartadas()    { return List.copyOf(descartadas); }
        public Map<MotivoDescarte, Integer> getPorMotivo() { return Map.copyOf(porMotivo); }
        public int getLineasLeidas()     { return lineasLeidas; }
        public int getLineasIgnoradas()  { return lineasIgnoradas; }
        public int getImportadosTotal()  { return importados.size(); }
        public int getDescartesTotales() { return descartesTotales; }
        public long getMilisegundos()    { return milisegundos; }
        public boolean estaAbortada()    { return abortada; }
        public String getMotivoAborto()  { return motivoAborto; }

        public double tasaExito() {
            int procesadas = importados.size() + descartesTotales;
            return (procesadas == 0) ? 0.0 : (importados.size() * 100.0) / procesadas;
        }

        /** Informe legible para la capa de presentacion. */
        public String resumen() {
            StringBuilder sb = new StringBuilder();
            sb.append("=== INFORME DE IMPORTACION ===\n");

            if (abortada) {
                sb.append("  IMPORTACION ABORTADA: ").append(motivoAborto).append('\n');
                return sb.toString();
            }

            sb.append(String.format("  Lineas leidas    : %d%n", lineasLeidas));
            sb.append(String.format("  Ignoradas        : %d (vacias y comentarios)%n",
                    lineasIgnoradas));
            sb.append(String.format("  Importadas       : %d%n", importados.size()));
            sb.append(String.format("  Descartadas      : %d%n", descartesTotales));
            sb.append(String.format("  Tasa de exito    : %.1f%%%n", tasaExito()));
            sb.append(String.format("  Tiempo           : %d ms%n", milisegundos));

            if (!porMotivo.isEmpty()) {
                sb.append("  --- Descartes por motivo ---\n");
                for (Map.Entry<MotivoDescarte, Integer> e : porMotivo.entrySet()) {
                    sb.append(String.format("    %-22s %4d  (%s)%n",
                            e.getKey().name(), e.getValue(),
                            e.getKey().getDescripcion()));
                }
            }
            if (!descartadas.isEmpty()) {
                sb.append("  --- Primeras lineas descartadas ---\n");
                int mostradas = Math.min(10, descartadas.size());
                for (int i = 0; i < mostradas; i++) {
                    sb.append("    ").append(descartadas.get(i)).append('\n');
                }
                if (descartesTotales > mostradas) {
                    sb.append(String.format("    ... y %d mas%n",
                            descartesTotales - mostradas));
                }
            }
            return sb.toString();
        }
    }

    // ------------------------- LA IMPORTACION -------------------------

    /**
     * Importa el fichero al catalogo dado.
     *
     * @return el informe. Nunca null, ni siquiera si aborta.
     */
    public Informe importar(Catalogo catalogo) {
        Objects.requireNonNull(catalogo, "El catalogo no puede ser nulo");

        Informe informe = new Informe();
        long inicio = System.nanoTime();

        LOG.info(() -> "Iniciando importacion desde " + fichero.getAbsolutePath());

        // BufferedReader: memoria constante aunque el fichero tenga millones
        // de lineas. Charset explicito, el mismo que usa el exportador.
        try (BufferedReader lector = new BufferedReader(
                new FileReader(fichero, FormatoFicheros.CHARSET))) {

            String linea;
            int numero = 0;

            while ((linea = lector.readLine()) != null) {   // el bucle canonico
                numero++;
                informe.contarLinea();
                procesarLinea(linea, numero, informe);
            }

        } catch (java.io.FileNotFoundException e) {
            informe.abortar("No existe el fichero " + fichero.getAbsolutePath());
            LOG.log(Level.WARNING, "Importacion abortada: fichero no encontrado", e);
            return finalizar(informe, inicio);

        } catch (IOException e) {
            informe.abortar("Fallo de E/S leyendo " + fichero.getAbsolutePath()
                    + ": " + e.getMessage());
            LOG.log(Level.SEVERE, "Importacion abortada por fallo de E/S", e);
            return finalizar(informe, inicio);
        }

        // Comprobacion de cordura: si falla mas de la mitad, algo va mal con
        // el fichero entero (charset equivocado, formato distinto, columnas
        // cambiadas). Mejor rechazarlo que meter basura en el catalogo.
        int procesadas = informe.getImportadosTotal() + informe.getDescartesTotales();
        if (procesadas > 10
                && informe.getDescartesTotales() > procesadas * UMBRAL_FICHERO_CORRUPTO) {

            informe.abortar(String.format(
                    "Ha fallado el %.0f%% de las lineas (%d de %d). El fichero no parece "
                            + "tener el formato esperado; no se importa nada.",
                    100 - informe.tasaExito(), informe.getDescartesTotales(), procesadas));

            LOG.severe(() -> "Importacion rechazada: " + informe.getMotivoAborto());
            return finalizar(informe, inicio);
        }

        // Volcado al catalogo AL FINAL: hasta aqui no se ha tocado nada
        volcar(catalogo, informe);
        return finalizar(informe, inicio);
    }

    /** Valida y construye el material de una linea. Nunca lanza hacia arriba. */
    private void procesarLinea(String linea, int numero, Informe informe) {
        String limpia = linea.trim();

        if (limpia.isEmpty() || limpia.startsWith(FormatoFicheros.COMENTARIO)) {
            informe.contarIgnorada();
            return;
        }

        // El -1 conserva los campos vacios del final (07-01, solucion 3)
        String[] campos = limpia.split(FormatoFicheros.SEPARADOR_CAMPOS, -1);

        if (campos.length != CAMPOS_ESPERADOS) {
            informe.descartar(new LineaDescartada(numero, MotivoDescarte.NUMERO_DE_CAMPOS,
                    "hay " + campos.length + " y se esperaban " + CAMPOS_ESPERADOS,
                    limpia));
            return;
        }

        String tipo   = campos[0].trim().toUpperCase();
        String isbn   = campos[1].trim();
        String titulo = campos[2].trim();
        String autor  = campos[3].trim();
        String anioTx = campos[4].trim();

        if (!"LIBRO".equals(tipo)) {
            informe.descartar(new LineaDescartada(numero, MotivoDescarte.TIPO_DESCONOCIDO,
                    "'" + tipo + "'", limpia));
            return;
        }
        if (titulo.isEmpty() || isbn.isEmpty()) {
            informe.descartar(new LineaDescartada(numero, MotivoDescarte.CAMPO_VACIO,
                    titulo.isEmpty() ? "titulo" : "isbn", limpia));
            return;
        }
        if (!isbn.matches("\\d{3}-\\d{10}")) {
            informe.descartar(new LineaDescartada(numero, MotivoDescarte.ISBN_INVALIDO,
                    "'" + isbn + "' no cumple NNN-NNNNNNNNNN", limpia));
            return;
        }

        int anio;
        try {
            anio = Integer.parseInt(anioTx);
        } catch (NumberFormatException e) {
            informe.descartar(new LineaDescartada(numero, MotivoDescarte.ANIO_NO_NUMERICO,
                    "'" + anioTx + "'", limpia));
            return;
        }
        if (anio < ANIO_MINIMO || anio > ANIO_MAXIMO) {
            informe.descartar(new LineaDescartada(numero, MotivoDescarte.ANIO_FUERA_DE_RANGO,
                    anio + " fuera de [" + ANIO_MINIMO + ", " + ANIO_MAXIMO + "]", limpia));
            return;
        }

        informe.anadir(new Libro(titulo, autor.isEmpty() ? "Desconocido" : autor, isbn, anio));
    }

    /**
     * Vuelca al catalogo lo importado.
     *
     * Los duplicados se detectan AQUI, porque solo el catalogo los conoce.
     * Un duplicado no invalida la importacion entera: se descarta y se sigue.
     */
    private void volcar(Catalogo catalogo, Informe informe) {
        List<Material> pendientes = informe.getImportados();
        List<Material> aceptados = new ArrayList<>(pendientes.size());

        for (Material m : pendientes) {
            try {
                catalogo.registrar(m);
                aceptados.add(m);
            } catch (ReferenciaDuplicadaException e) {
                informe.descartar(new LineaDescartada(0, MotivoDescarte.DUPLICADO,
                        e.getMessage(), m.getReferencia()));
            }
        }
        informe.importados.clear();
        informe.importados.addAll(aceptados);      // solo lo realmente registrado
    }

    private Informe finalizar(Informe informe, long inicio) {
        informe.milisegundos = (System.nanoTime() - inicio) / 1_000_000;

        if (informe.estaAbortada()) {
            LOG.warning(() -> "Importacion abortada: " + informe.getMotivoAborto());
        } else {
            LOG.info(() -> String.format(
                    "Importacion terminada: %d importados, %d descartados, %d ms",
                    informe.getImportadosTotal(), informe.getDescartesTotales(),
                    informe.getMilisegundos()));
        }
        return informe;
    }
}

Y su uso desde la presentación:

package com.nexussoftware.bibliotech.presentacion;

import com.nexussoftware.bibliotech.servicio.Catalogo;
import com.nexussoftware.bibliotech.servicio.ImportadorCatalogo;

public class DemoImportacion {

    public static void main(String[] args) {
        Catalogo catalogo = new Catalogo();

        ImportadorCatalogo importador =
                new ImportadorCatalogo("datos/catalogo-completo.txt");

        ImportadorCatalogo.Informe informe = importador.importar(catalogo);

        System.out.println(informe.resumen());
        System.out.println("Materiales en el catalogo: " + catalogo.tamano());
    }
}

Salida con un fichero de 5 000 líneas con algunos errores:

=== INFORME DE IMPORTACION ===
  Lineas leidas    : 5003
  Ignoradas        : 3 (vacias y comentarios)
  Importadas       : 4962
  Descartadas      : 38
  Tasa de exito    : 99.2%
  Tiempo           : 87 ms
  --- Descartes por motivo ---
    ANIO_NO_NUMERICO         14  (El anio no es un numero)
    ISBN_INVALIDO            11  (El ISBN no tiene el formato esperado)
    NUMERO_DE_CAMPOS          7  (Numero de campos incorrecto)
    DUPLICADO                 4  (Referencia o ISBN ya presentes)
    CAMPO_VACIO               2  (Campo obligatorio vacio)
  --- Primeras lineas descartadas ---
    Linea 47 [ANIO_NO_NUMERICO]: 'mil novecientos' | LIBRO;978-0000000047;Un libro;Autor;mil...
    Linea 112 [NUMERO_DE_CAMPOS]: hay 4 y se esperaban 5 | LIBRO;978-0000000112;Otro;Autor
    Linea 340 [ISBN_INVALIDO]: '97800000340' no cumple NNN-NNNNNNNNNN | LIBRO;97800000340;...
    ... y 35 mas

Las siete decisiones de diseño que hay que entender de esta clase:

  1. BufferedReader y bucle canónico. Memoria constante: el fichero podría tener cinco millones de líneas y el consumo sería el mismo.
  2. Una línea mala no aborta. Se descarta con su motivo y su número de línea. Quien importa quiere ver todos los problemas de una vez.
  3. Un fichero mayoritariamente malo sí aborta. Si falla más de la mitad, el fichero no es el que se esperaba —charset equivocado, columnas cambiadas, formato distinto— y meter esa basura en el catálogo sería peor que no importar. Es la distinción recuperable/irrecuperable de 06-07 aplicada a la importación.
  4. El volcado al catálogo va al final. Hasta ese momento el catálogo no se ha tocado, así que un aborto lo deja exactamente como estaba. Es la coherencia de estado de 06-05, en versión importación.
  5. Los detalles se limitan a 100. Un fichero corrupto con un millón de líneas malas generaría un millón de objetos LineaDescartada y agotaría la memoria. Se cuentan todas y se detallan las cien primeras: es información suficiente para corregir, y acotada.
  6. MotivoDescarte es un enum, no una cadena. Permite agrupar, contar y traducir, y elimina las cadenas mágicas. Es 04-07 aplicado.
  7. Todo el diagnóstico va al logger; el informe se devuelve. La clase de servicio no imprime: devuelve un objeto y quien la llama decide si lo muestra por consola, lo guarda o lo envía por correo. Es la separación de capas de 06-07.

Errores Comunes y Consejos

  • Usar ready() como condición de fin. El bug que funciona en desarrollo y falla en producción con ficheros grandes o fuentes lentas. La condición correcta es siempre readLine() != null.
  • Llamar a readLine() dos veces por vuelta. Procesa las líneas pares y se salta las impares. Con un fichero de prueba pequeño puede parecer correcto.
  • Confundir null con "". null es fin de fichero; "" es una línea vacía. Usar isEmpty() como condición de fin detiene la lectura en el primer hueco y luego lanza NullPointerException.
  • Olvidar los paréntesis en while ((linea = lector.readLine()) != null). No compila, y es la forma idiomática que hay que memorizar.
  • Esperar que readLine() incluya el salto de línea. No lo incluye. Si estás reescribiendo el fichero, tienes que reponerlo con newLine().
  • No cerrar el BufferedWriter. El fichero queda vacío: los datos están en el búfer. En escritura, no cerrar es perder datos (07-02).
  • Creer que BufferedWriter escribe inmediatamente. No lo hace, y por diseño. Si necesitas que otro proceso vea los datos ya, flush().
  • Cerrar System.in. Deja la aplicación sin entrada para siempre. Es la excepción documentada a la regla de cerrarlo todo.
  • Envolver algo que ya tiene búfer. new BufferedReader(new BufferedReader(...)) no aporta nada; sobre un array en memoria, tampoco.
  • Poner un búfer enorme "por si acaso". Cien flujos con búferes de 1 MB son 100 MB de heap, y a partir de 64 KB la mejora es despreciable.
  • Usar PrintWriter sin checkError() para datos que importan. Se traga las IOException. Para ficheros de datos, BufferedWriter, cuyos métodos sí las declaran.
  • Usar Scanner para leer un fichero de cien mil líneas. Funciona y tarda varios segundos de más. BufferedReader para ficheros, Scanner para menús.
  • Acumular todas las líneas en una lista "para procesarlas luego". Anula la ventaja del procesamiento en flujo y reintroduce el OutOfMemoryError.
  • Guardar un objeto de error por cada línea mala sin límite. Un fichero corrupto de un millón de líneas agota la memoria mientras informa de que hay un problema.
  • Consejo: lleva siempre el número de línea. "Línea 4 217: el año no es un número" es accionable; "error al importar" no lo es. Es 06-04 aplicado a ficheros.
  • Consejo: pon un umbral de cordura en toda importación. Si falla más de la mitad, el problema es el fichero, no las líneas. Rechazarlo entero es más correcto que importar basura.
  • Consejo: que el servicio devuelva el informe, no lo imprima. Quien llama decide si lo muestra, lo guarda o lo envía. La capa de servicio no sabe quién hay al otro lado.
  • Consejo: usa Files.newBufferedReader en código nuevo. Es más corto, obliga al charset y da excepciones más específicas. Lo verás entero en 07-06.

Ejercicios

Ejercicio 1: contador de estadísticas de un fichero

Escribe EstadisticasFichero que analice un fichero de texto en un solo recorrido y con memoria constante, produciendo:

  1. Número de líneas, de palabras y de caracteres.
  2. Líneas vacías y líneas de comentario (que empiezan por #).
  3. Longitud media, mínima y máxima de línea.
  4. Las tres palabras más frecuentes (usa un HashMap del módulo 5; no uses la API de Streams, que es 10-04).
  5. El número de la línea más larga y sus primeros 60 caracteres.

Requisitos: BufferedReader, charset explícito, try-with-resources, y un record para el resultado. Debe funcionar igual con un fichero de 1 KB que con uno de 1 GB.

Ejercicio 2: filtro de líneas con reescritura

Escribe FiltroCatalogo que lea un fichero de catálogo y escriba otro fichero solo con las líneas que cumplan un criterio, sin cargar ninguno de los dos en memoria:

  1. filtrar(String origen, String destino, Predicado criterio) donde Predicado es una interfaz funcional propia (04-06) con boolean cumple(String linea, int numero).
  2. Conserva las líneas de comentario y la cabecera.
  3. Cuenta líneas leídas, escritas y descartadas, y devuelve el resultado en un record.
  4. Usa escritura atómica (07-02): escribe a un temporal y renombra al final.
  5. Un main que filtre por año de publicación posterior a 2000 y por autor que contenga un texto dado.

Ejercicio 3: comparador de dos catálogos

Escribe ComparadorCatalogos que compare dos ficheros de catálogo e informe de las diferencias:

  1. Materiales solo en el primero (bajas).
  2. Materiales solo en el segundo (altas).
  3. Materiales en ambos pero con datos distintos (modificaciones), indicando qué campo cambió.
  4. Materiales idénticos (solo el recuento).

Requisitos: lee cada fichero una sola vez con BufferedReader, indexa por ISBN con un HashMap (05-05), y produce un informe con printf. Explica en un comentario por qué este algoritmo necesita cargar uno de los dos ficheros en memoria y cuál conviene que sea, y qué harías si ninguno de los dos cupiera.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.util;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Estadisticas de un fichero de texto en UN SOLO RECORRIDO y con memoria
 * constante respecto al tamano del fichero.
 *
 * Lo unico que crece es el mapa de frecuencias, que depende del numero de
 * palabras DISTINTAS, no del tamano del fichero. Un fichero de 10 GB en
 * espanol tiene del orden de cien mil palabras distintas: perfectamente
 * manejable.
 */
public class EstadisticasFichero {

    private static final int PALABRAS_TOP = 3;
    private static final int LONGITUD_MUESTRA = 60;

    public record Estadisticas(
            long lineas, long palabras, long caracteres,
            long lineasVacias, long lineasComentario,
            double longitudMedia, int longitudMinima, int longitudMaxima,
            int numeroLineaMasLarga, String muestraLineaMasLarga,
            List<Map.Entry<String, Integer>> palabrasFrecuentes) {

        public String informe() {
            StringBuilder sb = new StringBuilder();
            sb.append("=== ESTADISTICAS DEL FICHERO ===\n");
            sb.append(String.format("  Lineas            : %d%n", lineas));
            sb.append(String.format("    vacias          : %d%n", lineasVacias));
            sb.append(String.format("    comentarios     : %d%n", lineasComentario));
            sb.append(String.format("  Palabras          : %d%n", palabras));
            sb.append(String.format("  Caracteres        : %d%n", caracteres));
            sb.append(String.format("  Longitud media    : %.1f%n", longitudMedia));
            sb.append(String.format("  Longitud minima   : %d%n", longitudMinima));
            sb.append(String.format("  Longitud maxima   : %d (linea %d)%n",
                    longitudMaxima, numeroLineaMasLarga));
            sb.append(String.format("    muestra         : %s%n", muestraLineaMasLarga));
            sb.append("  Palabras mas frecuentes:\n");
            for (Map.Entry<String, Integer> e : palabrasFrecuentes) {
                sb.append(String.format("    %-20s %6d%n", e.getKey(), e.getValue()));
            }
            return sb.toString();
        }
    }

    public static Estadisticas analizar(String ruta) throws IOException {
        long lineas = 0, palabras = 0, caracteres = 0;
        long vacias = 0, comentarios = 0;
        int  minima = Integer.MAX_VALUE, maxima = 0;
        int  numeroMasLarga = 0;
        String muestraMasLarga = "";

        // Solo esto crece, y con el numero de palabras DISTINTAS
        Map<String, Integer> frecuencias = new HashMap<>();

        try (BufferedReader lector = new BufferedReader(
                new FileReader(ruta, StandardCharsets.UTF_8))) {

            String linea;
            while ((linea = lector.readLine()) != null) {     // bucle canonico
                lineas++;
                caracteres += linea.length();

                int longitud = linea.length();
                if (longitud < minima) { minima = longitud; }
                if (longitud > maxima) {
                    maxima = longitud;
                    numeroMasLarga = (int) lineas;
                    muestraMasLarga = longitud <= LONGITUD_MUESTRA
                            ? linea
                            : linea.substring(0, LONGITUD_MUESTRA) + "...";
                }

                String limpia = linea.trim();
                if (limpia.isEmpty()) {
                    vacias++;
                    continue;
                }
                if (limpia.startsWith("#")) {
                    comentarios++;
                    continue;
                }

                // Separa por cualquier cosa que no sea letra o digito
                for (String palabra : limpia.toLowerCase().split("[^\\p{L}\\p{N}]+")) {
                    if (palabra.length() > 2) {           // ignora articulos cortos
                        palabras++;
                        frecuencias.merge(palabra, 1, Integer::sum);   // 05-05
                    }
                }
                // 'linea' se descarta aqui: memoria constante
            }
        }

        if (minima == Integer.MAX_VALUE) {
            minima = 0;                                   // fichero vacio
        }

        return new Estadisticas(
                lineas, palabras, caracteres, vacias, comentarios,
                lineas == 0 ? 0.0 : (double) caracteres / lineas,
                minima, maxima, numeroMasLarga, muestraMasLarga,
                masFrecuentes(frecuencias, PALABRAS_TOP));
    }

    /**
     * Las n entradas con mayor valor.
     *
     * Sin la API de Streams (10-04): lista + Comparator + sort, que es
     * exactamente lo de 05-09.
     */
    private static List<Map.Entry<String, Integer>> masFrecuentes(
            Map<String, Integer> frecuencias, int n) {

        List<Map.Entry<String, Integer>> entradas = new ArrayList<>(frecuencias.entrySet());

        // Descendente por valor; a igualdad, alfabetico por clave
        entradas.sort(Comparator
                .comparing(Map.Entry<String, Integer>::getValue).reversed()
                .thenComparing(Map.Entry::getKey));

        return List.copyOf(entradas.subList(0, Math.min(n, entradas.size())));
    }

    public static void main(String[] args) throws IOException {
        String ruta = (args.length > 0) ? args[0] : "datos/catalogo.txt";
        System.out.println(analizar(ruta).informe());
    }
}

Salida:

=== ESTADISTICAS DEL FICHERO ===
  Lineas            : 5003
  Lineas vacias     : 1
  Comentarios       : 2
  Palabras          : 19842
  Caracteres        : 248915
  Longitud media    : 49.8
  Longitud minima   : 0
  Longitud maxima   : 87 (linea 1204)
    muestra         : LIBRO;978-0000001204;Introduccion a los sistemas distribuidos...
  Palabras mas frecuentes:
    libro                  5000
    programacion            412
    sistemas                287

El punto clave está en el comentario de cabecera: el único dato que crece con el contenido es el mapa de frecuencias, y crece con el número de palabras distintas, no con el tamaño del fichero. Todo lo demás son acumuladores de tamaño fijo. Por eso este análisis funciona con un fichero de 10 GB. Fíjate también en que la "línea más larga" se guarda recortada: guardarla entera sería un riesgo si esa línea tuviera 100 MB.

Solución 2

package com.nexussoftware.bibliotech.servicio;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.logging.Logger;

/**
 * Filtra un fichero de catalogo escribiendo otro con las lineas que cumplan
 * un criterio, SIN cargar ninguno de los dos en memoria.
 *
 * Lee y escribe en flujo simultaneamente: en cada instante hay en memoria
 * una linea y dos buffers de 8 KB.
 */
public class FiltroCatalogo {

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

    private static final String COMENTARIO = "#";

    /**
     * Criterio de filtrado. Interfaz funcional propia (04-06): recibe la
     * linea y su numero, por si el criterio depende de la posicion.
     */
    @FunctionalInterface
    public interface Predicado {
        boolean cumple(String linea, int numero);
    }

    public record ResultadoFiltro(int leidas, int escritas, int descartadas,
                                  int comentariosConservados, long milisegundos) {

        public String resumen() {
            return String.format(
                    "Filtrado: %d leidas, %d escritas, %d descartadas, "
                            + "%d comentarios conservados (%d ms)",
                    leidas, escritas, descartadas, comentariosConservados, milisegundos);
        }
    }

    /**
     * Filtra origen hacia destino de forma ATOMICA (07-02).
     *
     * Si algo falla a mitad, el destino anterior queda intacto.
     */
    public ResultadoFiltro filtrar(String origen, String destino, Predicado criterio)
            throws IOException {

        long inicio = System.nanoTime();

        File ficheroDestino = new File(destino);
        File temporal = new File(ficheroDestino.getAbsolutePath() + ".tmp");

        int leidas = 0, escritas = 0, descartadas = 0, comentarios = 0;
        boolean completado = false;

        try {
            // Lectura y escritura simultaneas, ambas con buffer.
            // Se declaran los dos recursos en el mismo try-with-resources:
            // se cierran en orden inverso, el escritor primero (06-06).
            try (BufferedReader lector = new BufferedReader(
                         new FileReader(origen, StandardCharsets.UTF_8));
                 BufferedWriter escritor = new BufferedWriter(
                         new FileWriter(temporal, StandardCharsets.UTF_8))) {

                String linea;
                int numero = 0;

                while ((linea = lector.readLine()) != null) {
                    numero++;
                    leidas++;

                    String limpia = linea.trim();

                    // Comentarios y cabeceras: se conservan siempre
                    if (limpia.isEmpty() || limpia.startsWith(COMENTARIO)) {
                        escritor.write(linea);
                        escritor.newLine();
                        comentarios++;
                        continue;
                    }

                    if (criterio.cumple(linea, numero)) {
                        escritor.write(linea);
                        escritor.newLine();          // readLine NO devuelve el salto
                        escritas++;
                    } else {
                        descartadas++;
                    }
                }
                // BufferedWriter lanza IOException de verdad: sin checkError
            }

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

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

        ResultadoFiltro resultado = new ResultadoFiltro(leidas, escritas, descartadas,
                comentarios, (System.nanoTime() - inicio) / 1_000_000);

        LOG.info(resultado::resumen);
        return resultado;
    }

    // ------------------------- CRITERIOS -------------------------

    /** Anio de publicacion posterior al dado. Campo 5 del formato. */
    public static Predicado publicadoDespuesDe(int anio) {
        return (linea, numero) -> {
            String[] campos = linea.split(";", -1);
            if (campos.length < 5) {
                return false;
            }
            try {
                return Integer.parseInt(campos[4].trim()) > anio;
            } catch (NumberFormatException e) {
                return false;               // anio ilegible: no cumple
            }
        };
    }

    /** El autor contiene el texto dado, sin distinguir mayusculas. */
    public static Predicado autorContiene(String texto) {
        String buscado = texto.toLowerCase();
        return (linea, numero) -> {
            String[] campos = linea.split(";", -1);
            return campos.length >= 4 && campos[3].toLowerCase().contains(buscado);
        };
    }

    /** Combinacion de dos criterios (composicion de lambdas, 04-05). */
    public static Predicado y(Predicado a, Predicado b) {
        return (linea, numero) -> a.cumple(linea, numero) && b.cumple(linea, numero);
    }

    public static void main(String[] args) throws IOException {
        FiltroCatalogo filtro = new FiltroCatalogo();

        System.out.println(filtro.filtrar(
                "datos/catalogo.txt",
                "datos/catalogo-recientes.txt",
                publicadoDespuesDe(2000)).resumen());

        System.out.println(filtro.filtrar(
                "datos/catalogo.txt",
                "datos/catalogo-bloch.txt",
                autorContiene("bloch")).resumen());

        System.out.println(filtro.filtrar(
                "datos/catalogo.txt",
                "datos/catalogo-recientes-fowler.txt",
                y(publicadoDespuesDe(1995), autorContiene("fowler"))).resumen());
    }
}

Salida:

Filtrado: 5003 leidas, 3418 escritas, 1582 descartadas, 3 comentarios conservados (61 ms)
Filtrado: 5003 leidas, 12 escritas, 4988 descartadas, 3 comentarios conservados (48 ms)
Filtrado: 5003 leidas, 7 escritas, 4993 descartadas, 3 comentarios conservados (52 ms)

Los cuatro puntos didácticos:

  1. Lectura y escritura simultáneas en flujo. Ninguno de los dos ficheros se carga: el consumo es de una línea y dos búferes, sea cual sea el tamaño. Es el patrón que usan las herramientas de línea de comandos de toda la vida.
  2. Los dos recursos van en el mismo try-with-resources. Se cierran en orden inverso —el escritor primero— y las excepciones de cierre se registran como suprimidas (06-06). Sin ese cierre, el búfer del escritor no se vaciaría.
  3. escritor.newLine() es obligatorio. readLine() no devuelve el terminador, así que si no lo repones, el fichero de salida sale con todas las líneas pegadas. Es el error más frecuente al reescribir ficheros.
  4. La interfaz funcional propia permite componer criterios con lambdas y combinarlos con y(...), exactamente como ReglaTarifa y FiltroMaterial de 04-06. En 10-04 verás que la API de Streams tiene esto mismo con Predicate y and.

Solución 3

package com.nexussoftware.bibliotech.servicio;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Compara dos ficheros de catalogo e informa de altas, bajas y cambios.
 *
 * POR QUE ESTE ALGORITMO SI NECESITA MEMORIA:
 * Para saber si un material del fichero B existe en A, hay que poder
 * consultarlo. Como los flujos son SECUENCIALES y no permiten volver atras
 * (07-03), la unica forma de consultar A mientras se recorre B es tener A
 * indexado en memoria.
 *
 * CUAL CONVIENE CARGAR: el mas pequeno. Se recorre en flujo el grande.
 *
 * SI NINGUNO CABE: hay dos salidas clasicas. Ordenar ambos ficheros por
 * clave y hacer una "fusion" recorriendolos en paralelo, que necesita
 * memoria constante; o cargar solo las CLAVES (los ISBN) con un hash de
 * cada linea, que ocupa una fraccion minima. La segunda es la habitual.
 */
public class ComparadorCatalogos {

    private static final String SEPARADOR = ";";
    private static final String COMENTARIO = "#";

    /** Datos de un material tal como aparecen en el fichero. */
    private record Registro(String isbn, String titulo, String autor, int anio) {

        /** Que campos difieren respecto a otro registro con el mismo ISBN. */
        List<String> diferenciasCon(Registro otro) {
            List<String> cambios = new ArrayList<>();
            if (!titulo.equals(otro.titulo)) {
                cambios.add("titulo: '" + titulo + "' -> '" + otro.titulo + "'");
            }
            if (!autor.equals(otro.autor)) {
                cambios.add("autor: '" + autor + "' -> '" + otro.autor + "'");
            }
            if (anio != otro.anio) {
                cambios.add("anio: " + anio + " -> " + otro.anio);
            }
            return cambios;
        }
    }

    public record Diferencias(List<String> bajas, List<String> altas,
                              List<String> modificaciones, int identicos) {

        public String informe() {
            StringBuilder sb = new StringBuilder();
            sb.append("=== COMPARACION DE CATALOGOS ===\n");
            sb.append(String.format("  Identicos      : %d%n", identicos));
            sb.append(String.format("  Altas          : %d%n", altas.size()));
            sb.append(String.format("  Bajas          : %d%n", bajas.size()));
            sb.append(String.format("  Modificaciones : %d%n", modificaciones.size()));

            imprimirBloque(sb, "ALTAS (solo en el nuevo)", altas);
            imprimirBloque(sb, "BAJAS (solo en el antiguo)", bajas);
            imprimirBloque(sb, "MODIFICACIONES", modificaciones);
            return sb.toString();
        }

        private void imprimirBloque(StringBuilder sb, String titulo, List<String> lineas) {
            if (lineas.isEmpty()) {
                return;
            }
            sb.append("  --- ").append(titulo).append(" ---\n");
            int mostradas = Math.min(10, lineas.size());
            for (int i = 0; i < mostradas; i++) {
                sb.append("    ").append(lineas.get(i)).append('\n');
            }
            if (lineas.size() > mostradas) {
                sb.append(String.format("    ... y %d mas%n", lineas.size() - mostradas));
            }
        }
    }

    /**
     * Compara dos catalogos.
     *
     * @param rutaAntiguo el que se carga EN MEMORIA (conviene el mas pequeno)
     * @param rutaNuevo   el que se recorre EN FLUJO
     */
    public Diferencias comparar(String rutaAntiguo, String rutaNuevo) throws IOException {

        // FASE 1: indexar el antiguo. Un solo recorrido.
        Map<String, Registro> antiguos = indexar(rutaAntiguo);

        List<String> altas = new ArrayList<>();
        List<String> modificaciones = new ArrayList<>();
        int identicos = 0;

        // FASE 2: recorrer el nuevo EN FLUJO, consultando el indice
        try (BufferedReader lector = new BufferedReader(
                new FileReader(rutaNuevo, StandardCharsets.UTF_8))) {

            String linea;
            while ((linea = lector.readLine()) != null) {
                Registro nuevo = parsear(linea);
                if (nuevo == null) {
                    continue;                        // comentario o linea mala
                }

                // remove() en vez de get(): lo que quede al final son las BAJAS.
                // Ahorra un segundo recorrido y una estructura adicional.
                Registro antiguo = antiguos.remove(nuevo.isbn());

                if (antiguo == null) {
                    altas.add(nuevo.isbn() + " - " + nuevo.titulo());
                    continue;
                }

                List<String> cambios = antiguo.diferenciasCon(nuevo);
                if (cambios.isEmpty()) {
                    identicos++;
                } else {
                    modificaciones.add(nuevo.isbn() + " - " + String.join("; ", cambios));
                }
            }
        }

        // FASE 3: lo que sobra del indice son las bajas
        List<String> bajas = new ArrayList<>();
        for (Registro r : antiguos.values()) {
            bajas.add(r.isbn() + " - " + r.titulo());
        }

        bajas.sort(null);                            // orden natural (05-09)
        altas.sort(null);
        modificaciones.sort(null);

        return new Diferencias(bajas, altas, modificaciones, identicos);
    }

    /** Carga un catalogo indexado por ISBN. Un solo recorrido, con buffer. */
    private Map<String, Registro> indexar(String ruta) throws IOException {
        Map<String, Registro> indice = new HashMap<>();

        try (BufferedReader lector = new BufferedReader(
                new FileReader(ruta, StandardCharsets.UTF_8))) {

            String linea;
            while ((linea = lector.readLine()) != null) {
                Registro r = parsear(linea);
                if (r != null) {
                    indice.put(r.isbn(), r);
                }
            }
        }
        return indice;
    }

    /** Devuelve null si la linea no es un registro valido. */
    private Registro parsear(String linea) {
        String limpia = linea.trim();
        if (limpia.isEmpty() || limpia.startsWith(COMENTARIO)) {
            return null;
        }
        String[] c = limpia.split(SEPARADOR, -1);
        if (c.length != 5) {
            return null;
        }
        try {
            return new Registro(c[1].trim(), c[2].trim(), c[3].trim(),
                    Integer.parseInt(c[4].trim()));
        } catch (NumberFormatException e) {
            return null;
        }
    }

    public static void main(String[] args) throws IOException {
        Diferencias d = new ComparadorCatalogos()
                .comparar("datos/catalogo-2025.txt", "datos/catalogo-2026.txt");
        System.out.println(d.informe());
    }
}

Salida:

=== COMPARACION DE CATALOGOS ===
  Identicos      : 4871
  Altas          : 92
  Bajas          : 37
  Modificaciones : 14
  --- ALTAS (solo en el nuevo) ---
    978-0000005001 - Arquitectura hexagonal
    978-0000005002 - Java moderno
    ... y 90 mas
  --- BAJAS (solo en el antiguo) ---
    978-0000000341 - Manual de COBOL
    ... y 36 mas
  --- MODIFICACIONES ---
    978-0000000002 - titulo: 'Patrones de Diseno' -> 'Patrones de Diseño'
    978-0000000047 - anio: 2018 -> 2019
    ... y 12 mas

Los tres puntos que hay que extraer:

  1. El comentario de cabecera responde a la pregunta del enunciado. Un flujo es secuencial y no permite volver atrás, así que consultar un fichero mientras recorres otro obliga a tener uno en memoria. Carga el pequeño, recorre el grande. Y si ninguno cabe, las dos salidas clásicas son la fusión de ficheros ordenados —memoria constante— o cargar solo las claves con un hash de cada línea, que es una fracción mínima del contenido.
  2. remove() en lugar de get(). Un truco que ahorra trabajo: al quitar del índice lo que se encuentra, lo que queda al final son exactamente las bajas. Sin él harían falta un segundo recorrido y una estructura adicional.
  3. Fíjate en la primera modificación de la salida: 'Patrones de Diseno' -> 'Patrones de Diseño'. Eso es un fichero antiguo escrito sin acentos, o —más probable— un fichero antiguo escrito con el charset equivocado y corregido después. Es exactamente el tipo de diferencia que un comparador de catálogos saca a la luz, y una razón más para especificar siempre el charset.

Conclusión

Ya sabes qué es un búfer y por qué está en todas partes.

Entiendes que es un array intermedio que agrupa las operaciones para reducir las llamadas al sistema: una de cada 8192 en lugar de todas. Tienes las cifras que lo justifican —de 4 500 ms a 68 ms con solo envolver el flujo, un factor de sesenta y seis— y sabes que el precio son dieciséis kilobytes de memoria, probablemente el mejor intercambio de memoria por tiempo que existe. Y ahora tienes también la explicación completa de dos cosas que aceptaste antes: por qué FileReader.read() es tan lento, y por qué un fichero recién escrito tiene cero bytes.

Sabes construirlos por decoración sobre FileReader/FileWriter o sobre un puente, y sabes elegir el tamaño: 8192 salvo que hayas medido, con la advertencia de que cien flujos con búferes de un megabyte son cien megabytes de heap, y de que envolver algo que ya tiene búfer no aporta nada.

Dominas readLine() y sus cuatro puntos de contrato: devuelve la línea sin el terminador, reconoce las tres convenciones de fin de línea —por eso un fichero de Windows se lee sin problema en Linux—, devuelve null al final y no "", y bloquea hasta tener una línea completa. Escribes el bucle canónico sin dudar, con los paréntesis obligatorios y != null como única condición correcta, y reconoces al instante los tres errores que lo sustituyen: el isEmpty() que se para en la primera línea vacía, el doble readLine() que procesa una de cada dos, y el ready() que funciona en desarrollo y falla en producción con ficheros grandes o fuentes lentas. Sabes también que readLine() carga la línea entera en memoria, y qué significa eso con un fichero de una sola línea gigante.

Conoces mark/reset para inspeccionar el principio de un flujo sin perderlo, con sus límites reales. Y conoces BufferedWriter con su newLine() portable y —el argumento que más pesa— sus métodos sí declaran IOException, a diferencia de PrintWriter, que se las traga. De ahí la regla: PrintWriter para informes, BufferedWriter para datos cuyo fallo hay que detectar. Sabes qué aporta exactamente cada capa del trío PrintWriter + BufferedWriter + FileWriter, incluida la observación honesta de que la capa intermedia aporta menos de lo que se cree, porque PrintWriter ya tiene su propio búfer.

Sabes que Files.newBufferedReader y newBufferedWriter construyen exactamente estos objetos de forma más corta, obligando al charset y con excepciones más específicas, y que esa es la forma recomendada en código nuevo. Es NIO.2, y es la lección siguiente.

Y tienes la capacidad que va más allá del rendimiento: el procesamiento en flujo. Un fichero de dos gigabytes que revienta con readString() o readAllLines() se recorre con BufferedReader usando cincuenta kilobytes. Sabes que eso obliga a diseñar el algoritmo de otra manera —un solo recorrido, una línea cada vez— y qué se puede y qué no se puede hacer así. Conoces además la comparación completa entre BufferedReader y Scanner, con el criterio para elegir: Scanner para menús interactivos, BufferedReader para ficheros y entrada masiva; y la advertencia, otra vez, de no cerrar System.in jamás.

BiblioTech importa de verdad. ImportadorCatalogo recorre miles de líneas con memoria constante, valida cada una contra siete reglas, descarta las malas con su número de línea y su motivo tipado en un enum, cuenta todas y detalla las cien primeras para no agotar la memoria informando de un problema, aborta la importación entera si falla más de la mitad —porque entonces el problema es el fichero, no las líneas—, vuelca al catálogo solo al final para no dejarlo a medias, y devuelve un informe en lugar de imprimir nada, porque una clase de servicio no sabe quién hay al otro lado. Es el módulo 5, el módulo 6 y este, funcionando juntos en una sola clase.

Hasta ahora, todo lo que BiblioTech guarda es texto que tú has decidido cómo formatear: LIBRO;isbn;titulo;autor;anio. Funciona, pero tiene un límite evidente. ¿Y si quisieras guardar el estado completo del sistema —los préstamos con sus referencias a materiales y empleados, las reservas, el historial, las incidencias anidadas— con todas sus relaciones intactas? Escribir ese grafo de objetos a mano, con sus referencias cruzadas y sus objetos compartidos, sería un trabajo enorme y lleno de detalles.

En la lección 07-05, Serialización, verás que Java sabe hacerlo solo. Conocerás qué significa convertir un grafo de objetos en bytes conservando las referencias compartidas; la interfaz marcadora Serializable, que retoma las interfaces marcadoras de 04-01, con ObjectOutputStream y ObjectInputStream; qué se guarda y qué no, con transient y static; el serialVersionUID que ya declaraste en tus excepciones del módulo 6 sin saber del todo para qué servía, y la InvalidClassException que aparece cuando cambia la clase; qué cambios de una clase son compatibles y cuáles rompen los ficheros existentes; y —lo más importante de la lección— los riesgos de seguridad reales de deserializar datos que no controlas, que es una de las vías de ataque más explotadas de la historia de Java. Al terminarla, BiblioTech guardará y restaurará una sesión completa entre ejecuciones, y sabrás exactamente cuándo no debes usar esta herramienta.

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