1. El problema del código síncrono
Imaginemos que tienes un programa que debe descargar datos de Internet o leer un archivo grande. Escribes algo como:
String data = readFromFile("bigfile.txt");
System.out.println("Datos: " + data);
Todo iría bien, pero si el archivo es grande o la red es lenta, el programa simplemente se queda colgado en la línea de lectura. El usuario ve una interfaz «congelada», el servidor no puede atender otras peticiones, y el programador… se entristece.
A esto se le llama bloqueo: el hilo (por ejemplo, el hilo principal de tu aplicación) se ve obligado a esperar a que la operación termine. Y si hay muchas operaciones de este tipo — hola, retardos y bajo rendimiento.
Es como si fueras a una cafetería, hicieras el pedido y… tuvieras que quedarte en la barra hasta que te preparen el café. Y los demás clientes esperan detrás de ti hasta que el barista termine contigo. Ineficiente, ¿verdad?
Asincronía: cómo salva el mundo
La programación asíncrona es un enfoque en el que las operaciones largas (por ejemplo, leer un archivo, pedir datos a un servidor o acceder a una base de datos) se ejecutan en un hilo en segundo plano, mientras el hilo principal sigue funcionando: atiende usuarios, acepta nuevas peticiones y reacciona a eventos.
Es decir, haces el pedido (lanzas la tarea), te vas a hacer tus cosas y, cuando el café está listo (la tarea ha finalizado), simplemente te dicen: «¡Listo!»
En Java, antes de la llegada de CompletableFuture, esto no era muy cómodo. Veamos cómo evolucionó.
2. Enfoques históricos: Future y sus limitaciones
En Java 5 apareció la interfaz Future, el primer intento de facilitar un poco el trabajo con tareas asíncronas. Permitía encargar una tarea a un pool de hilos y obtener el resultado en algún momento.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> 2 + 2);
int result = future.get(); // Atención: el hilo se bloqueará hasta que la tarea termine
La idea parece buena, pero en la práctica Future es como un buzón antiguo: enviaste la carta, pero para saber si ha llegado la respuesta tienes que estar mirando dentro todo el rato.
No sabe notificar cuando el resultado está listo, no admite cadenas de acciones del tipo «haz esto y luego aquello», ni permite manejar errores de forma elegante. Todo desemboca en esa llamada bloqueante get(), por la cual la asincronía vuelve a convertirse en espera.
3. Aparición de CompletableFuture: un nuevo estilo de asincronía
En Java 8, para sustituir al ya anticuado Future, llegó el verdadero héroe de la asincronía: CompletableFuture. Esta clase del paquete java.util.concurrent se convirtió en una herramienta universal para quienes estaban cansados de esperar resultados «a mano» y querían escribir código asíncrono de forma clara, concisa y comprensible.
CompletableFuture puede hacer casi de todo. Puede lanzar tareas en otros hilos, encadenarlas —por ejemplo, primero calcular un resultado, luego procesarlo y después hacer algo más—. Combina fácilmente varias tareas: puedes esperar a que terminen todas a la vez o solo la primera que llegue. Los errores también se gestionan con elegancia, sin exceso de try-catch. Y todo el estilo se vuelve más funcional: en lugar de llamadas y esperas aburridas, aparecen métodos expresivos como thenApply, thenAccept y otros.
flowchart LR
A[Lanzar la tarea de forma asíncrona] --> B[Procesamiento del resultado]
B --> C[Siguiente operación]
C --> D[Gestión de errores]
Así, CompletableFuture transformó la asincronía de un oficio pesado en una herramienta cómoda y flexible con la que el código, por fin, respira tranquilo.
4. Ejemplo sencillo: primer paso al mundo de CompletableFuture
Veamos un ejemplo mínimo de una tarea asíncrona:
import java.util.concurrent.CompletableFuture;
public class AsyncDemo {
public static void main(String[] args) {
// Lanzamos la tarea de forma asíncrona
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
// Obtenemos el resultado (¡bloquea el hilo!)
try {
int result = future.get();
System.out.println("Resultado: " + result); // 4
} catch (Exception e) {
e.printStackTrace();
}
}
}
Este código ya ejecuta el cálculo en un hilo separado: el hilo principal no se bloquea en el momento de lanzar la tarea. Pero la llamada a get() sí bloquea el hilo hasta que el resultado esté listo.
¿Y cómo NO bloquear el hilo?
Muy fácil: usa métodos de callback que se invocan cuando la tarea termina:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
future.thenAccept(result -> System.out.println("Resultado: " + result));
System.out.println("¡No me bloqueo y puedo hacer algo más!");
Conclusión:
- thenAccept — «suscríbete al resultado»: cuando la tarea termine, ejecuta este código.
- El hilo principal no espera a que finalice la tarea, sino que sigue trabajando.
Visualización (pseudocódigo de eventos)
[Hilo principal] --> [Lanzar la tarea]
| |
v v
[Hace algo] [El hilo en segundo plano calcula 2+2]
| |
v v
[Imprime «¡No me bloqueo…!»]
| |
v v
[Cuando se calcula — se invoca thenAccept]
5. ¿Cómo se ve en una aplicación?
Imagina que desarrollas una aplicación de consola en la que el usuario puede pedir cargar datos (por ejemplo, de una base de datos o de un servidor) y, mientras se cargan, el programa no «se cuelga», sino que sigue aceptando comandos.
Ejemplo: simulación de una operación larga
import java.util.concurrent.CompletableFuture;
public class AsyncApp {
public static void main(String[] args) {
System.out.println("Empezamos a cargar los datos...");
CompletableFuture<String> dataFuture = CompletableFuture.supplyAsync(() -> {
// Simulación de una carga larga
try {
Thread.sleep(2000); // 2 segundos
} catch (InterruptedException e) {
return "Error de carga";
}
return "¡Los datos se han cargado correctamente!";
});
// Nos suscribimos al resultado
dataFuture.thenAccept(result -> System.out.println("Resultado: " + result));
// El programa sigue funcionando
System.out.println("Mientras los datos se cargan, ¡puedo hacer algo más!");
// Para que el programa no termine antes de tiempo (¡solo para la demo!)
try {
Thread.sleep(2500);
} catch (InterruptedException ignored) {}
}
}
Qué verás en la consola:
Empezamos a cargar los datos...
Mientras los datos se cargan, ¡puedo hacer algo más!
[tras 2 segundos]
Resultado: ¡Los datos se han cargado correctamente!
6. Detalles útiles
Un poco sobre los hilos bajo el capó
Cuando escribes CompletableFuture.supplyAsync(...), la tarea se ejecuta por defecto en el llamado ForkJoinPool: un pool de hilos especial que Java usa para tareas paralelas. Si necesitas más control (por ejemplo, tu propio ExecutorService), puedes pasarlo como segundo parámetro:
ExecutorService executor = Executors.newFixedThreadPool(2);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2, executor);
Pero para tareas simples basta con el pool estándar.
Obtener el resultado: get(), join(), thenAccept
- get() — bloquea el hilo hasta que el resultado esté listo (lanza excepciones comprobadas, checked).
- join() — también bloquea, pero lanza excepciones no comprobadas (RuntimeException).
- thenAccept(), thenApply() y otros — NO bloquean, sino que invocan la función dada cuando el resultado está listo.
En aplicaciones asíncronas reales, intenta evitar get()/join() en el hilo principal.
7. Errores típicos en los primeros pasos con CompletableFuture
Error n.º 1: Uso de get() o join() en el hilo principal.
Así vuelves a bloquear el programa y pierdes todas las ventajas de la asincronía. En su lugar, usa thenAccept, thenApply y otros métodos para procesar el resultado.
Error n.º 2: Olvidar manejar el error.
Si en la tarea asíncrona se produce una excepción, no «saltará» al hilo principal. Sin manejarla con exceptionally o handle, simplemente no sabrás que algo salió mal.
Error n.º 3: No esperar a que el programa termine.
En las demos a menudo hay que «frenar» el hilo principal con Thread.sleep; de lo contrario, el programa terminará antes de que la tarea se complete. En aplicaciones reales (por ejemplo, en servidores web) esto no es un problema, pero en demos de consola tenlo en cuenta.
Error n.º 4: Confundir thenAccept y thenApply.
thenAccept es para «efectos secundarios» (no devuelve nada), thenApply es para transformar el resultado (devuelve un nuevo resultado).
Error n.º 5: Mezclar código asíncrono y síncrono sin necesidad.
Si empezaste a escribir de forma asíncrona, no vuelvas a lo síncrono con get()/join(), salvo que sea un caso extremo (por ejemplo, en tests).
GO TO FULL VERSION