Java ExecutorService

Na aula de Threads, você criou cada thread à mão com new Thread. Em programas de verdade quase ninguém faz isso: usa-se um ExecutorService ("serviço executor"), um time de threads prontas para trabalhar. Você entrega tarefas ao time, e ele cuida de quem faz o quê. De quebra, as tarefas podem devolver resultados.

Você vai aprender
  • O que é um pool de threads e como criar um com Executors.newFixedThreadPool.
  • Enviar tarefas com submit e buscar o resultado com Future.get.
  • Enviar uma lista inteira com invokeAll.
  • Encerrar o time: shutdown, awaitTermination e o executor em try-with-resources Java 19+; e o que acontece se você esquecer.
  • O básico de CompletableFuture: supplyAsync, thenApply e join.

Esta aula continua a de Threads e usa lambdas, generics e try-with-resources. Os exemplos rodaram no JDK 25 da máquina da Ana, na pasta ~/java.


A ideia antes do código

Pense numa central de entregas. A Ana não contrata um motoboy novo para cada pedido e o demite na volta: isso seria caro e lento. Ela tem uma equipe fixa de, digamos, dois motoboys, e um quadro de pedidos. Cada pedido novo vai para o quadro; quem estiver livre pega o próximo. No fim do dia, ela avisa: "não entram mais pedidos; terminem o que falta e podem ir".

  • A equipe fixa é o pool de threads (pool = "grupo", "reserva").
  • O quadro é a fila de tarefas.
  • O pedido é a tarefa: um Runnable (não devolve nada) ou um Callable (devolve um resultado).
  • O comprovante que a Ana recebe ao entregar um pedido ao quadro é o Future ("futuro"): com ele, ela pergunta depois "já ficou pronto? qual o resultado?".
  • "Não entram mais pedidos" é o shutdown().

1. Criar o time e enviar tarefas

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

public class Main {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService equipe = Executors.newFixedThreadPool(2);
        for (int i = 1; i <= 5; i++) {
            int pedido = i;
            equipe.submit(() -> {
                System.out.println(Thread.currentThread().getName() + " embalou o pedido " + pedido);
            });
        }
        equipe.shutdown();
        equipe.awaitTermination(10, TimeUnit.SECONDS);
        System.out.println("Expediente encerrado.");
    }
}
$ javac Main.java && java Main
pool-1-thread-1 embalou o pedido 1
pool-1-thread-2 embalou o pedido 2
pool-1-thread-1 embalou o pedido 3
pool-1-thread-2 embalou o pedido 4
pool-1-thread-1 embalou o pedido 5
Expediente encerrado.
LinhaO que faz
import java.util.concurrent.…O pacote da concorrência (concurrent = "simultâneo").
Executors.newFixedThreadPool(2)Cria um time fixo de 2 threads. Executors (com "s") é a "fábrica" de executores.
int pedido = i;Uma cópia de i para a lambda. Lambdas só podem usar variáveis que não mudam depois (effectively final), e i muda a cada volta.
equipe.submit(() -> { … })"Submeter": põe a tarefa na fila. Volta na hora, sem esperar a tarefa rodar.
equipe.shutdown()"Desligar": não aceita mais tarefas, mas termina as que estão na fila.
awaitTermination(10, TimeUnit.SECONDS)"Aguardar o término": a main espera o time terminar tudo, por no máximo 10 segundos.

Cinco pedidos e só duas threads: pool-1-thread-1 e pool-1-thread-2 (os nomes que o Java dá às threads do primeiro pool) se revezaram. As threads foram reaproveitadas: nenhuma foi criada para um pedido só. Como na aula anterior, a ordem e a divisão entre as threads podem mudar de uma execução para outra.


2. Tarefas que devolvem resultado: submit + Future.get

Se a lambda devolve um valor, ela é um Callable ("chamável"), e o submit entrega um Future com o resultado prometido. A Ana precisa consultar o estoque de três frutas num sistema lento, que demora 1 segundo por consulta:

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

public class Main {
    static int consultarEstoque(String fruta) throws InterruptedException {
        Thread.sleep(1000); // finge que demora: consulta a um sistema lento
        return fruta.length() * 10;
    }

    public static void main(String[] args) throws Exception {
        ExecutorService equipe = Executors.newFixedThreadPool(3);
        long inicio = System.currentTimeMillis();

        Future<Integer> maca = equipe.submit(() -> consultarEstoque("maçã"));
        Future<Integer> banana = equipe.submit(() -> consultarEstoque("banana"));
        Future<Integer> uva = equipe.submit(() -> consultarEstoque("uva"));

        System.out.println("Pedidos enviados; esperando as respostas...");
        int total = maca.get() + banana.get() + uva.get();

        long ms = System.currentTimeMillis() - inicio;
        System.out.println("Estoque total: " + total);
        System.out.println("Tempo: cerca de " + ms / 100 * 100 + " ms");
        equipe.shutdown();
    }
}
$ javac Main.java && java Main
Pedidos enviados; esperando as respostas...
Estoque total: 130
Tempo: cerca de 1000 ms
Future<Integer>
"Um Integer que vai existir no futuro." É o comprovante do pedido.
maca
O nome da variável.
= equipe.submit(
Entrega a tarefa ao time e recebe o comprovante na hora.
() -> consultarEstoque("maçã"))
A tarefa: um Callable<Integer>, porque devolve um int. Um Callable pode lançar exceções checked (o InterruptedException do sleep), diferente do Runnable.

E maca.get() ("pegar") espera o resultado ficar pronto e o devolve. As três consultas rodaram ao mesmo tempo, então o total levou cerca de 1 segundo, e não 3. Para conferir, a Ana trocou newFixedThreadPool(3) por newFixedThreadPool(1) (um motoboy só):

$ javac Main.java && java Main
Pedidos enviados; esperando as respostas...
Estoque total: 130
Tempo: cerca de 3000 ms

Com uma thread, as consultas fizeram fila: 3 segundos. Esse é o ganho de um pool quando as tarefas passam a maior parte do tempo esperando (rede, disco, outro sistema).

Missão: envie ao time as duas contas como tarefas com submit (uma devolve 12 + 8, a outra 7 + 3), guarde os dois Future<Integer> e mostre Total: 30 somando os resultados com get().

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

public class Main {
    public static void main(String[] args) throws Exception {
        ExecutorService equipe = Executors.newFixedThreadPool(2);
        // envie as duas tarefas e some os resultados

        equipe.shutdown();
    }
}
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Main {
    public static void main(String[] args) throws Exception {
        ExecutorService equipe = Executors.newFixedThreadPool(2);
        Future<Integer> manha = equipe.submit(() -> 12 + 8);
        Future<Integer> tarde = equipe.submit(() -> 7 + 3);
        System.out.println("Total: " + (manha.get() + tarde.get()));
        equipe.shutdown();
    }
}

3. Uma lista de tarefas: invokeAll e o executor em try

Quando as tarefas já estão numa lista, invokeAll ("invocar todas") envia todas, espera todas terminarem e devolve a lista de Futures na mesma ordem. E desde o Java 19, o ExecutorService pode ficar num try-with-resources: ao sair do bloco, ele faz o shutdown e espera as tarefas sozinho.

import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Main {
    public static void main(String[] args) throws Exception {
        List<String> frutas = List.of("maçã", "banana", "uva", "kiwi");
        List<Callable<String>> tarefas = frutas.stream()
            .map(f -> (Callable<String>) () -> f.toUpperCase() + " (" + Thread.currentThread().getName() + ")")
            .toList();

        try (ExecutorService equipe = Executors.newFixedThreadPool(2)) {
            List<Future<String>> resultados = equipe.invokeAll(tarefas);
            for (Future<String> r : resultados) {
                System.out.println(r.get());
            }
        }
        System.out.println("Fim");
    }
}
$ javac Main.java && java Main
MAÇÃ (pool-1-thread-1)
BANANA (pool-1-thread-2)
UVA (pool-1-thread-2)
KIWI (pool-1-thread-2)
Fim

Os resultados saíram na ordem da lista de frutas, mesmo que as tarefas tenham terminado em outra ordem: quem garante isso é a lista de Futures. A coluna entre parênteses mostra qual thread fez cada uma (a thread-2 pegou três: estava livre antes). O .map(…) transforma cada fruta numa tarefa (veja streams); o (Callable<String>) diz ao compilador que tipo de lambda é aquela.

Encerrar o executorComoDesde
À mãoshutdown() + awaitTermination(tempo, unidade)Java 5
Automáticotry (ExecutorService e = …) { … }: o close() faz o shutdown e esperaJava 19+
UrgenteshutdownNow(): tenta interromper as tarefas em andamento e devolve as que nem começaramJava 5

4. CompletableFuture: encadear passos

Com o Future comum, a única forma de usar o resultado é parar e esperar com get(). O CompletableFuture ("futuro completável") permite dizer o que fazer depois, como uma receita: "busque o preço; quando chegar, aplique o desconto; depois monte a etiqueta".

import java.util.concurrent.CompletableFuture;

public class Main {
    static double buscarPreco(String produto) {
        System.out.println("Buscando " + produto + " em " + Thread.currentThread().getName());
        return 50.0;
    }

    public static void main(String[] args) {
        CompletableFuture<String> etiqueta = CompletableFuture
            .supplyAsync(() -> buscarPreco("café"))
            .thenApply(preco -> preco * 0.9)
            .thenApply(preco -> "Café com desconto: R$ " + preco);

        System.out.println("main continua livre...");
        System.out.println(etiqueta.join());
    }
}
$ javac Main.java && java Main
main continua livre...
Buscando café em ForkJoinPool.commonPool-worker-1
Café com desconto: R$ 45.0
CompletableFuture.supplyAsync(
"Fornecer de forma assíncrona": roda a lambda em outra thread e promete o resultado. Sem um executor indicado, usa o pool comum do Java (ForkJoinPool.commonPool, como mostra a saída).
.thenApply(preco -> preco * 0.9)
"Depois, aplique": quando o preço chegar, transforma-o (10% de desconto). Devolve outro CompletableFuture, por isso dá para encadear.
.thenApply(preco -> "…" + preco)
Mais um passo: transforma o número em texto.
etiqueta.join()
"Juntar": espera o resultado final e o devolve, como o get(), mas sem exceção checked.

Repare que a main mostrou "continua livre" antes mesmo de a busca começar: montar a receita não trava ninguém. Só o join() espera. Outros métodos úteis:

import java.util.concurrent.CompletableFuture;

public class Main {
    public static void main(String[] args) {
        CompletableFuture<Double> cafe = CompletableFuture.supplyAsync(() -> 18.0);
        CompletableFuture<Double> pao = CompletableFuture.supplyAsync(() -> 6.5);
        double total = cafe.thenCombine(pao, Double::sum).join();
        System.out.println("Total do pedido: " + total);

        CompletableFuture<Integer> erro = CompletableFuture.supplyAsync(() -> 10 / 0);
        int r = erro.exceptionally(e -> {
            System.out.println("Deu problema: " + e.getMessage());
            return -1;
        }).join();
        System.out.println("Resultado: " + r);
    }
}
$ javac Main.java && java Main
Total do pedido: 24.5
Deu problema: java.lang.ArithmeticException: / by zero
Resultado: -1

thenCombine ("depois, combine") junta dois resultados independentes: café e pão foram buscados ao mesmo tempo e somados com Double::sum (uma referência de método). exceptionally ("em caso de exceção") dá um valor de reserva quando algum passo falha.


Formas diferentes de criar o executor

FábricaO timeQuando usar
Executors.newFixedThreadPool(n)n threads fixasO caso geral. Para contas pesadas, n = número de núcleos (Runtime.getRuntime().availableProcessors()).
Executors.newSingleThreadExecutor()1 threadTarefas que precisam rodar uma de cada vez, em ordem.
Executors.newCachedThreadPool()Cria threads conforme a necessidade e reaproveita as livresMuitas tarefas curtas (cuidado: sem limite de threads).
Executors.newVirtualThreadPerTaskExecutor() Java 21+Uma thread virtual nova por tarefaMilhares de tarefas que esperam (rede, disco). Veja Threads Virtuais.
CompletableFuture.supplyAsync(…)Pool comum do Java (ou um executor que você passar)Encadear passos assíncronos.
No editor do guia

O simulador executa as tarefas do ExecutorService em sequência, numa thread só, e o Thread.sleep não espera de verdade. As missões funcionam, mas os tempos medidos e a mistura das saídas só aparecem no JDK real, como nos terminais desta página.


Quando dá errado

Esquecer o shutdown: o programa não termina

Este código tem um erro de propósito.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) {
        ExecutorService equipe = Executors.newFixedThreadPool(2);
        equipe.submit(() -> System.out.println("Pedido embalado"));
        System.out.println("Fim do main");
    }
}
$ javac Main.java
$ timeout 5 java Main      « o timeout encerra o programa à força depois de 5 segundos
Fim do main
Pedido embalado
$ echo $?
124                        « 124 = o timeout teve de matar o programa

O que aconteceu: o trabalho terminou, mas as threads do pool continuaram vivas, esperando novos pedidos que nunca vão chegar. A JVM só termina quando as threads comuns acabam, então o programa ficaria parado para sempre (sem o timeout, você teria de apertar Ctrl+C). Conserto: shutdown() no fim, ou o executor num try-with-resources.

A tarefa lançou uma exceção: ExecutionException

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

public class Main {
    public static void main(String[] args) throws Exception {
        try (ExecutorService equipe = Executors.newFixedThreadPool(2)) {
            Future<Integer> conta = equipe.submit(() -> 100 / 0);
            System.out.println("Tarefa enviada");
            System.out.println(conta.get());
        }
    }
}
$ javac Main.java && java Main
Tarefa enviada
Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.ArithmeticException: / by zero
	at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:124)
	at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:193)
	at Main.main(Main.java:10)
Caused by: java.lang.ArithmeticException: / by zero
	at Main.lambda$main$0(Main.java:8)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614)
	at java.base/java.lang.Thread.run(Thread.java:1474)

O que quer dizer: a divisão por zero aconteceu dentro da tarefa, numa thread do pool (linha 8, lambda$main$0). O pool guardou a exceção no Future, e ela só apareceu no get() (linha 10), embrulhada numa ExecutionException ("exceção de execução"). A causa verdadeira vem em Caused by:. Conserto: trate a ExecutionException com try/catch em volta do get() e olhe e.getCause(). Atenção: se você nunca chamar get(), a exceção some em silêncio.

Enviar tarefa depois do shutdown

Este código tem um erro de propósito.
        ExecutorService equipe = Executors.newFixedThreadPool(2);
        equipe.shutdown();
        equipe.submit(() -> System.out.println("Pedido atrasado"));
$ javac Main.java && java Main
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@33909752[Not completed, task = java.util.concurrent.Executors$RunnableAdapter@70dea4e[Wrapped task = Main$$Lambda/0x000000009e040210@5c647e05]] rejected from java.util.concurrent.ThreadPoolExecutor@764c12b6[Terminated, pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0]
	at java.base/java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2032)
	at java.base/java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:787)
	at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1328)
	at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:125)
	at Main.main(Main.java:8)

O que quer dizer: "execução rejeitada". A mensagem é enorme, mas o essencial está no começo (RejectedExecutionException), no meio (rejected from … ThreadPoolExecutor) e no estado do pool (Terminated, encerrado). Os números depois de @ mudam a cada execução. Conserto: envie todas as tarefas antes do shutdown().


Referência

CódigoO que faz
Executors.newFixedThreadPool(n)Cria um time de n threads
ex.submit(runnable) / ex.submit(callable)Envia a tarefa; devolve um Future
ex.execute(runnable)Envia sem devolver Future
ex.invokeAll(listaDeCallables)Envia todas, espera todas; devolve List<Future>
ex.shutdown()Não aceita mais tarefas; termina as da fila
ex.awaitTermination(t, TimeUnit.SECONDS)Espera o fim, até o tempo limite
try (ExecutorService ex = …) { }Fecha e espera sozinho (Java 19+)
future.get()Espera e devolve o resultado (ExecutionException se a tarefa falhou)
future.isDone()Já terminou?
CompletableFuture.supplyAsync(() -> …)Começa um cálculo assíncrono
cf.thenApply(x -> …)Transforma o resultado quando chegar
cf.thenCombine(outro, (a, b) -> …)Junta dois resultados
cf.exceptionally(e -> valor)Valor de reserva se der erro
cf.join()Espera e devolve o resultado

Exercícios

1. Complete para criar um time fixo de 4 threads:

ExecutorService ex = Executors.;

2. Complete para esperar o resultado do Future f:

int valor = f.;

3. Complete para transformar o resultado quando ele chegar:

CompletableFuture.supplyAsync(() -> 10).(x -> x * 2).join();

4. Um programa cria um pool, envia tarefas e não chama shutdown(). O que acontece?

5. Uma tarefa enviada com submit lança ArithmeticException. Onde você fica sabendo?

Missão: os três caixas da loja são tarefas que devolvem o faturamento de cada um. Abra o executor num try-with-resources, use invokeAll e some os resultados para mostrar Faturamento do dia: 245.

import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Main {
    public static void main(String[] args) throws Exception {
        List<Callable<Integer>> caixas = List.of(() -> 120, () -> 85, () -> 40);
        int soma = 0;
        // abra o executor num try, chame invokeAll e some

        System.out.println("Faturamento do dia: " + soma);
    }
}
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Main {
    public static void main(String[] args) throws Exception {
        List<Callable<Integer>> caixas = List.of(() -> 120, () -> 85, () -> 40);
        int soma = 0;
        try (ExecutorService equipe = Executors.newFixedThreadPool(3)) {
            for (Future<Integer> f : equipe.invokeAll(caixas)) {
                soma = soma + f.get();
            }
        }
        System.out.println("Faturamento do dia: " + soma);
    }
}

Missão final: monte uma receita com CompletableFuture: supplyAsync fornece o preço 50.0, um thenApply aplica 10% de desconto (* 0.9), outro monta o texto "Preço final: " + preco, e o join() busca o resultado para mostrar.

import java.util.concurrent.CompletableFuture;

public class Main {
    public static void main(String[] args) {
        String r = "";   // troque pela receita com CompletableFuture
        System.out.println(r);
    }
}
import java.util.concurrent.CompletableFuture;

public class Main {
    public static void main(String[] args) {
        String r = CompletableFuture
            .supplyAsync(() -> 50.0)
            .thenApply(preco -> preco * 0.9)
            .thenApply(preco -> "Preço final: " + preco)
            .join();
        System.out.println(r);
    }
}

Resumo

  • Um ExecutorService é um time de threads reaproveitáveis; você envia tarefas, não cria threads.
  • submit devolve um Future; get() espera o resultado (e lança ExecutionException se a tarefa falhou). invokeAll envia e espera uma lista inteira.
  • Sempre encerre: shutdown() + awaitTermination, ou try-with-resources Java 19+. Sem isso, o programa não termina.
  • CompletableFuture encadeia passos: supplyAsync → thenApply → join.
  • O ganho de tempo vem quando as tarefas esperam (3 consultas de 1 s: cerca de 1000 ms com 3 threads, 3000 ms com 1).

Próxima aula: Threads Virtuais, para quando são milhares de tarefas esperando.