Java

Tutorial Java: Future y CompletableFuture

Aprende los fundamentos y composición avanzada de Future y CompletableFuture en Java para programación concurrente eficiente y asíncrona.

Aprende Java y certifícate

Fundamentos de Future y obtención de resultados

La programación concurrente en Java permite ejecutar tareas en paralelo, mejorando significativamente el rendimiento de las aplicaciones. Uno de los componentes fundamentales para gestionar operaciones asíncronas es la interfaz Future, introducida en Java 5 como parte del paquete java.util.concurrent.

Un Future representa el resultado pendiente de una tarea asíncrona. Cuando iniciamos una operación que puede tomar tiempo (como una consulta a base de datos o una llamada a API externa), podemos obtener inmediatamente un objeto Future y continuar con otras tareas mientras la operación original se completa en segundo plano.

Características básicas de Future

La interfaz Future<V> proporciona métodos para:

  • Verificar si la tarea ha finalizado
  • Esperar a que finalice la tarea
  • Recuperar el resultado cuando esté disponible
  • Cancelar la tarea si aún no ha finalizado

Veamos la definición simplificada de la interfaz:

public interface Future<V> {
    boolean cancel(boolean mayInterruptIfRunning);
    boolean isCancelled();
    boolean isDone();
    V get() throws InterruptedException, ExecutionException;
    V get(long timeout, TimeUnit unit) throws InterruptedException, 
                                             ExecutionException, 
                                             TimeoutException;
}

Creación de tareas asíncronas con ExecutorService

Para obtener un objeto Future, normalmente utilizamos un ExecutorService, que gestiona un grupo de hilos y nos permite enviar tareas para su ejecución:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class FutureExample {
    public static void main(String[] args) {
        // Crear un pool de hilos con un único hilo
        ExecutorService executor = Executors.newSingleThreadExecutor();
        
        // Enviar una tarea y obtener un Future
        Future<String> future = executor.submit(() -> {
            // Simulamos una operación que toma tiempo
            Thread.sleep(2000);
            return "Resultado de la operación";
        });
        
        System.out.println("Tarea enviada, continuando con otras operaciones...");
        
        // No olvidar cerrar el ExecutorService cuando ya no se necesite
        executor.shutdown();
    }
}

En este ejemplo, creamos un ExecutorService con un solo hilo y le enviamos una tarea que devuelve un String después de esperar 2 segundos. El método submit() devuelve inmediatamente un objeto Future<String> mientras la tarea se ejecuta en segundo plano.

Obtención de resultados con el método get()

El método más importante de Future es get(), que nos permite obtener el resultado de la tarea asíncrona:

import java.util.concurrent.*;

public class FutureGetExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        
        Future<Integer> future = executor.submit(() -> {
            // Simulamos un cálculo complejo
            Thread.sleep(3000);
            return 42;
        });
        
        try {
            System.out.println("Esperando resultado...");
            
            // Bloquea hasta que el resultado esté disponible
            Integer result = future.get();
            
            System.out.println("Resultado obtenido: " + result);
        } catch (InterruptedException e) {
            System.out.println("La operación fue interrumpida");
        } catch (ExecutionException e) {
            System.out.println("Error durante la ejecución: " + e.getCause());
        } finally {
            executor.shutdown();
        }
    }
}

Es importante entender que get() es un método bloqueante: el hilo que lo llama se detendrá hasta que el resultado esté disponible o se produzca una excepción. Esto puede afectar negativamente al rendimiento si no se maneja adecuadamente.

Estableciendo tiempos de espera con get(timeout, unit)

Para evitar bloqueos indefinidos, podemos usar la versión sobrecargada de get() que acepta un tiempo máximo de espera:

try {
    // Esperar como máximo 5 segundos
    Integer result = future.get(5, TimeUnit.SECONDS);
    System.out.println("Resultado: " + result);
} catch (TimeoutException e) {
    System.out.println("La operación tardó demasiado tiempo");
    // Podemos decidir cancelar la tarea
    future.cancel(true);
}

Si la tarea no se completa dentro del tiempo especificado, se lanzará una TimeoutException, permitiéndonos tomar medidas alternativas.

Verificación del estado de la tarea

Antes de intentar obtener el resultado, podemos verificar el estado de la tarea:

if (future.isDone()) {
    // La tarea ha finalizado (con éxito, con excepción o fue cancelada)
    try {
        Integer result = future.get();
        System.out.println("Resultado disponible: " + result);
    } catch (Exception e) {
        System.out.println("Error al obtener el resultado");
    }
} else {
    System.out.println("La tarea aún está en progreso");
}

if (future.isCancelled()) {
    System.out.println("La tarea fue cancelada");
}

Cancelación de tareas

Podemos intentar cancelar una tarea en curso utilizando el método cancel():

// El parámetro indica si se debe interrumpir el hilo si la tarea está en ejecución
boolean cancelled = future.cancel(true);

if (cancelled) {
    System.out.println("La tarea fue cancelada exitosamente");
} else {
    System.out.println("No se pudo cancelar la tarea (ya terminó o ya fue cancelada)");
}

Ejemplo práctico: consulta paralela a múltiples servicios

Veamos un ejemplo más completo donde consultamos varios servicios en paralelo y procesamos sus resultados a medida que estén disponibles:

import java.util.*;
import java.util.concurrent.*;

public class ParallelServiceQuery {
    public static void main(String[] args) {
        // Crear un pool con 3 hilos
        ExecutorService executor = Executors.newFixedThreadPool(3);
        
        // Lista para almacenar los futures
        List<Future<String>> futures = new ArrayList<>();
        
        // Enviar tres consultas en paralelo
        futures.add(executor.submit(() -> consultarServicio("Servicio A", 2000)));
        futures.add(executor.submit(() -> consultarServicio("Servicio B", 1000)));
        futures.add(executor.submit(() -> consultarServicio("Servicio C", 3000)));
        
        // Procesar resultados a medida que estén disponibles
        for (Future<String> future : futures) {
            try {
                String resultado = future.get(4, TimeUnit.SECONDS);
                System.out.println("Recibido: " + resultado);
            } catch (TimeoutException e) {
                System.out.println("Tiempo de espera agotado para un servicio");
                future.cancel(true);
            } catch (Exception e) {
                System.out.println("Error al consultar servicio: " + e.getMessage());
            }
        }
        
        executor.shutdown();
    }
    
    private static String consultarServicio(String nombre, long tiempoRespuesta) 
            throws InterruptedException {
        System.out.println("Consultando " + nombre + "...");
        Thread.sleep(tiempoRespuesta);  // Simulamos tiempo de respuesta variable
        return nombre + " respondió correctamente";
    }
}

En este ejemplo, enviamos tres consultas a servicios ficticios con diferentes tiempos de respuesta. Luego, iteramos sobre los Future y obtenemos cada resultado con un tiempo máximo de espera de 4 segundos.

Limitaciones de Future

Aunque Future es útil para tareas asíncronas básicas, tiene algunas limitaciones importantes:

  • No proporciona notificaciones de finalización (callbacks)
  • No permite encadenar operaciones de forma fluida
  • No facilita la combinación de múltiples resultados asíncronos
  • Requiere manejo explícito de excepciones para cada operación

Para superar estas limitaciones, Java 8 introdujo la clase CompletableFuture, que extiende Future con capacidades más avanzadas para la programación asíncrona.

Buenas prácticas al trabajar con Future

  • Evita bloqueos innecesarios: Usa get() con timeout para evitar bloqueos indefinidos.
  • Cierra siempre el ExecutorService: Utiliza shutdown() cuando ya no necesites ejecutar más tareas.
  • Maneja adecuadamente las excepciones: Las tareas asíncronas pueden fallar, asegúrate de capturar ExecutionException y examinar su causa.
  • Considera alternativas para operaciones complejas: Para flujos asíncronos más sofisticados, considera usar CompletableFuture o bibliotecas reactivas.

CompletableFuture: creación y operaciones básicas

La clase CompletableFuture introducida en Java 8 representa una evolución significativa respecto a la interfaz Future tradicional. Esta implementación avanzada permite crear flujos asíncronos más flexibles y expresivos, superando las limitaciones del modelo básico de concurrencia.

CompletableFuture implementa tanto la interfaz Future<T> como CompletionStage<T>, lo que le permite combinar lo mejor de ambos mundos: la capacidad de representar un resultado futuro y la posibilidad de componer operaciones de forma fluida.

Creación de CompletableFuture

Existen varias formas de crear instancias de CompletableFuture, dependiendo de nuestras necesidades:

1. Creación de un CompletableFuture ya completado

Cuando ya disponemos del resultado, podemos crear un CompletableFuture que ya esté completado:

CompletableFuture<String> completado = CompletableFuture.completedFuture("Resultado inmediato");
String resultado = completado.get(); // No bloquea, el resultado ya está disponible

2. Creación de un CompletableFuture vacío para completar manualmente

Podemos crear un CompletableFuture vacío y completarlo más tarde, lo que resulta útil cuando el resultado provendrá de otra fuente:

CompletableFuture<Integer> futuro = new CompletableFuture<>();

// En algún momento, quizás en otro hilo:
futuro.complete(42);

// O si ocurre un error:
// futuro.completeExceptionally(new RuntimeException("Error en el proceso"));

3. Ejecución asíncrona con runAsync y supplyAsync

Los métodos más comunes para crear tareas asíncronas son runAsync (para tareas sin resultado) y supplyAsync (para tareas que devuelven un valor):

// Para operaciones que no devuelven resultado (Runnable)
CompletableFuture<Void> tarea = CompletableFuture.runAsync(() -> {
    System.out.println("Ejecutando tarea en hilo: " + Thread.currentThread().getName());
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    System.out.println("Tarea completada");
});

// Para operaciones que devuelven un resultado (Supplier)
CompletableFuture<String> resultado = CompletableFuture.supplyAsync(() -> {
    System.out.println("Calculando en hilo: " + Thread.currentThread().getName());
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    return "Resultado calculado";
});

Por defecto, estos métodos utilizan el ForkJoinPool.commonPool() para ejecutar las tareas. Sin embargo, podemos especificar nuestro propio Executor:

ExecutorService executor = Executors.newFixedThreadPool(4);

CompletableFuture<String> futuro = CompletableFuture.supplyAsync(() -> {
    return "Tarea ejecutada en pool personalizado";
}, executor);

// No olvidar cerrar el executor cuando ya no se necesite
executor.shutdown();

Operaciones básicas con CompletableFuture

Una vez creado un CompletableFuture, podemos realizar diversas operaciones sobre él:

Obtención del resultado

Además del método get() heredado de Future, CompletableFuture ofrece alternativas no bloqueantes:

CompletableFuture<String> futuro = CompletableFuture.supplyAsync(() -> "Hola");

// Obtener resultado de forma bloqueante (evitar si es posible)
try {
    String resultado = futuro.get();
} catch (InterruptedException | ExecutionException e) {
    e.printStackTrace();
}

// Obtener con timeout
try {
    String resultado = futuro.get(500, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
    System.out.println("La operación tardó demasiado");
}

// Obtener resultado o valor por defecto (no lanza excepciones)
String resultado = futuro.getNow("Valor por defecto si no está listo");

// Comprobar si ya está completado
if (futuro.isDone()) {
    System.out.println("La tarea ha finalizado");
}

Transformación de resultados con thenApply

El método thenApply permite transformar el resultado de un CompletableFuture usando una función:

CompletableFuture<String> saludo = CompletableFuture.supplyAsync(() -> "Hola");

CompletableFuture<String> saludoCompleto = saludo.thenApply(s -> s + " Mundo");
CompletableFuture<Integer> longitud = saludo.thenApply(String::length);

// Encadenamiento de transformaciones
CompletableFuture<Integer> caracteresEnSaludoCompleto = saludo
    .thenApply(s -> s + " Mundo")
    .thenApply(String::length);

Consumo de resultados con thenAccept

Cuando queremos consumir el resultado sin devolver un nuevo valor, usamos thenAccept:

CompletableFuture<Void> mostrarResultado = CompletableFuture
    .supplyAsync(() -> "Resultado de operación")
    .thenAccept(resultado -> System.out.println("Obtenido: " + resultado));

Ejecución de acciones con thenRun

Para ejecutar una acción después de que se complete un CompletableFuture, sin acceder a su resultado:

CompletableFuture<Void> notificarFinalizacion = CompletableFuture
    .supplyAsync(() -> procesarDatos())
    .thenRun(() -> System.out.println("Procesamiento finalizado"));

Manejo de excepciones

CompletableFuture ofrece métodos específicos para manejar excepciones de forma elegante:

CompletableFuture<String> futuro = CompletableFuture.supplyAsync(() -> {
    if (Math.random() > 0.5) {
        throw new RuntimeException("Error simulado");
    }
    return "Operación exitosa";
});

// Recuperarse de excepciones
CompletableFuture<String> recuperado = futuro.exceptionally(ex -> {
    System.err.println("Error: " + ex.getMessage());
    return "Valor de recuperación";
});

// Manejar tanto el resultado exitoso como las excepciones
CompletableFuture<String> manejado = futuro.handle((resultado, excepcion) -> {
    if (excepcion != null) {
        return "Error manejado: " + excepcion.getMessage();
    } else {
        return "Éxito: " + resultado;
    }
});

Ejemplo práctico: consulta a servicio externo

Veamos un ejemplo más completo que simula una consulta a un servicio externo con manejo de errores y transformación de resultados:

public class ServicioEjemplo {
    
    public static void main(String[] args) throws Exception {
        ServicioEjemplo servicio = new ServicioEjemplo();
        
        // Simular consulta a servicio externo
        CompletableFuture<Usuario> usuarioFuturo = servicio.buscarUsuario(123)
            .thenApply(servicio::enriquecerUsuario)
            .exceptionally(ex -> {
                System.err.println("Error al procesar usuario: " + ex.getMessage());
                return new Usuario(0, "desconocido", "N/A");
            });
        
        // Procesar resultado cuando esté disponible
        usuarioFuturo.thenAccept(usuario -> 
            System.out.println("Usuario procesado: " + usuario.nombre));
        
        // Esperar para ver el resultado (en aplicaciones reales usaríamos callbacks)
        Thread.sleep(2000);
    }
    
    // Simula una consulta asíncrona a base de datos
    public CompletableFuture<Usuario> buscarUsuario(int id) {
        return CompletableFuture.supplyAsync(() -> {
            System.out.println("Buscando usuario " + id);
            try {
                Thread.sleep(1000); // Simular latencia de red/BD
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            
            if (id <= 0) {
                throw new IllegalArgumentException("ID de usuario inválido");
            }
            
            return new Usuario(id, "Usuario" + id, "usuario" + id + "@ejemplo.com");
        });
    }
    
    // Simula enriquecimiento de datos de usuario
    public Usuario enriquecerUsuario(Usuario usuario) {
        System.out.println("Enriqueciendo datos de " + usuario.nombre);
        usuario.nombre = usuario.nombre + " (Verificado)";
        return usuario;
    }
    
    // Clase interna para representar un usuario
    static class Usuario {
        int id;
        String nombre;
        String email;
        
        Usuario(int id, String nombre, String email) {
            this.id = id;
            this.nombre = nombre;
            this.email = email;
        }
    }
}

Versiones asíncronas y síncronas de los métodos

CompletableFuture ofrece variantes de sus métodos principales con diferentes comportamientos de ejecución:

  • Métodos estándar (thenApply, thenAccept, etc.): Utilizan el mismo hilo que completa la etapa anterior si está disponible.
  • Métodos con sufijo Async (thenApplyAsync, thenAcceptAsync, etc.): Siempre ejecutan la función en un hilo diferente del pool por defecto.
// Se ejecuta en el mismo hilo que completó el futuro original si es posible
CompletableFuture<Integer> sinAsync = CompletableFuture
    .supplyAsync(() -> "Texto")
    .thenApply(String::length);

// Garantiza ejecución en un hilo diferente del pool
CompletableFuture<Integer> conAsync = CompletableFuture
    .supplyAsync(() -> "Texto")
    .thenApplyAsync(String::length);

// Con executor personalizado
ExecutorService executor = Executors.newSingleThreadExecutor();
CompletableFuture<Integer> conExecutor = CompletableFuture
    .supplyAsync(() -> "Texto")
    .thenApplyAsync(String::length, executor);

Completando manualmente un CompletableFuture

En algunos escenarios, necesitamos crear un CompletableFuture y completarlo manualmente, por ejemplo, cuando integramos con APIs de callback:

public CompletableFuture<Resultado> operacionAsincrona() {
    CompletableFuture<Resultado> futuro = new CompletableFuture<>();
    
    // Simular una API basada en callbacks
    ejecutarOperacionConCallback(new Callback() {
        @Override
        public void onSuccess(DatoExterno dato) {
            // Convertir y completar el futuro con éxito
            Resultado resultado = convertirAResultado(dato);
            futuro.complete(resultado);
        }
        
        @Override
        public void onError(Exception e) {
            // Completar el futuro con excepción
            futuro.completeExceptionally(e);
        }
    });
    
    return futuro;
}

// Métodos simulados para el ejemplo
private void ejecutarOperacionConCallback(Callback callback) {
    new Thread(() -> {
        try {
            Thread.sleep(1000);
            callback.onSuccess(new DatoExterno("datos"));
        } catch (Exception e) {
            callback.onError(e);
        }
    }).start();
}

private Resultado convertirAResultado(DatoExterno dato) {
    return new Resultado(dato.valor);
}

interface Callback {
    void onSuccess(DatoExterno dato);
    void onError(Exception e);
}

class DatoExterno {
    String valor;
    DatoExterno(String valor) { this.valor = valor; }
}

class Resultado {
    String datos;
    Resultado(String datos) { this.datos = datos; }
}

Este patrón es especialmente útil cuando trabajamos con APIs antiguas basadas en callbacks y queremos adaptarlas al modelo de programación asíncrona moderna.

Composición de operaciones asíncronas

Una de las principales ventajas de CompletableFuture frente a la interfaz Future tradicional es su capacidad para componer operaciones asíncronas de forma fluida y expresiva. Esta característica nos permite construir flujos de trabajo complejos donde cada paso depende de los resultados de operaciones anteriores, todo ello sin bloquear hilos ni caer en el temido "callback hell".

La composición de operaciones asíncronas permite expresar dependencias entre tareas y combinar sus resultados de manera declarativa, lo que resulta en código más limpio y mantenible.

Combinación de dos futuros independientes

Cuando necesitamos combinar los resultados de dos operaciones asíncronas que no dependen entre sí, podemos utilizar el método thenCombine:

CompletableFuture<Integer> precioFuturo = CompletableFuture.supplyAsync(() -> {
    // Simular consulta de precio a un servicio
    sleep(1000);
    return 100;
});

CompletableFuture<Double> impuestoFuturo = CompletableFuture.supplyAsync(() -> {
    // Simular consulta de tasa de impuesto
    sleep(800);
    return 0.21;
});

// Combinar ambos resultados cuando estén disponibles
CompletableFuture<Double> precioFinalFuturo = precioFuturo.thenCombine(
    impuestoFuturo,
    (precio, impuesto) -> precio * (1 + impuesto)
);

// Resultado: 121.0

En este ejemplo, las consultas de precio e impuesto se ejecutan en paralelo, y la operación de combinación se realiza solo cuando ambos resultados están disponibles.

Composición secuencial con thenCompose

Cuando una operación asíncrona depende del resultado de otra operación asíncrona, utilizamos thenCompose para evitar la anidación de futuros:

CompletableFuture<Usuario> usuarioFuturo = CompletableFuture.supplyAsync(() -> {
    // Simular búsqueda de usuario por ID
    sleep(700);
    return new Usuario(1, "usuario@ejemplo.com");
});

// Operación que depende del resultado anterior y devuelve otro CompletableFuture
CompletableFuture<List<Pedido>> pedidosFuturo = usuarioFuturo.thenCompose(usuario -> {
    // Con el usuario obtenido, consultamos sus pedidos
    return CompletableFuture.supplyAsync(() -> {
        sleep(500);
        return obtenerPedidosDeUsuario(usuario.id);
    });
});

Es importante entender la diferencia entre thenApply y thenCompose:

  • thenApply: Transforma el resultado de un CompletableFuture<T> en otro tipo, resultando en un CompletableFuture<U>.
  • thenCompose: Se utiliza cuando la función de transformación ya devuelve un CompletableFuture<U>, evitando así un CompletableFuture<CompletableFuture<U>>.

Ejecución de múltiples futuros en paralelo

Cuando necesitamos ejecutar varias operaciones asíncronas en paralelo y continuar cuando todas hayan finalizado, podemos utilizar allOf:

List<String> urls = Arrays.asList(
    "https://api.ejemplo.com/productos",
    "https://api.ejemplo.com/categorias",
    "https://api.ejemplo.com/ofertas"
);

// Crear un CompletableFuture para cada solicitud
List<CompletableFuture<String>> futures = urls.stream()
    .map(url -> CompletableFuture.supplyAsync(() -> descargarContenido(url)))
    .collect(Collectors.toList());

// Esperar a que todas las solicitudes se completen
CompletableFuture<Void> todasLasSolicitudes = CompletableFuture.allOf(
    futures.toArray(new CompletableFuture[0])
);

// Procesar todos los resultados cuando estén disponibles
CompletableFuture<List<String>> todosLosResultados = todasLasSolicitudes.thenApply(v -> 
    futures.stream()
        .map(CompletableFuture::join) // join no lanza excepciones checked
        .collect(Collectors.toList())
);

// Consumir los resultados
todosLosResultados.thenAccept(resultados -> {
    System.out.println("Todos los datos descargados:");
    resultados.forEach(System.out::println);
});

El método allOf devuelve un CompletableFuture<Void> que se completa cuando todos los futuros proporcionados se han completado. Observa que utilizamos join() en lugar de get() para obtener los resultados, ya que sabemos que los futuros ya están completados y join() no lanza excepciones comprobadas.

Ejecución de múltiples futuros y obtener el primero en completarse

En algunos escenarios, como consultar múltiples servicios redundantes, nos interesa obtener el primer resultado disponible:

CompletableFuture<String> servicio1 = CompletableFuture.supplyAsync(() -> {
    sleep(500);
    return "Respuesta del servicio 1";
});

CompletableFuture<String> servicio2 = CompletableFuture.supplyAsync(() -> {
    sleep(300);  // Este termina primero
    return "Respuesta del servicio 2";
});

// Obtener el resultado del que termine primero
CompletableFuture<String> primerResultado = CompletableFuture.anyOf(servicio1, servicio2)
    .thenApply(resultado -> (String) resultado);

// Resultado: "Respuesta del servicio 2"

El método anyOf devuelve un CompletableFuture<Object> que se completa con el resultado del primer futuro que termine. Debemos realizar un cast al tipo esperado, ya que anyOf no puede preservar la información de tipo genérico.

Manejo de errores en composiciones

Al componer operaciones asíncronas, es crucial manejar adecuadamente los errores para evitar que una excepción interrumpa todo el flujo:

CompletableFuture<Double> precioConDescuento = CompletableFuture
    .supplyAsync(() -> consultarPrecioBase())
    .thenCompose(precioBase -> 
        CompletableFuture.supplyAsync(() -> consultarDescuento())
            .exceptionally(ex -> {
                System.err.println("Error al consultar descuento: " + ex.getMessage());
                return 0.0;  // Valor por defecto si falla la consulta de descuento
            })
            .thenApply(descuento -> aplicarDescuento(precioBase, descuento))
    )
    .exceptionally(ex -> {
        System.err.println("Error en el proceso: " + ex.getMessage());
        return 0.0;  // Valor por defecto si falla todo el proceso
    });

En este ejemplo, manejamos errores en dos niveles: uno específico para la consulta de descuento y otro general para todo el proceso.

Patrones avanzados de composición

Ejecución condicional

Podemos implementar lógica condicional en nuestros flujos asíncronos:

CompletableFuture<String> resultado = CompletableFuture
    .supplyAsync(() -> obtenerDatos())
    .thenCompose(datos -> {
        if (datos.requiereProcesamientoAdicional()) {
            return CompletableFuture.supplyAsync(() -> procesarDatos(datos));
        } else {
            return CompletableFuture.completedFuture(datos.toString());
        }
    });

Reintentos asíncronos

Implementar un mecanismo de reintentos para operaciones que pueden fallar temporalmente:

public <T> CompletableFuture<T> conReintentos(
        Supplier<CompletableFuture<T>> operacion, 
        int maxIntentos, 
        long esperaEntreIntentos) {
    
    CompletableFuture<T> futuroResultado = new CompletableFuture<>();
    
    ejecutarConReintentos(operacion, futuroResultado, maxIntentos, esperaEntreIntentos, 1);
    
    return futuroResultado;
}

private <T> void ejecutarConReintentos(
        Supplier<CompletableFuture<T>> operacion,
        CompletableFuture<T> resultado,
        int maxIntentos,
        long esperaEntreIntentos,
        int intentoActual) {
    
    operacion.get().whenComplete((valor, excepcion) -> {
        if (excepcion == null) {
            // Éxito
            resultado.complete(valor);
        } else if (intentoActual < maxIntentos) {
            // Falló, pero podemos reintentar
            System.out.println("Intento " + intentoActual + " falló. Reintentando en " 
                + esperaEntreIntentos + "ms");
            
            CompletableFuture.delayedExecutor(
                esperaEntreIntentos, TimeUnit.MILLISECONDS)
                .execute(() -> ejecutarConReintentos(
                    operacion, resultado, maxIntentos, 
                    esperaEntreIntentos, intentoActual + 1));
        } else {
            // Agotamos los reintentos
            resultado.completeExceptionally(excepcion);
        }
    });
}

Este patrón es especialmente útil para operaciones de red o servicios externos que pueden fallar por problemas transitorios.

Ejemplo práctico: procesamiento de pedidos

Veamos un ejemplo completo que simula un flujo de procesamiento de pedidos utilizando composición de operaciones asíncronas:

public class ProcesadorPedidos {
    
    public CompletableFuture<ResultadoPedido> procesarPedido(Pedido pedido) {
        return verificarInventario(pedido.getProductoId(), pedido.getCantidad())
            .thenCompose(inventarioOk -> {
                if (!inventarioOk) {
                    return CompletableFuture.completedFuture(
                        new ResultadoPedido(false, "Inventario insuficiente"));
                }
                
                return CompletableFuture.allOf(
                    reservarInventario(pedido.getProductoId(), pedido.getCantidad()),
                    verificarPago(pedido.getMetodoPago(), pedido.getTotal())
                ).thenCompose(v -> {
                    // Ambas operaciones completadas con éxito
                    return generarFactura(pedido)
                        .thenCompose(factura -> 
                            enviarConfirmacion(pedido.getEmailCliente(), factura)
                                .thenApply(enviado -> 
                                    new ResultadoPedido(true, "Pedido procesado correctamente")
                                )
                        );
                }).exceptionally(ex -> {
                    liberarInventario(pedido.getProductoId(), pedido.getCantidad());
                    return new ResultadoPedido(false, "Error en el procesamiento: " + ex.getMessage());
                });
            });
    }
    
    // Métodos simulados para las diferentes etapas del proceso
    private CompletableFuture<Boolean> verificarInventario(String productoId, int cantidad) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(200);
            return cantidad <= 10; // Simulación simple
        });
    }
    
    private CompletableFuture<Void> reservarInventario(String productoId, int cantidad) {
        return CompletableFuture.runAsync(() -> {
            sleep(300);
            System.out.println("Inventario reservado: " + productoId + " x " + cantidad);
        });
    }
    
    private void liberarInventario(String productoId, int cantidad) {
        System.out.println("Inventario liberado: " + productoId + " x " + cantidad);
    }
    
    private CompletableFuture<Boolean> verificarPago(String metodoPago, double total) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(500);
            System.out.println("Pago verificado: " + total + " con " + metodoPago);
            return true;
        });
    }
    
    private CompletableFuture<String> generarFactura(Pedido pedido) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(200);
            String numeroFactura = "F-" + System.currentTimeMillis();
            System.out.println("Factura generada: " + numeroFactura);
            return numeroFactura;
        });
    }
    
    private CompletableFuture<Boolean> enviarConfirmacion(String email, String factura) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(300);
            System.out.println("Confirmación enviada a " + email + " para factura " + factura);
            return true;
        });
    }
    
    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    
    // Clases auxiliares
    static class Pedido {
        private String productoId;
        private int cantidad;
        private String metodoPago;
        private double total;
        private String emailCliente;
        
        // Constructor y getters
        public Pedido(String productoId, int cantidad, String metodoPago, 
                     double total, String emailCliente) {
            this.productoId = productoId;
            this.cantidad = cantidad;
            this.metodoPago = metodoPago;
            this.total = total;
            this.emailCliente = emailCliente;
        }
        
        public String getProductoId() { return productoId; }
        public int getCantidad() { return cantidad; }
        public String getMetodoPago() { return metodoPago; }
        public double getTotal() { return total; }
        public String getEmailCliente() { return emailCliente; }
    }
    
    static class ResultadoPedido {
        private boolean exito;
        private String mensaje;
        
        public ResultadoPedido(boolean exito, String mensaje) {
            this.exito = exito;
            this.mensaje = mensaje;
        }
        
        public boolean isExito() { return exito; }
        public String getMensaje() { return mensaje; }
        
        @Override
        public String toString() {
            return "ResultadoPedido{exito=" + exito + ", mensaje='" + mensaje + "'}";
        }
    }
}

Este ejemplo muestra cómo podemos modelar un flujo de trabajo complejo con dependencias entre operaciones, ejecución en paralelo cuando es posible, y manejo adecuado de errores.

Buenas prácticas para la composición de operaciones asíncronas

  • Evita bloqueos: No uses get() o join() dentro de las funciones de transformación o composición.
  • Maneja los errores en cada nivel: Utiliza exceptionally o handle para recuperarte de fallos en puntos específicos.
  • Prefiere thenCompose sobre thenApply cuando trabajes con funciones que devuelven CompletableFuture.
  • Utiliza el executor adecuado: Para operaciones intensivas en CPU o E/S, considera usar un executor específico.
  • Evita futuros huérfanos: Asegúrate de que todos los CompletableFuture se completen eventualmente (con éxito o error).
  • Considera timeouts: Para operaciones que pueden bloquearse indefinidamente, establece tiempos de espera máximos.
// Aplicar timeout a una operación asíncrona
CompletableFuture<String> conTimeout = operacionLarga()
    .orTimeout(5, TimeUnit.SECONDS)
    .exceptionally(ex -> {
        if (ex instanceof TimeoutException) {
            return "La operación excedió el tiempo máximo";
        }
        return "Error: " + ex.getMessage();
    });

La composición de operaciones asíncronas con CompletableFuture nos permite expresar flujos de trabajo complejos de manera clara y concisa, mejorando la legibilidad y mantenibilidad del código mientras aprovechamos al máximo los recursos disponibles.

CONSTRUYE TU CARRERA EN IA Y PROGRAMACIÓN SOFTWARE

Accede a +1000 lecciones y cursos con certificado. Mejora tu portfolio con certificados de superación para tu CV.

30 % DE DESCUENTO

Plan mensual

19.00 /mes

13.30 € /mes

Precio normal mensual: 19 €
63 % DE DESCUENTO

Plan anual

10.00 /mes

7.00 € /mes

Ahorras 144 € al año
Precio normal anual: 120 €
Aprende Java online

Ejercicios de esta lección Future y CompletableFuture

Evalúa tus conocimientos de esta lección Future y CompletableFuture con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.

Streams: match

Test

Gestión de errores y excepciones

Código

CRUD en Java de modelo Customer sobre un ArrayList

Proyecto

Clases abstractas

Test

Listas

Código

Métodos de la clase String

Código

Streams: reduce()

Test

API java.nio 2

Puzzle

Polimorfismo

Código

Pattern Matching

Código

Streams: flatMap()

Test

Llamada y sobrecarga de funciones

Puzzle

Métodos referenciados

Test

Métodos de la clase String

Código

Representación de Fecha

Puzzle

Operadores lógicos

Test

Inferencia de tipos con var

Código

Tipos de datos

Código

Estructuras de iteración

Puzzle

Streams: forEach()

Test

Objetos

Puzzle

Funciones lambda

Test

Uso de Scanner

Puzzle

Tipos de variables

Puzzle

Streams: collect()

Puzzle

Operadores aritméticos

Puzzle

Arrays y matrices

Código

Clases y objetos

Código

Interfaz funcional Consumer

Test

CRUD en Java de modelo Customer sobre un HashMap

Proyecto

Interfaces

Código

Enumeraciones Enums

Código

API Optional

Test

Interfaz funcional Function

Test

Encapsulación

Test

Interfaces

Código

Uso de API Optional

Puzzle

Representación de Hora

Test

Herencia básica

Test

Clases y objetos

Código

Interfaz funcional Supplier

Puzzle

HashMap

Puzzle

Sobrecarga de métodos

Test

Polimorfismo de tiempo de ejecución

Puzzle

OOP en Java

Proyecto

Sobrecarga de métodos

Código

CRUD de productos en Java

Proyecto

Clases sealed

Código

Creación de Streams

Test

Records

Código

Encapsulación

Código

Streams: min max

Puzzle

Herencia

Código

Métodos avanzados de la clase String

Puzzle

Funciones

Código

Polimorfismo de tiempo de compilación

Test

Reto sintaxis Java

Proyecto

Conjuntos

Código

Estructuras de control

Código

Recursión

Código

Excepciones

Puzzle

Herencia avanzada

Puzzle

Estructuras de selección

Test

Uso de interfaces

Test

Operadores

Código

Variables

Código

HashSet

Test

Objeto Scanner

Test

Streams: filter()

Puzzle

Operaciones de Streams

Puzzle

Interfaz funcional Predicate

Puzzle

Streams: sorted()

Test

Configuración de entorno

Test

Uso de variables

Test

Clases

Test

Streams: distinct()

Puzzle

Streams: count()

Test

ArrayList

Test

Mapas

Código

Datos de referencia

Test

Interfaces funcionales

Puzzle

Métodos básicos de la clase String

Test

Tipos de datos

Código

Clases abstractas

Código

Instalación

Test

Funciones

Código

Excepciones

Código

Estructuras de control

Código

Herencia de clases

Código

La clase Scanner

Código

Generics

Código

Streams: map()

Puzzle

Funciones y encapsulamiento

Test

Todas las lecciones de Java

Accede a todas las lecciones de Java y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Instalación De Java

Introducción Y Entorno

Configuración De Entorno Java

Introducción Y Entorno

Tipos De Datos

Sintaxis

Variables

Sintaxis

Operadores

Sintaxis

Estructuras De Control

Sintaxis

Funciones

Sintaxis

Recursión

Sintaxis

Arrays Y Matrices

Sintaxis

Excepciones

Programación Orientada A Objetos

Clases Y Objetos

Programación Orientada A Objetos

Encapsulación

Programación Orientada A Objetos

Herencia

Programación Orientada A Objetos

Clases Abstractas

Programación Orientada A Objetos

Interfaces

Programación Orientada A Objetos

Sobrecarga De Métodos

Programación Orientada A Objetos

Polimorfismo

Programación Orientada A Objetos

La Clase Scanner

Programación Orientada A Objetos

Métodos De La Clase String

Programación Orientada A Objetos

Excepciones

Programación Orientada A Objetos

Records

Programación Orientada A Objetos

Pattern Matching

Programación Orientada A Objetos

Inferencia De Tipos Con Var

Programación Orientada A Objetos

Enumeraciones Enums

Programación Orientada A Objetos

Generics

Programación Orientada A Objetos

Clases Sealed

Programación Orientada A Objetos

Listas

Framework Collections

Conjuntos

Framework Collections

Mapas

Framework Collections

Funciones Lambda

Programación Funcional

Interfaz Funcional Consumer

Programación Funcional

Interfaz Funcional Predicate

Programación Funcional

Interfaz Funcional Supplier

Programación Funcional

Interfaz Funcional Function

Programación Funcional

Métodos Referenciados

Programación Funcional

Creación De Streams

Programación Funcional

Operaciones Intermedias Con Streams: Map()

Programación Funcional

Operaciones Intermedias Con Streams: Filter()

Programación Funcional

Operaciones Intermedias Con Streams: Distinct()

Programación Funcional

Operaciones Finales Con Streams: Collect()

Programación Funcional

Operaciones Finales Con Streams: Min Max

Programación Funcional

Operaciones Intermedias Con Streams: Flatmap()

Programación Funcional

Operaciones Intermedias Con Streams: Sorted()

Programación Funcional

Operaciones Finales Con Streams: Reduce()

Programación Funcional

Operaciones Finales Con Streams: Foreach()

Programación Funcional

Operaciones Finales Con Streams: Count()

Programación Funcional

Operaciones Finales Con Streams: Match

Programación Funcional

Api Optional

Programación Funcional

Transformación

Programación Funcional

Reducción Y Acumulación

Programación Funcional

Mapeo

Programación Funcional

Streams Paralelos

Programación Funcional

Agrupación Y Partición

Programación Funcional

Filtrado Y Búsqueda

Programación Funcional

Api Java.nio 2

Entrada Y Salida Io

Fundamentos De Io

Entrada Y Salida Io

Leer Y Escribir Archivos

Entrada Y Salida Io

Httpclient Moderno

Entrada Y Salida Io

Clases De Nio2

Entrada Y Salida Io

Api Java.time

Api Java.time

Localtime

Api Java.time

Localdatetime

Api Java.time

Localdate

Api Java.time

Executorservice

Concurrencia

Virtual Threads (Project Loom)

Concurrencia

Future Y Completablefuture

Concurrencia

Spring Framework

Frameworks Para Java

Micronaut

Frameworks Para Java

Maven

Frameworks Para Java

Gradle

Frameworks Para Java

Lombok Para Java

Frameworks Para Java

Quarkus

Frameworks Para Java

Ecosistema Jakarta Ee De Java

Frameworks Para Java

Introducción A Junit 5

Testing

Accede GRATIS a Java y certifícate

Certificados de superación de Java

Supera todos los ejercicios de programación del curso de Java y obtén certificados de superación para mejorar tu currículum y tu empleabilidad.

En esta lección

Objetivos de aprendizaje de esta lección

  • Comprender la interfaz Future y su uso para gestionar tareas asíncronas básicas.
  • Aprender a crear y obtener resultados de tareas asíncronas con ExecutorService y Future.
  • Conocer las ventajas y funcionalidades avanzadas de CompletableFuture frente a Future.
  • Aprender a componer operaciones asíncronas de forma fluida y manejar errores con CompletableFuture.
  • Aplicar buenas prácticas en la programación concurrente y asíncrona en Java.