1. O problema do código síncrono
Vamos imaginar: você tem um programa que precisa carregar dados da internet ou ler um arquivo grande. Você escreve algo como:
String data = readFromFile("bigfile.txt");
System.out.println("Dados: " + data);
Tudo estaria bem, mas se o arquivo for grande ou a rede for lenta, o programa simplesmente fica travado na linha de leitura. O usuário olha para uma interface “travada”, o servidor não consegue atender outras requisições e o programador… fica frustrado.
Essa situação é chamada de bloqueio: a thread (por exemplo, a thread principal do seu aplicativo) é obrigada a esperar até a operação terminar. E se houver muitas dessas operações — pronto, olá atrasos (lags) e baixo desempenho.
É como se você fosse a um café, fizesse o pedido e… tivesse de ficar parado no balcão até o café ficar pronto. Outros clientes ficam atrás de você e também esperam até o barista terminar com você. Ineficiente, certo?
Assincronia: como ela salva o mundo
Programação assíncrona — é a abordagem em que operações demoradas (por exemplo, leitura de arquivo, requisição a um servidor, acesso ao banco de dados) são executadas em uma thread em background, enquanto a thread principal continua trabalhando: atendendo usuários, recebendo novas requisições, reagindo a eventos.
Ou seja, você faz o pedido (inicia a tarefa), vai cuidar de outras coisas e, quando o café fica pronto (a tarefa terminou), simplesmente avisam você: “Pronto!”
Em Java, antes do CompletableFuture, isso não era muito conveniente. Vamos ver como tudo evoluiu.
2. Abordagens históricas: Future e suas limitações
No Java 5 apareceu a interface Future — a primeira tentativa de tornar o trabalho com tarefas assíncronas um pouco mais conveniente. Ela permitia delegar uma tarefa a um pool de threads e, algum dia, obter o resultado.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> 2 + 2);
int result = future.get(); // Cuidado: a thread ficará bloqueada até a tarefa terminar!
A ideia parece boa, mas na prática descobriu-se que Future é como uma velha caixa de correio: você envia a carta, mas, para saber se chegou uma resposta, precisa ficar conferindo o tempo todo.
Ele não sabe notificar quando o resultado fica pronto, não suporta cadeias de ações do tipo “faça isto e depois aquilo”, nem permite tratar erros de forma elegante. Tudo esbarra na chamada bloqueante get(), que transforma a assincronia de volta em espera.
3. O surgimento do CompletableFuture: um novo estilo de assincronia
No Java 8, para substituir o Future obsoleto, chegou o verdadeiro herói da assincronia — CompletableFuture. Essa classe do pacote java.util.concurrent tornou-se uma ferramenta universal para quem se cansou de esperar resultados “manualmente” e queria escrever código assíncrono de forma bonita, compacta e clara.
CompletableFuture faz quase tudo. Ele pode executar tarefas em outras threads, organizá-las em cadeias — por exemplo, primeiro calcular o resultado, depois processá-lo e então fazer mais alguma coisa. Ele combina tarefas com facilidade: é possível esperar a conclusão de todas de uma vez ou apenas da primeira que terminar. Os erros também são tratados de forma elegante — sem try-catch desnecessários. E todo o estilo de trabalho fica mais próximo do funcional: em vez de chamadas entediantes e esperas, surgem métodos expressivos como thenApply, thenAccept e outros.
flowchart LR
A[Iniciar tarefa de forma assíncrona] --> B[Processamento do resultado]
B --> C[Próxima operação]
C --> D[Tratamento de erros]
Assim, o CompletableFuture transformou a assincronia de um ofício pesado em uma ferramenta prática e flexível, com a qual o código finalmente respira aliviado.
4. Exemplo mais simples: o primeiro passo no mundo do CompletableFuture
Vamos ver um exemplo mínimo de tarefa assíncrona:
import java.util.concurrent.CompletableFuture;
public class AsyncDemo {
public static void main(String[] args) {
// Iniciamos a tarefa de forma assíncrona
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
// Obtemos o resultado (bloqueia a thread!)
try {
int result = future.get();
System.out.println("Resultado: " + result); // 4
} catch (Exception e) {
e.printStackTrace();
}
}
}
Este código já executa o cálculo em outra thread — a thread principal não fica bloqueada no momento do início da tarefa. Mas a chamada get() ainda bloqueia a thread até que o resultado esteja pronto.
E como NÃO bloquear a thread?
Simples: use métodos de callback, que são chamados quando a tarefa terminar:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
future.thenAccept(result -> System.out.println("Resultado: " + result));
System.out.println("Eu não fico bloqueado e posso fazer outra coisa!");
Conclusão:
- thenAccept — é “inscrever-se para o resultado”: quando a tarefa terminar, execute este código.
- A thread principal não espera a execução da tarefa e continua trabalhando.
Visualização (pseudocódigo de eventos)
[Thread principal] --> [Iniciar tarefa]
| |
v v
[Faz alguma coisa] [Thread em background calcula 2+2]
| |
v v
[Imprime "Eu não fico bloqueado..."]
| |
v v
[Quando terminar — thenAccept é chamado]
5. Como isso fica em um aplicativo?
Vamos imaginar que você está desenvolvendo um aplicativo de console em que o usuário pode solicitar o carregamento de dados (por exemplo, de um banco de dados ou servidor) e, enquanto os dados são carregados, o programa não “trava”, mas continua aceitando comandos.
Exemplo: simulação de uma operação demorada
import java.util.concurrent.CompletableFuture;
public class AsyncApp {
public static void main(String[] args) {
System.out.println("Iniciando o carregamento dos dados...");
CompletableFuture<String> dataFuture = CompletableFuture.supplyAsync(() -> {
// Simulação de um carregamento demorado
try {
Thread.sleep(2000); // 2 segundos
} catch (InterruptedException e) {
return "Erro no carregamento";
}
return "Dados carregados com sucesso!";
});
// Registramos um callback para o resultado
dataFuture.thenAccept(result -> System.out.println("Resultado: " + result));
// O programa continua funcionando
System.out.println("Enquanto os dados carregam, posso fazer outra coisa!");
// Para o programa não terminar antes da hora (apenas para demo!)
try {
Thread.sleep(2500);
} catch (InterruptedException ignored) {}
}
}
O que você verá no console:
Iniciando o carregamento dos dados...
Enquanto os dados carregam, posso fazer outra coisa!
[depois de 2 segundos]
Resultado: Dados carregados com sucesso!
6. Detalhes úteis
Um pouco sobre threads por baixo dos panos
Quando você escreve CompletableFuture.supplyAsync(...), a tarefa é executada por padrão no chamado ForkJoinPool — um pool de threads especial que o Java usa para tarefas paralelas. Se você precisar de mais controle (por exemplo, seu próprio ExecutorService), pode passá-lo como segundo parâmetro:
ExecutorService executor = Executors.newFixedThreadPool(2);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2, executor);
Mas para tarefas simples, o pool padrão é suficiente.
Obtendo o resultado: get(), join(), thenAccept
- get() — bloqueia a thread até que o resultado esteja pronto (lança exceções checked).
- join() — também bloqueia, mas lança exceções unchecked (RuntimeException).
- thenAccept(), thenApply() e outros — NÃO bloqueiam; chamam a função fornecida quando o resultado estiver pronto.
Em aplicativos assíncronos reais, tente evitar get()/join() na thread principal!
7. Erros comuns nos primeiros passos com CompletableFuture
Erro nº 1: usar get() ou join() na thread principal.
Assim você volta a bloquear o programa e perde todas as vantagens da assincronia. Em vez disso, use thenAccept, thenApply e outros métodos para tratar o resultado.
Erro nº 2: esquecer de tratar o erro.
Se ocorrer uma exceção em uma tarefa assíncrona, ela não “saltará” para a thread principal. Sem tratamento via exceptionally ou handle, você simplesmente não saberá que algo deu errado.
Erro nº 3: não esperar o término do programa.
Em exemplos de demonstração, muitas vezes é preciso “dar uma desacelerada” na thread main usando Thread.sleep — caso contrário, o programa termina antes da tarefa concluir. Em aplicativos reais (por exemplo, em servidores web) isso não é um problema, mas em demos de console — leve isso em conta.
Erro nº 4: confundir thenAccept e thenApply.
thenAccept — para “efeitos colaterais” (não retorna nada); thenApply — para transformar o resultado (retorna um novo resultado).
Erro nº 5: misturar código assíncrono e síncrono sem necessidade.
Se você começou a escrever de forma assíncrona, não volte ao síncrono com get()/join(), a menos que seja um caso extremo (por exemplo, em testes).
GO TO FULL VERSION