Programação Assíncrona em Java: Dominando a API CompletableFuture

Introdução à Execução Assíncrona

Em cenários de negócios complexos, a execução assíncrona de tarefas é fundamental para otimizar o tempo de resposta. Antes do Java 8, a interface Future<V> era a principal ferramenta para isso. No entanto, extrair o resultado de um Future exigia chamadas bloqueantes com get() ou verificações contínuas (polling) com isDone(), o que resultava em código ineficiente e pouco elegante.

A Abordagem Tradicional com Future e Bibliotecas de Terceiros

Para contornar as limitações do Future nativo, desenvolvedores frequentemente recorriam a bibliotecas como o Guava, que introduziu o ListenableFuture, permitindo o registro de callbacks. Veja um exemplo de autenticação de usuário:

ExecutorService threadPool = Executors.newFixedThreadPool(5);
ListenableFutureTask<String> authTask = ListenableFutureTask.create(() -> {
    Thread.sleep(2000);
    return "JWT_TOKEN_GENERATED";
});

Futures.addCallback(authTask, new FutureCallback<String>() {
    @Override
    public void onSuccess(@NullableDecl String token) {
        System.out.println("Token recebido: " + token);
    }
    @Override
    public void onFailure(Throwable t) {
        System.err.println("Falha na autenticação: " + t.getMessage());
    }
}, Executors.newSingleThreadExecutor());

threadPool.submit(authTask);

Orquestração de Múltiplas Tarefas com CountDownLatch

Quando tarefas possuem depandências, o ListenableFuture ainda não é suficiente. Imagine a montagem de uma página de reserva de viagens, onde precisamos buscar:

  • Tarefa A: Detalhes do voo.
  • Tarefa B: Ofertas de hotéis.
  • Tarefa C: Disponibilidade de carros para aluguel (depende de A e B para saber o destino e datas).

Para coordenar isso no modelo antigo, usaríamos um CountDownLatch:

ExecutorService executor = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(2);

Future<String> flightFuture = executor.submit(() -> {
    Thread.sleep(1500);
    System.out.println("Voos carregados");
    latch.countDown();
    return "FLIGHT_DATA";
});

Future<String> hotelFuture = executor.submit(() -> {
    Thread.sleep(2000);
    System.out.println("Hotéis carregados");
    latch.countDown();
    return "HOTEL_DATA";
});

latch.await(); // Bloqueia até que voos e hotéis sejam carregados

Future<String> carRentalFuture = executor.submit(() -> {
    System.out.println("Buscando carros com base no voo e hotel...");
    Thread.sleep(1000);
    return "CAR_RENTAL_DATA";
});

System.out.println("Resultado final: " + carRentalFuture.get());

A Revolução do CompletableFuture

Introduzido no JDK 8, o CompletableFuture elimina a necessidade de bloqueios manuais e orquestração complexa, permitindo a composição de tarefas de forma fluida e declarativa.

CompletableFuture<String> flightTask = CompletableFuture.supplyAsync(() -> {
    sleepMillis(1500);
    System.out.println("Voos carregados");
    return "FLIGHT_DATA";
});

CompletableFuture<String> hotelTask = CompletableFuture.supplyAsync(() -> {
    sleepMillis(2000);
    System.out.println("Hotéis carregados");
    return "HOTEL_DATA";
});

CompletableFuture<String> carRentalTask = flightTask.thenCombine(hotelTask, (flight, hotel) -> {
    System.out.println("Buscando carros com base no voo e hotel...");
    sleepMillis(1000);
    return "CAR_RENTAL_DATA";
});

System.out.println("Resultado final: " + carRentalTask.join());

Vantagens claras:

  • Gerenciamento automático de threads pelo pool padrão do JDK.
  • Sintaxe encadeada (fluent API) que melhora a legibilidade.
  • Capacidade nativa de orquestrar dependências complexas entre tarefas assíncronas.

Criação e Obtenção de Resultados

Instanciação

Além do construtor padrão e do método estático completedFuture(U value) (útil para retornar valores já computados ou em testes), as formas mais comuns de criação são:

  • supplyAsync(Supplier<U> supplier): Executa uma tarefa que retorna um valor.
  • runAsync(Runnable runnable): Executa uma tarefa sem retorno (Void).

Ambos possuem sobrecargas que aceitam um Executor customizado. Se não fornecdio, utilizam o ForkJoinPool.commonPool().

Extração de Resultados

O CompletableFuture oferece diversas formas de obter o resultado computado:

public T get() throws InterruptedException, ExecutionException;
public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
public T getNow(T valueIfAbsent);
public T join();

  • get(): Bloqueia a thread atual até a conclusão. Lança exceções verificadas.
  • get(timeout, unit): Idem, mas lança TimeoutException se o tempo expirar.
  • getNow(valueIfAbsent): Não bloqueia. Retorna o resultado se pronto, ou o valor padrão fornecido.
  • join(): Bloqueia até a conclusão, mas encapsula exceções em CompletionException (não verificada), sendo ideal para uso com Streams e expressões Lambda.

Tratamento de Exceções Manuais

Quando você instancia um CompletableFuture via new e o completa manualmente em outra thread, deve tratar falhas explicitamente:

CompletableFuture<Integer> manualFuture = new CompletableFuture<>();
new Thread(() -> {
    try {
        int result = 10 / 0; // Simula erro
        manualFuture.complete(result);
    } catch (Exception ex) {
        manualFuture.completeExceptionally(ex);
    }
}).start();

Comparação: Síncrono vs Assíncrono em Coleções

Considere a busca de temperaturas em múltiplas cidades. A abordagem síncrona processa a lista sequencialmente:

private static List<String> fetchTemperaturesSync(List<String> cities) {
    return cities.stream()
            .map(city -> String.format("%s: %d°C", city, WeatherApi.getTemp(city)))
            .collect(Collectors.toList());
}

A abordagem assíncrona dispara todas as requisições simultaneamente e aguarda a conclusão:

private static List<String> fetchTemperaturesAsync(List<String> cities) {
    List<CompletableFuture<String>> futures = cities.stream()
            .map(city -> CompletableFuture.supplyAsync(() -> 
                String.format("%s: %d°C", city, WeatherApi.getTemp(city))))
            .collect(Collectors.toList());

    return futures.stream()
            .map(CompletableFuture::join)
            .collect(Collectors.toList());
}

API de Transformação e Composição

whenComplete

Permite executar uma ação após a conclusão da tarefa, independentemente de sucesso ou falha, sem alterar o resultado original.

CompletableFuture<String> task = CompletableFuture.supplyAsync(() -> "Data");
task.whenComplete((result, exception) -> {
    if (exception == null) {
        System.out.println("Sucesso: " + result);
    } else {
        System.err.println("Erro: " + exception.getMessage());
    }
});

thenApply

Transforma o resultado de um CompletableFuture em outro tipo, semelhante ao map de um Stream.

CompletableFuture<Integer> lengthFuture = CompletableFuture.supplyAsync(() -> "Hello World")
        .thenApply(String::length);
System.out.println(lengthFuture.join()); // Saída: 11

thenAccept

Consome o resultado sem retornar um novo valor (retorna CompletableFuture<Void>).

CompletableFuture.supplyAsync(() -> Arrays.asList("A", "B", "C"))
        .thenAccept(list -> list.forEach(System.out::println));

thenCompose

Usado para encadear tarefas assíncronas onde a segunda tarefa depende do resultado da primeira, evitando o aninhamento de futuros (semelhante ao flatMap).

CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> "User_123");
CompletableFuture<String> profileFuture = userFuture.thenCompose(userId -> 
    CompletableFuture.supplyAsync(() -> "Profile_of_" + userId)
);

thenCombine

Combina os resultados de dois futuros independentes quando ambos estiverem prontos.

CompletableFuture<Integer> futureA = CompletableFuture.supplyAsync(() -> 10);
CompletableFuture<Integer> futureB = CompletableFuture.supplyAsync(() -> 20);

CompletableFuture<Integer> sumFuture = futureA.thenCombine(futureB, Integer::sum);
System.out.println(sumFuture.join()); // Saída: 30

allOf e anyOf

Métodos estáticos para agregar múltiplos futuros:

  • allOf(CompletableFuture<?>... cfs): Retorna um futuro que completa quando todos os futuros fornecidos completam. Não retorna um valor agregado diretamente.
  • anyOf(CompletableFuture<?>... cfs): Retorna um futuro que completa assim que o primeiro futuro da lista completa, retornando o resultado deste.

Considerações sobre o Pool de Threads Padrão

Os métodos supplyAsync e runAsync, quando não recebem um Executor, utilizam o ForkJoinPool.commonPool(). O tamanho deste pool é baseado no número de núcleos de CPU (Runtime.getRuntime().availableProcessors() - 1).

Isso é excelente para tarefas intensivas em CPU (CPU-bound). No entanto, operações de I/O (como chamadas de rede ou acesso a banco de dados) fazem a thread ficar bloqueada aguardando respostas. Para cenários intensivos em I/O (I/O-bound), o pool padrão pode se tornar um gargalo. A fórmula recomendada para dimensionar pools de I/O é:

Tamanho do Pool = N_cpu * U_cpu * (1 + W/C)

Onde W/C é a razão entre o tempo de espera (Wait) e o tempo de computação (Compute). Portanto, em aplicações de produção, é altamente recomendável injetar um ThreadPoolExecutor customizado e configurado adequadamente para as tarefas do CompletableFuture.

Cuidados com Métodos Assíncronos (Sufixo Async)

Muitos métodos de composição possuem variantes com o sufixo Async (ex: thenApplyAsync). Essas variantes delegam a execução da etapa seguinte para uma thread do pool, o que envolve troca de contexto (context switching). Em pipelines curtos ou operações muito rápidas, a sobrecarga da troca de contexto pode anular os benefícios da assincronicidade. Utilize as versões Async apenas quando a etapa seguinte for uma operação bloqueante ou pesada, e sempre valide a performance com testes de carga reais.

Tags: java CompletableFuture Concurrency Multithreading forkjoinpool

Publicado em 10-14 11:30