Introdução aos pools de threads em Java

Introdução aos pools de threads em Java

1. Introdução

Este artigo é uma olhada nos pools de threads em Java - começando com as diferentes implementações na biblioteca Java padrão e, em seguida, examinando a biblioteca Guava do Google.

2. The Thread Pool

Em Java, os threads são mapeados para threads no nível do sistema, que são recursos do sistema operacional. Se você criar threads incontrolavelmente, poderá ficar sem esses recursos rapidamente.

A alternância de contexto entre os threads também é feita pelo sistema operacional - para simular o paralelismo. Uma visão simplista é que: quanto mais threads você gerar, menos tempo cada thread gasta no trabalho real.

O padrão Pool de threads ajuda a economizar recursos em um aplicativo multithread e também a conter o paralelismo em certos limites predefinidos.

Ao usar um pool de threads, vocêwrite your concurrent code in the form of parallel tasks and submit them for execution to an instance of a thread pool. Esta instância controla vários threads reutilizados para executar essas tarefas. 2016-08-10_10-16-52-1024x572

O padrão permitecontrol the number of threads the application is creating, seu ciclo de vida, bem como agendar a execução de tarefas e manter as tarefas recebidas em uma fila.

3. Pools de threads em Java

3.1. Executors,Executor eExecutorService

A classe auxiliarExecutors contém vários métodos para a criação de instâncias de pool de threads pré-configuradas para você. Essas aulas são um bom lugar para começar - use-as se não precisar aplicar nenhum ajuste fino personalizado.

As interfacesExecutoreExecutorService são usadas para trabalhar com diferentes implementações de pool de threads em Java. Normalmente, você devekeep your code decoupled from the actual implementation of the thread poole usar essas interfaces em todo o seu aplicativo.

A interfaceExecutor tem um único métodoexecute para enviar instânciasRunnable para execução.

Here’s a quick example de como você pode usar a APIExecutors para adquirir uma instânciaExecutor apoiada por um único pool de threads e uma fila ilimitada para executar tarefas sequencialmente. Aqui, executamos uma única tarefa que simplesmente imprime “Hello World” na tela. A tarefa é enviada como lambda (um recurso Java 8) que é inferido comoRunnable.

Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> System.out.println("Hello World"));

A interfaceExecutorService contém um grande número de métodos paracontrolling the progress of the tasks and managing the termination of the service. Usando essa interface, você pode enviar as tarefas para execução e também controlar sua execução usando a instânciaFuture retornada.

In the following example, criamos umExecutorService, enviamos uma tarefa e usamos o métodoget retornado deFuture para esperar até que a tarefa enviada seja concluída e o valor retornado:

ExecutorService executorService = Executors.newFixedThreadPool(10);
Future future = executorService.submit(() -> "Hello World");
// some operations
String result = future.get();

Claro, em um cenário da vida real, você geralmente não deseja chamarfuture.get() imediatamente, mas adiar a chamada até que você realmente precise do valor do cálculo.

O métodosubmit está sobrecarregado para receberRunnable ouCallable, ambos são interfaces funcionais e podem ser passados ​​como lambdas (começando com Java 8).

O método único deRunnable não lança uma exceção e não retorna valor. A interfaceCallable pode ser mais conveniente, pois permite lançar uma exceção e retornar um valor.

Finalmente - para deixar o compilador inferir o tipoCallable, simplesmente retorne um valor do lambda.

Para obter mais exemplos sobre como usar a interfaceExecutorService e futuros, dê uma olhada em “A Guide to the Java ExecutorService“.

3.2. ThreadPoolExecutor

OThreadPoolExecutor é uma implementação extensível de pool de threads com muitos parâmetros e ganchos para ajuste fino.

Os principais parâmetros de configuração que discutiremos aqui são:corePoolSize,maximumPoolSize ekeepAliveTime.

O pool consiste em um número fixo de threads principais que são mantidos em uso o tempo todo e em alguns threads excessivos que podem ser gerados e finalizados quando não são mais necessários. O parâmetrocorePoolSize é a quantidade de threads principais que serão instanciados e mantidos no pool. Se todos os threads principais estiverem ocupados e mais tarefas forem enviadas, o pool poderá crescer atémaximumPoolSize.

O parâmetrokeepAliveTime é o intervalo de tempo para o qual os threads excessivos (ou seja, threads que são instanciados em excesso decorePoolSize) podem existir no estado ocioso.

Esses parâmetros abrangem uma ampla gama de casos de uso, masthe most typical configurations are predefined in the Executors static methods.

For example, métodonewFixedThreadPool cria um métodoThreadPoolExecutor com valores de parâmetrocorePoolSizeemaximumPoolSize iguais e umkeepAliveTime. zero. Isso significa que o número de threads em este pool de threads é sempre o mesmo:

ThreadPoolExecutor executor =
  (ThreadPoolExecutor) Executors.newFixedThreadPool(2);
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});

assertEquals(2, executor.getPoolSize());
assertEquals(1, executor.getQueue().size());

No exemplo acima, instanciamos aThreadPoolExecutor com uma contagem de threads fixa de 2. Isso significa que, se a quantidade de tarefas em execução simultâneas for menor ou igual a duas em todos os momentos, elas serão executadas imediatamente. Caso contrário,some of these tasks may be put into a queue to wait for their turn.

Criamos três tarefasCallable que imitam o trabalho pesado dormindo por 1000 milissegundos. As duas primeiras tarefas serão executadas ao mesmo tempo e a terceira terá que esperar na fila. Podemos verificar isso chamando os métodosgetPoolSize()egetQueue().size() imediatamente após o envio das tarefas.

OutroThreadPoolExecutor pré-configurado pode ser criado com o métodoExecutors.newCachedThreadPool(). Este método não recebe um número de threads. OcorePoolSize é realmente definido como 0 e omaximumPoolSize é definido comoInteger.MAX_VALUE para esta instância. OkeepAliveTime é 60 segundos para este.

Esses valores de parâmetro significam quethe cached thread pool may grow without bounds to accommodate any amount of submitted tasks. Mas quando os encadeamentos não forem mais necessários, eles serão descartados após 60 segundos de inatividade. Um caso de uso típico é quando você tem muitas tarefas de curta duração em seu aplicativo.

ThreadPoolExecutor executor =
  (ThreadPoolExecutor) Executors.newCachedThreadPool();
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});

assertEquals(3, executor.getPoolSize());
assertEquals(0, executor.getQueue().size());

O tamanho da fila no exemplo acima sempre será zero porque internamente uma instânciaSynchronousQueue é usada. Em aSynchronousQueue, pares de operaçõesinserteremove sempre ocorrem simultaneamente, portanto, a fila nunca contém realmente nada.

A APIExecutors.newSingleThreadExecutor() cria outra forma típica deThreadPoolExecutor contendo um único encadeamento. The single thread executor is ideal for creating an event loop. Os parâmetroscorePoolSizeemaximumPoolSize são iguais a 1 ekeepAliveTime é zero.

As tarefas no exemplo acima serão executadas sequencialmente, de modo que o valor do sinalizador será 2 após a conclusão da tarefa:

AtomicInteger counter = new AtomicInteger();

ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> {
    counter.set(1);
});
executor.submit(() -> {
    counter.compareAndSet(1, 2);
});

Além disso, esteThreadPoolExecutor é decorado com um invólucro imutável, portanto, não pode ser reconfigurado após a criação. Observe que esse também é o motivo pelo qual não podemos convertê-lo emThreadPoolExecutor.

3.3. ScheduledThreadPoolExecutor

OScheduledThreadPoolExecutor estende a classeThreadPoolExecutore também implementa a interfaceScheduledExecutorService com vários métodos adicionais:

  • O métodoschedule permite executar uma tarefa uma vez após um atraso especificado;

  • O métodoscheduleAtFixedRate permite executar uma tarefa após um determinado atraso inicial e, em seguida, executá-la repetidamente com um determinado período; o argumentoperiod é o tempomeasured between the starting times of the tasks, então a taxa de execução é fixa;

  • O métodoscheduleWithFixedDelay é semelhante ascheduleAtFixedRate porque executa repetidamente a tarefa fornecida, mas o atraso especificado émeasured between the end of the previous task and the start of the next; a taxa de execução pode variar dependendo do tempo que leva para executar uma determinada tarefa.

O métodoExecutors.newScheduledThreadPool() é normalmente usado para criar umScheduledThreadPoolExecutor com um determinadocorePoolSize,maximumPoolSize ilimitado e zerokeepAliveTime. Veja como agendar uma tarefa para execução em 500 milissegundos:

ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
executor.schedule(() -> {
    System.out.println("Hello World");
}, 500, TimeUnit.MILLISECONDS);

O código a seguir mostra como executar uma tarefa após um atraso de 500 milissegundos e, em seguida, repeti-la a cada 100 milissegundos. Depois de agendar a tarefa, esperamos até que ela seja disparada três vezes usando o bloqueioCountDownLatch,, em seguida, cancelamos usando o métodoFuture.cancel().

CountDownLatch lock = new CountDownLatch(3);

ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
ScheduledFuture future = executor.scheduleAtFixedRate(() -> {
    System.out.println("Hello World");
    lock.countDown();
}, 500, 100, TimeUnit.MILLISECONDS);

lock.await(1000, TimeUnit.MILLISECONDS);
future.cancel(true);

3.4. ForkJoinPool

ForkJoinPool é a parte central da estruturafork/join introduzida no Java 7. Ele resolve um problema comum despawning multiple tasks in recursive algorithms. Usando umThreadPoolExecutor simples, você ficará sem threads rapidamente, pois cada tarefa ou subtarefa requer seu próprio thread para ser executado.

Em uma estruturafork/join, qualquer tarefa pode gerar (fork) uma série de subtarefas e aguardar sua conclusão usando o métodojoin. O benefício da estruturafork/join é que eladoes not create a new thread for each task or subtask, implementando o algoritmo de roubo de trabalho. Esta estrutura é completamente descrita no artigo “Guide to the Fork/Join Framework in Java

Vejamos um exemplo simples de uso deForkJoinPool para percorrer uma árvore de nós e calcular a soma de todos os valores de folha. Aqui está uma implementação simples de uma árvore que consiste em um nó, um valorint e um conjunto de nós filhos:

static class TreeNode {

    int value;

    Set children;

    TreeNode(int value, TreeNode... children) {
        this.value = value;
        this.children = Sets.newHashSet(children);
    }
}

Agora, se quisermos somar todos os valores em uma árvore em paralelo, precisamos implementar uma interfaceRecursiveTask<Integer>. Cada tarefa recebe seu próprio nó e adiciona seu valor à soma dos valores de seuschildren. Para calcular a soma dos valores dechildren, a implementação da tarefa faz o seguinte:

  • transmite o conjuntochildren,

  • mapeia sobre este fluxo, criando um novoCountingTask para cada elemento,

  • executa cada subtarefa bifurcando-a,

  • coleta os resultados chamando o métodojoin em cada tarefa bifurcada,

  • soma os resultados usando o coletorCollectors.summingInt.

public static class CountingTask extends RecursiveTask {

    private final TreeNode node;

    public CountingTask(TreeNode node) {
        this.node = node;
    }

    @Override
    protected Integer compute() {
        return node.value + node.children.stream()
          .map(childNode -> new CountingTask(childNode).fork())
          .collect(Collectors.summingInt(ForkJoinTask::join));
    }
}

O código para executar o cálculo em uma árvore real é muito simples:

TreeNode tree = new TreeNode(5,
  new TreeNode(3), new TreeNode(2,
    new TreeNode(2), new TreeNode(8)));

ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
int sum = forkJoinPool.invoke(new CountingTask(tree));

4. Implementação do Thread Pool no Guava

Guava é uma biblioteca popular de utilitários do Google. Ele tem muitas classes de simultaneidade úteis, incluindo várias implementações úteis deExecutorService. As classes de implementação não são acessíveis para instanciação direta ou subclasse, então o único ponto de entrada para criar suas instâncias é a classe auxiliarMoreExecutors.

4.1. Adicionando Guava como uma dependência Maven

Adicione a seguinte dependência ao seu arquivo pom Maven para incluir a biblioteca Guava no seu projeto. Você pode encontrar a versão mais recente da biblioteca Guava no repositórioMaven Central:


    com.google.guava
    guava
    19.0

4.2. Executor direto e serviço de execução direta

Às vezes, você deseja executar a tarefa no encadeamento atual ou em um conjunto de encadeamentos, dependendo de algumas condições. Você prefere usar uma única interfaceExecutor e apenas alternar a implementação. Embora não seja tão difícil chegar a uma implementação deExecutor ouExecutorService que execute as tarefas no thread atual, ainda é necessário escrever algum código clichê.

Felizmente, o Guava fornece instâncias predefinidas para nós.

Here’s an example que demonstra a execução de uma tarefa no mesmo thread. Embora a tarefa fornecida durma por 500 milissegundos, éblocks the current thread, e o resultado está disponível imediatamente após o término da chamadaexecute:

Executor executor = MoreExecutors.directExecutor();

AtomicBoolean executed = new AtomicBoolean();

executor.execute(() -> {
    try {
        Thread.sleep(500);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    executed.set(true);
});

assertTrue(executed.get());

A instância retornada pelo métododirectExecutor() é na verdade um singleton estático, portanto, usar esse método não fornece nenhuma sobrecarga na criação do objeto.

Você deve preferir esse método aoMoreExecutors.newDirectExecutorService(), porque essa API cria uma implementação de serviço executor completa em cada chamada.

4.3. Saindo dos Serviços de Executor

Outro problema comum éshutting down the virtual machine enquanto um pool de threads ainda está executando suas tarefas. Mesmo com um mecanismo de cancelamento em vigor, não há garantia de que as tarefas se comportem bem e parem seu trabalho quando o serviço do executor for encerrado. Isso pode fazer com que a JVM seja interrompida indefinidamente enquanto as tarefas continuam realizando seu trabalho.

Para resolver esse problema, o Guava apresenta uma família de serviços executores existentes. Eles são baseados emdaemon threads which terminate together with the JVM.

Esses serviços também adicionam um gancho de desligamento com o métodoRuntime.getRuntime().addShutdownHook() e evitam que a VM seja encerrada por um período de tempo configurado antes de desistir de tarefas suspensas.

No exemplo a seguir, estamos enviando a tarefa que contém um loop infinito, mas usamos um serviço de executor existente com um tempo configurado de 100 milissegundos para aguardar as tarefas após o término da VM. Sem oexitingExecutorService no lugar, esta tarefa faria com que a VM travasse indefinidamente:

ThreadPoolExecutor executor =
  (ThreadPoolExecutor) Executors.newFixedThreadPool(5);
ExecutorService executorService =
  MoreExecutors.getExitingExecutorService(executor,
    100, TimeUnit.MILLISECONDS);

executorService.submit(() -> {
    while (true) {
    }
});

4.4. Decoradores de escuta

Os decoradores de escuta permitem que você empacoteExecutorServicee recebaListenableFuture instâncias no envio da tarefa, em vez de instâncias simplesFuture. A interfaceListenableFuture estendeFuturee tem um único método adicionaladdListener. Este método permite adicionar um ouvinte chamado após a conclusão futura.

Você raramente desejará usar o métodoListenableFuture.addListener() diretamente, mas éessential to most of the helper methods in the Futures utility class. Por exemplo, com o métodoFutures.allAsList(), você pode combinar várias instâncias deListenableFuture em um únicoListenableFuture que é concluído após a conclusão bem-sucedida de todos os futuros combinados:

ExecutorService executorService = Executors.newCachedThreadPool();
ListeningExecutorService listeningExecutorService =
  MoreExecutors.listeningDecorator(executorService);

ListenableFuture future1 =
  listeningExecutorService.submit(() -> "Hello");
ListenableFuture future2 =
  listeningExecutorService.submit(() -> "World");

String greeting = Futures.allAsList(future1, future2).get()
  .stream()
  .collect(Collectors.joining(" "));
assertEquals("Hello World", greeting);

5. Conclusão

Neste artigo, discutimos o padrão Thread Pool e suas implementações na biblioteca Java padrão e na biblioteca Guava do Google.

O código-fonte do artigo está disponívelover on GitHub.