CodeGym /Cursos /JAVA 25 SELF /ForkJoinPool e RecursiveTask: tarefas recursivas

ForkJoinPool e RecursiveTask: tarefas recursivas

JAVA 25 SELF
Nível 54 , Lição 3
Disponível

1. ForkJoinPool: o que é e para que serve

ForkJoinPool — é um pool de threads especial que implementa a abordagem “dividir e conquistar” (divide and conquer). Seu objetivo é paralelizar o trabalho de forma máxima quando uma tarefa grande pode ser dividida em várias subtarefas independentes, executadas em paralelo, e então ter seus resultados combinados.

  • Fork (dividir) — a tarefa é dividida em subtarefas.
  • Join (juntar) — os resultados das subtarefas são combinados no resultado final.

ForkJoinPool é o coração dos streams paralelos em Java: quando você escreve list.parallelStream(), é ele que é usado por baixo dos panos. Mas você também pode usá-lo diretamente, obtendo mais controle.

Quando o ForkJoinPool é especialmente útil

ForkJoinPool brilha em tarefas que podem ser facilmente divididas em partes independentes: por exemplo, o processamento de arrays muito grandes, quando cada fragmento é processado separadamente e, em seguida, os resultados são combinados.

  • A tarefa é fácil de dividir em subtarefas independentes: ordenação, busca, soma.
  • As subtarefas têm tamanhos semelhantes e não dependem umas das outras.
  • É necessário usar todos os núcleos do processador para obter velocidade máxima.
+---------------------+
|   Tarefa grande     |
+---------------------+
          |
          v
+---------+---------+
|  Subtarefa 1      |
|  Subtarefa 2      |
|  ...              |
+-------------------+
          |
          v
+---------+---------+
|  Resultados       |
+-------------------+

É exatamente assim que funciona “dividir e conquistar”: dividimos — calculamos em paralelo — combinamos.

2. RecursiveTask e RecursiveAction: duas faces da mesma moeda

No ForkJoinPool, as tarefas são definidas por meio de classes especiais que sabem se dividir em subtarefas e combinar resultados. RecursiveTask<T> retorna um resultado, enquanto RecursiveAction — não. Na prática, usa-se mais RecursiveTask para, por exemplo, retornar uma soma, um máximo ou uma contagem.

Para criar tal tarefa, extendemos a classe e implementamos o método compute(). Nele descrevemos a lógica: se a tarefa for pequena — resolvemos imediatamente; se for grande — dividimos em subtarefas, executamos em paralelo com fork() e combinamos os resultados com join(). Assim nasce o paralelismo recursivo natural.

3. Sintaxe e exemplo: cálculo paralelo da soma de um array

Suponha que temos um array grande de números e queremos somar seus elementos rapidamente.

Etapa 1. Classe da tarefa

import java.util.concurrent.RecursiveTask;

public class ArraySumTask extends RecursiveTask<Long> {
    private static final int THRESHOLD = 1_000; // Limite para dividir a tarefa
    private final int[] array;
    private final int start, end;

    public ArraySumTask(int[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        // Se a tarefa for pequena — calculamos diretamente
        if (end - start <= THRESHOLD) {
            long sum = 0;
            for (int i = start; i < end; i++) {
                sum += array[i];
            }
            return sum;
        } else {
            // Dividimos a tarefa em duas subtarefas
            int mid = (start + end) / 2;
            ArraySumTask leftTask = new ArraySumTask(array, start, mid);
            ArraySumTask rightTask = new ArraySumTask(array, mid, end);

            // Disparamos as subtarefas em paralelo
            leftTask.fork(); // Assíncrono
            long rightResult = rightTask.compute(); // Síncrono
            long leftResult = leftTask.join(); // Aguardamos a esquerda terminar

            // Combinamos o resultado
            return leftResult + rightResult;
        }
    }
}
  • Se a tarefa for pequena (menor que o limite THRESHOLD) — somamos com um loop comum.
  • Se for grande — dividimos em duas; uma é iniciada de forma assíncrona via fork(), a outra é computada de forma síncrona via compute(), e então combinamos com join().

Etapa 2. Execução da tarefa via ForkJoinPool

import java.util.concurrent.ForkJoinPool;

public class ForkJoinSumDemo {
    public static void main(String[] args) {
        int[] numbers = new int[10_000_000];
        for (int i = 0; i < numbers.length; i++) {
            numbers[i] = 1; // Para simplificar — a soma deve ser igual ao comprimento do array
        }

        ForkJoinPool pool = new ForkJoinPool(); // Padrão — de acordo com o número de núcleos

        ArraySumTask task = new ArraySumTask(numbers, 0, numbers.length);

        long result = pool.invoke(task); // Execução da tarefa

        System.out.println("Soma dos elementos do array: " + result);
    }
}

Como isso funciona?

  • ForkJoinPool decide sozinho quantas threads usar (normalmente — igual ao número de núcleos).
  • A tarefa é dividida automaticamente em subtarefas, cada uma podendo ser executada em um núcleo separado.
  • O desempenho geralmente é superior ao do código sequencial (especialmente com muitos dados e em sistemas multinúcleo).

4. Como o ForkJoinPool funciona: um pouco “por baixo do capô”

Work-stealing (roubo de trabalho)

ForkJoinPool implementa o “roubo de trabalho”: se uma thread ficar sem tarefas, ela “rouba” trabalho de outra. Isso garante balanceamento de carga eficiente e uso pleno de todos os núcleos.

Algoritmo básico

  • A tarefa principal é dividida em subtarefas.
  • As subtarefas são colocadas em filas especializadas.
  • As threads pegam tarefas de suas filas e, quando as esvaziam, “procuram” trabalho nas filas vizinhas.
  • Quando tudo termina, os resultados são combinados.

Diagrama de funcionamento

flowchart TD
    A[Tarefa principal] --> B1[Subtarefa 1]
    A --> B2[Subtarefa 2]
    B1 --> C1[Tarefa pequena 1]
    B1 --> C2[Tarefa pequena 2]
    B2 --> C3[Tarefa pequena 3]
    B2 --> C4[Tarefa pequena 4]
    C1 --> D[Combinação dos resultados]
    C2 --> D
    C3 --> D
    C4 --> D

5. RecursiveAction — quando não é necessário retornar resultado

Se você precisa apenas executar algo em paralelo e não retornar resultado, use RecursiveAction. Exemplos típicos — paralelizar o preenchimento de um array, impressão, ordenação “in-place” etc.

import java.util.concurrent.RecursiveAction;

public class PrintTask extends RecursiveAction {
    private static final int THRESHOLD = 100;
    private final int[] array;
    private final int start, end;

    public PrintTask(int[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected void compute() {
        if (end - start <= THRESHOLD) {
            for (int i = start; i < end; i++) {
                System.out.print(array[i] + " ");
            }
        } else {
            int mid = (start + end) / 2;
            invokeAll(
                new PrintTask(array, start, mid),
                new PrintTask(array, mid, end)
            );
        }
    }
}

6. Prática: busca paralela do valor máximo em um array

import java.util.concurrent.RecursiveTask;

public class MaxFindTask extends RecursiveTask<Integer> {
    private static final int THRESHOLD = 1000;
    private final int[] array;
    private final int start, end;

    public MaxFindTask(int[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Integer compute() {
        if (end - start <= THRESHOLD) {
            int max = array[start];
            for (int i = start + 1; i < end; i++) {
                if (array[i] > max) max = array[i];
            }
            return max;
        } else {
            int mid = (start + end) / 2;
            MaxFindTask left = new MaxFindTask(array, start, mid);
            MaxFindTask right = new MaxFindTask(array, mid, end);
            left.fork();
            int rightResult = right.compute();
            int leftResult = left.join();
            return Math.max(leftResult, rightResult);
        }
    }
}

Execução:

import java.util.concurrent.ForkJoinPool;

public class ForkJoinMaxDemo {
    public static void main(String[] args) {
        int[] array = new int[5_000_000];
        for (int i = 0; i < array.length; i++) {
            array[i] = (int)(Math.random() * 1_000_000);
        }

        ForkJoinPool pool = new ForkJoinPool();
        MaxFindTask task = new MaxFindTask(array, 0, array.length);

        int max = pool.invoke(task);

        System.out.println("Valor máximo: " + max);
    }
}

7. Vantagens e limitações do ForkJoinPool

Vantagens

  • Balanceamento automático de carga. O work-stealing permite usar todos os núcleos de forma eficiente.
  • Comodidade. Não é preciso criar e gerenciar threads manualmente.
  • Alto desempenho. Especialmente em tarefas grandes e sistemas multinúcleo.
  • Flexibilidade. Você pode dividir as tarefas em quantas partes forem necessárias.

Limitações

  • Forte acoplamento entre subtarefas. Se as subtarefas esperam com frequência umas pelas outras, o ganho diminui.
  • Tarefas pequenas demais. A sobrecarga de divisão/sincronização pode “comer” a vantagem.
  • Efeitos colaterais. Não altere variáveis compartilhadas sem sincronização — você terá race condition.
  • Aplicabilidade. Bom para tarefas divisíveis em partes independentes.

8. Erros comuns ao trabalhar com ForkJoinPool e RecursiveTask

Erro nº 1: divisão da tarefa fina demais. Se o limite (THRESHOLD) for muito pequeno, haverá muitas tarefinhas — o custo de criação e sincronização superará o ganho do paralelismo. Experimente com o limite: valores ideais costumam estar na casa dos milhares ou dezenas de milhares de elementos.

Erro nº 2: uso de variáveis mutáveis compartilhadas. Se as subtarefas escreverem em uma variável comum sem sincronização — você terá condições de corrida (race condition). Retorne o resultado via compute() e combine apenas em join().

Erro nº 3: uso incorreto de fork/join. Esqueceu de chamar fork() ou join() — e a subtarefa não será executada em paralelo ou o resultado “se perderá”. Observe atentamente a ordem das chamadas.

Erro nº 4: executar ForkJoinTask fora do ForkJoinPool. Se você simplesmente chamar compute() na tarefa, ela será executada na thread atual, sem paralelismo. Para a “mágica” de verdade, use pool.invoke() ou pool.submit().

Erro nº 5: ignorar exceções. Se ocorrer uma exceção na tarefa, ela aparecerá ao chamar join() ou invoke(). Não deixe de tratar erros.

Erro nº 6: usar ForkJoinPool para tarefas com bloqueios. ForkJoinPool não é apropriado para tarefas que bloqueiam com frequência (espera de I/O etc.). Nesses casos, prefira ExecutorService.

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