Guia Prático do CompletableFuture em Java

Criando instâncias básicas do CompletableFuture

O CompletableFuture é uma das ferramentas mais poderosas do Java para programação assíncrona. Vejamos como instanciá-lo manualmente e resolver a promise resultante:

import java.util.Random;
import java.util.concurrent.CompletableFuture;

public class AsyncDemo {

    private static final Random gen = new Random();

    public static void main(String[] args) {
        var future = new CompletableFuture<Double>();

        var worker = new Thread(() -> {
            double computedValue = simulateSlowCalculation();
            future.complete(computedValue);
        });
        worker.start();

        System.out.println("Executando outras tarefas no thread principal...");

        future.whenComplete((result, error) -> {
            System.out.println("Valor obtido: " + result);
            if (error != null) {
                error.printStackTrace();
            }
        });
    }

    private static double simulateSlowCalculation() {
        try {
            Thread.sleep(gen.nextInt(3000));
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
        }
        return gen.nextDouble();
    }
}

Saída esperada:

Executando outras tarefas no thread principal...
Valor obtido: 0.5478210938472156

Utilizando CompletableFuture.supplyAsync

Na prática, raramente criamos um CompletableFuture diretamente. O método estático supplyAsync é a forma recomendada. Porém, há uma armadilha comum: por padrão, o thread executor é um daemon, o que pode impedir a execução dos callbacks:

import java.util.concurrent.CompletableFuture;

public class DaemonPitfall {

    public static void main(String[] args) {
        // ATENÇÃO: o callback pode não executar pois o thread daemon encerra junto com main
        CompletableFuture.supplyAsync(() -> buscarDadoSimulado())
                .whenComplete((valor, erro) -> {
                    System.out.println("resultado = " + valor);
                    if (erro != null) erro.printStackTrace();
                });

        System.out.println("main finalizou antes do callback!");
    }

    static double buscarDadoSimulado() {
        try { Thread.sleep(1500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        return Math.random();
    }
}

Solução 1: Bloquear com flag manual

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;

public class FlagBasedWait {

    public static void main(String[] args) throws InterruptedException {
        var pronto = new AtomicBoolean(false);

        CompletableFuture.supplyAsync(() -> calcularRaiz(256))
                .whenComplete((valor, erro) -> {
                    System.out.println("√256 = " + valor);
                    if (erro != null) erro.printStackTrace();
                    pronto.set(true);
                });

        System.out.println("Aguardando conclusão...");

        while (!pronto.get()) {
            Thread.sleep(1);
        }
    }

    static double calcularRaiz(double n) {
        return Math.sqrt(n);
    }
}

Solução 2: Executor com threads não-daemon (recomendado)

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

public class CustomExecutorSolution {

    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(2, runnable -> {
            Thread t = new Thread(runnable);
            t.setDaemon(false);
            return t;
        });

        CompletableFuture.supplyAsync(() -> calcularRaiz(256), pool)
                .whenComplete((valor, erro) -> {
                    System.out.println("√256 = " + valor);
                    if (erro != null) erro.printStackTrace();
                });

        System.out.println("Thread principal livre para outras tarefas...");
        pool.shutdown();
    }

    static double calcularRaiz(double n) {
        return Math.sqrt(n);
    }
}

Transformando resultados com thenApply

O thenApply permite encadear transformações sobre o valor produzido pela etapa anterior, similar ao map de streams:

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

public class TransformExample {

    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(2, runnable -> {
            Thread t = new Thread(runnable);
            t.setDaemon(false);
            return t;
        });

        CompletableFuture.supplyAsync(() -> gerarNumeroAleatorio(), pool)
                .thenApply(numero -> numero * 100)
                .whenComplete((resultado, erro) -> System.out.println("Valor escalado: " + resultado));

        pool.shutdown();
    }

    private static double gerarNumeroAleatorio() {
        try { Thread.sleep(800); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        return Math.random() * 10;
    }
}

Saída:

Valor escalado: 432.87165432109876

Agregando múltiplos resultados com join()

Uma aplicação clássica é executar várias tarefas em paralelo e coletar seus resultados. O método join() bloqueia a execução até que o CompletableFuture seja concluído:

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;

public class ParallelAggregation {

    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(4, runnable -> {
            Thread t = new Thread(runnable);
            t.setDaemon(false);
            return t;
        });

        List<Integer> itemIds = List.of(101, 102, 103, 104, 105);

        List<Double> precosFinais = itemIds.stream()
                .map(id -> CompletableFuture.supplyAsync(() -> consultarPreco(id), pool))
                .map(fut -> fut.thenApply(preco -> preco * 1.1)) // aplica 10% de imposto
                .map(futComImposto -> futComImposto.join())
                .collect(Collectors.toList());

        System.out.println("Preços finais: " + precosFinais);
        pool.shutdown();
    }

    static double consultarPreco(int itemId) {
        try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        return itemId * 9.99;
    }
}

Saída:

Preços finais: [1109.889, 1130.868, 1151.847, 1172.826, 1193.805]

Entendendo o join() em profundidade

O join() é o método central para obter resultados de operações assíncronas. Suas características incluem:

  • Bloqueio do thread atual até a conclusão da tarefa
  • Lança CompletionException (unchecked) em caso de falha
  • Não responde a interrupções de thread
  • Extremamente conveniente dentro de expressões lambda

Comparação com get()

Aspecto join() get()
Exceções CompletionException (unchecked) ExecutionException / InterruptedException (checked)
Bloco try-catch Opcional Obrigatório
Interrupção Ignora Thread.interrupt() Responde a Thread.interrupt()
Legibilidade Mais conciso Verboso

Uso comanyOf e allOf

List<CompletableFuture<String>> futures = List.of(
    buscarUsuarioAsync(),
    buscarPedidoAsync(),
    buscarProdutoAsync()
);

// Aguarda todas as tarefas finalizarem
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
    .join();

// Coleta os resultados individuais
List<String> resultados = futures.stream()
    .map(CompletableFuture::join)
    .collect(Collectors.toList());

A chamada allOf().join() garante que todas as tarefas foram concluídas antes de prosseguir. Em seguida, cada join() individual retorna imediatamente, pois o resultado já está disponível.

Cenários onde allOf().join() é essencial

// Quando você precisa executar ações após TODAS as tarefas concluírem
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
repositorio.commitTransacao();
notificador.enviarAlerta(resultados);

Quando basta usar join() diretamente

// Para poucas tarefas simples
List<String> dados = List.of(
    buscarUsuario().join(),
    buscarConfig().join(),
    buscarPermissoes().join()
);

Diferenças entre whenComplete e handle

O handle funciona de forma semelhante ao whenComplete, porém permite transformar o valor ou fornecer um fallback em caso de erro:

CompletableFuture.supplyAsync(() -> dividir(10, 0))
    .handle((resultado, erro) -> {
        if (erro != null) return 0.0;
        return resultado;
    })
    .whenComplete((v, e) -> System.out.println("Valor seguro: " + v));

static double dividir(double a, double b) {
    return a / b;
}

Encadeamento com thenCompose

O thenCompose permite que o resultado de uma operação assíncrona alimente outra operação assíncrona, evitando CompletableFuture<CompletableFuture<T>>:

CompletableFuture.supplyAsync(() -> localizarUsuario("joao"))
    .thenCompose(usuario -> CompletableFuture.supplyAsync(() -> buscarPerfil(usuario.id())))
    .thenAccept(perfil -> System.out.println("Perfil: " + perfil));

Combinando dois futures com thenCombine

O thenCombine executa duas operações independentes em paralelo e funde seus resultados com uma função binária:

CompletableFuture<String> nomeFuture = CompletableFuture.supplyAsync(() -> "Maria");
CompletableFuture<Integer> idadeFuture = CompletableFuture.supplyAsync(() -> 30);

nomeFuture.thenCombine(idadeFuture, (nome, idade) -> nome + " (" + idade + " anos)")
    .thenAccept(System.out::println);

// Saída: Maria (30 anos)

Escolhendo o primeiro resultado com applyToEither

Quando deseja utilizar o resultado da primeira tarefa a finalizar:

var servidorA = CompletableFuture.supplyAsync(() -> {
    Thread.sleep(900);
    System.out.println("Servidor A respondeu");
    return "Resposta-A";
});

var servidorB = CompletableFuture.supplyAsync(() -> {
    Thread.sleep(50);
    System.out.println("Servidor B respondeu");
    return "Resposta-B";
});

servidorA.applyToEither(servidorB, resposta -> {
    System.out.println("Usando: " + resposta);
    return resposta.toUpperCase();
}).thenAccept(System.out::println);

Saída:

Servidor B respondeu
Usando: Resposta-B
RESPOSTA-B
Servidor A respondeu

Aguardando todos ou qualquer um com allOf e anyOf

import java.util.List;
import java.util.concurrent.CompletableFuture;

public class CombinacaoExample {

    public static void main(String[] args) throws InterruptedException {
        var tarefas = List.of(1, 2, 3, 4).stream()
                .map(i -> CompletableFuture.supplyAsync(() -> processarItem(i)))
                .toList();

        // anyOf: conclui assim que pelo menos UMA finalizar
        CompletableFuture.anyOf(tarefas.toArray(new CompletableFuture[0]))
                .thenRun(() -> System.out.println("Pelo menos uma tarefa concluída!"));

        Thread.currentThread().join();
    }

    static double processarItem(int id) {
        try { Thread.sleep((long)(Math.random() * 3000)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        double val = Math.random();
        System.out.println("Item " + id + " processado: " + val);
        return val;
    }
}

Melhores práticas e armadilhas comuns

Evite chamar join() dentro de threads do pool: isso pode causar deadlocks, pois bloqueia um thread que poderia estar executando outra tarefa.

// ERRADO: pode esgotar o pool
CompletableFuture.supplyAsync(() -> {
    var inner = outraTarefaAsync();
    return inner.join(); // bloqueia thread do pool!
});

// CORRETO: use thenCompose para encadear
CompletableFuture.supplyAsync(() -> "valor-inicial")
    .thenCompose(valor -> outraTarefaAsync(valor));

Controle de tempo (Java 9+):

CompletableFuture<String> future = buscarDadosAsync()
    .orTimeout(5, TimeUnit.SECONDS);

try {
    String resultado = future.join();
} catch (CompletionException e) {
    if (e.getCause() instanceof TimeoutException) {
        System.out.println("Tempo limite excedido!");
    }
}

Tratamento de exceções com exceptionally:

CompletableFuture<String> seguro = buscarDadosAsync()
    .exceptionally(ex -> {
        System.err.println("Falha: " + ex.getMessage());
        return "valor-padrao";
    });

String resultado = seguro.join(); // nunca lança exceção

Resumo dos principais métodos

Método Finalidade
supplyAsync Inicia uma computação assíncrona que retorna um valor
thenApply Transforma o resultado (síncrono)
thenCompose Encadeia um novo CompletableFuture (flatMap assíncrono)
thenCombine Dois futures em paralelo, fundidos por uma BiFunction
thenAccept Consome o resultado sem retornar nada
thenRun Executa Runnable após conclusão, sem acesso ao resultado
handle Trata resultado e exceção simultaneamente
whenComplete Observa resultado/erro sem transformá-lo
applyToEither Usa resultado do primeiro future a completar
acceptEither Consome resultado do primeiro a completar
thenAcceptBoth Consome resultados de ambos os futures
runAfterBoth Executa após ambos completarem
runAfterEither Executa após qualquer um completar
allOf Aguarda todos os futures completarem
anyOf Completa quando qualquer um dos futures finalizar

O princípio fundamental: priorize operações não-bloqueantes como thenApply, thenCompose e thenCombine. Reserve join() para momentos em que realmente precisa sincronizar a execução, como no fluxo principal da aplicação ou em testes.

Tags: CompletableFuture java concurrent-programming async-programming ExecutorService

Publicado em 6-20 21:19