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.
CompletableFuturetiene 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
- Las cuatro limitaciones de
Future - Qué es un
CompletableFuture - Creación:
supplyAsync,runAsync,completedFuture - El ejecutor por defecto y por qué conviene pasar el tuyo
- Transformación:
thenApply,thenAccept,thenRun thenApplyfrente athenCompose- Las variantes
...Asyncy en qué hilo se ejecuta cada etapa - Combinación:
thenCombine allOfyanyOf- Errores:
exceptionally,handle,whenComplete - Cómo viaja una excepción por la cadena
- Tiempos límite:
orTimeoutycompleteOnTimeout - Completar manualmente: adaptar una API de callbacks
- Cancelación y sus límites
- Buenas prácticas y trampas
- BiblioTech: la cadena asíncrona completa
- Comparación con el modelo reactivo y los hilos virtuales
- Errores Comunes y Consejos
- Ejercicios
- Las cuatro limitaciones de
Future
FutureFuture 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 nadaCinco líneas que describen todo el flujo, incluida la gestión de errores, sin un solo bloqueo.
- Qué es un
CompletableFuture
CompletableFutureCompletableFuture<T> implementa Future<T> —así que sigue teniendo get(), cancel() e isDone()— y añade dos capacidades:
- Es completable: se puede completar manualmente desde fuera con
complete(valor). - 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.
- Creación:
supplyAsync, runAsync, completedFuture
supplyAsync, runAsync, completedFutureimport 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);
}
- 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í.
- Transformación:
thenApply, thenAccept, thenRun
thenApply, thenAccept, thenRunTres 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.
thenApply frente a thenCompose
thenApply frente a thenComposeEsta 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 nivelRegla 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.
- Las variantes
...Async y en qué hilo se ejecuta cada etapa
...Async y en qué hilo se ejecuta cada etapaCasi 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 explicitoLa 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-propioCómo decidir:
- Transformaciones baratas (formatear, mapear, sumar):
thenApplysin sufijo. Evita un cambio de hilo innecesario. - Trabajo caro o bloqueante:
thenApplyAsynccon tu ejecutor. Si no, ocupas el hilo que completó la etapa anterior —que puede ser un hilo del pool común, o inclusomain—.
// 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)
- Combinación:
thenCombine
thenCombinethenCombine 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 |
allOf y anyOf
allOf y anyOfPara 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 castearCuidado 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 |
- Errores:
exceptionally, handle, whenComplete
exceptionally, handle, whenCompleteTres métodos con papeles distintos:
| Método | Se ejecuta | Recibe | Puede cambiar el resultado |
|---|---|---|---|
exceptionally(Function) |
Solo si hay error | La excepción | Sí: da un valor de respaldo |
handle(BiFunction) |
Siempre | Valor y excepción (uno será null) |
Sí |
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");
});
- 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-respaldoTres 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.
- Tiempos límite:
orTimeout y completeOnTimeout
orTimeout y completeOnTimeoutJava 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? | Sí | 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.
- 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? |
- 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; }
}
- 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:
- Ejecutor propio para todo lo que bloquee.
- Nunca bloquear dentro de una etapa:
thenCompose, nojoin(). thenComposecuando la función devuelve un futuro;thenApplycuando devuelve un valor.- Manejador final siempre:
exceptionallyohandleal final de la cadena. ...Asynccon tu ejecutor para el trabajo caro; sin sufijo para transformaciones baratas.- Extrae métodos con nombre: una cadena debe leerse como una lista de pasos.
- 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.txtCuatro cosas que demuestra esta salida:
- 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.
- Las dos cargas ocurren en paralelo, en
async-es-1yasync-es-2. El tiempo total de esa fase es el de la más lenta (500 ms), no la suma (900 ms). - Cada etapa se ejecuta en el pool correcto: E/S en los hilos
es, cálculo en loscpu. El aislamiento por mamparos de 08-05, aplicado dentro de una sola cadena. - 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.
- 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 | Sí | 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:
- Las tres se lanzan a la vez; el tiempo total debe ser ~600 ms, no 1300.
- Cada consulta individual debe tener su propio
exceptionallycon un valor degradado, de modo que el fallo de una no impida construir la ficha. - La combinación se hace con
thenCombineencadenados. completeOnTimeoutcon una ficha mínima si el conjunto tarda más de 2 segundos.- Un
mainque 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:
- Crear un
CompletableFuture<ResultadoAviso>por aviso, con un pool de E/S propio de 16 hilos y nombres decentes. - Blindar cada futuro con
exceptionallypara que un fallo individual produzca unResultadoAvisode fallo en lugar de romper el conjunto. - Agregar con
allOfy recoger todos los resultados con el patrón del apartado 9. - Sobre el futuro agregado, encadenar un
thenApplyque produzca unResumenEnvio(total, enviados, fallidos, milisegundos). - Aplicar
orTimeout(60, SECONDS)y unexceptionallyfinal. - Mientras la cadena corre, el
maindebe imprimir progreso usando unLongAdderque 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:
- 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.
- El fallo de una fuente no tumba la ficha. El
exceptionallyindividual 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 esosexceptionallyindividuales, 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.8xCuatro puntos:
- 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.
- El
exceptionallyindividual es esencial. Sin él, uno de los siete fallos habría hecho fallar elallOfcompleto y el resumen habría sido0 enviados / 200 fallidos. Blindar cada futuro antes de agregarlo es el patrón obligatorio. - El
LongAdderincrementado enwhenCompletepermite el progreso sin bloquear. Elmainleecompletados()cada 250 ms mientras la cadena avanza sola. Es 08-06 y 08-07 trabajando juntos. join()dentro delthenApplyno bloquea nada, porqueallOfya garantizó que todos los futuros están completos. Es el único sitio dondejoin()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
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
