1. ExecutorService: gerenciamos threads como profissionais
Por que não vale a pena apenas criar threads com new Thread
No início do multithreading tudo parece simples:
Thread t = new Thread(() -> {
// fazemos algo
});
t.start();
Esse método funciona, mas rapidamente vira um fardo quando há muitas tarefas. Cada chamada de new Thread() cria uma nova thread, e dezenas ou centenas de threads começam a sobrecarregar o sistema. Além disso, gerenciá-las é incômodo: é preciso acompanhar quando terminam, o que fazer em caso de erros, como parar e reutilizá-las.
É aqui que entra em cena o ExecutorService — um orquestrador inteligente de threads. Você apenas entrega as tarefas a ele, e ele decide com qual thread e quando executá-las. Como resultado, tudo funciona mais rápido, de forma mais estável e sem dor de cabeça.
Como o ExecutorService funciona
ExecutorService funciona com um princípio simples e eficiente.
- Dentro dele há um pool de threads — um conjunto de threads de trabalho pré-criadas (fixo ou dinâmico).
- As tarefas entram em uma fila e são puxadas por threads livres.
- O serviço gerencia o ciclo de vida: você pode aguardar a conclusão, encerrar o pool corretamente e liberar recursos.
Criando um ExecutorService
A forma mais comum — usar os métodos de fábrica da classe Executors:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService executor = Executors.newFixedThreadPool(4); // 4 threads
- newFixedThreadPool(N) — um pool com N threads (serve para a maioria dos casos).
- newCachedThreadPool() — pool dinâmico, cria threads conforme necessário (cuidado: pode esgotar a memória em uma avalanche de tarefas).
- newSingleThreadExecutor() — uma única thread (execução sequencial).
Exemplo: executar Runnable via ExecutorService
executor.submit(() -> {
System.out.println("Olá do pool de threads!");
});
Depois de terminar de usar o ExecutorService, é preciso encerrá-lo corretamente:
executor.shutdown(); // Proíbe adicionar novas tarefas, aguarda a conclusão das atuais
Importante: Se não chamar shutdown(), o programa pode não encerrar — as threads do pool ficarão esperando novas tarefas.
2. Runnable vs Callable: tipos diferentes de tarefas
Antes do Java 5, se você quisesse executar algo em uma thread, implementava a interface Runnable. É uma tarefa que não retorna nada e não lança exceções verificadas.
Runnable task = () -> {
System.out.println("Só trabalho, não retorno nada!");
};
executor.submit(task);
Callable: tarefa com resultado (e exceções)
Às vezes queremos que a tarefa não apenas “faça algo”, mas também retorne um resultado — por exemplo, a soma de números, o resultado de cálculos, dados de um servidor. Para isso existe a interface Callable<T>.
import java.util.concurrent.Callable;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
- O método call() retorna um resultado do tipo T.
- O método call() pode lançar exceção verificada.
Analogia: Runnable — “vá lavar a louça” (o resultado não importa), Callable — “vá trazer um chá e diga qual é a temperatura” (o resultado importa).
Executando um Callable: para obter o resultado, use executor.submit(...). Ele retornará um objeto Future<T>.
3. Future: uma promessa de resultado
Future é uma “promessa” de devolver um resultado no futuro. Quando você envia uma tarefa ao ExecutorService, recebe um Future, do qual poderá obter o resultado mais tarde, saber se a tarefa terminou ou cancelá-la.
Métodos principais do Future
- T get() — obter o resultado (aguarda até a tarefa terminar).
- boolean isDone() — verifica se a tarefa terminou.
- boolean cancel(boolean mayInterruptIfRunning) — tenta cancelar a tarefa.
- boolean isCancelled() — verifica se a tarefa foi cancelada.
Exemplo: executar um Callable e obter o resultado
import java.util.concurrent.*;
public class ParallelSumApp {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
Future<Integer> future = executor.submit(sumTask);
System.out.println("Tarefa iniciada, dá para fazer mais alguma coisa...");
// Obtendo o resultado (o método bloqueia a thread se a tarefa ainda não tiver sido concluída)
Integer result = future.get();
System.out.println("Resultado dos cálculos: " + result);
executor.shutdown();
}
}
- A tarefa é enviada ao pool de threads.
- Enquanto a tarefa executa, a thread principal pode fazer outras coisas.
- Quando o resultado for necessário, chamamos future.get() — a thread aguardará se a tarefa ainda estiver em execução.
- Assim que a tarefa terminar, o resultado será retornado.
4. Prática: várias tarefas e espera pela conclusão
Muitas vezes é preciso iniciar várias tarefas de uma vez e esperar que todas terminem. Por exemplo, você processa um array de dados, divide-o em partes e calcula a soma de cada parte em uma tarefa separada.
Exemplo: soma dos elementos do array por partes
import java.util.*;
import java.util.concurrent.*;
public class ParallelArraySum {
public static void main(String[] args) throws Exception {
int[] array = new int[1000];
Arrays.setAll(array, i -> i + 1); // Preenchemos com números de 1 a 1000
ExecutorService executor = Executors.newFixedThreadPool(4);
int chunkSize = array.length / 4;
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < 4; i++) {
int from = i * chunkSize;
int to = (i == 3) ? array.length : (i + 1) * chunkSize;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int j = from; j < to; j++) sum += array[j];
System.out.println("Soma de " + from + " até " + (to - 1) + " = " + sum);
return sum;
};
futures.add(executor.submit(sumTask));
}
int totalSum = 0;
for (Future<Integer> f : futures) {
totalSum += f.get(); // Aguardamos cada tarefa por sua vez
}
System.out.println("Soma total: " + totalSum);
executor.shutdown();
}
}
Aqui, o array é dividido em 4 partes. Para cada parte é criada uma tarefa (Callable) que calcula a soma. Todas as tarefas são enviadas ao ExecutorService, retornando Future. No final, reunimos os resultados de todas as tarefas e somamos.
Em casos reais é conveniente usar invokeAll para esperar todas as tarefas de uma vez.
5. Tratamento de erros ao usar Future
Quando você chama future.get(), se a tarefa terminou com exceção, ela será encapsulada como ExecutionException. Isso é importante: se algo deu errado na tarefa, você só saberá ao chamar get().
Exemplo: tratamento de exceções
Callable<Integer> errorTask = () -> {
throw new IllegalArgumentException("Algo deu errado!");
};
Future<Integer> badFuture = executor.submit(errorTask);
try {
badFuture.get();
} catch (ExecutionException e) {
System.out.println("A tarefa terminou com erro: " + e.getCause());
}
- Dentro da tarefa é lançada uma exceção.
- Ao chamar get() ela é “embrulhada” em ExecutionException.
- A causa real pode ser obtida por getCause().
6. Detalhes úteis
Como cancelar uma tarefa
Future<?> f = executor.submit(() -> {
while (true) {
// Trabalho infinito
if (Thread.currentThread().isInterrupted()) {
System.out.println("Pediram para eu encerrar!");
break;
}
}
});
Thread.sleep(100); // Vamos esperar um pouco
f.cancel(true); // Vamos tentar cancelar a tarefa
- cancel(true) tenta interromper a tarefa, se ela ainda não tiver sido concluída.
- Dentro da tarefa é aconselhável verificar Thread.currentThread().isInterrupted() e encerrar corretamente.
shutdown vs shutdownNow
shutdown() — parada suave: proíbe adicionar novas tarefas e permite que as atuais terminem em paz. Usado na maioria dos casos.
shutdownNow() — parada brusca: tenta interromper as threads ativas e retorna a lista de tarefas que não chegaram a iniciar. Use com cautela.
invokeAll e invokeAny
invokeAll(Collection<Callable<T>> tasks) inicia todas as tarefas passadas e aguarda até que todas terminem. Retorna uma lista de Future.
invokeAny(Collection<Callable<T>> tasks) aguarda apenas a primeira tarefa concluída com sucesso, retorna seu resultado e cancela as demais. Útil quando importa a primeira resposta bem-sucedida.
7. Erros comuns ao trabalhar com ExecutorService, Callable e Future
Erro nº 1: Não encerrar o ExecutorService. Se você esquecer de chamar shutdown(), o programa pode “ficar preso” após o término do main, porque as threads do pool aguardam novas tarefas.
Erro nº 2: Aguardar o resultado imediatamente após enviar a tarefa. Se logo após submit() você chamar get(), não obterá as vantagens da assincronia — a thread vai esperar de qualquer forma. Faça trabalho útil em paralelo e solicite o resultado quando ele realmente for necessário.
Erro nº 3: Ignorar exceções nas tarefas. Se você não tratar ExecutionException ao chamar get(), pode perder erros importantes que ocorreram na tarefa.
Erro nº 4: Usar variáveis mutáveis compartilhadas sem sincronização. Se várias tarefas trabalham com os mesmos dados — é preciso sincronização ou coleções thread-safe.
Erro nº 5: Criar threads demais. Não faça um pool com número de threads muito acima da quantidade de núcleos do processador — isso pode até desacelerar a execução.
Erro nº 6: Esquecer de cancelar tarefas. Se a tarefa não for mais necessária, cancele-a via cancel() para não desperdiçar recursos.
GO TO FULL VERSION