Guia do Framework Fork / Join em Java
1. Visão geral
A estrutura fork / join foi apresentada em Java 7. Ele fornece ferramentas para ajudar a acelerar o processamento paralelo, tentando usar todos os núcleos de processador disponíveis - o que é realizadothrough a divide and conquer approach.
Na prática, isso significa quethe framework first “forks”, dividindo recursivamente a tarefa em subtarefas independentes menores até que sejam simples o suficiente para serem executadas de forma assíncrona.
Depois disso,the “join” part begins, em que os resultados de todas as subtarefas são unidos recursivamente em um único resultado, ou no caso de uma tarefa que retorna void, o programa simplesmente espera até que cada subtarefa seja executada.
Para fornecer uma execução paralela eficaz, a estrutura fork / join usa um pool de threads chamadoForkJoinPool, que gerencia threads de trabalho do tipoForkJoinWorkerThread.
2. ForkJoinPool
OForkJoinPool é o coração da estrutura. É uma implementação doExecutorService que gerencia threads de trabalho e nos fornece ferramentas para obter informações sobre o estado e desempenho do pool de threads.
Threads de trabalho podem executar apenas uma tarefa por vez, mas oForkJoinPool não cria um thread separado para cada subtarefa. Em vez disso, cada thread no pool tem sua própria fila de duas pontas (oudeque, pronunciadodeck) que armazena tarefas.
Esta arquitetura é vital para equilibrar a carga de trabalho do encadeamento com a ajuda dowork-stealing algorithm.
2.1. Algoritmo de roubo de trabalho
Simplificando - threads livres tentam “roubar” trabalho de deques de threads ocupados.
Por padrão, um encadeamento de trabalho obtém tarefas do cabeçalho de seu próprio deque. Quando está vazio, o encadeamento recebe uma tarefa da cauda do deque de outro encadeamento ocupado ou da fila de entrada global, pois é aqui que as maiores peças de trabalho provavelmente estão localizadas.
Essa abordagem minimiza a possibilidade de os threads competirem por tarefas. Também reduz o número de vezes que o encadeamento precisará procurar trabalho, pois trabalha primeiro nos maiores blocos de trabalho disponíveis.
2.2. ForkJoinPool Instanciação
No Java 8, a maneira mais conveniente de obter acesso à instância deForkJoinPool é usar seu método estáticocommonPool().. Como o nome sugere, isso fornecerá uma referência ao pool comum, que é pool de threads padrão para cadaForkJoinTask.
De acordo comOracle’s documentation, o uso do pool comum predefinido reduz o consumo de recursos, pois isso desencoraja a criação de um pool de threads separado por tarefa.
ForkJoinPool commonPool = ForkJoinPool.commonPool();
O mesmo comportamento pode ser obtido no Java 7 criando umForkJoinPoole atribuindo-o a um campopublic static de uma classe de utilitário:
public static ForkJoinPool forkJoinPool = new ForkJoinPool(2);
Agora ele pode ser facilmente acessado:
ForkJoinPool forkJoinPool = PoolUtil.forkJoinPool;
Com os construtoresForkJoinPool’s, é possível criar um pool de threads customizado com um nível específico de paralelismo, fábrica de threads e manipulador de exceções. No exemplo acima, o pool tem um nível de paralelismo 2. Isso significa que o pool usará 2 núcleos de processador.
3. ForkJoinTask<V>
ForkJoinTask é o tipo de base para tarefas executadas emForkJoinPool. Na prática, uma de suas duas subclasses deve ser estendida: oRecursiveAction paravoid tarefas e oRecursiveTask<V> para tarefas que retornam um valor. _ They both have an abstract method _compute() em que a lógica da tarefa é definida.
3.1. RecursiveAction – An Example
No exemplo abaixo, a unidade de trabalho a ser processada é representada por umString chamadoworkload. Para fins de demonstração, a tarefa é sem sentido: simplesmente coloca em maiúscula sua entrada e a registra.
Para demonstrar o comportamento de bifurcação da estrutura, métodothe example splits the task if workload.length() is larger than a specified threshold_ using the _createSubtask().
A String é dividida recursivamente em substrings, criando instâncias deCustomRecursiveTask que são baseadas nessas substrings.
Como resultado, o método retorna umList<CustomRecursiveAction>.
A lista é enviada paraForkJoinPool usando o métodoinvokeAll():
public class CustomRecursiveAction extends RecursiveAction {
private String workload = "";
private static final int THRESHOLD = 4;
private static Logger logger =
Logger.getAnonymousLogger();
public CustomRecursiveAction(String workload) {
this.workload = workload;
}
@Override
protected void compute() {
if (workload.length() > THRESHOLD) {
ForkJoinTask.invokeAll(createSubtasks());
} else {
processing(workload);
}
}
private List createSubtasks() {
List subtasks = new ArrayList<>();
String partOne = workload.substring(0, workload.length() / 2);
String partTwo = workload.substring(workload.length() / 2, workload.length());
subtasks.add(new CustomRecursiveAction(partOne));
subtasks.add(new CustomRecursiveAction(partTwo));
return subtasks;
}
private void processing(String work) {
String result = work.toUpperCase();
logger.info("This result - (" + result + ") - was processed by "
+ Thread.currentThread().getName());
}
}
Este padrão pode ser usado para desenvolver suas própriasRecursiveAction classes. Para fazer isso, crie um objeto que represente a quantidade total de trabalho, escolha um limite adequado, defina um método para dividir o trabalho e defina um método para fazer o trabalho.
3.2. RecursiveTask<V>
Para tarefas que retornam um valor, a lógica aqui é semelhante, exceto que o resultado de cada subtarefa é unido em um único resultado:
public class CustomRecursiveTask extends RecursiveTask {
private int[] arr;
private static final int THRESHOLD = 20;
public CustomRecursiveTask(int[] arr) {
this.arr = arr;
}
@Override
protected Integer compute() {
if (arr.length > THRESHOLD) {
return ForkJoinTask.invokeAll(createSubtasks())
.stream()
.mapToInt(ForkJoinTask::join)
.sum();
} else {
return processing(arr);
}
}
private Collection createSubtasks() {
List dividedTasks = new ArrayList<>();
dividedTasks.add(new CustomRecursiveTask(
Arrays.copyOfRange(arr, 0, arr.length / 2)));
dividedTasks.add(new CustomRecursiveTask(
Arrays.copyOfRange(arr, arr.length / 2, arr.length)));
return dividedTasks;
}
private Integer processing(int[] arr) {
return Arrays.stream(arr)
.filter(a -> a > 10 && a < 27)
.map(a -> a * 10)
.sum();
}
}
Neste exemplo, o trabalho é representado por uma matriz armazenada no campoarr da classeCustomRecursiveTask. O métodocreateSubtask() divide recursivamente a tarefa em partes menores de trabalho até que cada parte seja menor que o limite. Então, o métodoinvokeAll() envia subtarefas para o pull comum e retorna uma lista deFuture.
Para acionar a execução, o métodojoin() chamado para cada subtarefa.
Neste exemplo, isso é realizado usandoStream API; do Java 8, o métodosum() é usado como uma representação da combinação de sub resultados no resultado final.
4. Enviando tarefas paraForkJoinPool
Para enviar tarefas para o conjunto de encadeamentos, poucas abordagens podem ser usadas.
O métodosubmit() ouexecute() (seus casos de uso são os mesmos):
forkJoinPool.execute(customRecursiveTask);
int result = customRecursiveTask.join();
O métodoinvoke() bifurca a tarefa e espera pelo resultado, e não precisa de nenhuma junção manual:
int result = forkJoinPool.invoke(customRecursiveTask);
O métodoinvokeAll() é a maneira mais conveniente de enviar uma sequência deForkJoinTasks paraForkJoinPool.. Ele pega as tarefas como parâmetros (duas tarefas, var args ou uma coleção), bifurca-as retorna um coleção de objetosFuture na ordem em que foram produzidos.
Como alternativa, você pode usar métodosfork() and join() separados. O métodofork() envia uma tarefa para um pool, mas não dispara sua execução. O métodojoin() é usado para esse propósito. No caso deRecursiveAction,join() retorna nada além denull; paraRecursiveTask<V>, ele retorna o resultado da execução da tarefa:
customRecursiveTaskFirst.fork();
result = customRecursiveTaskLast.join();
Em nosso exemploRecursiveTask<V>, usamos o métodoinvokeAll() para enviar uma sequência de subtarefas ao pool. O mesmo trabalho pode ser feito comfork()ejoin(), embora isso tenha consequências para a ordenação dos resultados.
Para evitar confusão, geralmente é uma boa ideia usar o métodoinvokeAll() para enviar mais de uma tarefa para oForkJoinPool.
5. Conclusões
O uso da estrutura de junção / junção pode acelerar o processamento de grandes tarefas, mas para alcançar esse resultado, algumas diretrizes devem ser seguidas:
-
Use as few thread pools as possible - na maioria dos casos, a melhor decisão é usar um pool de threads por aplicativo ou sistema
-
Use the default common thread pool, se nenhum ajuste específico for necessário
-
Use a reasonable threshold para dividirForkJoingTask em subtarefas
-
Evite qualquer bloqueio em seu ForkJoingTasks
Os exemplos usados neste artigo estão disponíveis emlinked GitHub repository.