CodeGym /Cursos /JAVA 25 SELF /Introdução ao CompletableFuture

Introdução ao CompletableFuture

JAVA 25 SELF
Nível 55 , Lição 0
Disponível

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).

Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION