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-resourcesen todo, como siempre. Con búferes de escritura hay un motivo añadido para no olvidarlo: elclose()es lo que vacía el búfer al disco. Sin él, no has escrito nada.
Contenido
- Qué hace exactamente un búfer
- Sin búfer y con búfer: el diagrama
- Las cifras que justifican la diferencia
- Construcción por decoración y tamaño del búfer
BufferedReader.readLine()y su contrato- El bucle canónico de lectura
ready()y por qué no sirve como condición de finmarkyreset: mirar sin consumirBufferedWriter:write,newLineyflush- El trío
PrintWriter+BufferedWriter+FileWriter Files.newBufferedReaderynewBufferedWriter- Procesar un fichero grande con memoria constante
- Leer de consola con
BufferedReaderfrente aScanner - BiblioTech:
ImportadorCatalogocon informe completo - Errores Comunes y Consejos
- Ejercicios
- 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:
- Cuando le pides un carácter, mira su array interno.
- Si tiene datos, te devuelve uno sin salir de la memoria del proceso.
- 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:
- Cuando le pides escribir, guarda en su array.
- Cuando el array se llena —o cuando alguien hace
flush()oclose()— 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.
- 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.
- 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:
- La diferencia entre la primera y la segunda es una sola palabra en el código:
new BufferedReader(...). Factor 66. - La tercera es aún mejor porque
readLine()trabaja directamente sobre el array del búfer, buscando el salto de línea, sin llamar aread()una vez por carácter. - 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.
- 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.
BufferedReader.readLine() y su contrato
BufferedReader.readLine() y su contratoreadLine() es, con diferencia, el método de E/S más usado de Java. Su contrato tiene cuatro puntos, y los cuatro importan:
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.
- 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:
String linea;se declara fuera del bucle, porque la condición necesita verla.linea = lector.readLine()lee y asigna. La asignación es una expresión, como en 07-01.- Los paréntesis internos son obligatorios: sin ellos,
linea = lector.readLine() != nullintentaría asignar unbooleana unStringy no compila. != nulles 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.
ready() y por qué no sirve como condición de fin
ready() y por qué no sirve como condición de finready() existe y su nombre invita al malentendido:
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.
mark y reset: mirar sin consumir
mark y reset: mirar sin consumirUn 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:
markpuede fallar si elreadAheadLimites demasiado grande. El búfer tiene que crecer hasta ese tamaño; pedirmark(Integer.MAX_VALUE)intenta reservar 2 GB.- Leer más de
ncaracteres invalida la marca yreset()lanzaIOException. - No todos los
Readerlo soportan.markSupported()lo dice.BufferedReadersí;FileReadera 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.
BufferedWriter: write, newLine y flush
BufferedWriter: write, newLine y flushLa 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 |
- El trío
PrintWriter + BufferedWriter + FileWriter
PrintWriter + BufferedWriter + FileWriterEsta 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 sí 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.
Files.newBufferedReader y newBufferedWriter
Files.newBufferedReader y newBufferedWriterDesde 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.
- 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.
- Leer de consola con
BufferedReader frente a Scanner
BufferedReader frente a ScannerDesde 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 |
Sí: coma o punto decimal | No: no parsea |
| Trampa del salto de línea | Sí: 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:
Scannerpara menús interactivos y entrada por consola, que es lo que haceMenuBiblioTechdesde el módulo 2. Su parseo compensa y el rendimiento es irrelevante cuando esperas a que alguien teclee.BufferedReaderpara ficheros, siempre. En un fichero de cien mil líneas, la diferencia de velocidad es de varios segundos.BufferedReaderpara entrada masiva por tubería, cuando el programa recibe datos porstdindesde 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.
- BiblioTech:
ImportadorCatalogo con informe completo
ImportadorCatalogo con informe completoHora 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:
- Leer un fichero de miles de líneas con memoria constante.
- Validar cada línea sin abortar la importación en la primera mala.
- Contar correctas y descartadas, con el motivo de cada descarte.
- Registrar los errores con el logger, no en consola.
- Devolver un informe que quien llama pueda mostrar o guardar.
- 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 masLas siete decisiones de diseño que hay que entender de esta clase:
BufferedReadery bucle canónico. Memoria constante: el fichero podría tener cinco millones de líneas y el consumo sería el mismo.- 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.
- 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.
- 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.
- Los detalles se limitan a 100. Un fichero corrupto con un millón de líneas malas generaría un millón de objetos
LineaDescartaday agotaría la memoria. Se cuentan todas y se detallan las cien primeras: es información suficiente para corregir, y acotada. MotivoDescartees unenum, no una cadena. Permite agrupar, contar y traducir, y elimina las cadenas mágicas. Es 04-07 aplicado.- 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 siemprereadLine() != 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
nullcon"".nulles fin de fichero;""es una línea vacía. UsarisEmpty()como condición de fin detiene la lectura en el primer hueco y luego lanzaNullPointerException. - 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 connewLine(). - 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
BufferedWriterescribe 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
PrintWritersincheckError()para datos que importan. Se traga lasIOException. Para ficheros de datos,BufferedWriter, cuyos métodos sí las declaran. - Usar
Scannerpara leer un fichero de cien mil líneas. Funciona y tarda varios segundos de más.BufferedReaderpara ficheros,Scannerpara 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.newBufferedReaderen 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:
- Número de líneas, de palabras y de caracteres.
- Líneas vacías y líneas de comentario (que empiezan por
#). - Longitud media, mínima y máxima de línea.
- Las tres palabras más frecuentes (usa un
HashMapdel módulo 5; no uses la API de Streams, que es 10-04). - 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:
filtrar(String origen, String destino, Predicado criterio)dondePredicadoes una interfaz funcional propia (04-06) conboolean cumple(String linea, int numero).- Conserva las líneas de comentario y la cabecera.
- Cuenta líneas leídas, escritas y descartadas, y devuelve el resultado en un
record. - Usa escritura atómica (07-02): escribe a un temporal y renombra al final.
- Un
mainque 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:
- Materiales solo en el primero (bajas).
- Materiales solo en el segundo (altas).
- Materiales en ambos pero con datos distintos (modificaciones), indicando qué campo cambió.
- 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 sí 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 287El 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:
- 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.
- 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. 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.- La interfaz funcional propia permite componer criterios con lambdas y combinarlos con
y(...), exactamente comoReglaTarifayFiltroMaterialde 04-06. En 10-04 verás que la API de Streams tiene esto mismo conPredicateyand.
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 masLos tres puntos que hay que extraer:
- 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.
remove()en lugar deget(). 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.- 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
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
