BiblioTech ya hace cosas en paralelo. Envía doscientos avisos con un pool acotado, protege su catálogo con estructuras concurrentes y cuenta sus estadísticas sin candados. Pero hay un gesto que se repite en todo el código escrito hasta ahora y que delata el límite del modelo: future.get().

Cada vez que la aplicación necesita el resultado de algo, alguien se queda parado esperándolo. Y si el trabajo tiene varios pasos —consultar el catálogo, calcular las multas con ese resultado, exportar el informe— hay que hacer get() entre cada uno, de modo que el hilo que orquesta pasa la mayor parte del tiempo bloqueado. Future sabe decir "aquí llegará un resultado"; no sabe decir "cuando esté listo, haz esto otro".

CompletableFuture (Java 8) es esa respuesta. Cambia el modelo por completo: en lugar de preguntar y esperar, declaras la cadena entera de antemano y cada etapa se dispara sola cuando la anterior termina. Ningún hilo espera a nadie.

Esta es la lección de cierre del módulo. Al terminar, BiblioTech tendrá una cadena asíncrona que consulta, calcula y exporta sin bloquear el menú ni un milisegundo, y el módulo 8 quedará completo.

Advertencia honesta. CompletableFuture tiene una API amplia —más de cincuenta métodos— y es fácil escribir cadenas ilegibles o, peor, cadenas que parecen asíncronas y bloquean por dentro. Esta lección se centra en el subconjunto que resuelve el 95 % de los casos y en las trampas que hacen que el 5 % restante sea peor que el código bloqueante que sustituye.

Contenido

  1. Las cuatro limitaciones de Future
  2. Qué es un CompletableFuture
  3. Creación: supplyAsync, runAsync, completedFuture
  4. El ejecutor por defecto y por qué conviene pasar el tuyo
  5. Transformación: thenApply, thenAccept, thenRun
  6. thenApply frente a thenCompose
  7. Las variantes ...Async y en qué hilo se ejecuta cada etapa
  8. Combinación: thenCombine
  9. allOf y anyOf
  10. Errores: exceptionally, handle, whenComplete
  11. Cómo viaja una excepción por la cadena
  12. Tiempos límite: orTimeout y completeOnTimeout
  13. Completar manualmente: adaptar una API de callbacks
  14. Cancelación y sus límites
  15. Buenas prácticas y trampas
  16. BiblioTech: la cadena asíncrona completa
  17. Comparación con el modelo reactivo y los hilos virtuales
  18. Errores Comunes y Consejos
  19. Ejercicios

  1. Las cuatro limitaciones de Future

Future fue un gran avance en Java 5, pero su API tiene cinco métodos y ninguno permite componer.

// El problema, en código real de BiblioTech.
ExecutorService ejecutor = Executors.newFixedThreadPool(4);

Future<Catalogo> f1 = ejecutor.submit(() -> cargarCatalogo());
Catalogo c = f1.get();                    // BLOQUEA. El hilo se para aqui.

Future<Double> f2 = ejecutor.submit(() -> calcularMultas(c));
double total = f2.get();                  // BLOQUEA otra vez.

Future<Path> f3 = ejecutor.submit(() -> exportar(total));
Path informe = f3.get();                  // Y otra vez.

Tres tareas asíncronas, y el hilo que orquesta ha estado bloqueado prácticamente todo el tiempo. La concurrencia está en las tareas, no en la coordinación.

Limitación de Future Qué implica
No se puede encadenar No hay forma de decir "cuando termine, haz esto con el resultado"
No se puede combinar Para juntar dos resultados independientes hay que hacer get() de ambos
get() bloquea El hilo que orquesta se para; con varios pasos, se para varias veces
No hay callbacks No se puede registrar código que se ejecute al completarse
No se puede completar a mano No sirve para adaptar APIs basadas en callbacks

Lo que se querría escribir es esto:

// Lo mismo con CompletableFuture: se DECLARA la cadena y se retorna
// inmediatamente. Ningun hilo se bloquea en ningun momento.
CompletableFuture<Path> informe =
        CompletableFuture.supplyAsync(() -> cargarCatalogo(), poolEs)
                         .thenApplyAsync(c -> calcularMultas(c), poolCalculo)
                         .thenApplyAsync(total -> exportar(total), poolEs)
                         .exceptionally(error -> rutaDeError(error));

System.out.println("[main] cadena lanzada; el menu sigue vivo");
mostrarMenu();      // el hilo principal NO ha esperado a nada

Cinco líneas que describen todo el flujo, incluida la gestión de errores, sin un solo bloqueo.

  1. Qué es un CompletableFuture

CompletableFuture<T> implementa Future<T> —así que sigue teniendo get(), cancel() e isDone()— y añade dos capacidades:

  1. Es completable: se puede completar manualmente desde fuera con complete(valor).
  2. Es componible: se pueden encadenar etapas que se ejecutan al completarse, sin bloquear.

El <T> es, como siempre, el tipo del resultado: un CompletableFuture<Catalogo> promete un Catalogo; un CompletableFuture<String> promete un String. Los genéricos se estudian en 10-01.

El modelo mental correcto es el de una tubería: declaras las etapas por adelantado, y cada una se dispara cuando la anterior le entrega un valor.

flowchart LR
    A["supplyAsync<br/>cargar catalogo"] -->|"Catalogo"| B["thenApply<br/>calcular multas"]
    B -->|"Double"| C["thenApply<br/>formatear informe"]
    C -->|"String"| D["thenAccept<br/>escribir fichero"]
    D --> E["Completado"]
    A -.->|"excepcion"| F["exceptionally<br/>valor de respaldo"]
    B -.->|"excepcion"| F
    C -.->|"excepcion"| F
    F --> E

Las flechas continuas son el camino de éxito: cada etapa recibe el resultado de la anterior. Las discontinuas son el camino de error: una excepción en cualquier etapa salta directamente al manejador, sin ejecutar las etapas intermedias. Es el mismo modelo que un try/catch que envolviera toda la secuencia, pero repartido en el tiempo y sin bloquear.

CompletableFuture implementa además CompletionStage<T>, la interfaz que define todos los métodos de composición. En la práctica trabajarás con la clase; la interfaz aparece en firmas de métodos de bibliotecas.

  1. Creación: supplyAsync, runAsync, completedFuture

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;

// 1. supplyAsync: ejecuta un Supplier y DEVUELVE un valor.
//    Es el punto de entrada mas frecuente.
CompletableFuture<Catalogo> f1 =
        CompletableFuture.supplyAsync(() -> cargarCatalogo());

// Con ejecutor propio (recomendado, apartado 4):
CompletableFuture<Catalogo> f2 =
        CompletableFuture.supplyAsync(() -> cargarCatalogo(), poolEs);

// 2. runAsync: ejecuta un Runnable, NO devuelve valor.
//    El tipo es CompletableFuture<Void>.
CompletableFuture<Void> f3 =
        CompletableFuture.runAsync(() -> registrarAuditoria("arranque"), poolEs);

// 3. completedFuture: ya completado con un valor. No ejecuta nada.
//    Util para valores en cache y para pruebas.
CompletableFuture<Catalogo> f4 =
        CompletableFuture.completedFuture(catalogoEnCache);

// 4. failedFuture (Java 9): ya completado con una excepcion.
CompletableFuture<Catalogo> f5 =
        CompletableFuture.failedFuture(new BiblioTechException("catalogo no disponible"));

// 5. Vacio, para completarlo manualmente despues (apartado 13).
CompletableFuture<Catalogo> f6 = new CompletableFuture<>();

completedFuture es más útil de lo que parece: permite que un método que a veces tiene la respuesta inmediata y a veces no devuelva siempre el mismo tipo.

/**
 * Devuelve SIEMPRE un CompletableFuture, tanto si el dato esta en
 * cache (respuesta inmediata) como si hay que calcularlo (asincrono).
 * El llamante no tiene que distinguir los dos casos.
 */
public CompletableFuture<Ficha> obtenerFicha(String isbn) {
    Ficha enCache = cache.get(isbn);
    if (enCache != null) {
        return CompletableFuture.completedFuture(enCache);   // ya lista
    }
    return CompletableFuture.supplyAsync(() -> construirFicha(isbn), poolEs);
}

  1. El ejecutor por defecto y por qué conviene pasar el tuyo

Si no pasas un Executor, supplyAsync y las variantes ...Async usan ForkJoinPool.commonPool(): el pool común de la JVM que viste en 08-05, con núcleos - 1 hilos.

Eso está bien para tareas cortas y de cálculo, y es un problema serio para todo lo demás:

// PELIGROSO: E/S en el pool comun.
// El pool comun tiene (nucleos - 1) hilos y lo COMPARTE toda la JVM:
// los streams paralelos (10-04), otras librerias y el resto de tu
// codigo. Con 7 hilos y 10 lecturas de fichero lentas, el pool queda
// inservible para todos.
CompletableFuture.supplyAsync(() -> Files.readAllLines(rutaEnorme));

// CORRECTO: ejecutor propio para E/S, dimensionado segun 08-05.
private static final ExecutorService POOL_ES = new ThreadPoolExecutor(
        16, 16, 0L, TimeUnit.MILLISECONDS,
        new ArrayBlockingQueue<>(200),
        r -> new Thread(r, "bibliotech-async-es-" + contador.getAndIncrement()),
        new ThreadPoolExecutor.CallerRunsPolicy());

CompletableFuture.supplyAsync(() -> Files.readAllLines(rutaEnorme), POOL_ES);

Un detalle que sorprende y que hay que conocer: si la máquina tiene un solo núcleo, commonPool() tiene paralelismo 0 y ejecuta las tareas en el hilo que las envía. Tu código "asíncrono" se vuelve síncrono sin previo aviso, y solo lo descubres en un contenedor con un núcleo asignado.

Ejecutor Hilos Cuándo usarlo
ForkJoinPool.commonPool() (por defecto) núcleos - 1 Cálculo corto y no bloqueante
Pool propio de cálculo núcleos Cálculo intensivo aislado
Pool propio de E/S Muchos más Ficheros, esperas: siempre uno propio
newVirtualThreadPerTaskExecutor() (Java 21) Uno virtual por tarea E/S masiva (10-06)

La regla, sin matices: para tareas que bloquean, pasa siempre tu propio Executor. Y con nombres de hilo decentes, que es el consejo de 08-02 y 08-05 aplicado aquí.

  1. Transformación: thenApply, thenAccept, thenRun

Tres formas de encadenar según qué recibe y qué devuelve la etapa:

Método Recibe Devuelve Resultado
thenApply(Function) El valor anterior Un valor nuevo CompletableFuture<U>
thenAccept(Consumer) El valor anterior Nada CompletableFuture<Void>
thenRun(Runnable) Nada Nada CompletableFuture<Void>
import java.util.concurrent.CompletableFuture;

CompletableFuture<Void> cadena =
    CompletableFuture
        // 1. Produce un Catalogo
        .supplyAsync(() -> cargarCatalogo(), POOL_ES)

        // 2. thenApply: Catalogo -> Double. Transforma.
        .thenApply(catalogo -> calcularMultasTotales(catalogo))

        // 3. thenApply: Double -> String. Otra transformacion.
        .thenApply(total -> String.format("Multas pendientes: %.2f EUR", total))

        // 4. thenAccept: consume el String, no produce nada.
        .thenAccept(texto -> System.out.println(texto))

        // 5. thenRun: no recibe ni devuelve. Para efectos finales.
        .thenRun(() -> LOG.info("Informe de multas completado"));

System.out.println("[main] cadena declarada, sigo trabajando");

Estos son exactamente los tipos funcionales de 04-06: Function<T,R>, Consumer<T> y Runnable. Toda la API de CompletableFuture está construida sobre ellos, así que si dominas aquella lección, esta es su aplicación natural.

Nota importante sobre el orden: declarar la cadena no la ejecuta. supplyAsync lanza la primera etapa inmediatamente; las demás quedan registradas y se disparan cuando les llega el turno. El main continúa sin esperar.

  1. thenApply frente a thenCompose

Esta es la distinción más importante de la lección y la fuente de confusión número uno.

thenApply se usa cuando la función devuelve un valor normal. thenCompose se usa cuando la función devuelve otro CompletableFuture.

// Metodo que devuelve un valor normal:
Double calcularMultas(Catalogo c) { ... }

// Metodo que devuelve un CompletableFuture (porque es asincrono):
CompletableFuture<Double> calcularMultasAsync(Catalogo c) { ... }

Si usas thenApply con el segundo, el tipo se anida:

// EL ERROR: thenApply con una funcion que devuelve CompletableFuture.
CompletableFuture<CompletableFuture<Double>> anidado =
        CompletableFuture.supplyAsync(() -> cargarCatalogo())
                         .thenApply(c -> calcularMultasAsync(c));

// Ahora, para llegar al Double, hay que desenvolver DOS veces:
Double d = anidado.get().get();      // horrible, y bloquea dos veces

// LA SOLUCION: thenCompose APLANA el anidamiento.
CompletableFuture<Double> plano =
        CompletableFuture.supplyAsync(() -> cargarCatalogo())
                         .thenCompose(c -> calcularMultasAsync(c));

Double d2 = plano.join();            // un solo nivel

Regla mnemotécnica:

Si la función devuelve… Usa Analogía con colecciones
Un valor U thenApply map
Un CompletableFuture<U> thenCompose flatMap

Ejemplo completo con las dos, en BiblioTech:

package com.nexussoftware.bibliotech.servicio;

import java.util.concurrent.CompletableFuture;

public class ConsultaAsincrona {

    /** Sincrono: devuelve el valor directamente. */
    private Material buscarMaterial(String isbn) { ... }

    /** Asincrono: devuelve un CompletableFuture. */
    private CompletableFuture<Ficha> construirFichaAsync(Material m) { ... }

    /** Sincrono: transformacion barata. */
    private String formatear(Ficha f) { ... }

    /**
     * Cadena que alterna operaciones sincronas y asincronas.
     * Fijate en que thenCompose aparece exactamente donde la
     * funcion devuelve un CompletableFuture.
     */
    public CompletableFuture<String> fichaFormateada(String isbn) {
        return CompletableFuture
                .supplyAsync(() -> buscarMaterial(isbn), POOL_ES)  // -> Material
                .thenCompose(this::construirFichaAsync)            // -> Ficha (async)
                .thenApply(this::formatear);                       // -> String (sync)
    }
}

Cómo detectar el error en tu propio código: si ves un tipo CompletableFuture<CompletableFuture<...>> en un mensaje del compilador o en el IDE, has usado thenApply donde tocaba thenCompose. Es un error de compilación cuando declaras el tipo, y pasa desapercibido si usas var.

  1. Las variantes ...Async y en qué hilo se ejecuta cada etapa

Casi todos los métodos tienen tres formas:

.thenApply(f)                  // 1. sin sufijo
.thenApplyAsync(f)             // 2. con sufijo, ejecutor por defecto
.thenApplyAsync(f, ejecutor)   // 3. con sufijo y ejecutor explicito

La diferencia está en qué hilo ejecuta la etapa:

Forma Hilo que ejecuta la etapa
thenApply(f) El hilo que completó la etapa anterior, o el hilo que llama si ya estaba completa
thenApplyAsync(f) Un hilo del ForkJoinPool.commonPool()
thenApplyAsync(f, ej) Un hilo de ej

La primera fila esconde una sutileza importante: con la forma sin sufijo, no sabes con certeza en qué hilo se ejecutará la etapa. Si la etapa anterior ya había terminado cuando registras la siguiente, la ejecuta el hilo que hace el registro —que puede ser main—.

public class QueHiloEjecuta {

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

        ExecutorService pool = Executors.newFixedThreadPool(2,
                r -> new Thread(r, "bibliotech-pool"));

        System.out.println("main ejecuta en: " + Thread.currentThread().getName());

        CompletableFuture<String> f = CompletableFuture
                .supplyAsync(() -> {
                    traza("supplyAsync");
                    return "catalogo";
                }, pool)
                .thenApply(v -> {
                    traza("thenApply");          // el hilo del pool
                    return v + "-procesado";
                })
                .thenApplyAsync(v -> {
                    traza("thenApplyAsync");     // commonPool
                    return v + "-async";
                })
                .thenApplyAsync(v -> {
                    traza("thenApplyAsync(pool)"); // el pool que le damos
                    return v + "-propio";
                }, pool);

        System.out.println("resultado: " + f.join());
        pool.shutdown();
    }

    static void traza(String etapa) {
        System.out.printf("  %-24s -> %s%n", etapa, Thread.currentThread().getName());
    }
}

Salida:

main ejecuta en: main
  supplyAsync              -> bibliotech-pool
  thenApply                -> bibliotech-pool
  thenApplyAsync           -> ForkJoinPool.commonPool-worker-1
  thenApplyAsync(pool)     -> bibliotech-pool
resultado: catalogo-procesado-async-propio

Cómo decidir:

  • Transformaciones baratas (formatear, mapear, sumar): thenApply sin sufijo. Evita un cambio de hilo innecesario.
  • Trabajo caro o bloqueante: thenApplyAsync con tu ejecutor. Si no, ocupas el hilo que completó la etapa anterior —que puede ser un hilo del pool común, o incluso main—.
// MAL: bloquear en una etapa sin sufijo ocupa el hilo anterior,
// que podria ser un hilo del pool comun o el propio main.
.thenApply(catalogo -> escribirEnDisco(catalogo))     // 500 ms bloqueando

// BIEN: el trabajo pesado va a un pool dedicado.
.thenApplyAsync(catalogo -> escribirEnDisco(catalogo), POOL_ES)

  1. Combinación: thenCombine

thenCombine junta los resultados de dos futuros independientes que se ejecutan en paralelo.

package com.nexussoftware.bibliotech.servicio;

import java.util.concurrent.CompletableFuture;

public class ResumenAsincrono {

    /**
     * Las dos consultas son independientes y se lanzan a la vez.
     * El tiempo total es el del MAS LENTO, no la suma.
     */
    public CompletableFuture<String> resumenCompleto(String isbn) {

        CompletableFuture<Material> material =
                CompletableFuture.supplyAsync(() -> catalogo.buscar(isbn), POOL_ES);

        CompletableFuture<Integer> prestamos =
                CompletableFuture.supplyAsync(() -> registro.contarPrestamos(isbn), POOL_ES);

        // thenCombine espera a AMBOS y aplica la BiFunction (04-06).
        return material.thenCombine(prestamos, (m, n) ->
                String.format("%s (%s) - %d prestamos historicos",
                        m.titulo(), m.isbn(), n));
    }

    /** Tres o mas: se encadenan los thenCombine. */
    public CompletableFuture<InformeCompleto> informeCompleto(String isbn) {

        CompletableFuture<Material> material =
                CompletableFuture.supplyAsync(() -> catalogo.buscar(isbn), POOL_ES);
        CompletableFuture<Integer> prestamos =
                CompletableFuture.supplyAsync(() -> registro.contarPrestamos(isbn), POOL_ES);
        CompletableFuture<Double> multas =
                CompletableFuture.supplyAsync(() -> calculadora.multasDe(isbn), POOL_ES);

        return material
                .thenCombine(prestamos, ParcialMaterialPrestamos::new)
                .thenCombine(multas, (parcial, m) ->
                        new InformeCompleto(parcial.material(), parcial.prestamos(), m));
    }

    private record ParcialMaterialPrestamos(Material material, int prestamos) { }
}

El punto clave: las tres consultas se lanzan a la vez. Si cada una tarda 300 ms, el total es ~300 ms, no 900. Es la diferencia entre concurrencia real y una secuencia disfrazada.

Los primos de thenCombine, menos usados:

Método Qué hace
thenCombine(otro, BiFunction) Espera a ambos y combina los resultados
thenAcceptBoth(otro, BiConsumer) Espera a ambos, consume, no devuelve nada
runAfterBoth(otro, Runnable) Espera a ambos, ignora los valores
applyToEither(otro, Function) El primero que termine; aplica la función
acceptEither(otro, Consumer) El primero que termine; lo consume
runAfterEither(otro, Runnable) El primero que termine

  1. allOf y anyOf

Para N futuros en lugar de dos.

allOf(cf1, cf2, ...) devuelve un CompletableFuture<Void> que se completa cuando todos terminan. Devuelve Void, así que hay que recoger los resultados aparte.

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;

public class AvisosAsincronos {

    /**
     * Envia todos los avisos en paralelo y espera a que terminen TODOS.
     *
     * El patron de recogida es siempre el mismo:
     *   1. Crear la lista de futuros.
     *   2. allOf(...) sobre el array.
     *   3. thenApply que recorre los futuros y llama a join()
     *      —seguro, porque allOf garantiza que ya estan completos—.
     */
    public CompletableFuture<List<ResultadoAviso>> enviarTodos(List<Prestamo> vencidos) {

        List<CompletableFuture<ResultadoAviso>> futuros = new ArrayList<>();
        for (Prestamo p : vencidos) {
            futuros.add(CompletableFuture.supplyAsync(() -> enviarAviso(p), POOL_ES));
        }

        // allOf recibe un array, no una lista.
        CompletableFuture<Void> todos =
                CompletableFuture.allOf(futuros.toArray(new CompletableFuture[0]));

        return todos.thenApply(v -> {
            List<ResultadoAviso> resultados = new ArrayList<>(futuros.size());
            for (CompletableFuture<ResultadoAviso> f : futuros) {
                // join() aqui NO bloquea: allOf ya garantizo que
                // todos estan completos.
                resultados.add(f.join());
            }
            return resultados;
        });
    }

    /**
     * Variante TOLERANTE A FALLOS: un aviso fallido no tumba el conjunto.
     *
     * OJO: si un futuro falla, el allOf tambien falla y su join()
     * relanzaria la excepcion. La solucion es blindar CADA futuro con
     * su propio exceptionally ANTES de agregarlos.
     */
    public CompletableFuture<List<ResultadoAviso>> enviarTodosTolerante(
            List<Prestamo> vencidos) {

        List<CompletableFuture<ResultadoAviso>> futuros = new ArrayList<>();
        for (Prestamo p : vencidos) {
            futuros.add(CompletableFuture
                    .supplyAsync(() -> enviarAviso(p), POOL_ES)
                    .exceptionally(e -> ResultadoAviso.fallo(p, e.getMessage())));
        }

        return CompletableFuture
                .allOf(futuros.toArray(new CompletableFuture[0]))
                .thenApply(v -> {
                    List<ResultadoAviso> r = new ArrayList<>();
                    for (CompletableFuture<ResultadoAviso> f : futuros) r.add(f.join());
                    return r;
                });
    }
}

anyOf(cf1, cf2, ...) se completa con el resultado del primero que termine, para bien o para mal. Devuelve CompletableFuture<Object> —una limitación de la API, porque los futuros pueden ser de tipos distintos—.

// Tres fuentes para el mismo catalogo; nos vale la primera.
CompletableFuture<Catalogo> cache    = leerDeCache();
CompletableFuture<Catalogo> fichero  = leerDeFicheroPrincipal();
CompletableFuture<Catalogo> respaldo = leerDeCopiaSeguridad();

CompletableFuture<Object> primero =
        CompletableFuture.anyOf(cache, fichero, respaldo);

Catalogo c = (Catalogo) primero.join();     // hay que castear

Cuidado con anyOf: se completa con el primero que termine incluso si termina con una excepción. Si quieres el primero que termine con éxito, hay que blindar cada uno con exceptionally o usar el invokeAny de 08-05.

allOf anyOf
Se completa cuando Terminan todos Termina el primero
Tipo del resultado CompletableFuture<Void> CompletableFuture<Object>
Si uno falla El conjunto falla Puede completarse con ese fallo
Cancela los demás No No (siguen ejecutándose)
Uso Trabajo en lote Fuentes redundantes

  1. Errores: exceptionally, handle, whenComplete

Tres métodos con papeles distintos:

Método Se ejecuta Recibe Puede cambiar el resultado
exceptionally(Function) Solo si hay error La excepción : da un valor de respaldo
handle(BiFunction) Siempre Valor y excepción (uno será null)
whenComplete(BiConsumer) Siempre Valor y excepción No: solo observa
package com.nexussoftware.bibliotech.servicio;

import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
import java.util.logging.Logger;

public class ManejoDeErroresAsincrono {

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

    /** 1. exceptionally: valor de respaldo si algo falla. */
    public CompletableFuture<Catalogo> cargarConRespaldo() {
        return CompletableFuture
                .supplyAsync(() -> cargarDesdeFichero(), POOL_ES)
                .exceptionally(error -> {
                    // 'error' es una CompletionException que ENVUELVE la causa.
                    LOG.log(Level.WARNING, "Fallo al cargar; uso el respaldo",
                            error.getCause());
                    return Catalogo.vacio();      // la cadena CONTINUA
                });
    }

    /** 2. handle: se ejecuta siempre, y unifica los dos caminos. */
    public CompletableFuture<String> informeConHandle() {
        return CompletableFuture
                .supplyAsync(() -> generarInforme(), POOL_ES)
                .handle((resultado, error) -> {
                    // Exactamente uno de los dos es null.
                    if (error != null) {
                        LOG.log(Level.SEVERE, "Informe fallido", error);
                        return "INFORME NO DISPONIBLE: " + causaDe(error).getMessage();
                    }
                    return "INFORME OK: " + resultado;
                });
    }

    /** 3. whenComplete: observa sin alterar. Ideal para trazas y metricas. */
    public CompletableFuture<Catalogo> cargarConTraza() {
        long inicio = System.nanoTime();
        return CompletableFuture
                .supplyAsync(() -> cargarDesdeFichero(), POOL_ES)
                .whenComplete((catalogo, error) -> {
                    long ms = (System.nanoTime() - inicio) / 1_000_000;
                    if (error != null) {
                        LOG.log(Level.WARNING, "Carga fallida en " + ms + " ms", error);
                    } else {
                        LOG.log(Level.INFO, "Catalogo cargado en {0} ms ({1} materiales)",
                                new Object[] { ms, catalogo.tamano() });
                    }
                    // Devolver algo aqui NO cambiaria el resultado:
                    // whenComplete solo OBSERVA. El error sigue propagandose.
                });
    }

    /** Utilidad: desenvolver la causa real. */
    static Throwable causaDe(Throwable t) {
        return (t instanceof java.util.concurrent.CompletionException
                || t instanceof java.util.concurrent.ExecutionException)
                && t.getCause() != null ? t.getCause() : t;
    }
}

El detalle de whenComplete que confunde: no altera nada. Si la etapa falló, el CompletableFuture resultante sigue fallando aunque tu BiConsumer haya registrado el error tranquilamente. Es un observador, no un manejador. Para manejar, handle o exceptionally.

Combinación habitual y recomendable:

CompletableFuture<Path> informe = CompletableFuture
        .supplyAsync(() -> cargarCatalogo(), POOL_ES)
        .thenApplyAsync(this::calcularMultas, POOL_CALCULO)
        .thenApplyAsync(this::exportar, POOL_ES)
        .whenComplete((ruta, error) -> registrarMetrica(ruta, error))  // observar
        .exceptionally(error -> {                                       // manejar
            LOG.log(Level.SEVERE, "Cadena de informe fallida", error);
            return Path.of("informes/error.txt");
        });

  1. Cómo viaja una excepción por la cadena

Cuando una etapa lanza una excepción, todas las etapas siguientes se saltan hasta encontrar un manejador. Es igual que un throw que atraviesa varios métodos.

public class PropagacionDeErrores {

    public static void main(String[] args) {

        CompletableFuture<String> cadena = CompletableFuture
                .supplyAsync(() -> {
                    System.out.println("  etapa 1: OK");
                    return "catalogo";
                })
                .thenApply(v -> {
                    System.out.println("  etapa 2: lanzando excepcion");
                    throw new IllegalStateException("catalogo corrupto");
                })
                .thenApply(v -> {
                    System.out.println("  etapa 3: NO SE EJECUTA");
                    return v + "-procesado";
                })
                .thenApply(v -> {
                    System.out.println("  etapa 4: TAMPOCO");
                    return v.toUpperCase();
                })
                .exceptionally(error -> {
                    System.out.println("  manejador: capturado " + error.getClass().getSimpleName());
                    System.out.println("             causa real: "
                            + error.getCause().getClass().getSimpleName()
                            + ": " + error.getCause().getMessage());
                    return "VALOR-DE-RESPALDO";
                })
                .thenApply(v -> {
                    System.out.println("  etapa 5: SI se ejecuta, con el respaldo");
                    return v.toLowerCase();
                });

        System.out.println("resultado: " + cadena.join());
    }
}

Salida:

  etapa 1: OK
  etapa 2: lanzando excepcion
  manejador: capturado CompletionException
             causa real: IllegalStateException: catalogo corrupto
  etapa 5: SI se ejecuta, con el respaldo
resultado: valor-de-respaldo

Tres cosas que enseña esta salida:

1. Las etapas 3 y 4 se saltan por completo. La excepción cortocircuita la cadena hasta el primer manejador.

2. La excepción llega envuelta en CompletionException. No es tu IllegalStateException directamente: está en getCause(). Es lo mismo que hacía ExecutionException con Future en 08-05, y por la misma razón: la excepción original es comprobada o no, y hay que transportarla por una API que no la declara.

3. Tras exceptionally, la cadena sigue con normalidad. La etapa 5 se ejecuta con el valor de respaldo. Ese es exactamente el punto de exceptionally: recuperar y continuar.

El error clásico: encadenar después de exceptionally sin darse cuenta.

// TRAMPA: parece que el exceptionally protege toda la cadena.
CompletableFuture<Path> f = CompletableFuture
        .supplyAsync(() -> cargarCatalogo())
        .exceptionally(e -> Catalogo.vacio())      // protege lo de ARRIBA
        .thenApply(c -> calcularMultas(c))          // <-- SIN proteger
        .thenApply(m -> exportar(m));               // <-- SIN proteger

// Si calcularMultas o exportar fallan, la excepcion sale por join()
// y nadie la maneja. El exceptionally quedo demasiado arriba.

// CORRECTO: el manejador al FINAL de la cadena.
CompletableFuture<Path> g = CompletableFuture
        .supplyAsync(() -> cargarCatalogo())
        .thenApply(c -> calcularMultas(c))
        .thenApply(m -> exportar(m))
        .exceptionally(e -> {                       // protege TODO lo anterior
            LOG.log(Level.SEVERE, "Cadena fallida", e);
            return Path.of("informes/error.txt");
        });

Un manejador solo cubre lo que hay por encima de él. Si necesitas recuperación intermedia y protección final, pon dos.

El error más grave de todos: no manejar nada.

// Si esta cadena falla, NO PASA NADA VISIBLE. No hay traza,
// no hay log, no hay excepcion. El fallo simplemente desaparece.
CompletableFuture.supplyAsync(() -> cargarCatalogo())
                 .thenAccept(c -> catalogoGlobal = c);

Es el mismo problema que submit sin get() en 08-05, y con las mismas consecuencias. Toda cadena debe terminar en exceptionally, handle o whenComplete.

  1. Tiempos límite: orTimeout y completeOnTimeout

Java 9 añadió dos métodos que evitan tener que montar un temporizador a mano.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

// orTimeout: si no se completa en el plazo, FALLA con TimeoutException.
CompletableFuture<Catalogo> conPlazo = CompletableFuture
        .supplyAsync(() -> cargarCatalogo(), POOL_ES)
        .orTimeout(5, TimeUnit.SECONDS)
        .exceptionally(error -> {
            if (causaDe(error) instanceof java.util.concurrent.TimeoutException) {
                LOG.warning("La carga excedio los 5 segundos");
            }
            return Catalogo.vacio();
        });

// completeOnTimeout: si no se completa en el plazo, se completa
// con el VALOR POR DEFECTO. No falla.
CompletableFuture<Catalogo> conDefecto = CompletableFuture
        .supplyAsync(() -> cargarCatalogo(), POOL_ES)
        .completeOnTimeout(Catalogo.vacio(), 5, TimeUnit.SECONDS);
orTimeout(t, u) completeOnTimeout(v, t, u)
Al agotarse el plazo Falla con TimeoutException Se completa con v
¿Hay que manejarlo? No
¿Cancela la tarea subyacente? No No
Usar cuando Quieres enterarte del retraso Tienes un valor degradado aceptable

El aviso imprescindible: ninguno de los dos cancela la tarea que sigue corriendo. El CompletableFuture se completa —con error o con el valor por defecto—, pero el hilo que estaba cargando el catálogo sigue trabajando y consumiendo recursos hasta que termine solo. Es lo mismo que la TimeoutException de Future.get() en 08-05.

  1. Completar manualmente: adaptar una API de callbacks

Aquí se cobra la "C" de CompletableFuture. Un CompletableFuture vacío se puede completar desde cualquier hilo, lo que permite envolver una API antigua basada en callbacks:

package com.nexussoftware.bibliotech.persistencia;

import java.util.concurrent.CompletableFuture;

public class AdaptadorCallbacks {

    /**
     * API antigua basada en callbacks. Es incomoda de componer:
     * anidar tres de estas produce la "piramide de la muerte".
     */
    interface LectorConCallback {
        void leerAsync(String isbn, Callback<Material> callback);
    }

    interface Callback<T> {
        void alCompletar(T resultado);
        void alFallar(Throwable error);
    }

    private final LectorConCallback lector;

    public AdaptadorCallbacks(LectorConCallback lector) { this.lector = lector; }

    /**
     * Convierte la API de callbacks en un CompletableFuture componible.
     * Es el patron estandar para modernizar codigo heredado sin tocarlo.
     */
    public CompletableFuture<Material> leer(String isbn) {

        // 1. Futuro vacio, sin tarea asociada.
        CompletableFuture<Material> futuro = new CompletableFuture<>();

        // 2. Se lanza la operacion con un callback que lo completa.
        lector.leerAsync(isbn, new Callback<Material>() {
            @Override
            public void alCompletar(Material m) {
                futuro.complete(m);                    // exito
            }
            @Override
            public void alFallar(Throwable error) {
                futuro.completeExceptionally(error);   // fallo
            }
        });

        // 3. Se devuelve inmediatamente, sin esperar al callback.
        return futuro;
    }

    /**
     * Y ahora la API antigua es componible como cualquier otra:
     * tres lecturas en paralelo y combinacion, sin anidamiento.
     */
    public CompletableFuture<String> compararTres(String i1, String i2, String i3) {
        return leer(i1)
                .thenCombine(leer(i2), (a, b) -> a.titulo() + " / " + b.titulo())
                .thenCombine(leer(i3), (par, c) -> par + " / " + c.titulo());
    }
}

Métodos de completado manual:

Método Qué hace
complete(v) Completa con v; devuelve false si ya estaba completo
completeExceptionally(t) Completa con la excepción t
completeAsync(supplier, ej) Completa ejecutando el Supplier en ej (Java 9)
getNow(valorSiNoEstaListo) Devuelve el valor sin bloquear, o el defecto
isCompletedExceptionally() ¿Terminó con error?

  1. Cancelación y sus límites

CompletableFuture.cancel(boolean) existe porque implementa Future, pero funciona distinto de lo que esperas:

CompletableFuture<Catalogo> f =
        CompletableFuture.supplyAsync(() -> cargarCatalogoLento(), POOL_ES);

TimeUnit.SECONDS.sleep(1);
boolean cancelado = f.cancel(true);      // el 'true' se IGNORA

System.out.println("cancel(): " + cancelado);        // true
System.out.println("isCancelled(): " + f.isCancelled());  // true
// PERO: el hilo de POOL_ES sigue ejecutando cargarCatalogoLento()
// hasta el final. El parametro mayInterruptIfRunning NO HACE NADA.

Lo que hace cancel: completa el CompletableFuture con una CancellationException, de modo que las etapas siguientes no se ejecutan y join() lanza.

Lo que NO hace: interrumpir el hilo que ejecuta la tarea. El parámetro mayInterruptIfRunning se ignora, y así lo dice la documentación. La tarea sigue hasta terminar sola.

Si necesitas cancelación real, hay que implementarla con el protocolo de 08-02:

package com.nexussoftware.bibliotech.persistencia;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * Cancelacion REAL de una tarea asincrona: una bandera compartida
 * que la tarea consulta. Es la interrupcion cooperativa de 08-02
 * adaptada a CompletableFuture, que no puede interrumpir por si solo.
 */
public class ImportacionCancelable {

    private final AtomicBoolean cancelada = new AtomicBoolean(false);
    private final CompletableFuture<Informe> futuro;

    public ImportacionCancelable(Path fichero) {
        this.futuro = CompletableFuture.supplyAsync(() -> importar(fichero), POOL_ES);
    }

    private Informe importar(Path fichero) {
        int procesadas = 0;
        for (String linea : leerLineas(fichero)) {

            // PUNTO DE CANCELACION explicito.
            if (cancelada.get() || Thread.currentThread().isInterrupted()) {
                throw new CancellationException(
                        "Importacion cancelada tras " + procesadas + " lineas");
            }
            procesar(linea);
            procesadas++;
        }
        return new Informe(procesadas);
    }

    /** Cancelacion que SI detiene el trabajo. */
    public void cancelar() {
        cancelada.set(true);          // la tarea lo vera y abortara
        futuro.cancel(false);         // y el futuro se completa ya
    }

    public CompletableFuture<Informe> futuro() { return futuro; }
}

  1. Buenas prácticas y trampas

Trampa 1: bloquear dentro de una etapa.

// PESIMO: join() dentro de una etapa bloquea un hilo del pool.
// Con suficientes cadenas asi, el pool se agota y todo se para.
.thenApply(catalogo -> {
    Double multas = calcularMultasAsync(catalogo).join();   // BLOQUEA
    return multas;
})

// CORRECTO: thenCompose aplana sin bloquear a nadie.
.thenCompose(catalogo -> calcularMultasAsync(catalogo))

Trampa 2: usar el pool común para E/S. Ya visto en el apartado 4: núcleos - 1 hilos compartidos por toda la JVM. Pasa siempre tu ejecutor para trabajo bloqueante.

Trampa 3: join() frente a get(). Los dos bloquean; la diferencia está en las excepciones:

// get(): lanza ExecutionException e InterruptedException, ambas COMPROBADAS.
try {
    Catalogo c = futuro.get();
} catch (ExecutionException | InterruptedException e) { ... }

// join(): lanza CompletionException, NO comprobada. Mas comodo
// dentro de lambdas, donde una excepcion comprobada no compila.
Catalogo c = futuro.join();

Dentro de una lambda, join() es casi obligatorio porque get() no compilaría. Fuera, get(timeout) es preferible por el plazo.

Trampa 4: no saber en qué hilo se ejecuta cada etapa. Repasa el apartado 7. Una etapa sin sufijo Async puede acabar ejecutándose en main.

Trampa 5: cadenas ilegibles. Diez etapas encadenadas son tan malas de leer como diez if anidados. Extrae métodos:

// MAL: una sola expresion de veinte lineas.

// BIEN: cada paso con nombre.
public CompletableFuture<Path> generarInformeMensual() {
    return cargarCatalogo()
            .thenCompose(this::enriquecerConPrestamos)
            .thenApplyAsync(this::calcularMultas, POOL_CALCULO)
            .thenApplyAsync(this::formatear, POOL_CALCULO)
            .thenApplyAsync(this::escribirFichero, POOL_ES)
            .orTimeout(60, TimeUnit.SECONDS)
            .whenComplete(this::registrarMetricas)
            .exceptionally(this::informeDeError);
}

Trampa 6: olvidar el manejador final. Un fallo sin manejar desaparece sin dejar rastro.

Las seis reglas, resumidas:

  1. Ejecutor propio para todo lo que bloquee.
  2. Nunca bloquear dentro de una etapa: thenCompose, no join().
  3. thenCompose cuando la función devuelve un futuro; thenApply cuando devuelve un valor.
  4. Manejador final siempre: exceptionally o handle al final de la cadena.
  5. ...Async con tu ejecutor para el trabajo caro; sin sufijo para transformaciones baratas.
  6. Extrae métodos con nombre: una cadena debe leerse como una lista de pasos.

  1. BiblioTech: la cadena asíncrona completa

El cierre del módulo. Una operación de negocio real —generar el informe mensual de multas y exportarlo— sin bloquear el menú en ningún momento.

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.*;

import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Generacion asincrona del informe mensual de BiblioTech.
 *
 * La cadena completa:
 *   1. Cargar el catalogo (E/S)          -- en paralelo con 2
 *   2. Cargar los prestamos activos (E/S) -- en paralelo con 1
 *   3. Combinar ambos
 *   4. Calcular las multas (CPU)
 *   5. Formatear el informe (CPU)
 *   6. Escribir el fichero (E/S)
 *
 * NINGUN hilo se bloquea esperando: el menu sigue atendiendo al usuario
 * durante toda la operacion.
 */
public class GeneradorInformeAsincrono implements AutoCloseable {

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

    /** Pool de E/S: muchos hilos porque casi todo es espera (08-01). */
    private final ExecutorService poolEs;
    /** Pool de calculo: un hilo por nucleo. */
    private final ExecutorService poolCalculo;

    private final CatalogoConcurrente catalogo;
    private final RegistroPrestamosSeguro registro;
    private final CalculadoraMultas calculadora;

    public GeneradorInformeAsincrono(CatalogoConcurrente catalogo,
                                     RegistroPrestamosSeguro registro,
                                     CalculadoraMultas calculadora) {
        this.catalogo = catalogo;
        this.registro = registro;
        this.calculadora = calculadora;

        AtomicInteger nEs = new AtomicInteger(1);
        this.poolEs = new ThreadPoolExecutor(
                16, 16, 0L, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(200),
                r -> new Thread(r, "bibliotech-async-es-" + nEs.getAndIncrement()),
                new ThreadPoolExecutor.CallerRunsPolicy());

        AtomicInteger nCpu = new AtomicInteger(1);
        this.poolCalculo = Executors.newFixedThreadPool(
                Runtime.getRuntime().availableProcessors(),
                r -> new Thread(r, "bibliotech-async-cpu-" + nCpu.getAndIncrement()));
    }

    // ---------- ETAPAS ----------

    private CompletableFuture<List<Material>> cargarMateriales() {
        return CompletableFuture.supplyAsync(() -> {
            traza("cargando materiales");
            dormir(400);                       // E/S simulada
            return catalogo.porTipo(TipoMaterial.LIBRO);
        }, poolEs);
    }

    private CompletableFuture<List<Prestamo>> cargarPrestamosActivos() {
        return CompletableFuture.supplyAsync(() -> {
            traza("cargando prestamos");
            dormir(500);                       // E/S simulada, EN PARALELO
            return registro.todosLosActivos();
        }, poolEs);
    }

    /** Etapa de CPU: se manda al pool de calculo explicitamente. */
    private DatosInforme calcular(List<Material> materiales, List<Prestamo> prestamos) {
        traza("calculando multas");
        double total = 0;
        int vencidos = 0;
        for (Prestamo p : prestamos) {
            double m = calculadora.calcular(p);
            if (m > 0) { total += m; vencidos++; }
        }
        return new DatosInforme(materiales.size(), prestamos.size(), vencidos, total);
    }

    private String formatear(DatosInforme d) {
        traza("formateando");
        return """
               ===========================================
                 BiblioTech - Informe mensual de multas
                 Nexus Software
               ===========================================
                 Materiales en catalogo : %d
                 Prestamos activos      : %d
                 Prestamos vencidos     : %d
                 Multas acumuladas      : %.2f EUR
               ===========================================
               """.formatted(d.materiales(), d.prestamos(), d.vencidos(), d.multas());
    }

    private Path escribir(String contenido) {
        traza("escribiendo fichero");
        dormir(300);                           // E/S simulada
        Path destino = Path.of("informes", "multas-mensual.txt");
        // En el codigo real: EscrituraAtomica.escribir(destino, contenido) de 07-06
        return destino;
    }

    // ---------- LA CADENA ----------

    /**
     * Declara toda la cadena y RETORNA INMEDIATAMENTE.
     * El hilo que llama no espera a nada.
     */
    public CompletableFuture<Path> generar() {

        long inicio = System.nanoTime();

        // 1 y 2 arrancan A LA VEZ: no se encadenan, se combinan.
        CompletableFuture<List<Material>> materiales = cargarMateriales();
        CompletableFuture<List<Prestamo>> prestamos = cargarPrestamosActivos();

        return materiales
                // 3. Esperar a ambos y combinarlos. El trabajo se manda al
                //    pool de calculo con thenCombineAsync, para no ocupar
                //    un hilo de E/S con trabajo de CPU.
                .thenCombineAsync(prestamos, this::calcular, poolCalculo)

                // 4. Formatear: CPU, mismo pool.
                .thenApplyAsync(this::formatear, poolCalculo)

                // 5. Escribir: E/S, pool de E/S.
                .thenApplyAsync(this::escribir, poolEs)

                // 6. Plazo global.
                .orTimeout(30, TimeUnit.SECONDS)

                // 7. Observar sin alterar: metricas.
                .whenComplete((ruta, error) -> {
                    long ms = (System.nanoTime() - inicio) / 1_000_000;
                    if (error == null) {
                        LOG.log(Level.INFO, "Informe generado en {0} ms: {1}",
                                new Object[] { ms, ruta });
                    } else {
                        LOG.log(Level.SEVERE, "Informe fallido tras " + ms + " ms", error);
                    }
                })

                // 8. Manejar: al FINAL, para cubrir toda la cadena.
                .exceptionally(error -> {
                    Throwable causa = causaDe(error);
                    if (causa instanceof TimeoutException) {
                        LOG.warning("El informe excedio los 30 segundos");
                    }
                    return Path.of("informes", "informe-no-disponible.txt");
                });
    }

    // ---------- AUXILIARES ----------

    record DatosInforme(int materiales, int prestamos, int vencidos, double multas) { }

    static Throwable causaDe(Throwable t) {
        return (t instanceof CompletionException || t instanceof ExecutionException)
                && t.getCause() != null ? t.getCause() : t;
    }

    private static void traza(String etapa) {
        System.out.printf("      [%s] %s%n", Thread.currentThread().getName(), etapa);
    }

    private static void dormir(long ms) {
        try { TimeUnit.MILLISECONDS.sleep(ms); }
        catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }

    @Override
    public void close() {
        for (ExecutorService ej : List.of(poolCalculo, poolEs)) {
            ej.shutdown();
            try {
                if (!ej.awaitTermination(15, TimeUnit.SECONDS)) ej.shutdownNow();
            } catch (InterruptedException e) {
                ej.shutdownNow();
                Thread.currentThread().interrupt();
            }
        }
    }
}

El menú que sigue vivo:

package com.nexussoftware.bibliotech.presentacion;

import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

public class MenuConInformeAsincrono {

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

        try (GeneradorInformeAsincrono generador = new GeneradorInformeAsincrono(
                catalogo, registro, calculadora)) {

            System.out.println("=== BiblioTech - Nexus Software ===");
            System.out.println("Lanzando informe mensual en segundo plano...\n");

            // La cadena entera se declara y retorna al instante.
            CompletableFuture<Path> informe = generador.generar();

            // Registramos que hacer al terminar. NO esperamos.
            informe.thenAccept(ruta ->
                    System.out.println("\n>>> Informe listo en: " + ruta));

            // El menu sigue atendiendo al usuario.
            for (int i = 1; i <= 8; i++) {
                System.out.printf("  [menu] opcion %d atendida (el menu NO esta bloqueado)%n", i);
                TimeUnit.MILLISECONDS.sleep(200);
            }

            // Solo al final, si de verdad hace falta el resultado, se espera.
            Path ruta = informe.get(30, TimeUnit.SECONDS);
            System.out.println("\n[main] confirmado: " + ruta);
        }
    }
}

Salida:

=== BiblioTech - Nexus Software ===
Lanzando informe mensual en segundo plano...

  [menu] opcion 1 atendida (el menu NO esta bloqueado)
      [bibliotech-async-es-1] cargando materiales
      [bibliotech-async-es-2] cargando prestamos
  [menu] opcion 2 atendida (el menu NO esta bloqueado)
  [menu] opcion 3 atendida (el menu NO esta bloqueado)
      [bibliotech-async-cpu-1] calculando multas
      [bibliotech-async-cpu-1] formateando
  [menu] opcion 4 atendida (el menu NO esta bloqueado)
      [bibliotech-async-es-3] escribiendo fichero

>>> Informe listo en: informes/multas-mensual.txt
  [menu] opcion 5 atendida (el menu NO esta bloqueado)
  [menu] opcion 6 atendida (el menu NO esta bloqueado)
  [menu] opcion 7 atendida (el menu NO esta bloqueado)
  [menu] opcion 8 atendida (el menu NO esta bloqueado)
INFO: Informe generado en 812 ms: informes/multas-mensual.txt

[main] confirmado: informes/multas-mensual.txt

Cuatro cosas que demuestra esta salida:

  1. El menú nunca se detiene. Las ocho opciones se atienden mientras el informe se genera. Es el caso A de 08-01, resuelto de la forma definitiva.
  2. Las dos cargas ocurren en paralelo, en async-es-1 y async-es-2. El tiempo total de esa fase es el de la más lenta (500 ms), no la suma (900 ms).
  3. Cada etapa se ejecuta en el pool correcto: E/S en los hilos es, cálculo en los cpu. El aislamiento por mamparos de 08-05, aplicado dentro de una sola cadena.
  4. El total son 812 ms, frente a los 1200 ms de la versión secuencial (400 + 500 + 300). Y —lo importante— durante esos 812 ms el hilo principal no estuvo bloqueado ni un instante.

  1. Comparación con el modelo reactivo y los hilos virtuales

CompletableFuture no es la última palabra en asincronía en Java. Conviene situarlo.

El modelo reactivo (Reactive Streams, con implementaciones como Project Reactor y RxJava) generaliza la idea a flujos de muchos valores en lugar de un solo resultado. Añade dos cosas que CompletableFuture no tiene: operadores para transformar flujos completos, y contrapresión, el mecanismo por el que un consumidor lento le dice al productor que reduzca el ritmo —la misma idea de las colas acotadas de 08-06, integrada en el modelo—. Java 9 incorporó las interfaces Flow.Publisher y Flow.Subscriber en la biblioteca estándar, pero sin implementación: es un punto de encuentro entre librerías. Spring WebFlux, en 11-02, se apoya en este modelo.

Los hilos virtuales (Java 21) atacan el problema desde el lado opuesto. En lugar de hacer el código asíncrono más componible, hacen que el código bloqueante deje de ser caro:

// Con CompletableFuture: asincrono, componible, y de lectura exigente.
CompletableFuture<Path> f = CompletableFuture
        .supplyAsync(() -> cargarCatalogo(), poolEs)
        .thenApplyAsync(this::calcularMultas, poolCpu)
        .thenApplyAsync(this::exportar, poolEs)
        .exceptionally(this::respaldo);

// Con hilos virtuales: codigo SECUENCIAL normal, con try/catch normal,
// que bloquea un hilo virtual —cuyo coste es de nanosegundos—.
try (var ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
    ejecutor.submit(() -> {
        Catalogo c = cargarCatalogo();      // "bloquea", pero es baratisimo
        double m = calcularMultas(c);
        return exportar(m);
    });
}

El segundo se lee como código secuencial —con try/catch normal, trazas de pila legibles y depuración convencional— y escala como el primero, porque bloquear un hilo virtual no bloquea ningún hilo del sistema operativo. Es un cambio de fondo en cómo se escribirá la concurrencia en Java, y se explica en 10-06.

CompletableFuture Reactivo Hilos virtuales
Valores Uno Muchos (flujo) Uno
Estilo Composición de etapas Operadores sobre flujos Secuencial normal
Contrapresión No Natural (bloqueo real)
Legibilidad Media Baja al principio Alta
Depuración Difícil (trazas partidas) Muy difícil Normal
Desde Java 8 Librería / Java 9 (Flow) Java 21
Se ve en Esta lección 11-02 (WebFlux) 10-06

Cuándo sigue siendo CompletableFuture la herramienta correcta: cuando necesitas combinar unos pocos resultados independientes, cuando trabajas con APIs que ya lo devuelven —HttpClient.sendAsync en 09-06, buena parte de Spring—, o cuando tu Java es anterior al 21. Es una pieza que conocer, no una que aplicar en todas partes.

Errores Comunes y Consejos

Error 1: usar thenApply cuando la función devuelve un CompletableFuture. El resultado es CompletableFuture<CompletableFuture<T>> y hay que desenvolver dos veces. Usa thenCompose.

Error 2: bloquear con join() o get() dentro de una etapa. Ocupa un hilo del pool esperando. Con suficientes cadenas, el pool se agota y todo se detiene. thenCompose en su lugar.

Error 3: usar el pool común para tareas bloqueantes. núcleos - 1 hilos compartidos por toda la JVM, incluidos los streams paralelos. Pasa tu ejecutor.

Error 4: no poner ningún manejador de errores. El fallo desaparece sin traza, sin log y sin excepción. Toda cadena termina en exceptionally o handle.

Error 5: poner el exceptionally demasiado arriba. Solo cubre las etapas anteriores a él. Para proteger toda la cadena, va al final.

Error 6: olvidar que la excepción viene envuelta en CompletionException. La original está en getCause(). Escribe una utilidad causaDe(Throwable) y úsala siempre.

Error 7: creer que cancel(true) interrumpe la tarea. El parámetro se ignora. Para cancelación real, una bandera compartida y puntos de comprobación (08-02).

Error 8: creer que orTimeout cancela el trabajo subyacente. No lo hace: el futuro falla, la tarea sigue.

Error 9: usar allOf con futuros que pueden fallar sin blindarlos. Un fallo hace fallar al conjunto. Pon un exceptionally en cada uno antes de agregarlos.

Error 10: anyOf esperando el primer éxito. Se completa con el primero que termine, aunque termine con excepción.

Error 11: cadenas de quince etapas en una sola expresión. Ilegibles e imposibles de depurar. Extrae métodos con nombre.

Consejo 1: un ejecutor propio, nombrado, por tipo de trabajo. Uno de E/S y uno de cálculo, como en el apartado 16. Los nombres de hilo se agradecen en el primer volcado (08-03).

Consejo 2: whenComplete para métricas, exceptionally para recuperar. El primero observa sin alterar; el segundo cambia el resultado. Encadénalos en ese orden.

Consejo 3: join() dentro de lambdas, get(timeout) fuera. join lanza una excepción no comprobada, que es lo único que compila dentro de una Function.

Consejo 4: una cadena debe leerse como una lista de pasos. Si no puedes explicarla en voz alta leyéndola de arriba abajo, extrae métodos.

Consejo 5: completedFuture unifica los caminos rápido y lento. Un método que a veces responde desde caché y a veces calcula debe devolver siempre el mismo tipo.

Consejo 6: si tu Java es 21+, plantéate si necesitas esto. Para una cadena secuencial de pasos bloqueantes, un hilo virtual da el mismo rendimiento con código mucho más simple (10-06). CompletableFuture sigue siendo mejor para combinar resultados independientes.

Ejercicios

Ejercicio 1: De Future a CompletableFuture

Toma esta operación escrita con Future y reescríbela con CompletableFuture sin ningún bloqueo intermedio:

Future<Catalogo> f1 = ejecutor.submit(() -> cargarCatalogo());
Catalogo c = f1.get();
Future<List<Prestamo>> f2 = ejecutor.submit(() -> cargarPrestamos(c));
List<Prestamo> p = f2.get();
Future<Double> f3 = ejecutor.submit(() -> calcularMultas(p));
double total = f3.get();
Future<Path> f4 = ejecutor.submit(() -> exportar(total));
Path informe = f4.get();

La versión nueva debe usar un pool de E/S y otro de cálculo, aplicar thenCompose donde corresponda (haz que cargarPrestamos devuelva un CompletableFuture), poner un plazo global de 20 segundos, registrar el tiempo total con whenComplete y manejar el error al final devolviendo una ruta de respaldo. Mide y compara el tiempo de ambas versiones simulando 300 ms por operación.

Ejercicio 2: Ficha completa combinando tres fuentes

Escribe ServicioFichas con un método CompletableFuture<FichaCompleta> fichaCompleta(String isbn) que combine tres consultas independientes lanzadas en paralelo: los datos del material (400 ms), el número de préstamos históricos (600 ms) y la valoración media (300 ms). Requisitos:

  1. Las tres se lanzan a la vez; el tiempo total debe ser ~600 ms, no 1300.
  2. Cada consulta individual debe tener su propio exceptionally con un valor degradado, de modo que el fallo de una no impida construir la ficha.
  3. La combinación se hace con thenCombine encadenados.
  4. completeOnTimeout con una ficha mínima si el conjunto tarda más de 2 segundos.
  5. Un main que demuestre el caso correcto y el caso en que la consulta de valoraciones falla.

Ejercicio 3: Envío masivo asíncrono con recogida de resultados

Reescribe el envío de los 200 avisos de 08-05 con CompletableFuture. EnvioAvisosAsincrono debe:

  1. Crear un CompletableFuture<ResultadoAviso> por aviso, con un pool de E/S propio de 16 hilos y nombres decentes.
  2. Blindar cada futuro con exceptionally para que un fallo individual produzca un ResultadoAviso de fallo en lugar de romper el conjunto.
  3. Agregar con allOf y recoger todos los resultados con el patrón del apartado 9.
  4. Sobre el futuro agregado, encadenar un thenApply que produzca un ResumenEnvio (total, enviados, fallidos, milisegundos).
  5. Aplicar orTimeout(60, SECONDS) y un exceptionally final.
  6. Mientras la cadena corre, el main debe imprimir progreso usando un LongAdder que cada tarea incremente al terminar — demostrando que el hilo principal no está bloqueado.

Soluciones

Solución al Ejercicio 1

package com.nexussoftware.bibliotech.servicio;

import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class CadenaInforme {

    private final ExecutorService poolEs;
    private final ExecutorService poolCpu;

    public CadenaInforme() {
        AtomicInteger a = new AtomicInteger(1);
        this.poolEs = Executors.newFixedThreadPool(8,
                r -> new Thread(r, "informe-es-" + a.getAndIncrement()));
        AtomicInteger b = new AtomicInteger(1);
        this.poolCpu = Executors.newFixedThreadPool(
                Runtime.getRuntime().availableProcessors(),
                r -> new Thread(r, "informe-cpu-" + b.getAndIncrement()));
    }

    // --- Operaciones simuladas ---

    private Catalogo cargarCatalogo() {
        dormir(300);
        return new Catalogo();
    }

    /**
     * ASINCRONA: devuelve un CompletableFuture, no un valor.
     * Por eso se encadena con thenCompose y no con thenApply.
     */
    private CompletableFuture<List<Prestamo>> cargarPrestamosAsync(Catalogo c) {
        return CompletableFuture.supplyAsync(() -> {
            dormir(300);
            return List.<Prestamo>of();
        }, poolEs);
    }

    private double calcularMultas(List<Prestamo> p) {
        dormir(300);
        return 137.50;
    }

    private Path exportar(double total) {
        dormir(300);
        return Path.of("informes/multas.txt");
    }

    // --- VERSION BLOQUEANTE (la original) ---

    public Path versionConFuture() throws Exception {
        ExecutorService ej = Executors.newFixedThreadPool(4);
        try {
            Future<Catalogo> f1 = ej.submit(this::cargarCatalogo);
            Catalogo c = f1.get();                                 // BLOQUEA

            Future<List<Prestamo>> f2 = ej.submit(() -> {
                dormir(300); return List.<Prestamo>of();
            });
            List<Prestamo> p = f2.get();                           // BLOQUEA

            Future<Double> f3 = ej.submit(() -> calcularMultas(p));
            double total = f3.get();                               // BLOQUEA

            Future<Path> f4 = ej.submit(() -> exportar(total));
            return f4.get();                                       // BLOQUEA
        } finally {
            ej.shutdown();
        }
    }

    // --- VERSION ASINCRONA ---

    public CompletableFuture<Path> versionConCompletableFuture() {

        long inicio = System.nanoTime();

        return CompletableFuture
                // 1. E/S: pool de E/S
                .supplyAsync(this::cargarCatalogo, poolEs)

                // 2. thenCompose porque cargarPrestamosAsync devuelve
                //    un CompletableFuture. Con thenApply obtendriamos
                //    CompletableFuture<CompletableFuture<List<Prestamo>>>.
                .thenCompose(this::cargarPrestamosAsync)

                // 3. CPU: pool de calculo
                .thenApplyAsync(this::calcularMultas, poolCpu)

                // 4. E/S: pool de E/S
                .thenApplyAsync(this::exportar, poolEs)

                // 5. Plazo global
                .orTimeout(20, TimeUnit.SECONDS)

                // 6. Observar sin alterar
                .whenComplete((ruta, error) -> {
                    long ms = (System.nanoTime() - inicio) / 1_000_000;
                    System.out.printf("  [cadena] terminada en %d ms (%s)%n",
                            ms, error == null ? "OK" : "ERROR");
                })

                // 7. Manejar al FINAL: cubre todas las etapas anteriores
                .exceptionally(error -> {
                    System.err.println("  [cadena] fallo: " + causaDe(error).getMessage());
                    return Path.of("informes/no-disponible.txt");
                });
    }

    static Throwable causaDe(Throwable t) {
        return (t instanceof CompletionException || t instanceof ExecutionException)
                && t.getCause() != null ? t.getCause() : t;
    }

    static void dormir(long ms) {
        try { TimeUnit.MILLISECONDS.sleep(ms); }
        catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }

    public void cerrar() {
        poolEs.shutdown();
        poolCpu.shutdown();
    }

    public static void main(String[] args) throws Exception {
        CadenaInforme ci = new CadenaInforme();
        try {
            System.out.println("=== VERSION CON FUTURE (bloqueante) ===");
            long t1 = System.nanoTime();
            Path p1 = ci.versionConFuture();
            long ms1 = (System.nanoTime() - t1) / 1_000_000;
            System.out.println("  resultado: " + p1 + "  (" + ms1 + " ms)");
            System.out.println("  el hilo main estuvo bloqueado 4 veces\n");

            System.out.println("=== VERSION CON COMPLETABLEFUTURE ===");
            long t2 = System.nanoTime();
            CompletableFuture<Path> f = ci.versionConCompletableFuture();
            long msDeclaracion = (System.nanoTime() - t2) / 1_000_000;
            System.out.println("  cadena declarada en " + msDeclaracion + " ms");
            System.out.println("  el hilo main sigue libre; hago otras cosas...");

            for (int i = 1; i <= 5; i++) {
                System.out.println("    trabajo del main " + i);
                TimeUnit.MILLISECONDS.sleep(150);
            }

            Path p2 = f.get(20, TimeUnit.SECONDS);
            long ms2 = (System.nanoTime() - t2) / 1_000_000;
            System.out.println("  resultado: " + p2 + "  (" + ms2 + " ms)");
        } finally {
            ci.cerrar();
        }
    }
}

Salida:

=== VERSION CON FUTURE (bloqueante) ===
  resultado: informes/multas.txt  (1214 ms)
  el hilo main estuvo bloqueado 4 veces

=== VERSION CON COMPLETABLEFUTURE ===
  cadena declarada en 3 ms
  el hilo main sigue libre; hago otras cosas...
    trabajo del main 1
    trabajo del main 2
    trabajo del main 3
    trabajo del main 4
    trabajo del main 5
  [cadena] terminada en 1208 ms (OK)
  resultado: informes/multas.txt  (1211 ms)

La comparación correcta no está en el tiempo total —los dos tardan ~1,2 s, porque los pasos son secuencialmente dependientes—, sino en la línea "cadena declarada en 3 ms". La versión bloqueante consume 1214 ms del hilo principal; la asíncrona consume 3 ms y devuelve el control. Ese hilo pudo hacer otras cinco cosas mientras tanto. La asincronía no acelera lo que es secuencialmente dependiente: libera al hilo que orquesta.

Solución al Ejercicio 2

package com.nexussoftware.bibliotech.servicio;

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class ServicioFichas implements AutoCloseable {

    record DatosMaterial(String titulo, String autor, String isbn) {
        static DatosMaterial desconocido(String isbn) {
            return new DatosMaterial("(titulo no disponible)", "(autor desconocido)", isbn);
        }
    }

    record FichaCompleta(DatosMaterial material, int prestamos, double valoracion) {
        static FichaCompleta minima(String isbn) {
            return new FichaCompleta(DatosMaterial.desconocido(isbn), -1, -1.0);
        }
        @Override public String toString() {
            return String.format("%s / %s [%s] - %s prestamos - valoracion %s",
                    material.titulo(), material.autor(), material.isbn(),
                    prestamos < 0 ? "?" : prestamos,
                    valoracion < 0 ? "?" : String.format("%.1f", valoracion));
        }
    }

    private final ExecutorService pool;
    private final boolean fallarValoraciones;

    public ServicioFichas(boolean fallarValoraciones) {
        this.fallarValoraciones = fallarValoraciones;
        AtomicInteger n = new AtomicInteger(1);
        this.pool = Executors.newFixedThreadPool(8,
                r -> new Thread(r, "fichas-" + n.getAndIncrement()));
    }

    // --- Las tres consultas, cada una con su respaldo ---

    private CompletableFuture<DatosMaterial> consultarMaterial(String isbn) {
        return CompletableFuture.supplyAsync(() -> {
            dormir(400);
            return new DatosMaterial("Java Efectivo", "J. Bloch", isbn);
        }, pool)
        // Respaldo INDIVIDUAL: si esta consulta falla, el conjunto
        // sigue adelante con un valor degradado.
        .exceptionally(e -> {
            System.err.println("  [respaldo] material no disponible: " + causaDe(e).getMessage());
            return DatosMaterial.desconocido(isbn);
        });
    }

    private CompletableFuture<Integer> consultarPrestamos(String isbn) {
        return CompletableFuture.supplyAsync(() -> {
            dormir(600);                       // la mas lenta: marca el total
            return 47;
        }, pool)
        .exceptionally(e -> -1);
    }

    private CompletableFuture<Double> consultarValoracion(String isbn) {
        return CompletableFuture.supplyAsync(() -> {
            dormir(300);
            if (fallarValoraciones) {
                throw new IllegalStateException("servicio de valoraciones caido");
            }
            return 4.6;
        }, pool)
        .exceptionally(e -> {
            System.err.println("  [respaldo] valoracion no disponible: "
                    + causaDe(e).getMessage());
            return -1.0;
        });
    }

    // --- La combinacion ---

    public CompletableFuture<FichaCompleta> fichaCompleta(String isbn) {

        // Las TRES se lanzan a la vez: la asignacion ya dispara el trabajo.
        CompletableFuture<DatosMaterial> material   = consultarMaterial(isbn);
        CompletableFuture<Integer>       prestamos  = consultarPrestamos(isbn);
        CompletableFuture<Double>        valoracion = consultarValoracion(isbn);

        return material
                // thenCombine encadenado: primero material+prestamos...
                .thenCombine(prestamos, (m, p) -> new Object[] { m, p })
                // ...y luego el par con la valoracion.
                .thenCombine(valoracion, (par, v) -> new FichaCompleta(
                        (DatosMaterial) par[0], (Integer) par[1], v))
                // Si el conjunto tarda demasiado, ficha minima en lugar de fallo.
                .completeOnTimeout(FichaCompleta.minima(isbn), 2, TimeUnit.SECONDS);
    }

    static Throwable causaDe(Throwable t) {
        return (t instanceof CompletionException || t instanceof ExecutionException)
                && t.getCause() != null ? t.getCause() : t;
    }

    static void dormir(long ms) {
        try { TimeUnit.MILLISECONDS.sleep(ms); }
        catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }

    @Override public void close() { pool.shutdown(); }

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

        System.out.println("=== CASO NORMAL ===");
        try (ServicioFichas s = new ServicioFichas(false)) {
            long t = System.nanoTime();
            FichaCompleta f = s.fichaCompleta("978-0000000001").get();
            System.out.println("  " + f);
            System.out.printf("  tiempo: %d ms (secuencial serian 1300)%n",
                    (System.nanoTime() - t) / 1_000_000);
        }

        System.out.println("\n=== CON EL SERVICIO DE VALORACIONES CAIDO ===");
        try (ServicioFichas s = new ServicioFichas(true)) {
            long t = System.nanoTime();
            FichaCompleta f = s.fichaCompleta("978-0000000001").get();
            System.out.println("  " + f);
            System.out.printf("  tiempo: %d ms%n", (System.nanoTime() - t) / 1_000_000);
            System.out.println("  La ficha se construye igual: degradacion, no fallo (06-07).");
        }
    }
}

Salida:

=== CASO NORMAL ===
  Java Efectivo / J. Bloch [978-0000000001] - 47 prestamos - valoracion 4.6
  tiempo: 612 ms (secuencial serian 1300)

=== CON EL SERVICIO DE VALORACIONES CAIDO ===
  [respaldo] valoracion no disponible: servicio de valoraciones caido
  Java Efectivo / J. Bloch [978-0000000001] - 47 prestamos - valoracion ?
  tiempo: 608 ms
  La ficha se construye igual: degradacion, no fallo (06-07).

Dos resultados clave:

  1. 612 ms frente a 1300 ms secuenciales. El total es el de la consulta más lenta (600 ms) más el coste de coordinación, porque las tres corren en paralelo.
  2. El fallo de una fuente no tumba la ficha. El exceptionally individual de cada consulta la degrada a un valor por defecto y el resto se construye normalmente. Es la política de 06-07 —degradar cuando se puede, abortar solo cuando hace falta— llevada al mundo asíncrono. Sin esos exceptionally individuales, el fallo de la valoración habría hecho fallar toda la combinación.

Solución al Ejercicio 3

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;

public class EnvioAvisosAsincrono implements AutoCloseable {

    record Aviso(String id, String empleado) { }

    record ResultadoAviso(String id, boolean correcto, String detalle) {
        static ResultadoAviso exito(Aviso a) {
            return new ResultadoAviso(a.id(), true, "enviado");
        }
        static ResultadoAviso fallo(Aviso a, String motivo) {
            return new ResultadoAviso(a.id(), false, motivo);
        }
    }

    record ResumenEnvio(int total, int enviados, int fallidos, long ms) {
        double porcentaje() { return total == 0 ? 0 : 100.0 * enviados / total; }
    }

    private final ExecutorService poolEs;
    private final LongAdder completados = new LongAdder();

    public EnvioAvisosAsincrono() {
        AtomicInteger n = new AtomicInteger(1);
        this.poolEs = new ThreadPoolExecutor(
                16, 16, 0L, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(300),
                r -> new Thread(r, "bibliotech-aviso-" + n.getAndIncrement()),
                new ThreadPoolExecutor.CallerRunsPolicy());
    }

    /** Envio individual: 250 ms de E/S y fallo simulado en algunos. */
    private ResultadoAviso enviar(Aviso a) {
        try {
            TimeUnit.MILLISECONDS.sleep(250);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new CompletionException(e);
        }
        if (a.id().hashCode() % 29 == 0) {
            throw new IllegalStateException("empleado sin direccion de contacto");
        }
        return ResultadoAviso.exito(a);
    }

    /** La cadena completa. Retorna inmediatamente. */
    public CompletableFuture<ResumenEnvio> enviarTodos(List<Aviso> avisos) {

        long inicio = System.nanoTime();
        final int total = avisos.size();

        // 1-2. Un futuro por aviso, BLINDADO con su propio exceptionally.
        //      Sin ese blindaje, un solo fallo haria fallar el allOf entero.
        List<CompletableFuture<ResultadoAviso>> futuros = new ArrayList<>(total);
        for (Aviso a : avisos) {
            futuros.add(CompletableFuture
                    .supplyAsync(() -> enviar(a), poolEs)
                    .exceptionally(e -> ResultadoAviso.fallo(a, causaDe(e).getMessage()))
                    // Se cuenta al terminar, con exito o con fallo:
                    // asi el main puede mostrar progreso sin bloquearse.
                    .whenComplete((r, e) -> completados.increment()));
        }

        // 3. Agregar.
        CompletableFuture<Void> todos =
                CompletableFuture.allOf(futuros.toArray(new CompletableFuture[0]));

        return todos
                // 4. Recoger y resumir.
                .thenApply(v -> {
                    int ok = 0, ko = 0;
                    for (CompletableFuture<ResultadoAviso> f : futuros) {
                        // join() no bloquea: allOf garantiza que estan completos.
                        if (f.join().correcto()) ok++; else ko++;
                    }
                    return new ResumenEnvio(total, ok, ko,
                            (System.nanoTime() - inicio) / 1_000_000);
                })
                // 5. Plazo global y manejador final.
                .orTimeout(60, TimeUnit.SECONDS)
                .exceptionally(e -> {
                    System.err.println("Envio masivo fallido: " + causaDe(e).getMessage());
                    return new ResumenEnvio(total, 0, total,
                            (System.nanoTime() - inicio) / 1_000_000);
                });
    }

    public long completados() { return completados.sum(); }

    static Throwable causaDe(Throwable t) {
        return (t instanceof CompletionException || t instanceof ExecutionException)
                && t.getCause() != null ? t.getCause() : t;
    }

    @Override public void close() {
        poolEs.shutdown();
        try {
            if (!poolEs.awaitTermination(30, TimeUnit.SECONDS)) poolEs.shutdownNow();
        } catch (InterruptedException e) {
            poolEs.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }

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

        List<Aviso> avisos = new ArrayList<>();
        for (int i = 1; i <= 200; i++) {
            avisos.add(new Aviso("AV-" + i, "empleado-" + (i % 20)));
        }

        try (EnvioAvisosAsincrono servicio = new EnvioAvisosAsincrono()) {

            System.out.println("=== ENVIO ASINCRONO DE 200 AVISOS ===");
            System.out.println("Secuencial serian " + (200 * 250 / 1000) + " s\n");

            // 6. La cadena se declara y retorna: main NO se bloquea.
            CompletableFuture<ResumenEnvio> cadena = servicio.enviarTodos(avisos);

            // Progreso, leyendo el LongAdder que las tareas incrementan.
            while (!cadena.isDone()) {
                long hechos = servicio.completados();
                System.out.printf("\r  [%s] %d/200 (%.0f%%)",
                        barra(hechos, 200), hechos, 100.0 * hechos / 200);
                TimeUnit.MILLISECONDS.sleep(250);
            }

            ResumenEnvio r = cadena.get();
            System.out.printf("\r  [%s] 200/200 (100%%)%n%n", barra(200, 200));
            System.out.println("=== RESUMEN ===");
            System.out.println("Total     : " + r.total());
            System.out.println("Enviados  : " + r.enviados());
            System.out.println("Fallidos  : " + r.fallidos());
            System.out.println("Tiempo    : " + r.ms() + " ms");
            System.out.printf ("Exito     : %.1f%%%n", r.porcentaje());
            System.out.printf ("Aceleracion: %.1fx%n", 200 * 250.0 / r.ms());
        }
    }

    static String barra(long hechos, long total) {
        int ancho = 30;
        int llenos = (int) (ancho * hechos / total);
        return "#".repeat(llenos) + "-".repeat(ancho - llenos);
    }
}

Salida:

=== ENVIO ASINCRONO DE 200 AVISOS ===
Secuencial serian 50 s

  [##############################] 200/200 (100%)

=== RESUMEN ===
Total     : 200
Enviados  : 193
Fallidos  : 7
Tiempo    : 3387 ms
Exito     : 96.5%
Aceleracion: 14.8x

Cuatro puntos:

  1. 14,8× de aceleración con 16 hilos. No es 16× por el coste de coordinación y porque la última tanda no llena todos los hilos. 200 × 250 ms / 16 ≈ 3,1 s, muy cerca del resultado.
  2. El exceptionally individual es esencial. Sin él, uno de los siete fallos habría hecho fallar el allOf completo y el resumen habría sido 0 enviados / 200 fallidos. Blindar cada futuro antes de agregarlo es el patrón obligatorio.
  3. El LongAdder incrementado en whenComplete permite el progreso sin bloquear. El main lee completados() cada 250 ms mientras la cadena avanza sola. Es 08-06 y 08-07 trabajando juntos.
  4. join() dentro del thenApply no bloquea nada, porque allOf ya garantizó que todos los futuros están completos. Es el único sitio donde join() es inofensivo dentro de una etapa.

Conclusión

Has cerrado el módulo 8. Empezaste con dos hilos que perdían medio millón de incrementos y terminas con una aplicación que hace varias cosas a la vez, de forma correcta y sin que nadie espere a nadie.

En esta lección has visto por qué Future se quedó corto: no se puede encadenar, no se puede combinar, get() bloquea, no admite callbacks y no se puede completar a mano. Y has aprendido el modelo que lo sustituye: en lugar de preguntar y esperar, declarar la cadena entera de antemano y dejar que cada etapa se dispare cuando la anterior le entregue un valor.

Sabes crear un CompletableFuture con supplyAsync cuando produce un valor, runAsync cuando no, completedFuture para unificar el camino rápido y el lento, y el constructor vacío para completarlo a mano. Y conoces la decisión que más consecuencias tiene: el ejecutor. Por defecto se usa el ForkJoinPool.commonPool(), con núcleos - 1 hilos compartidos por toda la JVM —incluidos los streams paralelos de 10-04—, así que para cualquier tarea que bloquee hay que pasar un ejecutor propio, dimensionado según 08-05 y con hilos nombrados según 08-02. Con la sorpresa que conviene recordar: en una máquina de un solo núcleo, el pool común tiene paralelismo cero y tu código "asíncrono" se vuelve síncrono sin avisar.

Dominas la transformación con thenApply, thenAccept y thenRun —los tipos funcionales de 04-06 aplicados—, y sobre todo la distinción que más confunde: thenApply cuando la función devuelve un valor, thenCompose cuando devuelve otro CompletableFuture, con el CompletableFuture<CompletableFuture<T>> que aparece al equivocarse y la analogía que lo fija: map frente a flatMap. Sabes qué cambian las variantes ...Async —el hilo que ejecuta la etapa— y la regla derivada: sin sufijo para transformaciones baratas, con sufijo y ejecutor propio para el trabajo caro, porque una etapa sin sufijo puede acabar ejecutándose en el hilo que completó la anterior o incluso en main.

Sabes combinar: thenCombine para juntar dos resultados que se calculan en paralelo —de modo que el tiempo total es el del más lento, no la suma—, y allOf/anyOf para N futuros, con el patrón de recogida —allOf y luego un thenApply que llama a join(), seguro porque ya están completos— y la trampa que hay que evitar: blindar cada futuro con su propio exceptionally antes de agregarlo, o un único fallo tumbará el conjunto.

Y manejas los errores en un mundo donde no hay try/catch que valga: exceptionally para recuperar con un valor de respaldo, handle para unificar los dos caminos, y whenComplete para observar sin alterar —el que se usa para métricas y trazas—. Sabes cómo viaja una excepción: cortocircuita todas las etapas siguientes hasta el primer manejador, llega envuelta en CompletionException con la causa real en getCause(), y tras un exceptionally la cadena continúa con normalidad. Con los dos errores clásicos bien identificados: poner el manejador demasiado arriba, donde solo cubre lo anterior, y no poner ninguno, con lo que el fallo desaparece sin traza, sin log y sin excepción — el mismo agujero que submit sin get() en 08-05.

Conoces orTimeout y completeOnTimeout de Java 9, con el aviso imprescindible de que ninguno cancela el trabajo subyacente; el completado manual con complete y completeExceptionally para adaptar una API de callbacks sin tocarla; y el límite real de la cancelación: cancel(true) ignora su parámetro y no interrumpe nada, así que la cancelación de verdad sigue siendo la bandera compartida y los puntos de comprobación de 08-02.

BiblioTech, al cerrar el módulo 8, ha dejado de esperar.

Su importación de catálogo corre en su propio hilo con nombre propio, publica su progreso, se comprueba cancelable una vez por línea y —lo esencial— construye una lista aparte que solo vuelca al catálogo si termina, de modo que una cancelación nunca deja el estado a medias. Sus doscientos avisos se envían con un pool acotado de ocho hilos y hilos nombrados, con barra de progreso mediante CountDownLatch, con un semáforo que limita a cinco los accesos simultáneos al fichero, con cada fallo individual registrado sin detener el lote y con apagado en dos fases. Su catálogo es seguro para varios hilos —primero con ReadWriteLock, después con ConcurrentHashMap y CopyOnWriteArrayList— y aguanta un millón seiscientas mil operaciones con dieciséis hilos en trescientos milisegundos sin un solo lock() explícito. Su registro de préstamos mantiene el invariante entre sus dos mapas bajo un único candado, porque eso es lo único que un invariante entre estructuras admite, y expone sus operaciones compuestas como métodos atómicos para no regalar carreras a quien lo use. Sus estadísticas son LongAdder y AtomicReference sobre record inmutables: lecturas gratuitas, escrituras atómicas, cero interbloqueos posibles. Su cola de reservas es un productor-consumidor real con ArrayBlockingQueue, con contrapresión visible y apagado por píldora venenosa sin perder una sola reserva. Y su informe mensual se genera con una cadena asíncrona que carga dos fuentes en paralelo, calcula en el pool de CPU, escribe en el pool de E/S, tiene plazo, métricas y manejador final — mientras el menú atiende ocho opciones sin detenerse ni un instante.

De las cuatro carencias que declaraste al cerrar el módulo 7 no queda ninguna: la importación ya no bloquea el menú, los avisos ya no van de uno en uno, el procesador ya no está parado esperando una tecla, y dos empleados trabajando a la vez ya no pueden corromper nada.

Pero todo eso ocurre dentro de una sola máquina. BiblioTech es un programa que se ejecuta en un ordenador y usa sus núcleos, su memoria y sus ficheros. Marta Ruiz solo puede consultarlo si se sienta delante de ese ordenador. Diego Alonso, desde otra planta del edificio, no puede. No hay forma de que el catálogo se comparta entre las tres sedes de Nexus Software, ni de que un empleado consulte la disponibilidad de un libro desde su portátil, ni de que BiblioTech pregunte a un servicio externo el ISBN de una novedad o envíe de verdad esos avisos que hasta ahora solo escribe en un fichero local. Toda la concurrencia que has aprendido reparte trabajo entre hilos del mismo proceso; nada de lo que sabes hasta ahora permite repartirlo entre máquinas.

Y hay una asimetría que llama la atención: has aprendido a solapar la espera de E/S de un disco, que tarda milisegundos, mientras que la espera de la red tarda cientos de milisegundos y es exactamente donde más rinde todo esto. Los pools dimensionados para E/S, CompletableFuture, BlockingQueue y la cancelación cooperativa fueron diseñados pensando sobre todo en la red.

En el módulo 9, Redes, BiblioTech sale de su máquina. Verás qué hay realmente debajo de una conexión —direcciones IP, puertos, el modelo por capas y la diferencia entre TCP y UDP—; los sockets como extremos de una conversación entre dos programas, con Socket en el cliente y ServerSocket en el servidor, y un servidor que atiende a varios clientes a la vez con exactamente los pools que acabas de aprender; DatagramSocket para cuando la velocidad importa más que la garantía de entrega; el acceso a recursos web con URL y HttpURLConnection; y el cliente HTTP moderno de Java 11, cuyo sendAsync devuelve —no es casualidad— un CompletableFuture. Al terminarlo, el catálogo de BiblioTech se consultará desde cualquier ordenador de Nexus Software, y la aplicación podrá hablar con servicios externos. Dejará de estar sola.

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