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çaTimeoutExceptionse 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 emCompletionException(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.