Esta es la lección que el curso lleva esperando desde el módulo 4.

Cuando aprendiste las expresiones lambda en 04-05, te dijimos que su verdadero destino estaba más adelante. Cuando dominaste Function, Predicate, Consumer y Supplier en 04-06, te dijimos lo mismo. En el módulo 5, cada informe de BiblioTech acabó siendo un bucle anidado con acumuladores y banderas. En 06-07, el patrón "objeto resultado" quedó a medias porque faltaba Optional. En 07-06, Files.lines() y Files.walk() aparecieron y solo pudiste usarlos con forEach. En 08-05 mencionaste parallelStream() y lo aplazaste. En el módulo 9, EnriquecedorCatalogo tuvo que esquivar stream() durante seis lecciones enteras.

Todo eso converge aquí.

Java 8, publicado en marzo de 2014, fue el cambio más grande de la historia del lenguaje. Java llevaba desde 1995 siendo estrictamente imperativo y orientado a objetos: para procesar una colección, declarabas una variable, escribías un bucle y acumulabas. Java 8 añadió un modelo declarativo y funcional en el que describes qué quieres obtener y la biblioteca decide cómo recorrerlo. No sustituyó a lo anterior —los bucles siguen ahí y siguen siendo correctos—, pero cambió el estilo por defecto de todo el ecosistema.

Y añadió Optional, una clase minúscula cuyo propósito es hacer imposible que te olvides de comprobar la ausencia de un valor. El null que devuelve buscarPorReferencia desde el módulo 6, y que dieciséis sitios distintos tienen que recordar comprobar, tiene los días contados.

Al terminar, BiblioTech tendrá su informe completo por tipo de material y por empleado en tres líneas, sus recorridos de ficheros procesados perezosamente sin cargar nada en memoria, y ni un solo null significando "no encontrado".

Contenido

  1. Qué trajo Java 8 y qué ya has visto
  2. Qué es un stream y qué NO es
  3. La tubería: fuente, operaciones intermedias, operación terminal
  4. Evaluación perezosa: el orden real de ejecución
  5. Fuentes de streams
  6. Operaciones intermedias
  7. flatMap: el caso que cuesta
  8. takeWhile y dropWhile
  9. Operaciones terminales
  10. reduce en sus tres formas
  11. Los Collectors básicos
  12. groupingBy y partitioningBy
  13. Streams de primitivos
  14. Streams paralelos
  15. El informe de BiblioTech: de treinta líneas a tres
  16. Optional: para qué se creó
  17. Crear y consumir un Optional
  18. orElse frente a orElseGet
  19. Los antipatrones de Optional
  20. Qué devolver cuando no hay valor
  21. BiblioTech refactorizado
  22. Errores Comunes y Consejos
  23. Ejercicios

  1. Qué trajo Java 8 y qué ya has visto

Java 8 introdujo un paquete de características que se sostienen unas a otras. Buena parte ya las conoces, y conviene ver el mapa completo antes de empezar:

Característica Qué es Dónde la viste
Expresiones lambda Funciones anónimas: x -> x * 2 04-05
Interfaces funcionales Interfaces con un solo método abstracto 04-06
java.util.function Function, Predicate, Consumer, Supplier, BiFunction 04-06
Referencias a métodos Libro::getTitulo, System.out::println 04-06
Métodos default Implementaciones en interfaces sin romper a nadie 04-01
Métodos static en interfaces Fábricas junto al contrato 04-01
Comparator fluido comparing, thenComparing, reversed 05-09
Map mejorado computeIfAbsent, merge, getOrDefault, forEach 05-05
CompletableFuture Composición asíncrona 08-07
java.time API de fechas nueva 10-05
Streams Procesamiento declarativo de secuencias Esta lección
Optional Contenedor de "puede que haya valor" Esta lección

Todo lo de las filas 1 a 4 es requisito para esta lección, y por eso llega ahora y no antes. Un stream es una tubería de operaciones a las que se les pasan lambdas y referencias a métodos; sin dominar eso, map(Libro::getTitulo) sería ruido.

El motivo de fondo de todo el paquete fue muy concreto: los procesadores dejaron de ser más rápidos y empezaron a ser más numerosos. Aprovechar ocho núcleos con bucles for exige escribir la partición, los hilos y la recolección a mano —lo que hiciste en el módulo 8 y sabes lo que cuesta—. Con una descripción declarativa de lo que quieres, la biblioteca puede paralelizarlo cambiando una palabra. Eso es parallelStream(), y llegaremos a él con las cautelas que merece.

  1. Qué es un stream y qué NO es

Un stream es una secuencia de elementos que soporta operaciones agregadas. Y la mejor forma de entenderlo es por contraste, porque el nombre invita a confusión con InputStream (07-03), con el que no tiene absolutamente nada que ver.

Un Stream no... Explicación
...es una colección No es una estructura de datos: es una vista de proceso sobre una fuente
...almacena elementos No ocupa memoria proporcional al tamaño; los elementos fluyen a través de él
...modifica la fuente filter no borra nada del List original
...se puede reutilizar Es de un solo uso: consumido, muere
...tiene índices No hay get(3); el acceso es secuencial
...es un InputStream No tiene nada que ver con la E/S de bytes de 07-03

Y lo que es:

  • Una descripción de un cálculo sobre una secuencia, que no se ejecuta hasta que se pide el resultado.
  • Perezoso: nada ocurre hasta la operación terminal.
  • Potencialmente infinito: Stream.iterate genera elementos sin fin, y limit los acota.
  • Paralelizable cambiando stream() por parallelStream().

La propiedad del solo uso es la que más sorprende:

List<Libro> catalogo = List.of(
        new Libro("978-0000000001", "Java Efectivo", 412, true),
        new Libro("978-0000000002", "Patrones de Diseño", 395, false));

Stream<Libro> flujo = catalogo.stream();

long cuantos = flujo.count();                 // primera operacion terminal: OK
List<Libro> lista = flujo.toList();           // segunda: EXPLOTA
Exception in thread "main" java.lang.IllegalStateException:
    stream has already been operated upon or closed
    at java.base/java.util.stream.AbstractPipeline.<init>(AbstractPipeline.java:203)

Un stream se consume una vez. Si necesitas dos recorridos, crea dos streams desde la fuente:

long cuantos = catalogo.stream().count();
List<Libro> lista = catalogo.stream().toList();

Por eso no se guarda un Stream en un campo ni se pasa por ahí: se crea, se usa y se descarta, normalmente en una sola expresión.

Y el contraste que resume la lección. El mismo problema —títulos de los libros de más de 400 páginas que están prestados, ordenados alfabéticamente— resuelto de las dos formas:

// ANTES (modulo 5): imperativo. Describe COMO.
List<String> resultado = new ArrayList<>();
for (Libro libro : catalogo) {
    if (libro.getPaginas() > 400 && libro.estaPrestado()) {
        resultado.add(libro.getTitulo());
    }
}
Collections.sort(resultado);
// AHORA: declarativo. Describe QUE.
List<String> resultado = catalogo.stream()
        .filter(libro -> libro.getPaginas() > 400)
        .filter(Libro::estaPrestado)
        .map(Libro::getTitulo)
        .sorted()
        .toList();

Las diferencias no son solo de líneas:

  • No hay variable acumuladora ni estado mutable que gestionar.
  • Se lee de arriba abajo como una frase: de los libros, los de más de 400 páginas, los prestados, sus títulos, ordenados.
  • No hay índices ni condiciones de contorno que equivocar.
  • Cambiar a paralelo es una palabra.

  1. La tubería: fuente, operaciones intermedias, operación terminal

Todo stream tiene exactamente tres partes:

graph LR
    F["FUENTE<br/>coleccion, array,<br/>fichero, generador"] --> I1["INTERMEDIA<br/>filter"]
    I1 --> I2["INTERMEDIA<br/>map"]
    I2 --> I3["INTERMEDIA<br/>sorted"]
    I3 --> T["TERMINAL<br/>collect, forEach,<br/>count, reduce"]
    T --> R["RESULTADO<br/>List, long, Optional..."]
Parte Cuántas Devuelve Cuándo se ejecuta
Fuente Exactamente 1 Stream<T> Al crear
Operación intermedia 0 o más Stream<R> (encadenable) Nunca por sí sola: perezosa
Operación terminal Exactamente 1 Un resultado (o nada) Dispara todo el procesamiento

La regla que lo explica todo: si un método de Stream devuelve un Stream, es intermedio y no hace nada; si devuelve otra cosa, es terminal y lo hace todo.

// Esto NO IMPRIME NADA y NO RECORRE NADA
catalogo.stream()
        .filter(l -> l.getPaginas() > 400)
        .map(Libro::getTitulo);
(no hay salida)

Sin operación terminal, la tubería está montada pero cerrada. El compilador ni siquiera avisa (aunque los IDE modernos sí). Es un error real y frecuente: alguien escribe lista.stream().filter(...) esperando que filtre la lista, y no pasa nada.

  1. Evaluación perezosa: el orden real de ejecución

Esta es la parte conceptualmente más importante de los streams, y la que más gente entiende mal.

Intuición equivocada: el stream aplica filter a todos los elementos, produce una lista intermedia, aplica map a todos, produce otra, y así.

Realidad: cada elemento atraviesa la tubería completa antes de que el siguiente empiece. No hay listas intermedias.

Demostrémoslo con trazas:

package com.nexussoftware.bibliotech;

import java.util.List;

public class DemostracionPereza {

    public static void main(String[] args) {

        List<Libro> catalogo = List.of(
                new Libro("978-0000000001", "Java Efectivo",      412),
                new Libro("978-0000000002", "Patrones de Diseño",  395),
                new Libro("978-0000000003", "Refactorización",     448),
                new Libro("978-0000000004", "Código Limpio",       464));

        System.out.println("--- Montando la tubería ---");

        List<String> resultado = catalogo.stream()
                .filter(libro -> {
                    System.out.println("  filter(" + libro.getTitulo() + ")");
                    return libro.getPaginas() > 400;
                })
                .map(libro -> {
                    System.out.println("    map(" + libro.getTitulo() + ")");
                    return libro.getTitulo().toUpperCase();
                })
                .limit(2)
                .toList();

        System.out.println("--- Resultado: " + resultado + " ---");
    }
}
--- Montando la tubería ---
  filter(Java Efectivo)
    map(Java Efectivo)
  filter(Patrones de Diseño)
  filter(Refactorización)
    map(Refactorización)
--- Resultado: [JAVA EFECTIVO, REFACTORIZACIÓN] ---

Analiza esa salida con calma, porque contiene tres lecciones.

1. El procesamiento es elemento a elemento, en vertical. "Java Efectivo" pasa por filter y luego por map antes de que "Patrones de Diseño" toque el filter. No hay una fase de filtrado y luego una de mapeo.

2. map no se ejecuta sobre los elementos filtrados. "Patrones de Diseño" tiene 395 páginas, no pasa el filtro, y su map nunca se ejecuta. Si map fuera una operación cara —una consulta a base de datos, una petición de red—, esto sería la diferencia entre cuatro llamadas y dos.

3. "Código Limpio" nunca se procesa. El limit(2) cortocircuitó la tubería: en cuanto hubo dos resultados, el stream dejó de pedir elementos a la fuente. El cuarto libro no se filtró siquiera.

Ese comportamiento se llama fusión de operaciones (loop fusion): el conjunto de operaciones intermedias se compila conceptualmente en un solo recorrido. Comparado con el enfoque ingenuo:

// Lo que NO hace el stream: tres recorridos y dos listas temporales
List<Libro> filtrados = new ArrayList<>();
for (Libro l : catalogo) if (l.getPaginas() > 400) filtrados.add(l);

List<String> mapeados = new ArrayList<>();
for (Libro l : filtrados) mapeados.add(l.getTitulo().toUpperCase());

List<String> limitados = mapeados.subList(0, Math.min(2, mapeados.size()));

Un stream con veinte operaciones intermedias sigue haciendo un solo recorrido y cero listas intermedias. Por eso encadenar operaciones no penaliza el rendimiento como cabría temer.

La pereza también permite lo imposible: trabajar con secuencias infinitas.

// Stream.iterate genera elementos SIN FIN
List<String> referencias = Stream.iterate(1, n -> n + 1)
        .map(n -> String.format("PR-2026-%04d", n))
        .limit(5)
        .toList();

System.out.println(referencias);
[PR-2026-0001, PR-2026-0002, PR-2026-0003, PR-2026-0004, PR-2026-0005]

Sin pereza, Stream.iterate colgaría el programa. Con ella, solo se generan los cinco elementos que el limit pide.

  1. Fuentes de streams

Fuente Sintaxis Notas
Colección coleccion.stream() La habitual. Está en Collection, así que la tienen List, Set, Queue
Array Arrays.stream(array) También Arrays.stream(array, desde, hasta)
Valores sueltos Stream.of(a, b, c) Varargs
Vacío Stream.empty() Útil como valor por defecto
Uno o ninguno Stream.ofNullable(x) Java 9: vacío si x es null
Iteración Stream.iterate(semilla, f) Infinito; hay variante con predicado (Java 9)
Generación Stream.generate(supplier) Infinito
Rango de enteros IntStream.range(0, 10) rangeClosed incluye el final
Líneas de fichero Files.lines(path, charset) Hay que cerrarlo
Árbol de ficheros Files.walk(path) Hay que cerrarlo
Caracteres texto.chars() Devuelve IntStream
Partes de un texto Pattern.compile(";").splitAsStream(linea) Perezoso, a diferencia de split
Mapa mapa.entrySet().stream() Un Map no tiene stream() propio
// Colecciones
Stream<Libro> s1 = catalogo.stream();
Stream<String> s2 = new HashSet<>(List.of("a", "b")).stream();

// Arrays
String[] empleados = { "Marta Ruiz", "Diego Alonso", "Nuria Vidal" };
Stream<String> s3 = Arrays.stream(empleados);

// Valores sueltos
Stream<String> s4 = Stream.of("978-0000000001", "978-0000000002");

// Infinitos, acotados con limit
Stream<Integer> s5 = Stream.iterate(1, n -> n * 2).limit(10);
Stream<Double> s6 = Stream.generate(Math::random).limit(5);

// Con condicion de parada (Java 9): ya no necesita limit
Stream<Integer> s7 = Stream.iterate(1, n -> n <= 100, n -> n * 2);

// Rango de enteros
IntStream s8 = IntStream.rangeClosed(1, 12);   // 1..12

// Mapa: siempre por sus entradas
Map<String, Integer> prestamosPorEmpleado = Map.of("Marta Ruiz", 4, "Diego Alonso", 2);
Stream<Map.Entry<String, Integer>> s9 = prestamosPorEmpleado.entrySet().stream();

Files.lines(): retomando 07-06

En 07-06 usaste Files.lines() con un simple forEach porque la API completa estaba pendiente. Ahora se puede aprovechar de verdad:

package com.nexussoftware.bibliotech.persistencia;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;
import java.util.stream.Stream;

public class ImportadorCatalogo {

    /**
     * Lee un CSV de catalogo con procesamiento PEREZOSO.
     *
     * try-with-resources (06-06) es OBLIGATORIO: Files.lines mantiene
     * abierto un descriptor de fichero hasta que el stream se cierra.
     */
    public List<Libro> importar(Path fichero) throws IOException {

        try (Stream<String> lineas = Files.lines(fichero, StandardCharsets.UTF_8)) {

            return lineas
                    .skip(1)                                    // saltar la cabecera
                    .map(String::strip)
                    .filter(linea -> !linea.isBlank())          // ignorar lineas vacias
                    .filter(linea -> !linea.startsWith("#"))    // ignorar comentarios
                    .map(this::parsear)
                    .filter(java.util.Objects::nonNull)         // descartar las mal formadas
                    .toList();
        }
    }

    private Libro parsear(String linea) {
        String[] campos = linea.split(";", -1);
        if (campos.length < 4) {
            return null;
        }
        try {
            return new Libro(campos[0], campos[1], campos[2], Integer.parseInt(campos[3]));
        } catch (NumberFormatException e) {
            return null;
        }
    }

    /** Cuenta lineas de un fichero de 2 GB sin cargarlo en memoria. */
    public long contarPrestamosDe(Path fichero, String empleado) throws IOException {
        try (Stream<String> lineas = Files.lines(fichero, StandardCharsets.UTF_8)) {
            return lineas.filter(l -> l.contains(empleado)).count();
        }
    }
}

Dos puntos críticos.

El try-with-resources no es opcional. Files.lines() devuelve un stream respaldado por un fichero abierto. Sin cerrarlo, agotas los descriptores del proceso y acabas con Too many open files. Es el mismo problema de 07-01, con la diferencia de que aquí el recurso está escondido dentro de un stream.

El procesamiento es perezoso de verdad. contarPrestamosDe sobre un fichero de dos gigabytes no carga el fichero en memoria: lee una línea, la evalúa, la descarta. Compáralo con Files.readAllLines(), que construye un List<String> completo y hace OutOfMemoryError con ficheros grandes. Es la misma distinción que en 07-04 entre leer todo y leer con BufferedReader, pero ahora con toda la API de procesamiento encima.

Y Files.walk(), también de 07-06, ahora rinde:

/** Los tres ficheros de respaldo mas recientes de un directorio. */
public List<Path> respaldosRecientes(Path directorio) throws IOException {
    try (Stream<Path> rutas = Files.walk(directorio, 2)) {   // profundidad maxima 2
        return rutas
                .filter(Files::isRegularFile)
                .filter(p -> p.getFileName().toString().endsWith(".bak"))
                .sorted(Comparator.comparing(this::fechaDeModificacion).reversed())
                .limit(3)
                .toList();
    }
}

  1. Operaciones intermedias

Todas devuelven un Stream y ninguna ejecuta nada por sí sola.

filter: quedarse con los que cumplen

Stream<Libro> filter(Predicate<? super T> criterio)
List<Libro> prestados = catalogo.stream()
        .filter(Libro::estaPrestado)
        .toList();

// Los predicados se pueden componer (04-06)
Predicate<Libro> largo     = l -> l.getPaginas() > 400;
Predicate<Libro> disponible = l -> !l.estaPrestado();

List<Libro> largosDisponibles = catalogo.stream()
        .filter(largo.and(disponible))
        .toList();

Fíjate en el Predicate<? super T>: PECS de 10-01, que ahora se lee sin esfuerzo.

map: transformar cada elemento

<R> Stream<R> map(Function<? super T, ? extends R> transformacion)

map cambia el tipo del stream. Un Stream<Libro> con map(Libro::getTitulo) se convierte en un Stream<String>:

List<String> titulos = catalogo.stream()
        .map(Libro::getTitulo)          // Stream<Libro> -> Stream<String>
        .toList();

// Encadenando transformaciones
List<Integer> longitudes = catalogo.stream()
        .map(Libro::getTitulo)          // Stream<String>
        .map(String::length)            // Stream<Integer>
        .toList();

// A un record (04-07)
List<Ficha> fichas = catalogo.stream()
        .map(l -> new Ficha(l.getIsbn(), l.getTitulo(), !l.estaPrestado()))
        .toList();

map no cambia el número de elementos: entran N, salen N. Solo cambia su tipo o su valor.

mapToInt, mapToObj y familia

Convierten entre streams de objetos y streams de primitivos (apartado 13):

// Objeto -> primitivo
int totalPaginas = catalogo.stream()
        .mapToInt(Libro::getPaginas)      // Stream<Libro> -> IntStream
        .sum();                           // sum() SOLO existe en IntStream

// Primitivo -> objeto
List<String> referencias = IntStream.rangeClosed(1, 3)
        .mapToObj(n -> String.format("PR-2026-%04d", n))   // IntStream -> Stream<String>
        .toList();

distinct: eliminar duplicados

List<String> empleadosUnicos = prestamos.stream()
        .map(Prestamo::getEmpleado)
        .distinct()
        .toList();

Usa equals/hashCode (03-09). Si tus objetos no los tienen bien implementados, distinct no elimina nada. Es un LinkedHashSet por dentro, con el coste de memoria correspondiente.

sorted: ordenar

// Orden natural: exige que T implemente Comparable (10-01)
List<String> ordenados = titulos.stream().sorted().toList();

// Con Comparator (05-09)
List<Libro> porPaginas = catalogo.stream()
        .sorted(Comparator.comparingInt(Libro::getPaginas).reversed())
        .toList();

// Ordenacion compuesta
List<Prestamo> ordenados = prestamos.stream()
        .sorted(Comparator.comparing(Prestamo::getEmpleado)
                          .thenComparing(Prestamo::getDiaPrestamo)
                          .reversed())
        .toList();

sorted es una operación con estado: necesita ver todos los elementos antes de emitir el primero. Eso rompe la pereza elemento a elemento y tiene dos consecuencias prácticas:

  1. Sobre un stream infinito, se cuelga. Stream.iterate(1, n -> n+1).sorted() nunca termina.
  2. Ponla lo más tarde posible. filter(...).sorted() ordena menos elementos que sorted().filter(...), y el resultado es el mismo.

peek: mirar sin tocar

Stream<T> peek(Consumer<? super T> accion)

Ejecuta una acción sobre cada elemento y lo deja pasar intacto. Su uso legítimo es uno solo: depurar.

List<String> resultado = catalogo.stream()
        .peek(l -> System.out.println("entra: " + l.getTitulo()))
        .filter(l -> l.getPaginas() > 400)
        .peek(l -> System.out.println("  pasa el filtro: " + l.getTitulo()))
        .map(Libro::getTitulo)
        .toList();

Por qué no usarlo para nada más:

  • No se garantiza que se ejecute. La documentación lo dice explícitamente: si la implementación puede calcular el resultado sin recorrer todos los elementos, puede saltarse el peek. En Java 9+, lista.stream().peek(...).count() no ejecuta el peek, porque el tamaño se conoce sin recorrer.
  • Invita a efectos secundarios. peek(l -> otraLista.add(l)) es exactamente el estilo que los streams vienen a eliminar, y en paralelo corrompe la lista.

Si quieres modificar, usa map. Si quieres acumular, usa collect. peek es solo para mirar.

limit y skip

List<Libro> primeros3 = catalogo.stream().limit(3).toList();
List<Libro> saltados2 = catalogo.stream().skip(2).toList();

// Paginacion: pagina 3 con 10 elementos por pagina
int pagina = 3, tamano = 10;
List<Libro> paginaActual = catalogo.stream()
        .skip((long) (pagina - 1) * tamano)
        .limit(tamano)
        .toList();

limit cortocircuita: en cuanto tiene N elementos, deja de pedir a la fuente (lo viste en el apartado 4). skip sí tiene que recorrer los primeros N para descartarlos.

  1. flatMap: el caso que cuesta

flatMap es la operación que más cuesta entender, y merece su propio apartado.

<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> transformacion)

Léelo así: map convierte cada elemento en otro elemento; flatMap convierte cada elemento en un stream de elementos, y luego aplana todos esos streams en uno solo.

graph LR
    subgraph map
    A1["Empleado 1"] --> B1["Lista de prestamos 1"]
    A2["Empleado 2"] --> B2["Lista de prestamos 2"]
    end
    subgraph flatMap
    C1["Empleado 1"] --> D1["p1, p2"]
    C2["Empleado 2"] --> D2["p3"]
    D1 --> E["p1, p2, p3"]
    D2 --> E
    end

El caso real de BiblioTech: cada empleado tiene una lista de préstamos, y queremos todos los préstamos de la biblioteca.

public class Empleado {
    private final String nombre;
    private final List<Prestamo> prestamos;

    public List<Prestamo> getPrestamos() { return prestamos; }
    public String getNombre() { return nombre; }
}
List<Empleado> plantilla = List.of(
        new Empleado("Marta Ruiz",   List.of(p1, p2, p3)),
        new Empleado("Diego Alonso", List.of(p4)),
        new Empleado("Nuria Vidal",  List.of(p5, p6)));

// CON map: no es lo que quieres
Stream<List<Prestamo>> conMap = plantilla.stream().map(Empleado::getPrestamos);
List<List<Prestamo>> anidada = conMap.toList();
System.out.println(anidada.size());       // 3: una lista por empleado

// CON flatMap: aplanado
List<Prestamo> todos = plantilla.stream()
        .flatMap(empleado -> empleado.getPrestamos().stream())   // ¡ojo al .stream()!
        .toList();
System.out.println(todos.size());         // 6: todos los prestamos

El detalle que se olvida siempre: la función que se pasa a flatMap debe devolver un Stream, no una List. Por eso hay que escribir .stream() dentro:

.flatMap(e -> e.getPrestamos().stream())      // BIEN
.flatMap(Empleado::getPrestamos)              // NO COMPILA: devuelve List, no Stream

Ahora se puede seguir procesando el resultado aplanado:

// Todos los prestamos con retraso de toda la biblioteca, ordenados
List<Prestamo> conRetraso = plantilla.stream()
        .flatMap(e -> e.getPrestamos().stream())
        .filter(Prestamo::tieneRetraso)
        .sorted(Comparator.comparingInt(Prestamo::getDiasDeRetraso).reversed())
        .toList();

// Todas las incidencias de todos los prestamos de todos los empleados: DOS niveles
List<Prestamo.Incidencia> todasLasIncidencias = plantilla.stream()
        .flatMap(e -> e.getPrestamos().stream())
        .flatMap(p -> p.getIncidencias().stream())
        .toList();

Otros usos habituales de flatMap:

// Partir cadenas en palabras
List<String> palabras = titulos.stream()
        .flatMap(t -> Arrays.stream(t.split("\\s+")))
        .map(String::toLowerCase)
        .distinct()
        .sorted()
        .toList();

// Aplanar un Map<String, List<Prestamo>>
List<Prestamo> planos = porEmpleado.values().stream()
        .flatMap(List::stream)
        .toList();

// Con Optional (Java 9): descartar los vacios de golpe
List<Libro> encontrados = isbns.stream()
        .map(catalogo::buscarPorIsbn)      // Stream<Optional<Libro>>
        .flatMap(Optional::stream)          // Stream<Libro>, sin los vacios
        .toList();

Ese último es un idioma muy útil que verás de nuevo en el apartado 17.

  1. takeWhile y dropWhile

Añadidas en Java 9, son primas de filter con una diferencia crucial: paran en cuanto la condición falla, en lugar de seguir evaluando.

Operación Comportamiento
filter(p) Se queda con todos los que cumplen, recorriendo todo
takeWhile(p) Toma elementos hasta el primero que no cumple, y para
dropWhile(p) Descarta elementos hasta el primero que no cumple, y toma el resto
List<Integer> paginas = List.of(500, 480, 450, 380, 420, 300);

System.out.println(paginas.stream().filter(p -> p > 400).toList());
System.out.println(paginas.stream().takeWhile(p -> p > 400).toList());
System.out.println(paginas.stream().dropWhile(p -> p > 400).toList());
[500, 480, 450, 420]      filter: TODOS los mayores de 400
[500, 480, 450]           takeWhile: para en el 380
[380, 420, 300]           dropWhile: descarta hasta el 380, luego todo

Solo tienen sentido sobre datos ordenados, y ahí son muy potentes:

// Log ordenado por fecha: leer solo hasta salir del rango, sin recorrer un fichero de 2 GB
try (Stream<String> lineas = Files.lines(logPath, StandardCharsets.UTF_8)) {
    List<String> deHoy = lineas
            .dropWhile(l -> !l.startsWith("2026-08-05"))    // saltar dias anteriores
            .takeWhile(l ->  l.startsWith("2026-08-05"))    // parar al llegar a mañana
            .toList();
}

Con filter habría que leer el fichero entero. Con takeWhile, la lectura se detiene en cuanto aparece la primera línea del día siguiente.

  1. Operaciones terminales

Disparan el procesamiento y devuelven un resultado (o nada).

forEach y forEachOrdered

catalogo.stream()
        .filter(Libro::estaPrestado)
        .forEach(l -> System.out.println(l.getTitulo()));

En un stream paralelo, forEach no garantiza el orden; forEachOrdered sí, a costa de rendimiento:

catalogo.parallelStream().forEach(System.out::println);         // orden impredecible
catalogo.parallelStream().forEachOrdered(System.out::println);  // orden del origen

Consejo: si vas a construir una colección, no uses forEach con un add. Usa collect. forEach es para efectos finales: imprimir, enviar, guardar.

toList() y collect

// Java 16+: la forma corta. Devuelve una lista INMODIFICABLE.
List<String> titulos = catalogo.stream().map(Libro::getTitulo).toList();

// La forma clasica, con Collectors
List<String> titulos2 = catalogo.stream().map(Libro::getTitulo)
        .collect(Collectors.toList());       // modificable, sin garantia de tipo

// Si necesitas una lista modificable de un tipo concreto
List<String> modificable = catalogo.stream().map(Libro::getTitulo)
        .collect(Collectors.toCollection(ArrayList::new));
Forma Modificable Admite null Versión
.toList() No Java 16+
.collect(Collectors.toList()) Normalmente sí (no garantizado) Java 8+
.collect(Collectors.toUnmodifiableList()) No No Java 10+
.collect(Collectors.toCollection(ArrayList::new)) Sí, garantizado Java 8+

Usa .toList() por defecto. Es más corto, más claro y devuelve inmodificable, que es lo que quieres el 90 % de las veces.

count

long prestados = catalogo.stream().filter(Libro::estaPrestado).count();

Atención a una optimización que sorprende: desde Java 9, si el stream puede saber su tamaño sin recorrer, count() no ejecuta las operaciones intermedias:

long n = catalogo.stream()
        .peek(l -> System.out.println("visto: " + l.getTitulo()))
        .count();
// No imprime nada: count() sabe el tamaño de la lista sin recorrerla

Con un filter delante sí recorre, porque el filtro puede cambiar el número.

min y max

Devuelven Optional porque el stream puede estar vacío:

Optional<Libro> masLargo = catalogo.stream()
        .max(Comparator.comparingInt(Libro::getPaginas));

masLargo.ifPresent(l -> System.out.println("El más largo: " + l.getTitulo()));

// Con valor por defecto
String titulo = catalogo.stream()
        .min(Comparator.comparing(Libro::getTitulo))
        .map(Libro::getTitulo)
        .orElse("(catálogo vacío)");

anyMatch, allMatch, noneMatch y el cortocircuito

boolean hayAlgunoPrestado  = catalogo.stream().anyMatch(Libro::estaPrestado);
boolean todosDisponibles   = catalogo.stream().allMatch(l -> !l.estaPrestado());
boolean ningunoSinTitulo   = catalogo.stream().noneMatch(l -> l.getTitulo().isBlank());

Los tres cortocircuitan: paran en cuanto conocen la respuesta.

System.out.println(catalogo.stream()
        .peek(l -> System.out.println("comprobando: " + l.getTitulo()))
        .anyMatch(l -> l.getPaginas() > 400));
comprobando: Java Efectivo
true

Solo evaluó el primero: al encontrar uno que cumple, la respuesta ya es true.

El caso trampa del stream vacío, que sigue la lógica matemática y confunde a todo el mundo:

List<Libro> vacio = List.of();
System.out.println(vacio.stream().anyMatch(l -> true));    // false
System.out.println(vacio.stream().allMatch(l -> false));   // ¡true!
System.out.println(vacio.stream().noneMatch(l -> true));   // true

allMatch sobre un conjunto vacío es verdadero por vacuidad: "todos los elementos cumplen X" no tiene contraejemplo si no hay elementos. Matemáticamente correcto y una fuente de bugs si no lo esperas.

findFirst y findAny

Optional<Libro> primero = catalogo.stream()
        .filter(l -> l.getPaginas() > 400)
        .findFirst();

Optional<Libro> cualquiera = catalogo.parallelStream()
        .filter(l -> l.getPaginas() > 400)
        .findAny();
Operación Secuencial Paralelo
findFirst() El primero en orden de encuentro El primero en orden, con coste de coordinación
findAny() Normalmente el primero Cualquiera, sin coordinación: más rápido

En streams secuenciales son prácticamente equivalentes. En paralelo, findAny es la opción correcta cuando cualquier coincidencia sirve.

  1. reduce en sus tres formas

reduce combina todos los elementos en uno solo aplicando repetidamente una operación binaria. Tiene tres sobrecargas y conviene entenderlas por separado.

Forma 1: solo el acumulador → devuelve Optional

Optional<T> reduce(BinaryOperator<T> acumulador)
Optional<Integer> totalPaginas = catalogo.stream()
        .map(Libro::getPaginas)
        .reduce((a, b) -> a + b);

System.out.println(totalPaginas.orElse(0));

Devuelve Optional porque si el stream está vacío no hay ningún valor que devolver. No hay ningún "cero" que la operación pueda inventarse: reduce no sabe si combinas números (donde sería 0), textos (donde sería "") o máximos (donde sería -∞).

Paso a paso, con [412, 395, 448]:

paso 1: acumulador = 412           (primer elemento)
paso 2: acumulador = 412 + 395 = 807
paso 3: acumulador = 807 + 448 = 1255
resultado: Optional[1255]

Forma 2: identidad + acumulador → devuelve T

T reduce(T identidad, BinaryOperator<T> acumulador)
int totalPaginas = catalogo.stream()
        .map(Libro::getPaginas)
        .reduce(0, Integer::sum);         // 0 es la identidad

String todosLosTitulos = catalogo.stream()
        .map(Libro::getTitulo)
        .reduce("", (a, b) -> a.isEmpty() ? b : a + " | " + b);

Al haber identidad, no hay Optional: con el stream vacío se devuelve la identidad.

La identidad debe serlo de verdad: op(identidad, x) debe ser igual a x para cualquier x. Para la suma es 0, para el producto 1, para la concatenación "", para el máximo Integer.MIN_VALUE. Poner 1 como identidad de una suma da resultados incorrectos, y en paralelo el error se multiplica porque la identidad se aplica en cada partición:

// MAL: identidad incorrecta
int suma = Stream.of(1, 2, 3, 4).reduce(10, Integer::sum);
System.out.println(suma);                              // 20, no 10

int sumaParalela = Stream.of(1, 2, 3, 4).parallel().reduce(10, Integer::sum);
System.out.println(sumaParalela);                      // ¡50! el 10 se sumo en cada particion

Forma 3: identidad + acumulador + combinador → cambia de tipo

<U> U reduce(U identidad, BiFunction<U, ? super T, U> acumulador, BinaryOperator<U> combinador)

Esta forma permite que el tipo del resultado sea distinto del de los elementos. El tercer argumento, el combinador, dice cómo fundir dos resultados parciales, y solo se usa en paralelo.

// Sumar las paginas de objetos Libro produciendo un int
int totalPaginas = catalogo.parallelStream()
        .reduce(0,                                  // identidad: un int
                (suma, libro) -> suma + libro.getPaginas(),   // acumular Libro en int
                Integer::sum);                      // fundir dos int parciales
graph TD
    A["catalogo completo"] --> B["particion 1"]
    A --> C["particion 2"]
    B -->|"acumulador"| D["parcial 1 = 807"]
    C -->|"acumulador"| E["parcial 2 = 464"]
    D -->|"combinador"| F["total = 1271"]
    E -->|"combinador"| F

En la práctica esta tercera forma se usa poco, porque collect con Collectors es más legible y mapToInt(...).sum() resuelve el caso numérico de un plumazo. Pero entenderla explica cómo funciona collect por dentro, y por qué un Collector necesita un combinador.

Cuándo usar reduce: cuando el resultado es un único valor obtenido combinando elementos y no existe un Collector que lo haga. Para sumas, medias y conteos, usa los streams de primitivos o los Collectors; son más claros y más rápidos.

  1. Los Collectors básicos

collect es la operación terminal más versátil: acumula los elementos en una estructura usando un Collector. La clase java.util.stream.Collectors trae fábricas para todo.

toList, toSet, toMap

List<String> lista = catalogo.stream().map(Libro::getTitulo).collect(Collectors.toList());
Set<String> conjunto = catalogo.stream().map(Libro::getAutor).collect(Collectors.toSet());

// toMap: clave y valor
Map<String, Libro> porIsbn = catalogo.stream()
        .collect(Collectors.toMap(Libro::getIsbn, libro -> libro));

Map<String, String> tituloPorIsbn = catalogo.stream()
        .collect(Collectors.toMap(Libro::getIsbn, Libro::getTitulo));

La trampa de toMap: las claves duplicadas lanzan una excepción.

// Dos libros del mismo autor
Map<String, String> porAutor = catalogo.stream()
        .collect(Collectors.toMap(Libro::getAutor, Libro::getTitulo));
Exception in thread "main" java.lang.IllegalStateException:
    Duplicate key Martin Fowler (attempted merging values Refactorización and UML Destilado)
    at java.base/java.util.stream.Collectors.duplicateKeyException(Collectors.java:135)

La solución es el tercer argumento, la función de mezcla, que decide qué hacer con el conflicto:

// Quedarse con el primero
Map<String, String> primero = catalogo.stream()
        .collect(Collectors.toMap(Libro::getAutor, Libro::getTitulo,
                                  (existente, nuevo) -> existente));

// Quedarse con el ultimo
Map<String, String> ultimo = catalogo.stream()
        .collect(Collectors.toMap(Libro::getAutor, Libro::getTitulo,
                                  (existente, nuevo) -> nuevo));

// Combinar
Map<String, String> combinado = catalogo.stream()
        .collect(Collectors.toMap(Libro::getAutor, Libro::getTitulo,
                                  (a, b) -> a + "; " + b));

// Y con un cuarto argumento se elige la implementacion del Map
Map<String, String> ordenado = catalogo.stream()
        .collect(Collectors.toMap(Libro::getIsbn, Libro::getTitulo,
                                  (a, b) -> a, TreeMap::new));

Segunda trampa: toMap no admite valores null. Lanza NullPointerException, a diferencia de HashMap.put. Si tus valores pueden ser nulos, filtra antes.

joining: concatenar cadenas

String titulos = catalogo.stream()
        .map(Libro::getTitulo)
        .collect(Collectors.joining(", "));
// Java Efectivo, Patrones de Diseño, Refactorización

String conBordes = catalogo.stream()
        .map(Libro::getTitulo)
        .collect(Collectors.joining(", ", "Catálogo [", "]"));
// Catálogo [Java Efectivo, Patrones de Diseño, Refactorización]

Usa StringBuilder por dentro, así que es eficiente incluso con miles de elementos (lo verás cuantificado en 10-07).

Estadísticas: counting, summing, averaging, summarizing

long total = catalogo.stream().collect(Collectors.counting());

int paginas = catalogo.stream().collect(Collectors.summingInt(Libro::getPaginas));
double media = catalogo.stream().collect(Collectors.averagingInt(Libro::getPaginas));
double sumaValoraciones = catalogo.stream().collect(Collectors.summingDouble(Libro::getValoracion));
double mediaValoraciones = catalogo.stream().collect(Collectors.averagingDouble(Libro::getValoracion));

// Todo de golpe, en un solo recorrido
IntSummaryStatistics stats = catalogo.stream()
        .collect(Collectors.summarizingInt(Libro::getPaginas));

System.out.println("Libros:  " + stats.getCount());
System.out.println("Total:   " + stats.getSum());
System.out.println("Media:   " + stats.getAverage());
System.out.println("Mínimo:  " + stats.getMin());
System.out.println("Máximo:  " + stats.getMax());
Libros:  4
Total:   1719
Media:   429.75
Mínimo:  395
Máximo:  464

summarizingInt calcula las cinco estadísticas en un solo recorrido. Con bucles harían falta cinco variables y cuidado con el mínimo y el máximo iniciales.

Nota: counting() y summingInt() sueltos son innecesariamente indirectos —stream().count() y stream().mapToInt(...).sum() son más claros—. Su verdadero valor aparece como downstream de groupingBy, en el apartado siguiente.

  1. groupingBy y partitioningBy

Aquí está la operación que convierte treinta líneas de bucles anidados en una.

groupingBy simple

Map<TipoMaterial, List<Material>> porTipo = inventario.stream()
        .collect(Collectors.groupingBy(Material::getTipo));
{LIBRO=[Java Efectivo, Patrones de Diseño, Refactorización],
 REVISTA=[Java Magazine],
 DVD=[Curso de Spring]}

Compara con el equivalente imperativo, que es exactamente lo que escribiste en el módulo 5:

Map<TipoMaterial, List<Material>> porTipo = new HashMap<>();
for (Material m : inventario) {
    porTipo.computeIfAbsent(m.getTipo(), t -> new ArrayList<>()).add(m);
}

Cuatro líneas contra una, y la de una dice mucho más claramente qué se pretende.

groupingBy con downstream

El segundo argumento es otro Collector que decide qué hacer con cada grupo, en lugar de acumularlo en una lista. Aquí es donde counting, summing y compañía cobran sentido:

// Cuantos materiales de cada tipo
Map<TipoMaterial, Long> conteoPorTipo = inventario.stream()
        .collect(Collectors.groupingBy(Material::getTipo, Collectors.counting()));
// {LIBRO=3, REVISTA=1, DVD=1}

// Paginas totales por tipo
Map<TipoMaterial, Integer> paginasPorTipo = inventario.stream()
        .collect(Collectors.groupingBy(Material::getTipo,
                                       Collectors.summingInt(Material::getPaginas)));

// Valoracion media por tipo
Map<TipoMaterial, Double> mediaPorTipo = inventario.stream()
        .collect(Collectors.groupingBy(Material::getTipo,
                                       Collectors.averagingDouble(Material::getValoracion)));

// Solo los TITULOS de cada grupo: mapping transforma antes de acumular
Map<TipoMaterial, List<String>> titulosPorTipo = inventario.stream()
        .collect(Collectors.groupingBy(Material::getTipo,
                                       Collectors.mapping(Material::getTitulo,
                                                          Collectors.toList())));

// El mas valorado de cada tipo: devuelve Optional dentro del mapa
Map<TipoMaterial, Optional<Material>> mejorPorTipo = inventario.stream()
        .collect(Collectors.groupingBy(Material::getTipo,
                 Collectors.maxBy(Comparator.comparingDouble(Material::getValoracion))));

// Sin el Optional, con maxBy reducido (Java 9)
Map<TipoMaterial, Material> mejorPorTipo2 = inventario.stream()
        .collect(Collectors.groupingBy(Material::getTipo,
                 Collectors.collectingAndThen(
                     Collectors.maxBy(Comparator.comparingDouble(Material::getValoracion)),
                     opt -> opt.orElse(null))));

// Estadisticas completas por tipo
Map<TipoMaterial, IntSummaryStatistics> statsPorTipo = inventario.stream()
        .collect(Collectors.groupingBy(Material::getTipo,
                                       Collectors.summarizingInt(Material::getPaginas)));

// Con TreeMap para que las claves salgan ordenadas
Map<String, Long> porAutorOrdenado = catalogo.stream()
        .collect(Collectors.groupingBy(Libro::getAutor, TreeMap::new, Collectors.counting()));

groupingBy de dos niveles

El downstream puede ser otro groupingBy, y ahí aparece el agrupamiento anidado:

// Prestamos por empleado y, dentro, por tipo de material
Map<String, Map<TipoMaterial, List<Prestamo>>> porEmpleadoYTipo = prestamos.stream()
        .collect(Collectors.groupingBy(
                Prestamo::getEmpleado,
                Collectors.groupingBy(p -> p.getMaterial().getTipo())));

// Y con conteo en el nivel interior
Map<String, Map<TipoMaterial, Long>> conteoPorEmpleadoYTipo = prestamos.stream()
        .collect(Collectors.groupingBy(
                Prestamo::getEmpleado,
                Collectors.groupingBy(p -> p.getMaterial().getTipo(),
                                      Collectors.counting())));
{Marta Ruiz={LIBRO=3, DVD=1},
 Diego Alonso={LIBRO=1, REVISTA=2},
 Nuria Vidal={LIBRO=2}}

Ese resultado, en el módulo 5, eran tres bucles anidados, dos HashMap, dos computeIfAbsent y unas veinte líneas.

partitioningBy

Caso particular de groupingBy con un Predicate: siempre produce un Map<Boolean, ...> con exactamente dos claves, true y false, incluso si un grupo está vacío.

Map<Boolean, List<Libro>> porDisponibilidad = catalogo.stream()
        .collect(Collectors.partitioningBy(Libro::estaPrestado));

List<Libro> prestados   = porDisponibilidad.get(true);
List<Libro> disponibles = porDisponibilidad.get(false);

// Con downstream
Map<Boolean, Long> conteo = catalogo.stream()
        .collect(Collectors.partitioningBy(l -> l.getPaginas() > 400,
                                           Collectors.counting()));
groupingBy(p::test) partitioningBy(p)
Claves Solo las que aparecen Siempre true y false
Grupo vacío No aparece la clave Aparece con lista vacía
Rendimiento Mapa general Optimizado para dos claves

Esa diferencia importa: con groupingBy, si ningún libro está prestado, get(true) devuelve null y produce un NullPointerException. Con partitioningBy, devuelve una lista vacía.

teeing

Añadido en Java 12, permite aplicar dos colectores a la vez y combinar sus resultados en un solo recorrido:

record ResumenCatalogo(long total, double mediaPaginas) { }

ResumenCatalogo resumen = catalogo.stream()
        .collect(Collectors.teeing(
                Collectors.counting(),
                Collectors.averagingInt(Libro::getPaginas),
                ResumenCatalogo::new));

Es útil cuando necesitas dos agregados y no quieres recorrer dos veces. Se menciona por completitud; en el día a día se usa poco.

  1. Streams de primitivos

Stream<Integer> guarda objetos Integer, y cada operación implica autoboxing: envolver un int en un objeto, con su reserva de memoria y su indirección. Para secuencias numéricas grandes eso es un desperdicio notable.

Java 8 trae tres streams especializados: IntStream, LongStream y DoubleStream.

Aspecto Stream<Integer> IntStream
Almacena Objetos Integer int primitivos
Autoboxing En cada operación Ninguno
Memoria ~16 bytes por elemento 4 bytes
Métodos numéricos No sum, average, max, min, summaryStatistics
map devuelve Stream<R> IntStream (usa mapToObj para salir)
// Suma sin autoboxing
int totalPaginas = catalogo.stream()
        .mapToInt(Libro::getPaginas)
        .sum();

// Media: devuelve OptionalDouble, porque un stream vacio no tiene media
OptionalDouble media = catalogo.stream()
        .mapToInt(Libro::getPaginas)
        .average();

System.out.printf("Media: %.1f páginas%n", media.orElse(0.0));

// Estadisticas de golpe
IntSummaryStatistics stats = catalogo.stream()
        .mapToInt(Libro::getPaginas)
        .summaryStatistics();

// Rangos
IntStream.range(0, 5).forEach(System.out::println);        // 0,1,2,3,4
IntStream.rangeClosed(1, 5).forEach(System.out::println);  // 1,2,3,4,5

// Volver a objetos
List<Integer> lista = IntStream.rangeClosed(1, 5).boxed().toList();
List<String> refs = IntStream.rangeClosed(1, 3)
        .mapToObj(n -> String.format("PR-2026-%04d", n))
        .toList();

// Caracteres de una cadena
long vocales = "Java Efectivo".chars()
        .filter(c -> "aeiouAEIOU".indexOf(c) >= 0)
        .count();

Los Optional primitivos. average() devuelve OptionalDouble, no Optional<Double>; max() sobre un IntStream devuelve OptionalInt. Son versiones sin autoboxing de Optional, con la misma API básica (isPresent, getAsDouble, orElse) pero sin map, filter ni flatMap. Si necesitas encadenar, conviértelos:

OptionalDouble media = catalogo.stream().mapToInt(Libro::getPaginas).average();

String texto = media.isPresent()
        ? String.format("%.1f", media.getAsDouble())
        : "sin datos";

// O convirtiendo a Optional<Double>
String texto2 = media.stream().boxed().findFirst()
        .map(d -> String.format("%.1f", d))
        .orElse("sin datos");

Regla práctica: usa streams de primitivos siempre que trabajes con números. El código es más corto (sum() en lugar de reduce(0, Integer::sum)) y evita millones de objetos temporales en secuencias grandes.

  1. Streams paralelos

Retomamos 08-05, ahora con todo el contexto.

Convertir un stream en paralelo es trivial:

List<Libro> resultado = catalogo.parallelStream()      // desde la coleccion
        .filter(l -> l.getPaginas() > 400)
        .toList();

List<Libro> resultado2 = catalogo.stream()
        .parallel()                                    // desde un stream existente
        .filter(l -> l.getPaginas() > 400)
        .toList();

Por debajo, el stream parte los datos, procesa las partes en varios hilos y combina los resultados. El trabajo lo hace el ForkJoinPool.commonPool() que conociste en 08-05.

graph TD
    A["Fuente: 10000 elementos"] --> B["Spliterator parte"]
    B --> C1["particion 1"]
    B --> C2["particion 2"]
    B --> C3["particion 3"]
    B --> C4["particion 4"]
    C1 --> D1["hilo 1: filter, map"]
    C2 --> D2["hilo 2: filter, map"]
    C3 --> D3["hilo 3: filter, map"]
    C4 --> D4["hilo 4: filter, map"]
    D1 --> E["combinar resultados"]
    D2 --> E
    D3 --> E
    D4 --> E
    E --> F["Resultado final"]

El commonPool y sus consecuencias

System.out.println("Paralelismo: " + ForkJoinPool.getCommonPoolParallelism());
System.out.println("Núcleos:     " + Runtime.getRuntime().availableProcessors());
Paralelismo: 7
Núcleos:     8

El commonPool tiene por defecto núcleos − 1 hilos (el hilo que llama participa también). Y esto tiene una consecuencia que hay que tener muy presente:

Todos los streams paralelos de toda la aplicación comparten el mismo pool. Si una tarea bloquea un hilo del commonPool con una operación de red o de E/S, está bloqueando a todos los demás streams paralelos de la aplicación. Por eso:

Nunca uses parallelStream() con operaciones que bloquean (E/S, red, esperas). Para eso están los ExecutorService propios de 08-05 y CompletableFuture de 08-07 — o los hilos virtuales de 10-06.

Se puede ejecutar en un pool propio, aunque es un apaño que conviene conocer:

ForkJoinPool poolPropio = new ForkJoinPool(4);
try {
    List<Libro> resultado = poolPropio.submit(() ->
            catalogo.parallelStream().filter(...).toList()).get();
} finally {
    poolPropio.shutdown();
}

Cuándo ayuda y cuándo perjudica

El paralelismo no es gratis: partir, coordinar hilos y combinar resultados tiene un coste fijo. Solo compensa si el trabajo útil es mucho mayor que ese coste.

Factor Favorable al paralelo Desfavorable
Tamaño Decenas de miles de elementos o más Cientos o menos
Coste por elemento Alto (cálculo intenso) Trivial (sumar, comparar)
Fuente ArrayList, arrays, IntStream.range (partición barata) LinkedList, Stream.iterate (partición cara o imposible)
Operaciones Sin estado: filter, map Con estado: sorted, distinct, limit
Terminal reduce, sum, collect(toList) forEachOrdered, findFirst (exigen orden)
Contexto Aplicación de escritorio, proceso por lotes Servidor con muchas peticiones concurrentes

Esa última fila se olvida a menudo: en un servidor que ya atiende 200 peticiones a la vez, todos los núcleos están ocupados. Paralelizar dentro de cada petición no aporta nada y añade coordinación.

Una medición honesta

package com.nexussoftware.bibliotech;

import java.util.*;
import java.util.stream.*;

public class MedicionParalela {

    public static void main(String[] args) {

        // ADVERTENCIA: esto es una medicion CASERA. Los numeros son
        // orientativos; para medir de verdad hace falta JMH (10-07).

        List<Integer> pequena = IntStream.range(0, 1_000).boxed().toList();
        List<Integer> grande  = IntStream.range(0, 10_000_000).boxed().toList();

        calentar(grande);

        medir("1.000 elementos, suma trivial, secuencial",
              () -> pequena.stream().mapToLong(Integer::longValue).sum());
        medir("1.000 elementos, suma trivial, PARALELO   ",
              () -> pequena.parallelStream().mapToLong(Integer::longValue).sum());

        medir("10M elementos, suma trivial, secuencial   ",
              () -> grande.stream().mapToLong(Integer::longValue).sum());
        medir("10M elementos, suma trivial, PARALELO     ",
              () -> grande.parallelStream().mapToLong(Integer::longValue).sum());

        medir("10M elementos, calculo caro, secuencial   ",
              () -> grande.stream().mapToLong(MedicionParalela::caro).sum());
        medir("10M elementos, calculo caro, PARALELO     ",
              () -> grande.parallelStream().mapToLong(MedicionParalela::caro).sum());

        // LinkedList: particion cara
        List<Integer> enlazada = new LinkedList<>(IntStream.range(0, 1_000_000).boxed().toList());
        medir("1M en LinkedList, secuencial              ",
              () -> enlazada.stream().mapToLong(Integer::longValue).sum());
        medir("1M en LinkedList, PARALELO                ",
              () -> enlazada.parallelStream().mapToLong(Integer::longValue).sum());
    }

    private static long caro(int n) {
        return (long) (Math.sqrt(n) * Math.log(n + 1) * Math.sin(n));
    }

    private static void calentar(List<Integer> datos) {
        for (int i = 0; i < 5; i++) {
            datos.stream().mapToLong(Integer::longValue).sum();
            datos.parallelStream().mapToLong(Integer::longValue).sum();
        }
    }

    private static void medir(String etiqueta, java.util.function.LongSupplier tarea) {
        long inicio = System.nanoTime();
        long resultado = tarea.getAsLong();
        long ms = (System.nanoTime() - inicio) / 1_000_000;
        System.out.printf("%s -> %6d ms  (resultado: %d)%n", etiqueta, ms, resultado);
    }
}

Salida orientativa en una máquina de 8 núcleos:

1.000 elementos, suma trivial, secuencial ->      0 ms  (resultado: 499500)
1.000 elementos, suma trivial, PARALELO    ->      2 ms  (resultado: 499500)
10M elementos, suma trivial, secuencial    ->     58 ms  (resultado: 49999995000000)
10M elementos, suma trivial, PARALELO      ->     21 ms  (resultado: 49999995000000)
10M elementos, cálculo caro, secuencial    ->    712 ms  (resultado: -1583)
10M elementos, cálculo caro, PARALELO      ->    134 ms  (resultado: -1583)
1M en LinkedList, secuencial               ->     11 ms  (resultado: 499999500000)
1M en LinkedList, PARALELO                 ->     34 ms  (resultado: 499999500000)

Cuatro conclusiones que valen más que la tabla:

  1. Con 1.000 elementos, el paralelo es más lento. El coste de coordinar supera al trabajo.
  2. Con 10 millones y trabajo trivial, mejora 2,8× en 8 núcleos. No 8×: el acceso a memoria y la combinación no escalan linealmente.
  3. Con trabajo caro, mejora 5,3×. Cuanto más cuesta cada elemento, mejor amortiza el coste fijo.
  4. Con LinkedList, el paralelo es 3× más LENTO. Partir una lista enlazada exige recorrerla; no hay acceso por índice. La estructura de datos decide (módulo 5, y lo retomarás en 10-07).

La regla de oro: nada de estado compartido

// CATASTROFICO: ArrayList no es seguro entre hilos (08-06)
List<String> resultado = new ArrayList<>();
catalogo.parallelStream().forEach(l -> resultado.add(l.getTitulo()));

Esto produce, de forma impredecible: elementos perdidos, null en medio de la lista, ArrayIndexOutOfBoundsException o ConcurrentModificationException. Y lo peor: funciona correctamente en pruebas con pocos elementos y falla en producción.

// CORRECTO: collect gestiona la combinacion por ti
List<String> resultado = catalogo.parallelStream()
        .map(Libro::getTitulo)
        .toList();

La regla, sin excepciones: las lambdas de un stream paralelo no deben modificar ningún estado compartido. Deja que collect o reduce construyan el resultado.

La lista de comprobación antes de escribir parallelStream()

  1. ¿Hay decenas de miles de elementos o más?
  2. ¿El coste por elemento es apreciable?
  3. ¿La fuente es fácil de partir (ArrayList, array, rango)?
  4. ¿Las operaciones son sin estado y sin bloqueo?
  5. ¿Lo has medido y es realmente más rápido?
  6. ¿La aplicación no está ya saturando los núcleos?

Si alguna respuesta es "no", usa stream().

  1. El informe de BiblioTech: de treinta líneas a tres

Ahora, la demostración que justifica toda la lección. Este es el informe que EstadisticasBiblioTech generaba en el módulo 5:

package com.nexussoftware.bibliotech.servicio;

import java.util.*;

/** VERSION ANTIGUA: la del modulo 5. */
public class EstadisticasBiblioTechAntiguo {

    public Map<TipoMaterial, Long> conteoPorTipo(List<Material> inventario) {
        Map<TipoMaterial, Long> resultado = new HashMap<>();
        for (Material m : inventario) {
            Long actual = resultado.get(m.getTipo());
            resultado.put(m.getTipo(), actual == null ? 1L : actual + 1);
        }
        return resultado;
    }

    public Map<String, List<String>> titulosPorEmpleado(List<Prestamo> prestamos) {
        Map<String, List<String>> resultado = new HashMap<>();
        for (Prestamo p : prestamos) {
            List<String> lista = resultado.get(p.getEmpleado());
            if (lista == null) {
                lista = new ArrayList<>();
                resultado.put(p.getEmpleado(), lista);
            }
            lista.add(p.getMaterial().getTitulo());
        }
        for (List<String> lista : resultado.values()) {
            Collections.sort(lista);
        }
        return resultado;
    }

    public String empleadoConMasPrestamos(List<Prestamo> prestamos) {
        Map<String, Integer> conteo = new HashMap<>();
        for (Prestamo p : prestamos) {
            Integer actual = conteo.get(p.getEmpleado());
            conteo.put(p.getEmpleado(), actual == null ? 1 : actual + 1);
        }
        String mejor = null;
        int maximo = -1;
        for (Map.Entry<String, Integer> e : conteo.entrySet()) {
            if (e.getValue() > maximo) {
                maximo = e.getValue();
                mejor = e.getKey();
            }
        }
        return mejor;                    // null si la lista estaba vacia
    }

    public double mediaPaginasDeLosPrestados(List<Material> inventario) {
        int suma = 0;
        int cuenta = 0;
        for (Material m : inventario) {
            if (m.estaPrestado() && m instanceof Libro) {
                suma += ((Libro) m).getPaginas();
                cuenta++;
            }
        }
        return cuenta == 0 ? 0.0 : (double) suma / cuenta;
    }
}

Y esta es la misma clase con streams:

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.*;

import java.util.*;
import java.util.stream.Collectors;

/**
 * VERSION CON STREAMS. Mismo comportamiento, un tercio del codigo,
 * y cada metodo se lee como la frase que lo describe.
 */
public class EstadisticasBiblioTech {

    /** Cuantos materiales hay de cada tipo. */
    public Map<TipoMaterial, Long> conteoPorTipo(List<Material> inventario) {
        return inventario.stream()
                .collect(Collectors.groupingBy(Material::getTipo, Collectors.counting()));
    }

    /** Titulos prestados a cada empleado, ordenados alfabeticamente. */
    public Map<String, List<String>> titulosPorEmpleado(List<Prestamo> prestamos) {
        return prestamos.stream()
                .collect(Collectors.groupingBy(
                        Prestamo::getEmpleado,
                        TreeMap::new,
                        Collectors.mapping(p -> p.getMaterial().getTitulo(),
                                           Collectors.collectingAndThen(
                                                   Collectors.toList(),
                                                   lista -> { lista.sort(null); return lista; }))));
    }

    /** El empleado con mas prestamos. Optional en lugar de null. */
    public Optional<String> empleadoConMasPrestamos(List<Prestamo> prestamos) {
        return prestamos.stream()
                .collect(Collectors.groupingBy(Prestamo::getEmpleado, Collectors.counting()))
                .entrySet().stream()
                .max(Map.Entry.comparingByValue())
                .map(Map.Entry::getKey);
    }

    /** Media de paginas de los libros prestados. */
    public OptionalDouble mediaPaginasDeLosPrestados(List<Material> inventario) {
        return inventario.stream()
                .filter(Material::estaPrestado)
                .filter(Libro.class::isInstance)         // el instanceof de 10-03
                .map(Libro.class::cast)
                .mapToInt(Libro::getPaginas)
                .average();
    }

    /** Informe completo por tipo: cuenta, paginas y valoracion media. */
    public Map<TipoMaterial, ResumenTipo> informeCompleto(List<Material> inventario) {
        return inventario.stream()
                .collect(Collectors.groupingBy(
                        Material::getTipo,
                        Collectors.collectingAndThen(
                                Collectors.toList(),
                                lista -> new ResumenTipo(
                                        lista.size(),
                                        lista.stream().mapToInt(Material::getPaginas).sum(),
                                        lista.stream().mapToDouble(Material::getValoracion)
                                             .average().orElse(0.0)))));
    }

    public record ResumenTipo(int cuantos, int paginasTotales, double valoracionMedia) { }

    /** Prestamos por empleado y tipo: el informe de dos niveles. */
    public Map<String, Map<TipoMaterial, Long>> matrizEmpleadoTipo(List<Prestamo> prestamos) {
        return prestamos.stream()
                .collect(Collectors.groupingBy(
                        Prestamo::getEmpleado,
                        TreeMap::new,
                        Collectors.groupingBy(p -> p.getMaterial().getTipo(),
                                              () -> new EnumMap<>(TipoMaterial.class),
                                              Collectors.counting())));
    }

    /** Los N materiales mas valorados. */
    public List<Material> topValorados(List<Material> inventario, int n) {
        return inventario.stream()
                .sorted(Comparator.comparingDouble(Material::getValoracion).reversed())
                .limit(n)
                .toList();
    }

    /** Todas las incidencias de todos los prestamos, agrupadas por gravedad. */
    public Map<Gravedad, List<String>> incidenciasPorGravedad(List<Prestamo> prestamos) {
        return prestamos.stream()
                .flatMap(p -> p.getIncidencias().stream())
                .collect(Collectors.groupingBy(
                        Prestamo.Incidencia::gravedad,
                        () -> new EnumMap<>(Gravedad.class),
                        Collectors.mapping(Prestamo.Incidencia::descripcion,
                                           Collectors.toList())));
    }

    /** Linea de resumen para el panel de la biblioteca. */
    public String resumenEnUnaLinea(List<Material> inventario) {
        return inventario.stream()
                .collect(Collectors.groupingBy(Material::getTipo,
                         () -> new EnumMap<>(TipoMaterial.class),
                         Collectors.counting()))
                .entrySet().stream()
                .map(e -> e.getKey() + "=" + e.getValue())
                .collect(Collectors.joining(", ", "Inventario [", "]"));
    }
}

El programa que lo ejecuta:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.dominio.*;
import com.nexussoftware.bibliotech.servicio.EstadisticasBiblioTech;

import java.util.List;

public class InformeBiblioTech {

    public static void main(String[] args) {

        List<Material> inventario = DatosDePrueba.inventario();
        List<Prestamo> prestamos  = DatosDePrueba.prestamos();

        EstadisticasBiblioTech stats = new EstadisticasBiblioTech();

        System.out.println("=== INFORME BIBLIOTECH ===");
        System.out.println(stats.resumenEnUnaLinea(inventario));
        System.out.println();

        System.out.println("--- Materiales por tipo ---");
        stats.informeCompleto(inventario).forEach((tipo, r) ->
                System.out.printf("  %-8s %2d unidades, %5d páginas, valoración %.2f%n",
                        tipo, r.cuantos(), r.paginasTotales(), r.valoracionMedia()));

        System.out.println();
        System.out.println("--- Préstamos por empleado y tipo ---");
        stats.matrizEmpleadoTipo(prestamos).forEach((empleado, porTipo) ->
                System.out.printf("  %-14s %s%n", empleado, porTipo));

        System.out.println();
        System.out.println("--- Títulos por empleado ---");
        stats.titulosPorEmpleado(prestamos).forEach((empleado, titulos) ->
                System.out.printf("  %-14s %s%n", empleado, titulos));

        System.out.println();
        stats.empleadoConMasPrestamos(prestamos)
             .ifPresentOrElse(e -> System.out.println("Quien más lee: " + e),
                              () -> System.out.println("No hay préstamos registrados"));

        System.out.printf("Media de páginas de lo prestado: %.1f%n",
                stats.mediaPaginasDeLosPrestados(inventario).orElse(0.0));

        System.out.println();
        System.out.println("--- Top 3 más valorados ---");
        stats.topValorados(inventario, 3)
             .forEach(m -> System.out.printf("  %.2f  %s%n", m.getValoracion(), m.getTitulo()));
    }
}
=== INFORME BIBLIOTECH ===
Inventario [LIBRO=5, REVISTA=2, DVD=1]

--- Materiales por tipo ---
  LIBRO     5 unidades,  2119 páginas, valoración 4,62
  REVISTA   2 unidades,   140 páginas, valoración 3,90
  DVD       1 unidades,     0 páginas, valoración 4,10

--- Préstamos por empleado y tipo ---
  Diego Alonso   {LIBRO=1, REVISTA=2}
  Marta Ruiz     {LIBRO=3, DVD=1}
  Nuria Vidal    {LIBRO=2}

--- Títulos por empleado ---
  Diego Alonso   [Java Magazine 04, Java Magazine 05, Refactorización]
  Marta Ruiz     [Código Limpio, Curso de Spring, Java Efectivo, Patrones de Diseño]
  Nuria Vidal    [Domain-Driven Design, Java Efectivo]

Quien más lee: Marta Ruiz
Media de páginas de lo prestado: 428,3
--- Top 3 más valorados ---
  4,85  Java Efectivo
  4,72  Refactorización
  4,60  Patrones de Diseño

La comparación en números: 74 líneas de bucles con acumuladores, mapas manuales y comprobaciones de null, frente a 12 líneas de expresiones que se leen como su propio enunciado. Y la versión con streams hace más: devuelve Optional en lugar de null, usa EnumMap para las claves de enum y ordena las claves con TreeMap.

  1. Optional: para qué se creó

Cambiamos de tema, aunque no de lección: Optional llegó en la misma versión y por la misma filosofía.

Recuerda el buscarPorReferencia de BiblioTech del módulo 6:

public Prestamo buscarPorReferencia(String referencia) {
    for (Prestamo p : prestamos) {
        if (p.getReferencia().equals(referencia)) {
            return p;
        }
    }
    return null;                       // "no encontrado"
}

El problema no es el null en sí: es que la firma miente. Prestamo buscarPorReferencia(String) promete devolver un Prestamo. Quien lo llama no tiene ninguna indicación de que a veces no lo hará:

Prestamo p = registro.buscarPorReferencia("PR-2026-0041");
System.out.println(p.getEmpleado());       // NullPointerException si no existe

Y este error tiene una propiedad especialmente mala: el compilador no puede ayudarte. No hay ningún tipo que distinga "un Prestamo" de "un Prestamo o nada". Tony Hoare, que introdujo la referencia nula en ALGOL en 1965, la llamó públicamente "mi error de mil millones de dólares" por la cantidad de fallos, vulnerabilidades y horas de depuración que ha causado.

Optional<T> es la respuesta: un contenedor que puede tener un valor o estar vacío, y cuyo tipo lo declara explícitamente.

public Optional<Prestamo> buscarPorReferencia(String referencia) {
    for (Prestamo p : prestamos) {
        if (p.getReferencia().equals(referencia)) {
            return Optional.of(p);
        }
    }
    return Optional.empty();
}

Ahora la firma dice la verdad, y quien la usa no puede ignorarlo:

Optional<Prestamo> p = registro.buscarPorReferencia("PR-2026-0041");
// p.getEmpleado();       // NO COMPILA: Optional no tiene getEmpleado()

String empleado = p.map(Prestamo::getEmpleado).orElse("(sin préstamo)");

El compilador te obliga a enfrentarte a la ausencia. Ese es todo el valor de Optional, y es enorme.

Fíjate además en la conexión con 10-01: Optional<T> es el caso particular de Resultado<T> en el que el fallo no lleva información. Si necesitas saber por qué no hay valor, usa Resultado<T>; si basta con saber que no lo hay, Optional<T>.

  1. Crear y consumir un Optional

Creación

// of: el valor NO puede ser null. Si lo es, NullPointerException inmediata.
Optional<Libro> a = Optional.of(libro);

// ofNullable: acepta null y produce un Optional vacio
Optional<Libro> b = Optional.ofNullable(puedeSerNull);

// empty: explicitamente vacio
Optional<Libro> c = Optional.empty();
Método Si el argumento es null Cuándo usarlo
Optional.of(x) NullPointerException Cuando sabes que hay valor. El fallo rápido es deseable
Optional.ofNullable(x) Devuelve vacío Al envolver una API antigua que devuelve null
Optional.empty() Cuando no hay valor

Optional.of(null) es un error, no un Optional vacío. Es un error frecuente:

// MAL: si el mapa no tiene la clave, esto lanza NullPointerException
Optional<Libro> libro = Optional.of(mapa.get(isbn));

// BIEN
Optional<Libro> libro = Optional.ofNullable(mapa.get(isbn));

Consumo: transformar sin desenvolver

La forma idiomática de usar Optional no es preguntar si hay valor, sino encadenar operaciones que se aplican solo si lo hay.

// map: transforma el valor si existe. Si esta vacio, sigue vacio.
Optional<String> titulo = catalogo.buscarPorIsbn(isbn).map(Libro::getTitulo);

// Encadenando
String mayusculas = catalogo.buscarPorIsbn(isbn)
        .map(Libro::getTitulo)
        .map(String::toUpperCase)
        .orElse("DESCONOCIDO");

// filter: vacia el Optional si no cumple
Optional<Libro> largoYDisponible = catalogo.buscarPorIsbn(isbn)
        .filter(l -> l.getPaginas() > 400)
        .filter(l -> !l.estaPrestado());

flatMap: cuando la función ya devuelve Optional.

public class Libro {
    public Optional<String> getEditorial() { ... }   // puede no tenerla
}

// Con map: te quedan Optionals anidados
Optional<Optional<String>> mal = catalogo.buscarPorIsbn(isbn).map(Libro::getEditorial);

// Con flatMap: aplanado
Optional<String> bien = catalogo.buscarPorIsbn(isbn).flatMap(Libro::getEditorial);

Es exactamente la misma distinción que en los streams (apartado 7): map cuando la función devuelve un valor, flatMap cuando devuelve un contenedor.

Consumo: obtener el valor

// orElse: valor por defecto
String titulo = opt.map(Libro::getTitulo).orElse("(desconocido)");

// orElseGet: valor por defecto CALCULADO PEREZOSAMENTE (apartado 18)
Libro libro = opt.orElseGet(() -> cargarDesdeRed(isbn));

// orElseThrow: lanzar si no hay
Libro libro2 = opt.orElseThrow(() ->
        new BiblioTechException("No existe el material " + isbn));

// orElseThrow() sin argumentos (Java 10): lanza NoSuchElementException
Libro libro3 = opt.orElseThrow();

// or: otro Optional si este esta vacio (Java 9)
Optional<Libro> encontrado = catalogoLocal.buscarPorIsbn(isbn)
        .or(() -> catalogoRemoto.buscarPorIsbn(isbn))
        .or(() -> catalogoHistorico.buscarPorIsbn(isbn));

Ese or encadenado es un patrón muy útil: busca en el primer sitio, si no está en el segundo, si no en el tercero — y los Supplier solo se evalúan si hacen falta.

Consumo: ejecutar una acción

// ifPresent: hacer algo si hay valor
catalogo.buscarPorIsbn(isbn).ifPresent(l -> System.out.println("Encontrado: " + l.getTitulo()));

// ifPresentOrElse: y si no, otra cosa (Java 9)
catalogo.buscarPorIsbn(isbn).ifPresentOrElse(
        l  -> System.out.println("Encontrado: " + l.getTitulo()),
        () -> System.out.println("No hay ningún material con ISBN " + isbn));

Optional.stream(): el puente con los streams (Java 9)

Convierte un Optional en un stream de cero o un elemento. Su uso estrella es filtrar los vacíos de una colección de Optional:

List<String> isbns = List.of("978-0000000001", "978-9999999999", "978-0000000003");

// ANTES de Java 9: dos pasos
List<Libro> encontrados1 = isbns.stream()
        .map(catalogo::buscarPorIsbn)      // Stream<Optional<Libro>>
        .filter(Optional::isPresent)
        .map(Optional::get)
        .toList();

// DESDE Java 9: un paso
List<Libro> encontrados2 = isbns.stream()
        .map(catalogo::buscarPorIsbn)      // Stream<Optional<Libro>>
        .flatMap(Optional::stream)          // Stream<Libro>, sin los vacios
        .toList();

La segunda forma es la idiomática y no requiere get() en ningún momento.

  1. orElse frente a orElseGet

Parecen lo mismo y no lo son, y la diferencia importa de verdad.

T orElse(T otro)                    // recibe un VALOR ya calculado
T orElseGet(Supplier<? extends T> proveedor)   // recibe una FUNCION que lo calcula

orElse evalúa su argumento SIEMPRE, haya valor o no. orElseGet solo lo evalúa si el Optional está vacío. Demostración:

package com.nexussoftware.bibliotech;

import java.util.Optional;

public class OrElseFrenteAOrElseGet {

    private static Libro cargarDesdeRed(String isbn) {
        System.out.println("  >>> CONSULTANDO LA RED para " + isbn + " (300 ms)");
        return new Libro(isbn, "Recuperado de la red");
    }

    public static void main(String[] args) {

        Optional<Libro> conValor = Optional.of(new Libro("978-0000000001", "Java Efectivo"));

        System.out.println("--- orElse con valor presente ---");
        Libro a = conValor.orElse(cargarDesdeRed("978-0000000001"));
        System.out.println("Resultado: " + a.getTitulo());

        System.out.println();
        System.out.println("--- orElseGet con valor presente ---");
        Libro b = conValor.orElseGet(() -> cargarDesdeRed("978-0000000001"));
        System.out.println("Resultado: " + b.getTitulo());

        System.out.println();
        System.out.println("--- orElseGet con Optional vacío ---");
        Libro c = Optional.<Libro>empty().orElseGet(() -> cargarDesdeRed("978-9999999999"));
        System.out.println("Resultado: " + c.getTitulo());
    }
}
--- orElse con valor presente ---
  >>> CONSULTANDO LA RED para 978-0000000001 (300 ms)
Resultado: Java Efectivo

--- orElseGet con valor presente ---
Resultado: Java Efectivo

--- orElseGet con Optional vacío ---
  >>> CONSULTANDO LA RED para 978-9999999999 (300 ms)
Resultado: Recuperado de la red

Mira la primera sección con atención. El Optional tenía valor, y aun así se consultó la red — 300 milisegundos desperdiciados y una petición innecesaria a un servicio externo. El resultado devuelto fue el correcto, así que el bug es invisible: solo se manifiesta como lentitud inexplicable.

Y puede ser peor que lento:

// Un contador que se incrementa aunque el valor exista
int siguienteId = opt.orElse(generarNuevoId());     // ¡genera un ID siempre!

// Un Optional vacio que produce NullPointerException
String s = opt.orElse(otro.getTitulo());            // si 'otro' es null, explota siempre
Situación Usa
El valor por defecto es una constante o una variable ya calculada orElse
El valor por defecto requiere cálculo, E/S, red o consulta orElseGet
El valor por defecto tiene efectos secundarios orElseGet
Ante la duda orElseGet
// BIEN: constante
String titulo = opt.map(Libro::getTitulo).orElse("(sin título)");

// BIEN: calculo caro, evaluado solo si hace falta
Libro libro = opt.orElseGet(() -> cargarDesdeRed(isbn));

// BIEN: excepcion construida solo si hace falta
// (construir una excepcion captura la traza de pila, que no es gratis)
Libro libro2 = opt.orElseThrow(() -> new BiblioTechException("No existe " + isbn));

Ese último punto explica por qué orElseThrow recibe un Supplier y no una excepción ya construida: crear una excepción rellena su traza de pila, que es una operación medible (10-07).

  1. Los antipatrones de Optional

Optional se usa mal con mucha frecuencia. Estos cinco son los antipatrones que hay que reconocer y evitar.

Antipatrón 1: isPresent() + get()

// MAL: es un if disfrazado, con mas ruido que el null original
Optional<Libro> opt = catalogo.buscarPorIsbn(isbn);
if (opt.isPresent()) {
    System.out.println(opt.get().getTitulo());
} else {
    System.out.println("No encontrado");
}

Esto no aporta nada sobre comprobar null: mismas líneas, misma estructura, y encima has añadido un objeto envoltorio. El valor de Optional está en la API funcional:

// BIEN
catalogo.buscarPorIsbn(isbn).ifPresentOrElse(
        l  -> System.out.println(l.getTitulo()),
        () -> System.out.println("No encontrado"));

// O si necesitas el valor
String titulo = catalogo.buscarPorIsbn(isbn)
        .map(Libro::getTitulo)
        .orElse("No encontrado");

Regla: si escribes .get(), algo va mal. De hecho, Optional.get() está marcado como candidato a deprecación precisamente por lo mal que se usa; su alternativa es orElseThrow(), que al menos deja claro que puede fallar.

Antipatrón 2: Optional como parámetro de un método

// MAL
public List<Libro> buscar(String texto, Optional<TipoMaterial> tipo) { ... }

Tres razones por las que es un error:

  1. Quien llama tiene que envolver: buscar("java", Optional.of(LIBRO)) es más ruidoso que buscar("java", LIBRO).
  2. Optional puede ser null. Nada impide buscar("java", null), y entonces tienes un NullPointerException más un Optional. Lo peor de los dos mundos.
  3. Hay alternativas mejores: sobrecarga de métodos.
// BIEN
public List<Libro> buscar(String texto) {
    return buscar(texto, null);
}

public List<Libro> buscar(String texto, TipoMaterial tipo) { ... }

Optional se diseñó para tipos de RETORNO. Eso está escrito explícitamente en su documentación y en las notas de diseño del JDK.

Antipatrón 3: Optional como campo de una entidad

// MAL
public class Libro {
    private Optional<String> editorial;      // NO
}
  • Optional no es serializable (07-05), así que rompe la serialización de la entidad.
  • Añade un objeto por campo y por instancia: con un millón de libros, un millón de objetos extra.
  • Los frameworks de persistencia no lo manejan bien (Hibernate, 11-03).
// BIEN: el campo es null; el ACCESOR devuelve Optional
public class Libro {

    private String editorial;                // puede ser null

    public Optional<String> getEditorial() {
        return Optional.ofNullable(editorial);
    }
}

Así, el mundo exterior nunca ve un null y el interior no paga el envoltorio.

Antipatrón 4: Optional de una colección

// MAL
public Optional<List<Prestamo>> prestamosDe(String empleado) { ... }

Ahora quien llama tiene que comprobar dos ausencias: que el Optional tenga valor y que la lista no esté vacía. Y no hay ninguna diferencia semántica útil entre "no hay lista" y "la lista está vacía".

// BIEN: una lista vacia YA significa "no hay nada"
public List<Prestamo> prestamosDe(String empleado) {
    return prestamos.stream()
            .filter(p -> p.getEmpleado().equals(empleado))
            .toList();                       // vacia si no hay ninguno
}

Regla general: para colecciones, devuelve la colección vacía, nunca null ni Optional. Es de las recomendaciones más antiguas y más útiles de Effective Java.

Antipatrón 5: Optional.of(null) y devolver null desde un Optional

// MAL: NullPointerException garantizada
return Optional.of(mapa.get(clave));

// PEOR: un metodo que devuelve Optional y a veces devuelve null
public Optional<Libro> buscar(String isbn) {
    if (isbn == null) return null;           // ¡destruye todo el propósito!
    ...
}

Un método que devuelve Optional nunca debe devolver null. Quien lo llama hará .map(...) sin comprobar, porque para eso está Optional, y recibirá un NullPointerException en el sitio donde precisamente confiaba en que no podía haberlo.

  1. Qué devolver cuando no hay valor

Tabla de decisión que resume el criterio:

Situación Devuelve Por qué
Búsqueda que puede no encontrar (buscarPorIsbn) Optional<T> La ausencia es un resultado normal y esperable
Colección que puede estar vacía Colección vacía (List.of()) Vacío ya significa "nada"; nunca null ni Optional
Array que puede estar vacío Array vacío Igual que las colecciones
Cadena que puede estar vacía "" Salvo que "vacía" y "ausente" signifiquen cosas distintas
Operación que falla de forma esperable, con motivo Resultado<T> (10-01) El error lleva información
Operación que falla por un error de programación Excepción no comprobada IllegalArgumentException, IllegalStateException
Operación que falla por causa externa recuperable Excepción comprobada (06-01) IOException, BiblioTechException
Campo interno que puede no tener valor null en el campo, Optional en el getter Sin coste de memoria ni de serialización
Parámetro opcional de un método Sobrecarga del método Nunca Optional como parámetro
Media o agregado de un stream vacío OptionalDouble / OptionalInt Es lo que devuelve la API

  1. BiblioTech refactorizado

Cerramos aplicando todo a la vez. Repositorio<T> de 10-01 se moderniza con streams y Optional:

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Identificable;

import java.util.*;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Repositorio generico (10-01) con API de streams y Optional (10-04).
 */
public class Repositorio<T extends Identificable> {

    private final Map<String, T> porId = new ConcurrentHashMap<>();
    private final String nombre;

    public Repositorio(String nombre) {
        this.nombre = Objects.requireNonNull(nombre, "nombre");
    }

    public Optional<T> guardar(T entidad) {
        Objects.requireNonNull(entidad, "entidad");
        return Optional.ofNullable(porId.put(entidad.getId(), entidad));
    }

    /** La firma DICE que puede no encontrar nada. */
    public Optional<T> buscarPorId(String id) {
        return Optional.ofNullable(porId.get(id));
    }

    public Optional<T> eliminar(String id) {
        return Optional.ofNullable(porId.remove(id));
    }

    /**
     * Expone un stream en lugar de una copia de la lista.
     * Quien llama decide que hacer sin que el repositorio
     * tenga que ofrecer un metodo por cada consulta.
     */
    public Stream<T> flujo() {
        return porId.values().stream();
    }

    /** Nunca null: una lista vacia ya significa "ninguno". */
    public List<T> buscar(Predicate<? super T> criterio) {
        return flujo().filter(criterio).toList();
    }

    /** El primero que cumpla, o vacio. */
    public Optional<T> buscarPrimero(Predicate<? super T> criterio) {
        return flujo().filter(criterio).findFirst();
    }

    public List<T> listarOrdenado(Comparator<? super T> orden) {
        return flujo().sorted(orden).toList();
    }

    public long contar(Predicate<? super T> criterio) {
        return flujo().filter(criterio).count();
    }

    public boolean existeAlguno(Predicate<? super T> criterio) {
        return flujo().anyMatch(criterio);
    }

    /** Indexa por cualquier clave derivada. */
    public <K> Map<K, List<T>> agrupar(Function<? super T, ? extends K> clasificador) {
        return flujo().collect(Collectors.groupingBy(clasificador));
    }

    public int tamano() {
        return porId.size();
    }

    @Override
    public String toString() {
        return "Repositorio[" + nombre + ", " + porId.size() + " entidades]";
    }
}

Y GestorPrestamos deja de comprobar null:

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.anotaciones.Auditable;
import com.nexussoftware.bibliotech.dominio.*;

import java.util.*;
import java.util.stream.Collectors;

public class GestorPrestamos implements ServicioPrestamos {

    private final Repositorio<Material> catalogo;
    private final Repositorio<Prestamo> prestamos;

    public GestorPrestamos(Repositorio<Material> catalogo, Repositorio<Prestamo> prestamos) {
        this.catalogo = catalogo;
        this.prestamos = prestamos;
    }

    /**
     * ANTES: buscarPorId devolvia null y habia que comprobarlo.
     * AHORA: la ausencia se encadena y produce el fallo correcto.
     */
    @Override
    @Auditable(value = "prestar", nivel = Gravedad.ALTA, registrarArgumentos = true)
    public Resultado<Prestamo> prestar(String isbn, String empleado) {

        return catalogo.buscarPorId(isbn)
                .map(material -> registrar(material, empleado))
                .orElseGet(() -> Resultado.fallo("No existe ningún material con ISBN " + isbn));
    }

    private Resultado<Prestamo> registrar(Material material, String empleado) {
        if (material.estaPrestado()) {
            return Resultado.fallo("El material '" + material.getTitulo() + "' ya está prestado");
        }
        material.marcarPrestado(empleado);
        Prestamo prestamo = new Prestamo(siguienteReferencia(), material.getId(), empleado);
        prestamos.guardar(prestamo);
        return Resultado.exito(prestamo);
    }

    /** Lista vacia, nunca null. */
    @Override
    public List<Prestamo> prestamosDe(String empleado) {
        return prestamos.buscar(p -> p.getEmpleado().equals(empleado));
    }

    /** Optional en lugar de null. */
    @Override
    public Optional<Prestamo> buscarPorReferencia(String referencia) {
        return prestamos.buscarPorId(referencia);
    }

    /** El titulo del material prestado a un empleado que mas retraso lleva. */
    public Optional<String> materialMasRetrasadoDe(String empleado) {
        return prestamos.flujo()
                .filter(p -> p.getEmpleado().equals(empleado))
                .filter(Prestamo::tieneRetraso)
                .max(Comparator.comparingInt(Prestamo::getDiasDeRetraso))
                .flatMap(p -> catalogo.buscarPorId(p.getIsbn()))   // flatMap: devuelve Optional
                .map(Material::getTitulo);
    }

    /** Deuda total de multas por empleado, solo de quienes deben algo. */
    public Map<String, Double> deudaPorEmpleado(CalculadoraMultas calculadora) {
        return prestamos.flujo()
                .filter(Prestamo::tieneRetraso)
                .collect(Collectors.groupingBy(
                        Prestamo::getEmpleado,
                        TreeMap::new,
                        Collectors.summingDouble(p -> calculadora.calcular(p.getDiasDeRetraso()))));
    }

    private String siguienteReferencia() {
        return String.format("PR-2026-%04d", prestamos.tamano() + 1);
    }
}

El uso, sin una sola comprobación de null:

gestor.prestar("978-0000000001", "Marta Ruiz")
      .map(Prestamo::getId)
      .map(id -> "Préstamo registrado: " + id)
      .ifPresent(System.out::println);

gestor.materialMasRetrasadoDe("Diego Alonso")
      .ifPresentOrElse(
              t  -> System.out.println("Devolver urgentemente: " + t),
              () -> System.out.println("Diego Alonso no tiene retrasos"));

gestor.deudaPorEmpleado(calculadora)
      .forEach((empleado, deuda) -> System.out.printf("  %-14s %.2f €%n", empleado, deuda));
Préstamo registrado: PR-2026-0009
Devolver urgentemente: Refactorización
  Diego Alonso   3,25 €
  Marta Ruiz     1,50 €

Errores Comunes y Consejos

1. Reutilizar un stream. IllegalStateException: stream has already been operated upon or closed. Un stream es de un solo uso; crea uno nuevo desde la fuente.

2. Olvidar la operación terminal. lista.stream().filter(...) no hace absolutamente nada. Si tu stream no produce resultado ni efecto, comprueba que termina en collect, forEach, count, reduce o similar.

3. Modificar la colección de origen mientras se recorre. Produce ConcurrentModificationException, igual que con un for-each (05-02). Recoge en una lista nueva.

4. Usar forEach con un add a una lista externa. Es el estilo imperativo disfrazado, y en paralelo corrompe la lista. Usa collect.

5. Confundir map con flatMap. Si tu resultado es List<List<X>> o Optional<Optional<X>>, querías flatMap. Y recuerda que la función de flatMap debe devolver un Stream, así que hace falta el .stream() interior.

6. Collectors.toMap con claves duplicadas. IllegalStateException: Duplicate key. Pasa la función de mezcla como tercer argumento, o usa groupingBy si en realidad querías agrupar.

7. orElse con un cálculo caro. Se evalúa siempre, tenga valor o no el Optional. Usa orElseGet en cuanto haya cálculo, E/S o efectos secundarios.

8. isPresent() + get(). Es un if con más ruido. Usa map, filter, ifPresent, ifPresentOrElse u orElse.

9. Optional como parámetro o como campo. Se diseñó para tipos de retorno. Como parámetro, usa sobrecarga; como campo, null dentro y Optional en el getter.

10. parallelStream() sin medir. Con pocos elementos es más lento; con LinkedList es mucho más lento; con operaciones que bloquean, envenena el commonPool de toda la aplicación.

11. No cerrar los streams de ficheros. Files.lines() y Files.walk() mantienen recursos abiertos. Siempre en try-with-resources.

12. Usar peek para otra cosa que depurar. No se garantiza que se ejecute, y desde Java 9 count() puede saltárselo por completo.

13. sorted() sobre un stream infinito. Se cuelga: necesita todos los elementos. Pon limit antes.

Consejo 1: una operación por línea. Cada .filter, .map y .collect en su propia línea, alineados. Un stream de siete operaciones en una sola línea es ilegible.

Consejo 2: extrae los predicados y funciones complejos. Si la lambda ocupa más de dos líneas, dale nombre:

Predicate<Libro> esLargoYEstaPrestado = l -> l.getPaginas() > 400 && l.estaPrestado();
List<Libro> resultado = catalogo.stream().filter(esLargoYEstaPrestado).toList();

Consejo 3: prefiere referencias a métodos. map(Libro::getTitulo) se lee mejor que map(l -> l.getTitulo()).

Consejo 4: no fuerces todo a streams. Un bucle con break complejo, con índices, o que modifica varias variables a la vez, se lee mejor como bucle. Los streams brillan en transformación y agregación, no en control de flujo intrincado.

Consejo 5: pon filter antes que map y sorted al final. Menos elementos a transformar y menos elementos a ordenar. El resultado es el mismo y el trabajo, mucho menor.

Consejo 6: usa streams de primitivos con números. mapToInt(...).sum() en lugar de map(...).reduce(0, Integer::sum): más claro y sin autoboxing.

Ejercicios

Ejercicio 1: el panel de la biblioteca

Escribe una clase PanelBiblioTech que genere, usando exclusivamente streams, un informe de texto con:

  1. Total de materiales y desglose por tipo.
  2. Los 3 materiales más valorados, con su valoración.
  3. Préstamos activos agrupados por empleado, con el número y la lista de títulos ordenada.
  4. El empleado con más préstamos (Optional) y el que lleva más días de retraso acumulados.
  5. Media, mínimo y máximo de páginas de los libros, en un solo recorrido.
  6. Materiales nunca prestados (los que no aparecen en ningún préstamo).
  7. Un histograma de texto con el número de préstamos por día de la semana (usa diaPrestamo % 7).

Requisitos: ni un solo bucle for, ni un solo null, y ningún .get() sobre un Optional.

Ejercicio 2: analizador de logs con streams perezosos

BiblioTech genera un log con líneas del formato FECHA|NIVEL|OPERACIÓN|EMPLEADO|MILISEGUNDOS:

2026-08-05|INFO|prestar|Marta Ruiz|12
2026-08-05|SEVERE|devolver|Diego Alonso|340

Escribe AnalizadorLog que, leyendo el fichero una sola vez y sin cargarlo en memoria, produzca:

  • Número de operaciones por nivel.
  • Las 5 operaciones más lentas, con su empleado y duración.
  • Duración media por tipo de operación.
  • Empleados con al menos un SEVERE, ordenados.
  • Porcentaje de operaciones que superan los 200 ms.
  • Un Optional<String> con la primera línea corrupta encontrada (campos insuficientes o milisegundos no numéricos), o vacío si el fichero está bien.

Usa un record LineaLog y Optional para el parseo de cada línea. Genera un fichero de prueba de 50.000 líneas con streams.

Ejercicio 3: CacheFichas con Optional y medición del paralelismo

Refactoriza el CacheFichas de BiblioTech y mide el paralelismo honestamente.

  1. Reescribe CacheFichas para que buscar(String isbn) devuelva Optional<Ficha> y tenga un obtenerOCalcular(String, Function<String, Ficha>).
  2. Añade List<Ficha> buscarTodas(Collection<String> isbns) que devuelva solo las encontradas usando flatMap(Optional::stream).
  3. Añade Map<Boolean, List<String>> clasificar(Collection<String> isbns) con partitioningBy para separar los que están en caché de los que no.
  4. Escribe un banco de pruebas que compare stream() frente a parallelStream() para un cálculo caro de fichas, con calentamiento previo y varias repeticiones, sobre 100, 10.000 y 1.000.000 de ISBN, e imprima una tabla con la mejora en cada caso.
  5. Demuestra con código por qué acumular en un ArrayList con forEach en paralelo produce resultados incorrectos.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.presentacion;

import com.nexussoftware.bibliotech.dominio.*;

import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

/**
 * Panel de la biblioteca generado enteramente con streams.
 * Ni un for, ni un null, ni un Optional.get().
 */
public class PanelBiblioTech {

    private final List<Material> inventario;
    private final List<Prestamo> prestamos;

    public PanelBiblioTech(List<Material> inventario, List<Prestamo> prestamos) {
        this.inventario = List.copyOf(inventario);
        this.prestamos  = List.copyOf(prestamos);
    }

    public String generar() {
        StringBuilder sb = new StringBuilder();
        sb.append("=".repeat(58)).append('\n');
        sb.append("        PANEL DE BIBLIOTECH -- Nexus Software\n");
        sb.append("=".repeat(58)).append('\n');

        seccionInventario(sb);
        seccionTop(sb);
        seccionPrestamos(sb);
        seccionRanking(sb);
        seccionPaginas(sb);
        seccionNuncaPrestados(sb);
        seccionHistograma(sb);

        return sb.toString();
    }

    // --- 1. Inventario por tipo ---
    private void seccionInventario(StringBuilder sb) {
        Map<TipoMaterial, Long> porTipo = inventario.stream()
                .collect(Collectors.groupingBy(Material::getTipo,
                         () -> new EnumMap<>(TipoMaterial.class),
                         Collectors.counting()));

        sb.append("\n1. INVENTARIO (").append(inventario.size()).append(" materiales)\n");
        porTipo.forEach((tipo, n) ->
                sb.append(String.format("   %-10s %3d  %s%n", tipo, n, "#".repeat(n.intValue()))));
    }

    // --- 2. Top 3 mas valorados ---
    private void seccionTop(StringBuilder sb) {
        sb.append("\n2. MEJOR VALORADOS\n");
        inventario.stream()
                .sorted(Comparator.comparingDouble(Material::getValoracion).reversed())
                .limit(3)
                .forEach(m -> sb.append(String.format("   %.2f  %s%n",
                        m.getValoracion(), m.getTitulo())));
    }

    // --- 3. Prestamos por empleado ---
    private void seccionPrestamos(StringBuilder sb) {
        Map<String, List<String>> porEmpleado = prestamos.stream()
                .collect(Collectors.groupingBy(
                        Prestamo::getEmpleado,
                        TreeMap::new,
                        Collectors.mapping(Prestamo::getTituloMaterial,
                                Collectors.collectingAndThen(
                                        Collectors.toList(),
                                        lista -> lista.stream().sorted().toList()))));

        sb.append("\n3. PRÉSTAMOS ACTIVOS\n");
        porEmpleado.forEach((empleado, titulos) -> {
            sb.append(String.format("   %-14s (%d)%n", empleado, titulos.size()));
            titulos.forEach(t -> sb.append("      - ").append(t).append('\n'));
        });
    }

    // --- 4. Rankings, con Optional ---
    private void seccionRanking(StringBuilder sb) {
        sb.append("\n4. RANKINGS\n");

        Optional<String> masPrestamos = prestamos.stream()
                .collect(Collectors.groupingBy(Prestamo::getEmpleado, Collectors.counting()))
                .entrySet().stream()
                .max(Map.Entry.comparingByValue())
                .map(Map.Entry::getKey);

        sb.append("   Más préstamos:  ")
          .append(masPrestamos.orElse("(no hay préstamos)")).append('\n');

        Optional<String> masRetraso = prestamos.stream()
                .filter(Prestamo::tieneRetraso)
                .collect(Collectors.groupingBy(Prestamo::getEmpleado,
                         Collectors.summingInt(Prestamo::getDiasDeRetraso)))
                .entrySet().stream()
                .max(Map.Entry.comparingByValue())
                .map(e -> e.getKey() + " (" + e.getValue() + " días)");

        sb.append("   Más retraso:    ")
          .append(masRetraso.orElse("(nadie con retraso)")).append('\n');
    }

    // --- 5. Estadisticas de paginas en UN recorrido ---
    private void seccionPaginas(StringBuilder sb) {
        IntSummaryStatistics stats = inventario.stream()
                .filter(Libro.class::isInstance)
                .map(Libro.class::cast)
                .mapToInt(Libro::getPaginas)
                .summaryStatistics();

        sb.append("\n5. PÁGINAS DE LOS LIBROS\n");
        sb.append(String.format("   libros=%d  media=%.1f  mín=%d  máx=%d  total=%d%n",
                stats.getCount(), stats.getAverage(),
                // Con 0 libros, getMin() devuelve MAX_VALUE: hay que contemplarlo
                stats.getCount() == 0 ? 0 : stats.getMin(),
                stats.getCount() == 0 ? 0 : stats.getMax(),
                stats.getSum()));
    }

    // --- 6. Nunca prestados ---
    private void seccionNuncaPrestados(StringBuilder sb) {
        // Conjunto de ISBN prestados alguna vez: la busqueda pasa a O(1)
        Set<String> prestadosAlgunaVez = prestamos.stream()
                .map(Prestamo::getIsbn)
                .collect(Collectors.toSet());

        List<String> nunca = inventario.stream()
                .filter(m -> !prestadosAlgunaVez.contains(m.getId()))
                .map(Material::getTitulo)
                .sorted()
                .toList();

        sb.append("\n6. NUNCA PRESTADOS (").append(nunca.size()).append(")\n");
        sb.append(nunca.isEmpty()
                ? "   (todo el catálogo ha salido alguna vez)\n"
                : nunca.stream().collect(Collectors.joining("\n   ", "   ", "\n")));
    }

    // --- 7. Histograma por dia de la semana ---
    private void seccionHistograma(StringBuilder sb) {
        String[] dias = { "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo" };

        Map<Integer, Long> porDia = prestamos.stream()
                .collect(Collectors.groupingBy(p -> p.getDiaPrestamo() % 7,
                                               Collectors.counting()));

        long maximo = porDia.values().stream().mapToLong(Long::longValue).max().orElse(1);

        sb.append("\n7. PRÉSTAMOS POR DÍA DE LA SEMANA\n");
        IntStream.range(0, 7).forEach(i -> {
            long n = porDia.getOrDefault(i, 0L);
            int barra = (int) (n * 30 / maximo);
            sb.append(String.format("   %-10s %3d %s%n", dias[i], n, "█".repeat(barra)));
        });
    }
}
==========================================================
        PANEL DE BIBLIOTECH -- Nexus Software
==========================================================

1. INVENTARIO (8 materiales)
   LIBRO        5  #####
   REVISTA      2  ##
   DVD          1  #

2. MEJOR VALORADOS
   4,85  Java Efectivo
   4,72  Refactorización
   4,60  Patrones de Diseño

3. PRÉSTAMOS ACTIVOS
   Diego Alonso   (3)
      - Java Magazine 04
      - Java Magazine 05
      - Refactorización
   Marta Ruiz     (4)
      - Código Limpio
      - Curso de Spring
      - Java Efectivo
      - Patrones de Diseño
   Nuria Vidal    (2)
      - Domain-Driven Design
      - Java Efectivo

4. RANKINGS
   Más préstamos:  Marta Ruiz
   Más retraso:    Diego Alonso (23 días)

5. PÁGINAS DE LOS LIBROS
   libros=5  media=423.8  mín=380  máx=464  total=2119

6. NUNCA PRESTADOS (1)
   UML Destilado

7. PRÉSTAMOS POR DÍA DE LA SEMANA
   Lunes        3 ██████████████████████████████
   Martes       1 ██████████
   Miércoles    2 ████████████████████
   Jueves       0
   Viernes      2 ████████████████████
   Sábado       1 ██████████
   Domingo      0

Comentarios. Cuatro puntos.

El conjunto de ISBN prestados es una decisión de rendimiento, no de estilo. Sin él, la sección 6 sería filter(m -> prestamos.stream().noneMatch(p -> p.getIsbn().equals(m.getId()))), que es un stream anidado dentro de otro: O(n·m). Con el Set, es O(n+m). Streams no exime de pensar en la complejidad (módulo 5).

IntSummaryStatistics con cero elementos devuelve MAX_VALUE como mínimo y MIN_VALUE como máximo, que es matemáticamente coherente (el mínimo del conjunto vacío es el infinito) y visualmente absurdo. Hay que contemplarlo.

getOrDefault(i, 0L) en el histograma evita el null que devolvería get() para un día sin préstamos. Es el equivalente de orElse para mapas (05-05).

collectingAndThen es la pieza que permite ordenar dentro de un grupo. Recoge en una lista y aplica una función final. Sin él, mapping(..., toList()) daría las listas en orden de aparición.

Solución 2

package com.nexussoftware.bibliotech.servicio;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * Analiza el log de BiblioTech con streams perezosos.
 * NUNCA carga el fichero en memoria: un log de 2 GB se procesa igual.
 */
public class AnalizadorLog {

    /** Una linea valida del log. Optional para el parseo (04-07 + 10-04). */
    public record LineaLog(String fecha, String nivel, String operacion,
                           String empleado, long milisegundos) {

        static Optional<LineaLog> parsear(String linea) {
            String[] c = linea.split("\\|", -1);
            if (c.length != 5) {
                return Optional.empty();
            }
            try {
                return Optional.of(new LineaLog(
                        c[0].strip(), c[1].strip(), c[2].strip(), c[3].strip(),
                        Long.parseLong(c[4].strip())));
            } catch (NumberFormatException e) {
                return Optional.empty();
            }
        }
    }

    public record Informe(Map<String, Long> porNivel,
                          List<LineaLog> masLentas,
                          Map<String, Double> mediaPorOperacion,
                          List<String> empleadosConErrores,
                          double porcentajeLentas,
                          long totalValidas,
                          long totalCorruptas) { }

    private final Path fichero;

    public AnalizadorLog(Path fichero) {
        this.fichero = fichero;
    }

    /**
     * UN SOLO recorrido del fichero: se materializan las lineas validas
     * en memoria una vez y se derivan todos los agregados de ahi.
     * Para ficheros que no caben en memoria, cada agregado necesitaria
     * su propio recorrido perezoso.
     */
    public Informe analizar() throws IOException {

        List<LineaLog> validas;
        long total;

        try (Stream<String> lineas = Files.lines(fichero, StandardCharsets.UTF_8)) {
            validas = lineas
                    .filter(l -> !l.isBlank())
                    .map(LineaLog::parsear)
                    .flatMap(Optional::stream)      // descarta las vacias sin get()
                    .toList();
        }
        total = contarLineasNoVacias();

        Map<String, Long> porNivel = validas.stream()
                .collect(Collectors.groupingBy(LineaLog::nivel,
                         TreeMap::new, Collectors.counting()));

        List<LineaLog> masLentas = validas.stream()
                .sorted(Comparator.comparingLong(LineaLog::milisegundos).reversed())
                .limit(5)
                .toList();

        Map<String, Double> mediaPorOperacion = validas.stream()
                .collect(Collectors.groupingBy(LineaLog::operacion,
                         TreeMap::new,
                         Collectors.averagingLong(LineaLog::milisegundos)));

        List<String> empleadosConErrores = validas.stream()
                .filter(l -> "SEVERE".equals(l.nivel()))
                .map(LineaLog::empleado)
                .distinct()
                .sorted()
                .toList();

        long lentas = validas.stream().filter(l -> l.milisegundos() > 200).count();
        double porcentaje = validas.isEmpty() ? 0.0 : lentas * 100.0 / validas.size();

        return new Informe(porNivel, masLentas, mediaPorOperacion, empleadosConErrores,
                           porcentaje, validas.size(), total - validas.size());
    }

    /** La primera linea corrupta, con procesamiento PEREZOSO: para al encontrarla. */
    public Optional<String> primeraCorrupta() throws IOException {
        try (Stream<String> lineas = Files.lines(fichero, StandardCharsets.UTF_8)) {
            return lineas
                    .filter(l -> !l.isBlank())
                    .filter(l -> LineaLog.parsear(l).isEmpty())
                    .findFirst();       // CORTOCIRCUITA: no lee el resto del fichero
        }
    }

    private long contarLineasNoVacias() throws IOException {
        try (Stream<String> lineas = Files.lines(fichero, StandardCharsets.UTF_8)) {
            return lineas.filter(l -> !l.isBlank()).count();
        }
    }

    // ------------------------------------------------------------------

    public static void main(String[] args) throws IOException {

        Path log = Path.of("bibliotech.log");
        generarLogDePrueba(log, 50_000);

        AnalizadorLog analizador = new AnalizadorLog(log);
        Informe informe = analizador.analizar();

        System.out.println("=== ANÁLISIS DEL LOG ===");
        System.out.printf("Líneas válidas: %d, corruptas: %d%n%n",
                informe.totalValidas(), informe.totalCorruptas());

        System.out.println("Operaciones por nivel:");
        informe.porNivel().forEach((n, c) -> System.out.printf("  %-8s %6d%n", n, c));

        System.out.println("\nLas 5 más lentas:");
        informe.masLentas().forEach(l -> System.out.printf("  %6d ms  %-10s %s%n",
                l.milisegundos(), l.operacion(), l.empleado()));

        System.out.println("\nDuración media por operación:");
        informe.mediaPorOperacion().forEach((op, ms) ->
                System.out.printf("  %-12s %7.1f ms%n", op, ms));

        System.out.println("\nEmpleados con errores graves: " + informe.empleadosConErrores());
        System.out.printf("Operaciones por encima de 200 ms: %.2f %%%n",
                informe.porcentajeLentas());

        analizador.primeraCorrupta().ifPresentOrElse(
                l  -> System.out.println("\nPrimera línea corrupta: \"" + l + "\""),
                () -> System.out.println("\nNo hay líneas corruptas"));
    }

    /** Genera el fichero de prueba, tambien con streams. */
    private static void generarLogDePrueba(Path destino, int lineas) throws IOException {
        String[] niveles    = { "INFO", "INFO", "INFO", "WARNING", "SEVERE" };
        String[] operaciones = { "prestar", "devolver", "buscar", "exportar", "renovar" };
        String[] empleados  = { "Marta Ruiz", "Diego Alonso", "Nuria Vidal" };
        Random azar = new Random(42);          // semilla fija: salida reproducible

        List<String> contenido = java.util.stream.IntStream.rangeClosed(1, lineas)
                .mapToObj(i -> {
                    if (i % 9_999 == 0) {
                        return "2026-08-05|LÍNEA CORRUPTA SIN CAMPOS";
                    }
                    return String.format("2026-08-%02d|%s|%s|%s|%d",
                            1 + azar.nextInt(28),
                            niveles[azar.nextInt(niveles.length)],
                            operaciones[azar.nextInt(operaciones.length)],
                            empleados[azar.nextInt(empleados.length)],
                            1 + azar.nextInt(500));
                })
                .toList();

        Files.write(destino, contenido, StandardCharsets.UTF_8);
    }
}
=== ANÁLISIS DEL LOG ===
Líneas válidas: 49995, corruptas: 5

Operaciones por nivel:
  INFO      30012
  SEVERE     9981
  WARNING   10002

Las 5 más lentas:
     500 ms  buscar     Nuria Vidal
     500 ms  prestar    Marta Ruiz
     500 ms  renovar    Diego Alonso
     500 ms  devolver   Marta Ruiz
     500 ms  exportar   Nuria Vidal

Duración media por operación:
  buscar         250.9 ms
  devolver       250.1 ms
  exportar       250.4 ms
  prestar        250.7 ms
  renovar        250.3 ms

Empleados con errores graves: [Diego Alonso, Marta Ruiz, Nuria Vidal]
Operaciones por encima de 200 ms: 59.94 %

Primera línea corrupta: "2026-08-05|LÍNEA CORRUPTA SIN CAMPOS"

Comentarios.

map(LineaLog::parsear).flatMap(Optional::stream) es el idioma clave. El parseo devuelve Optional<LineaLog>, y flatMap(Optional::stream) descarta los vacíos y desenvuelve los llenos en un solo paso, sin isPresent(), sin get() y sin null.

primeraCorrupta() cortocircuita de verdad. findFirst() sobre un Files.lines() para en cuanto encuentra la primera coincidencia: en un fichero de dos gigabytes con la corrupción en la línea 12, se leen doce líneas. Con readAllLines() se leerían dos gigabytes.

El try-with-resources aparece tres veces, una por cada apertura del fichero. Y ese es el compromiso consciente de este diseño: se recorre el fichero tres veces (contar, analizar, buscar corrupta) para no cargarlo entero. Si el fichero cupiera holgadamente en memoria, un solo toList() inicial sería más rápido; si no cupiera, la lectura perezosa es la única opción. Elegir entre las dos es una decisión de ingeniería, no de estilo.

Solución 3

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Ficha;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * Cache de fichas con API basada en Optional y streams.
 */
public class CacheFichas {

    private final Map<String, Ficha> cache = new ConcurrentHashMap<>();
    private long aciertos = 0;
    private long fallos = 0;

    /** Optional en lugar de null: la firma dice que puede no estar. */
    public Optional<Ficha> buscar(String isbn) {
        Optional<Ficha> resultado = Optional.ofNullable(cache.get(isbn));
        if (resultado.isPresent()) { aciertos++; } else { fallos++; }
        return resultado;
    }

    /** Devuelve la cacheada o la calcula y la guarda. */
    public Ficha obtenerOCalcular(String isbn, Function<String, Ficha> calculo) {
        return cache.computeIfAbsent(isbn, clave -> {
            fallos++;
            return calculo.apply(clave);
        });
    }

    public void guardar(Ficha ficha) {
        cache.put(ficha.isbn(), ficha);
    }

    /** Solo las encontradas, sin isPresent() ni get(). */
    public List<Ficha> buscarTodas(Collection<String> isbns) {
        return isbns.stream()
                .map(cache::get)
                .filter(Objects::nonNull)
                .toList();
    }

    /** Version equivalente pasando por Optional, para ver el idioma. */
    public List<Ficha> buscarTodasConOptional(Collection<String> isbns) {
        return isbns.stream()
                .map(isbn -> Optional.ofNullable(cache.get(isbn)))
                .flatMap(Optional::stream)
                .toList();
    }

    /** true = en caché, false = hay que calcularlas. SIEMPRE las dos claves. */
    public Map<Boolean, List<String>> clasificar(Collection<String> isbns) {
        return isbns.stream()
                .collect(Collectors.partitioningBy(cache::containsKey));
    }

    public int tamano() { return cache.size(); }

    public double tasaAciertos() {
        long total = aciertos + fallos;
        return total == 0 ? 0.0 : (double) aciertos / total;
    }
}

El banco de pruebas:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.dominio.Ficha;

import java.util.*;
import java.util.concurrent.ForkJoinPool;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class BancoDePruebasParalelo {

    /** Calculo caro que simula generar una ficha. */
    private static Ficha calcularFicha(String isbn) {
        double acumulado = 0;
        for (int i = 1; i <= 2_000; i++) {
            acumulado += Math.sqrt(i) * Math.log(i + isbn.hashCode() % 7 + 8);
        }
        return new Ficha(isbn, "Ficha " + isbn + " (" + (long) acumulado + ")", true);
    }

    public static void main(String[] args) {

        System.out.println("Núcleos disponibles: " + Runtime.getRuntime().availableProcessors());
        System.out.println("Paralelismo del commonPool: "
                + ForkJoinPool.getCommonPoolParallelism());
        System.out.println();

        System.out.printf("%-12s %14s %14s %10s%n",
                "TAMAÑO", "SECUENCIAL", "PARALELO", "MEJORA");
        System.out.println("-".repeat(54));

        for (int tamano : new int[] { 100, 10_000, 1_000_000 }) {
            List<String> isbns = generarIsbns(tamano);

            // CALENTAMIENTO: sin esto se mide el interprete, no el codigo compilado (10-07)
            calentar(isbns.subList(0, Math.min(500, tamano)));

            long secuencial = medirMediana(() ->
                    isbns.stream().map(BancoDePruebasParalelo::calcularFicha).count());

            long paralelo = medirMediana(() ->
                    isbns.parallelStream().map(BancoDePruebasParalelo::calcularFicha).count());

            System.out.printf("%-12d %11d ms %11d ms %9.2fx%n",
                    tamano, secuencial, paralelo,
                    paralelo == 0 ? 0.0 : (double) secuencial / paralelo);
        }

        System.out.println();
        demostrarCorrupcionParalela();
    }

    private static List<String> generarIsbns(int cuantos) {
        return IntStream.rangeClosed(1, cuantos)
                .mapToObj(n -> String.format("978-%010d", n))
                .toList();
    }

    private static void calentar(List<String> muestra) {
        for (int i = 0; i < 3; i++) {
            muestra.stream().map(BancoDePruebasParalelo::calcularFicha).count();
            muestra.parallelStream().map(BancoDePruebasParalelo::calcularFicha).count();
        }
    }

    /** Mediana de 5 repeticiones: menos sensible a un pico puntual que la media. */
    private static long medirMediana(Runnable tarea) {
        long[] tiempos = new long[5];
        for (int i = 0; i < tiempos.length; i++) {
            long inicio = System.nanoTime();
            tarea.run();
            tiempos[i] = (System.nanoTime() - inicio) / 1_000_000;
        }
        Arrays.sort(tiempos);
        return tiempos[tiempos.length / 2];
    }

    /** Por que NUNCA se acumula en una coleccion no segura desde un stream paralelo. */
    private static void demostrarCorrupcionParalela() {

        List<String> isbns = generarIsbns(100_000);

        System.out.println("--- Acumular en ArrayList desde parallelStream ---");
        for (int intento = 1; intento <= 3; intento++) {
            List<String> destino = new ArrayList<>();      // NO es seguro entre hilos
            try {
                isbns.parallelStream().forEach(destino::add);
                System.out.printf("  intento %d: esperados 100000, obtenidos %d%s%n",
                        intento, destino.size(),
                        destino.size() == 100_000 ? "" : "   <-- ELEMENTOS PERDIDOS");
            } catch (Exception e) {
                System.out.printf("  intento %d: %s%n", intento, e.getClass().getSimpleName());
            }
        }

        System.out.println("--- La forma correcta: collect ---");
        List<String> correcto = isbns.parallelStream().collect(Collectors.toList());
        System.out.println("  collect: " + correcto.size() + " elementos, siempre");
    }
}

Salida orientativa (8 núcleos):

Núcleos disponibles: 8
Paralelismo del commonPool: 7

TAMAÑO           SECUENCIAL       PARALELO     MEJORA
------------------------------------------------------
100                       3 ms           2 ms      1,50x
10000                   287 ms          49 ms      5,86x
1000000               28640 ms        4712 ms      6,08x

--- Acumular en ArrayList desde parallelStream ---
  intento 1: esperados 100000, obtenidos 87423   <-- ELEMENTOS PERDIDOS
  intento 2: ArrayIndexOutOfBoundsException
  intento 3: esperados 100000, obtenidos 92108   <-- ELEMENTOS PERDIDOS
--- La forma correcta: collect ---
  collect: 100000 elementos, siempre

Comentarios. Cuatro cosas que este ejercicio demuestra mejor que cualquier explicación.

El calentamiento cambia los números. Sin él, la primera medición incluye la interpretación del bytecode antes de que el JIT compile el método caliente, y puede ser cinco o diez veces más lenta. Por eso el banco calienta antes de medir y toma la mediana de cinco repeticiones. Aun así sigue siendo una medición casera: en 10-07 verás por qué solo JMH da resultados en los que confiar de verdad.

Con 100 elementos el paralelismo apenas aporta, y con un cálculo más barato sería contraproducente. Con 10.000 y 1.000.000 la mejora se estabiliza en torno a 6× sobre 8 núcleos: no 8×, porque el reparto, la combinación y la memoria compartida no escalan linealmente. La ley de Amdahl aplicada.

La corrupción del ArrayList es no determinista, y eso es lo peligroso. Tres intentos, tres resultados distintos: dos con elementos perdidos y uno con excepción. Si esto estuviera en producción, fallaría de forma intermitente e irreproducible — el peor tipo de bug. Y funcionaría perfectamente en las pruebas con diez elementos.

collect funciona porque conoce su contrato. Cada hilo acumula en su propio contenedor y luego se combinan; no hay estado compartido entre hilos en ningún momento. Es exactamente el papel del combinador del reduce de tres argumentos del apartado 10.

Conclusión

Ha llegado y ha valido la pena.

Sabes qué es un stream y —tan importante— qué no es: no es una colección, no almacena elementos, no modifica el origen, no tiene índices, no tiene nada que ver con InputStream, y es de un solo uso (IllegalStateException: stream has already been operated upon or closed). Es una descripción de un cálculo sobre una secuencia.

Dominas la tubería: una fuente, cero o más operaciones intermedias que devuelven Stream y no hacen nada, y exactamente una operación terminal que lo dispara todo. Y has visto con trazas la evaluación perezosa: cada elemento atraviesa la tubería completa antes de que empiece el siguiente, map no se ejecuta sobre lo que el filter descartó, y limit(2) cortocircuita el recorrido de forma que el cuarto elemento nunca se procesa. Un stream con veinte operaciones sigue siendo un solo recorrido y cero listas intermedias.

Conoces las fuentesstream(), Arrays.stream, Stream.of, iterate/generate con limit, IntStream.range, y Files.lines() y Files.walk() de 07-06, ahora con toda la API encima y siempre en try-with-resources—. Las operaciones intermedias: filter, map, mapToInt/mapToObj, distinct (que depende de equals/hashCode), sorted (con estado: nunca sobre un stream infinito y siempre lo más tarde posible), peek (solo para depurar, porque no se garantiza que se ejecute), limit, skip, y takeWhile/dropWhile que paran en lugar de seguir filtrando. Y flatMap, el caso que cuesta: convierte cada elemento en un stream y los aplana todos — con el .stream() interior que todo el mundo olvida.

Las operaciones terminales: forEach/forEachOrdered, toList() de Java 16 que devuelve inmodificable, collect, count (que desde Java 9 puede saltarse las intermedias), min/max que devuelven Optional, anyMatch/allMatch/noneMatch que cortocircuitan —con allMatch sobre vacío devolviendo true por vacuidad—, y findFirst/findAny con su diferencia real en paralelo. Y reduce en sus tres formas: sin identidad devuelve Optional porque no hay un cero universal; con identidad devuelve T y la identidad debe serlo de verdad, o en paralelo se aplica en cada partición y el resultado es absurdo; con combinador cambia de tipo y explica por qué un Collector necesita combinar.

Collectors a fondo: toList/toSet/toMap —con su IllegalStateException: Duplicate key y la función de mezcla que la resuelve—, joining con prefijo y sufijo, counting, summingInt, averagingDouble, summarizingInt que da cinco estadísticas en un recorrido, mapping y collectingAndThen como downstream, teeing para dos colectores a la vez, groupingBy simple, con downstream y de dos niveles, y partitioningBy que siempre trae las dos claves. Con eso, el informe de BiblioTech que en el módulo 5 eran 74 líneas de bucles anidados, mapas manuales y comprobaciones de null, son 12 líneas que se leen como su propio enunciado — y hacen más: Optional en lugar de null, EnumMap para claves de enum, TreeMap para claves ordenadas.

Sabes usar streams de primitivos (IntStream, LongStream, DoubleStream) para evitar el autoboxing, con boxed para volver, mapToObj para salir, y average() devolviendo OptionalDouble porque un stream vacío no tiene media.

Y sobre los streams paralelos tienes el criterio, no solo la sintaxis. Sabes que corren sobre el ForkJoinPool.commonPool compartido por toda la aplicación, y que por eso jamás se usan con operaciones que bloquean. Sabes que compensan con decenas de miles de elementos, coste por elemento apreciable, fuente fácil de partir (ArrayList, arrays, rangos — nunca LinkedList, que fue 3× más lenta en paralelo) y operaciones sin estado. Y sabes la regla que no admite excepciones: nada de estado compartido en las lambdas, porque acumular en un ArrayList desde un parallelStream pierde elementos de forma no determinista, funciona en las pruebas y falla en producción.

Y tienes Optional. Sabes para qué se creó: porque Prestamo buscarPorReferencia(String) miente, y el compilador no puede ayudarte a recordar el null — el "error de mil millones de dólares" de Tony Hoare. Con Optional<Prestamo>, la firma dice la verdad y el compilador te obliga a enfrentarte a la ausencia. Lo creas con of (que rechaza null), ofNullable (que lo acepta) o empty. Lo consumes sin desenvolverlo: map, flatMap, filter, ifPresent, ifPresentOrElse, or encadenado para buscar en varios sitios, stream() para descartar los vacíos de golpe. Y conoces la diferencia entre orElse y orElseGet, demostrada con trazas: orElse evalúa su argumento siempre, y una consulta a la red innecesaria de 300 ms es un bug invisible que solo se manifiesta como lentitud inexplicable.

Reconoces los cinco antipatrones: isPresent() + get() (un if con más ruido), Optional como parámetro (usa sobrecarga; y un Optional también puede ser null), Optional como campo (no es serializable y multiplica los objetos: null dentro, Optional en el getter), Optional de una colección (la colección vacía ya significa "nada") y Optional.of(null). Y tienes la tabla de qué devolver cuando no hay valor: Optional para búsquedas, colección vacía para colecciones, Resultado<T> cuando el fallo lleva información, excepción cuando es un error de verdad.

BiblioTech ha cambiado por dentro. EstadisticasBiblioTech genera informes de dos niveles en tres líneas. Repositorio<T> expone un flujo() y devuelve Optional en las búsquedas y listas vacías en las consultas. GestorPrestamos.prestar encadena buscarPorId().map().orElseGet() sin un solo if (x == null). ImportadorCatalogo procesa un CSV de dos gigabytes sin cargarlo en memoria. AnalizadorLog encuentra la primera línea corrupta leyendo doce líneas de dos gigabytes. Y el null que significaba "no encontrado" ha desaparecido del código.

Queda una deuda, y es de las más viejas del curso. Prestamo sigue teniendo int diaPrestamo e int diaVencimiento. El histograma del ejercicio 1 calcula el día de la semana con diaPrestamo % 7, que es un apaño que solo funciona por casualidad. CalculadoraMultas resta enteros y llama a eso "días de retraso". Nadie puede responder a "¿este préstamo vence en un mes?" sin decidir arbitrariamente si un mes son 30 o 31 días. El CSV exporta números que no significan nada fuera de BiblioTech. Y las marcas de tiempo de los ficheros, que en 07-06 mostraste como FileTime sin poder hacer nada con ellas, siguen esperando.

En 10-05, Fechas y Horas con java.time, se acaba. Verás por qué Date y Calendar fueron un desastre —mutables, con los meses empezando en cero, inseguros entre hilos, con el SimpleDateFormat compartido que es el bug clásico de las aplicaciones Java— y por qué java.time es lo contrario: inmutable, seguro entre hilos, fluido y explícito sobre si hay zona horaria o no. Aprenderás cuándo usar LocalDate, LocalDateTime, ZonedDateTime e Instant —y la regla de oro de qué guardar en la base de datos y qué mostrar al usuario—, la diferencia entre Duration y Period, los ajustadores temporales para calcular vencimientos, las zonas horarias con los dos casos peligrosos del horario de verano, el formateo con DateTimeFormatter y Locale, y Clock como fuente de tiempo inyectable, sin la cual es imposible probar código que depende de la fecha.

Y al terminar, Prestamo tendrá LocalDate fechaPrestamo y LocalDate fechaVencimiento, las multas se calcularán con ChronoUnit.DAYS.between, los avisos usarán TemporalAdjusters para caer en día hábil, y el CSV llevará fechas ISO-8601 que cualquier sistema del mundo entiende.

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