1. thenCompose vs. thenApply: diferencias y cuándo usar cada uno
En la programación asíncrona en Java (con CompletableFuture) a menudo hay que ejecutar cadenas de acciones. Para ello existen dos métodos similares: thenApply y thenCompose. ¡Pero funcionan de forma distinta!
thenApply
El método thenApply se usa cuando el siguiente paso es una simple transformación del valor, sin iniciar nuevas operaciones asíncronas. Recibe el resultado del paso anterior, lo procesa y devuelve un nuevo valor (no un CompletableFuture).
Si conoces el Stream API, thenApply se comporta de forma parecida a map: toma el resultado, aplica una función y devuelve la versión transformada.
Ejemplo:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> "42");
CompletableFuture<Integer> lengthFuture = cf.thenApply(s -> s.length());
// lengthFuture contiene 2 (longitud de la cadena "42")
En pocas palabras, thenApply es una forma de decir: «Cuando el resultado esté listo, haz esto con él».
thenCompose
- Se usa cuando el siguiente paso es otra operación asíncrona (devuelve un CompletableFuture).
- Permite «desenvolver» CompletableFuture anidados (análogo a flatMap).
- Si usas thenApply con una función asíncrona, obtendrás CompletableFuture<CompletableFuture<T>> — ¡incómodo!
Ejemplo:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> "user42");
// Supongamos que necesitamos obtener los pedidos por nombre de usuario (de forma asíncrona)
CompletableFuture<List<Order>> ordersFuture = cf.thenCompose(username -> fetchOrdersAsync(username));
// fetchOrdersAsync devuelve CompletableFuture<List<Order>>
Visualmente:
- thenApply: CF<String> → thenApply(s -> s.length()) → CF<Integer>
- thenCompose: CF<User> → thenCompose(u -> fetchOrdersAsync(u.id)) → CF<List<Order>>
¿Cuándo usar cada uno?
- La función devuelve un valor normal — usa thenApply.
- La función devuelve un CompletableFuture — usa thenCompose.
Ejemplo de error:
cf.thenApply(username -> fetchOrdersAsync(username)); // Obtendrás CF<CF<List<Order>>>
cf.thenCompose(username -> fetchOrdersAsync(username)); // Obtendrás CF<List<Order>>
2. Gestión del pool de hilos (Executor): por qué y cómo usar tu propio Executor
Por defecto: ForkJoinPool.commonPool()
Cuando escribes CompletableFuture.supplyAsync(...) o thenApplyAsync(...) sin indicar un Executor, Java usa el pool de hilos compartido — ForkJoinPool.commonPool(). Es cómodo, pero no siempre adecuado:
- Si tienes muchas operaciones largas o bloqueantes (peticiones de red, trabajo con ficheros), el pool compartido puede «atascarse» y todas las tareas tendrán que esperar.
- A veces necesitas aislar tareas con distintas prioridades u orientar el número de hilos que se ejecutan simultáneamente.
¿Cuándo necesitas tu propio Executor?
- Operaciones largas y bloqueantes (por ejemplo, consultas a BD, HTTP o lectura de archivos).
- Aislamiento de tareas: para que las tareas de usuario no interfieran con las del sistema.
- Limitación de recursos: por ejemplo, no lanzar más de 10 descargas simultáneas.
Cómo crear tu propio Executor
Normalmente se usa ThreadPoolExecutor o fábricas de Executors:
ExecutorService myExecutor = Executors.newFixedThreadPool(10);
Cómo usar tu Executor con CompletableFuture
- En los métodos supplyAsync, runAsync, thenApplyAsync, thenComposeAsync y otros puedes pasar un segundo argumento — tu Executor.
Ejemplos:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(
() -> loadDataFromNetwork(), myExecutor
);
cf.thenApplyAsync(data -> processData(data), myExecutor)
.thenAcceptAsync(result -> System.out.println(result), myExecutor);
Importante: si no pasas un Executor, se usará ForkJoinPool.commonPool().
¿Cuándo basta con el Executor por defecto?
- Para tareas cortas y CPU-bound (cálculos sencillos).
- Cuando no importa en qué hilo se ejecute la tarea.
3. Gestión de tiempos de espera: orTimeout y completeOnTimeout
Las operaciones asíncronas pueden quedar colgadas o tardar demasiado (por ejemplo, si el servidor no responde). Para no esperar eternamente, en CompletableFuture hay métodos para trabajar con tiempos de espera.
orTimeout
- Finaliza el CompletableFuture con la excepción TimeoutException si la operación no termina en el tiempo indicado.
- No cancela la tarea que realmente se está ejecutando, pero la cadena downstream recibirá un error.
Sintaxis:
cf.orTimeout(3, TimeUnit.SECONDS)
.exceptionally(ex -> {
System.out.println("Tiempo de espera: " + ex);
return null;
});
Ejemplo:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
Thread.sleep(5000); // simulamos una operación larga
return "OK";
});
cf.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> {
System.out.println("Error: " + ex);
return "TIMEOUT";
});
Resultado:
Tras 2 segundos se lanzará TimeoutException y exceptionally gestionará el error.
completeOnTimeout
- Finaliza el CompletableFuture con el valor especificado si la operación no termina antes de que expire el tiempo.
- No lanza una excepción; devuelve un valor «de reserva».
Sintaxis:
cf.completeOnTimeout("DEFAULT", 2, TimeUnit.SECONDS);
Ejemplo:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
Thread.sleep(5000);
return "OK";
});
cf.completeOnTimeout("TIMEOUT", 2, TimeUnit.SECONDS)
.thenAccept(System.out::println); // En 2 segundos imprimirá "TIMEOUT"
Comparación entre orTimeout y completeOnTimeout
| Método | ¿Qué hace al expirar el tiempo? | ¿Cómo se procesa después? |
|---|---|---|
|
Finaliza con TimeoutException | Se puede manejar con exceptionally/handle |
|
Finaliza con el valor indicado | thenAccept/thenApply recibirá ese valor |
4. Práctica: ejemplo con thenCompose, Executor personalizado y tiempo de espera
Tarea:
- Obtener al usuario por id (asíncronamente, con retardo).
- Después obtener asíncronamente la lista de pedidos del usuario (también con retardo).
- Usar un Executor personalizado.
- Añadir tiempo de espera para obtener los pedidos.
import java.util.concurrent.*;
import java.util.*;
public class AsyncDemo {
static ExecutorService ioExecutor = Executors.newFixedThreadPool(4);
// Simulación de obtención asíncrona de usuario
static CompletableFuture<String> fetchUserAsync(int userId) {
return CompletableFuture.supplyAsync(() -> {
sleep(1000);
return "user" + userId;
}, ioExecutor);
}
// Simulación de obtención asíncrona de pedidos del usuario
static CompletableFuture<List<String>> fetchOrdersAsync(String username) {
return CompletableFuture.supplyAsync(() -> {
sleep(3000); // ¡Operación larga!
return List.of("order1", "order2");
}, ioExecutor);
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
public static void main(String[] args) {
fetchUserAsync(42)
.thenCompose(username ->
fetchOrdersAsync(username)
.orTimeout(2, TimeUnit.SECONDS) // Tiempo de espera para obtener los pedidos
.exceptionally(ex -> {
System.out.println("No se pudieron obtener los pedidos: " + ex);
return List.of();
})
)
.thenAccept(orders -> System.out.println("Pedidos: " + orders))
.join(); // Esperamos a que termine toda la cadena
ioExecutor.shutdown();
}
}
Qué ocurre:
- Obtenemos el usuario (1 segundo).
- Obtenemos los pedidos (3 segundos, pero el tiempo de espera es de 2 segundos).
- Si no llega a tiempo — se captura TimeoutException y se devuelve una lista vacía.
- Todo funciona en el Executor personalizado.
Resultado:
No se pudieron obtener los pedidos: java.util.concurrent.TimeoutException
Pedidos: []
Si reduces la demora en fetchOrdersAsync a 1_000 ms, verás los pedidos reales.
5. Errores típicos y matices
Error n.º 1: Uso de thenApply en lugar de thenCompose para operaciones asíncronas.
Si la función devuelve un CompletableFuture y aplicas thenApply, obtendrás un tipo anidado CompletableFuture<CompletableFuture<T>>. Esto complica la cadena y añade envoltorios innecesarios. Solución: usa thenCompose para «aplanar» el resultado a CompletableFuture<T>.
Error n.º 2: Ejecutar tareas largas o de E/S sin un Executor propio.
Por defecto, las tareas se ejecutan en ForkJoinPool.commonPool(). Si lo sobrecargas, las demoras empezarán a crecer y otras tareas de la aplicación pueden ralentizarse. Solución: crea tu propio ExecutorService y pásalo a supplyAsync/thenApplyAsync.
Error n.º 3: Pensar que orTimeout cancela la ejecución de la tarea.
orTimeout solo finaliza el CompletableFuture con una excepción por tiempo de espera, pero la tarea en sí sigue ejecutándose en segundo plano. Solución: si necesitas detener la ejecución, usa cancel(true) u otros mecanismos propios de interrupción.
Error n.º 4: Entender mal el alcance del tiempo de espera.
orTimeout y completeOnTimeout funcionan solo para un paso concreto de la cadena, no para toda ella. Solución: si necesitas un tiempo de espera global para toda la cadena, envuélvela en un CompletableFuture aparte y aplica el tiempo de espera a ese.
Error n.º 5: No cerrar ExecutorService.
Si después de ejecutar las tareas no llamas a shutdown()/shutdownNow() de ExecutorService, los hilos seguirán funcionando y el programa puede «quedarse colgado». Solución: cierra siempre el ExecutorService en un finally o usa try-with-resources en Java 21+.
GO TO FULL VERSION