tutoriales.com

Dominando la Programación Asíncrona en Java con CompletableFuture

Este tutorial te guiará a través del mundo de la programación asíncrona en Java utilizando la clase CompletableFuture. Descubrirás cómo escribir código concurrente más limpio, manejable y eficiente, mejorando el rendimiento de tus aplicaciones y la experiencia del usuario.

Intermedio20 min de lectura21 views11 de marzo de 2026Reportar error

¡Bienvenido a la programación asíncrona en Java! 🚀 En el desarrollo de software moderno, la capacidad de ejecutar tareas de forma no bloqueante es crucial para construir aplicaciones rápidas, responsivas y escalables. Aquí es donde CompletableFuture entra en juego, revolucionando la forma en que manejamos la concurrencia y el paralelismo en Java.

Tradicionalmente, la concurrencia en Java se ha manejado con hilos (Threads) y java.util.concurrent utilidades, que a menudo pueden ser complejas y propensas a errores. CompletableFuture, introducido en Java 8, ofrece una API más fluida y funcional para la composición de tareas asíncronas, el manejo de errores y la orquestación de operaciones paralelas.

🎯 ¿Por Qué Programación Asíncrona?

Imagina una aplicación web donde un usuario realiza una solicitud. Si esta solicitud implica operaciones lentas (como acceder a una base de datos externa, llamar a un microservicio, o procesar archivos grandes), un modelo de programación síncrono bloquearía el hilo de ejecución hasta que la operación termine. Esto significa que el servidor no podría atender a otras solicitudes, degradando el rendimiento y la experiencia del usuario. La programación asíncrona resuelve esto permitiendo que el hilo principal continúe su trabajo mientras la operación lenta se ejecuta en segundo plano. Una vez completada, el resultado se maneja de forma no bloqueante.

Beneficios Clave:

  • Responsividad: La UI (Interfaz de Usuario) no se congela mientras se realizan operaciones largas.
  • Eficiencia de Recursos: Los hilos se usan de forma más eficiente, ya que no están bloqueados esperando.
  • Escalabilidad: Las aplicaciones pueden manejar más solicitudes concurrentemente.
  • Mejor Experiencia de Usuario: Los usuarios experimentan aplicaciones más rápidas y fluidas.
📌 **Nota:** Aunque CompletableFuture es excelente para la concurrencia, no es una bala de plata. Es fundamental entender cuándo y cómo aplicarlo para evitar complejidades innecesarias.

🛠️ Entendiendo CompletableFuture

CompletableFuture implementa las interfaces Future y CompletionStage. Esto significa que no solo representa el resultado de una operación asíncrona que puede o no haber completado (como Future), sino que también permite encadenar y componer múltiples etapas de cálculo de forma declarativa y no bloqueante (CompletionStage).

Aquí hay un desglose de sus características principales:

CaracterísticaDescripción
AsíncronoEjecuta tareas en un hilo separado (generalmente un ForkJoinPool por defecto).
No BloqueantePermite que el hilo que inició la tarea continúe su ejecución, mejorando la responsividad.
ComposiciónEncadena múltiples operaciones asíncronas de forma secuencial o paralela.
Manejo de ErroresProporciona mecanismos robustos para manejar excepciones en cualquier etapa de la cadena asíncrona.
Programación FuncionalUtiliza expresiones lambda y referencias a métodos para definir las transformaciones de datos y las acciones a realizar.
💡 Consejo: Piensa en `CompletableFuture` como una promesa de un valor que estará disponible en el futuro. Puedes definir qué hacer con ese valor una vez que llega, o qué hacer si ocurre un error.

✨ Creando y Completando CompletableFuture

Hay varias formas de crear un CompletableFuture. Empecemos con las más básicas.

1. supplyAsync() para tareas que devuelven un valor

Se utiliza cuando la tarea asíncrona producirá un resultado. Recibe un Supplier<T>.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class CompletableFutureExample {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        System.out.println("Inicio del programa en hilo: " + Thread.currentThread().getName());

        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Ejecutando tarea asíncrona en hilo: " + Thread.currentThread().getName());
            try {
                Thread.sleep(2000); // Simula una operación larga
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return "Hola desde la tarea asíncrona!";
        });

        // Aquí el hilo principal puede seguir haciendo otras cosas
        System.out.println("El hilo principal sigue trabajando...");

        // Bloquea hasta que la tarea asíncrona complete y obtiene el resultado
        String result = future.get(); 
        System.out.println("Resultado obtenido: " + result);

        System.out.println("Fin del programa en hilo: " + Thread.currentThread().getName());
    }
}

Salida esperada:

Inicio del programa en hilo: main
El hilo principal sigue trabajando...
Ejecutando tarea asíncrona en hilo: ForkJoinPool.commonPool-worker-1
Resultado obtenido: Hola desde la tarea asíncrona!
Fin del programa en hilo: main

Observa cómo la tarea asíncrona se ejecuta en un hilo diferente (ForkJoinPool.commonPool-worker-1) mientras el hilo principal (main) continúa. future.get() bloquea el hilo principal solo en ese punto para esperar el resultado.

2. runAsync() para tareas que no devuelven un valor

Se utiliza para ejecutar una tarea asíncrona que no produce un resultado, es decir, un Runnable.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class RunAsyncExample {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        System.out.println("Inicio del programa en hilo: " + Thread.currentThread().getName());

        CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
            System.out.println("Ejecutando tarea sin retorno en hilo: " + Thread.currentThread().getName());
            try {
                Thread.sleep(1500);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            System.out.println("Tarea sin retorno completada.");
        });

        System.out.println("El hilo principal continúa con otras operaciones...");

        future.get(); // Bloquea hasta que la tarea se complete
        System.out.println("La tarea asíncrona sin retorno ha finalizado desde el punto de vista del hilo principal.");

        System.out.println("Fin del programa.");
    }
}

3. Completando manualmente un CompletableFuture

Puedes crear un CompletableFuture que no está vinculado a una tarea asíncrona inicial y completarlo manualmente más tarde. Esto es útil para integrar con APIs que no son asíncronas por naturaleza.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class ManualCompletionExample {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> future = new CompletableFuture<>();

        new Thread(() -> {
            try {
                System.out.println("Tarea en segundo plano esperando...");
                Thread.sleep(3000);
                future.complete("Datos obtenidos de forma manual"); // Completa el futuro
            } catch (InterruptedException e) {
                future.completeExceptionally(e); // Completa con una excepción
            }
        }).start();

        System.out.println("Esperando el resultado del futuro...");
        String result = future.get();
        System.out.println("Resultado manual: " + result);
    }
}

⛓️ Componiendo y Encadenando CompletableFuture

La verdadera potencia de CompletableFuture reside en su capacidad para encadenar y componer múltiples operaciones asíncronas. Aquí exploraremos los métodos más comunes.

1. thenApply(): Transformar el resultado

Se utiliza para aplicar una función al resultado de un CompletableFuture cuando este completa, devolviendo un nuevo CompletableFuture con el resultado transformado. La transformación se ejecuta en el mismo hilo que completó la etapa anterior, o en un hilo de ForkJoinPool si la etapa se completa de forma síncrona.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class ThenApplyExample {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> initialFuture = CompletableFuture.supplyAsync(() -> {
            System.out.println("Obteniendo nombre...");
            return "Alice";
        });

        CompletableFuture<String> transformedFuture = initialFuture.thenApply(name -> {
            System.out.println("Transformando nombre...");
            return name.toUpperCase();
        });

        System.out.println("Resultado final: " + transformedFuture.get()); // ALICE
    }
}

2. thenAccept(): Consumir el resultado

Permite ejecutar una acción (un Consumer) cuando el CompletableFuture completa, sin devolver ningún valor (retorna CompletableFuture<Void>).

import java.util.concurrent.CompletableFuture;

public class ThenAcceptExample {

    public static void main(String[] args) {
        CompletableFuture.supplyAsync(() -> {
            System.out.println("Generando número...");
            return 42;
        }).thenAccept(number -> {
            System.out.println("El número generado es: " + number);
        });

        // Esperar un poco para que el hilo asíncrono termine
        try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

3. thenRun(): Ejecutar una acción sin acceso al resultado

Ejecuta un Runnable cuando el CompletableFuture completa, sin acceso al resultado ni devolver valor.

import java.util.concurrent.CompletableFuture;

public class ThenRunExample {

    public static void main(String[] args) {
        CompletableFuture.supplyAsync(() -> {
            System.out.println("Tarea inicial...");
            return "Datos";
        }).thenRun(() -> {
            System.out.println("¡Tarea finalizada!");
        });

        try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

4. thenCompose(): Encadenar CompletableFutures

Este es crucial cuando la función de mapeo (la que recibe el resultado de la etapa anterior) también devuelve un CompletableFuture. Permite aplanar futuros anidados. Es el equivalente asíncrono de flatMap.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class ThenComposeExample {

    // Simula una llamada a un servicio que devuelve un CompletableFuture
    private static CompletableFuture<String> getUserDetails(String userId) {
        return CompletableFuture.supplyAsync(() -> {
            System.out.println("Obteniendo detalles del usuario " + userId);
            try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return "Detalles para " + userId + ": Email=" + userId.toLowerCase() + "@example.com";
        });
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Obteniendo ID de usuario...");
            try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return "user123";
        }).thenCompose(userId -> getUserDetails(userId)); // Aquí se encadenan los futuros

        System.out.println("El hilo principal espera el resultado combinado...");
        System.out.println("Resultado compuesto: " + future.get());
    }
}
🔥 Importante: La diferencia entre `thenApply` y `thenCompose` es clave. Usa `thenApply` cuando la función de transformación devuelve un valor *directo*. Usa `thenCompose` cuando la función de transformación devuelve *otro `CompletableFuture`*.

5. thenCombine(): Combinar resultados de dos CompletableFuture independientes

Si tienes dos CompletableFutures independientes y quieres combinarlos cuando ambos completen, puedes usar thenCombine(). Este método toma otro CompletableFuture y una BiFunction para combinar sus resultados.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class ThenCombineExample {

    private static CompletableFuture<String> getProductName() {
        return CompletableFuture.supplyAsync(() -> {
            System.out.println("Obteniendo nombre del producto...");
            try { Thread.sleep(1500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return "Laptop XZ-PRO";
        });
    }

    private static CompletableFuture<Double> getProductPrice() {
        return CompletableFuture.supplyAsync(() -> {
            System.out.println("Obteniendo precio del producto...");
            try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return 1200.50;
        });
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> combinedFuture = getProductName()
            .thenCombine(getProductPrice(), (productName, price) -> {
                System.out.println("Combinando nombre y precio...");
                return "Producto: " + productName + ", Precio: " + price + " USD";
            });

        System.out.println("Esperando la combinación de futuros...");
        System.out.println(combinedFuture.get());
    }
}
60% de Dominio

🚨 Manejo de Errores en CompletableFuture

Los errores son inevitables, especialmente en operaciones asíncronas. CompletableFuture ofrece formas elegantes de gestionarlos.

1. exceptionally(): Recuperación de un error

Permite especificar una función de recuperación que se ejecutará si la etapa anterior falla. Si la etapa anterior completa con éxito, exceptionally() se ignora.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class ExceptionallyExample {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> futureWithError = CompletableFuture.supplyAsync(() -> {
            if (true) {
                throw new RuntimeException("¡Error simulado en la tarea!");
            }
            return "Éxito";
        }).exceptionally(ex -> {
            System.err.println("Excepción capturada: " + ex.getMessage());
            return "Valor de respaldo debido al error"; // Proporciona un valor de respaldo
        });

        System.out.println("Resultado final (con manejo de error): " + futureWithError.get());

        CompletableFuture<String> futureWithoutError = CompletableFuture.supplyAsync(() -> {
            return "Éxito sin error";
        }).exceptionally(ex -> {
            System.err.println("Esto no se ejecutará.");
            return "Valor de respaldo";
        });

        System.out.println("Resultado final (sin error): " + futureWithoutError.get());
    }
}

2. handle(): Manejo de resultado o excepción

handle() se ejecuta tanto si la etapa anterior completa con éxito como si falla. Recibe el resultado y la excepción (uno de ellos será null).

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class HandleExample {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            // throw new RuntimeException("Falló!"); // Descomentar para probar el caso de error
            return "Datos válidos";
        }).handle((result, ex) -> {
            if (ex != null) {
                System.err.println("Manejando excepción: " + ex.getMessage());
                return "Error manejado";
            } else {
                System.out.println("Manejando resultado: " + result);
                return result.toUpperCase();
            }
        });

        System.out.println("Resultado de handle: " + future.get());
    }
}

⏳ Ejecutores Personalizados y Timeout

Por defecto, CompletableFuture utiliza el ForkJoinPool.commonPool() para ejecutar sus tareas asíncronas. Sin embargo, puedes especificar un Executor diferente para tener más control sobre la gestión de hilos.

Usando un Executor personalizado

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

public class CustomExecutorExample {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // Crear un pool de hilos con un número fijo de hilos
        ExecutorService customExecutor = Executors.newFixedThreadPool(5);

        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Tarea en ejecutor personalizado en hilo: " + Thread.currentThread().getName());
            try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return "¡Completado con ejecutor personalizado!";
        }, customExecutor); // Pasa el ejecutor aquí

        System.out.println("Esperando resultado del ejecutor personalizado...");
        System.out.println(future.get());

        // Apagar el ejecutor al finalizar
        customExecutor.shutdown();
    }
}
💡 Consejo: Usa ejecutores personalizados cuando necesites controlar el número de hilos, el comportamiento de la cola o la gestión de recursos de tus tareas asíncronas.

Control de Timeout

Desde Java 9, CompletableFuture incluye métodos para manejar timeouts.

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

public class TimeoutExample {

    public static void main(String[] args) {
        CompletableFuture<String> longRunningTask = CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(5000); // Simula una tarea muy larga
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return "Tarea completada exitosamente";
        });

        try {
            // Espera un máximo de 2 segundos. Si excede, lanza TimeoutException.
            String result = longRunningTask.get(2, TimeUnit.SECONDS);
            System.out.println("Resultado: " + result);
        } catch (InterruptedException | ExecutionException e) {
            System.err.println("Error al obtener el resultado: " + e.getMessage());
        } catch (TimeoutException e) {
            System.err.println("La tarea excedió el tiempo límite: " + e.getMessage());
            // Puedes completar el futuro con un valor por defecto o excepción aquí si lo deseas
            longRunningTask.cancel(true); // Opcional: intentar cancelar la tarea subyacente
        }

        // Para un manejo más integrado (Java 9+)
        CompletableFuture<String> futureWithTimeout = CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(3000); // Tarea que tarda 3 segundos
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return "¡Hola, soy rápido!";
        }).orTimeout(1, TimeUnit.SECONDS) // Establece un timeout de 1 segundo
          .exceptionally(ex -> {
              if (ex instanceof TimeoutException) {
                  return "Operación de respaldo por timeout";
              }
              return "Error desconocido: " + ex.getMessage();
          });

        try {
            System.out.println("Resultado con orTimeout: " + futureWithTimeout.get());
        } catch (InterruptedException | ExecutionException e) {
            System.err.println("Error con orTimeout: " + e.getMessage());
        }
    }
}

📈 Uso Avanzado y Patrones Comunes

1. allOf() y anyOf(): Combinando múltiples futuros

  • allOf(): Espera a que todos los CompletableFutures proporcionados completen. Retorna un CompletableFuture<Void>. Si alguno falla, el futuro combinado también falla.
  • anyOf(): Espera a que cualquiera de los CompletableFutures proporcionados complete. Retorna un CompletableFuture<Object>. El resultado es el de la primera tarea que termina.
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

public class AllOfAnyOfExample {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // Ejemplo de allOf()
        System.out.println("\n--- Usando CompletableFuture.allOf() ---");
        CompletableFuture<String> task1 = CompletableFuture.supplyAsync(() -> {
            try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return "Resultado de Tarea 1";
        });

        CompletableFuture<String> task2 = CompletableFuture.supplyAsync(() -> {
            try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return "Resultado de Tarea 2";
        });

        CompletableFuture<String> task3 = CompletableFuture.supplyAsync(() -> {
            try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return "Resultado de Tarea 3";
        });

        List<CompletableFuture<String>> allTasks = Arrays.asList(task1, task2, task3);

        CompletableFuture<Void> allOfFuture = CompletableFuture.allOf(allTasks.toArray(new CompletableFuture[0]));

        // Bloquea hasta que todas las tareas completen
        allOfFuture.get(); 

        // Para obtener los resultados individuales (requiere un poco más de trabajo)
        List<String> results = allTasks.stream()
                                       .map(CompletableFuture::join) // join() es como get() pero lanza UncheckedExecutionException
                                       .collect(Collectors.toList());

        System.out.println("Todos los resultados: " + results);

        // Ejemplo de anyOf()
        System.out.println("\n--- Usando CompletableFuture.anyOf() ---");
        CompletableFuture<String> fastTask = CompletableFuture.supplyAsync(() -> {
            try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return "¡Soy el más rápido!";
        });

        CompletableFuture<String> mediumTask = CompletableFuture.supplyAsync(() -> {
            try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return "Soy el del medio.";
        });

        CompletableFuture<Object> anyOfFuture = CompletableFuture.anyOf(fastTask, mediumTask);

        System.out.println("El primer resultado es: " + anyOfFuture.get()); // ¡Soy el más rápido!

    }
}

Diagrama de Flujo de CompletableFuture

Este diagrama ilustra un flujo común de operaciones asíncronas con CompletableFuture.

Inicio de Tarea Paso 1: supplyAsync() Paso 2: thenApply() Paso 3: anotherAsync() Paso 4: thenCombine() Manejo de Error: exceptionally() Completado: thenAccept() Fin del Flujo Asíncrono Task A Task B On Error On Success

⚠️ Consideraciones y Mejores Prácticas

  • Evita get() sin Timeout: Bloquear un hilo indefinidamente con future.get() puede llevar a Deadlocks o a una baja responsividad. Siempre que sea posible, usa versiones con timeout o encadena operaciones de forma no bloqueante.
  • Manejo de Excepciones: No olvides usar exceptionally() o handle() para gestionar los errores de forma robusta. Las excepciones no manejadas en futuros asíncronos pueden ser difíciles de depurar.
  • Ejecutores: Entiende cuándo usar el ForkJoinPool.commonPool() (por defecto) y cuándo necesitas un Executor personalizado. Para tareas I/O intensivas, a menudo es mejor un ThreadPoolExecutor con un tamaño de pool adecuado. Para tareas CPU intensivas, el commonPool suele ser suficiente.
  • Composición vs. Anidación: Prefiere la composición con thenCompose() y thenCombine() en lugar de anidar llamadas a get() dentro de otras llamadas, lo que recrea el problema del bloqueo.
  • Programación Funcional: Aprovecha al máximo las expresiones lambda y las referencias a métodos para que tu código sea más conciso y legible.
¿Cuándo usar un Executor personalizado y cuándo el commonPool? El `ForkJoinPool.commonPool()` es adecuado para la mayoría de las tareas de CPU-bound que no bloquean (como cálculos intensivos). Está optimizado para la eficiencia en este tipo de cargas. Sin embargo, para tareas I/O-bound (que esperan mucho por recursos externos como bases de datos, APIs o sistemas de archivos), el `commonPool` puede verse afectado porque sus hilos se bloquearían. En estos casos, un `ExecutorService` configurado con un `ThreadPoolExecutor` (por ejemplo, `Executors.newCachedThreadPool()` o `newFixedThreadPool()`) puede ser más apropiado, ya que permite más hilos para manejar el tiempo de espera sin agotar el commonPool.

✅ Conclusión

CompletableFuture es una herramienta increíblemente poderosa para modernizar tu código Java, haciéndolo más concurrente, eficiente y fácil de mantener. Al dominar sus métodos de creación, composición y manejo de errores, estarás un paso más cerca de construir aplicaciones de alto rendimiento que aprovechen al máximo los recursos de tu sistema.

La programación asíncrona es una habilidad esencial en el panorama actual del desarrollo de software, y CompletableFuture te proporciona las herramientas para abordarla con confianza. ¡Experimenta con los ejemplos y lleva tus habilidades de concurrencia al siguiente nivel!

Programación Asíncrona Java 8+ Rendimiento

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!

Dominando la Programación Asíncrona en Java con CompletableFuture | tutoriales.com