Do you really understand and use ForkJoinPool correctly?

ForkJoinPoolIs a powerful Java class for processing computation-intensive tasks, using to ForkJoinPooldecompose computation-intensive tasks and execute them in parallel can yield better performance. It works by breaking down tasks into smaller subtasks, operating using a divide-and-conquer strategy, enabling them to execute tasks concurrently, increasing throughput and reducing processing time.

One of the unique features of ForkJoinPool is its work-stealing algorithm for optimized performance. When a worker thread completes its assigned tasks, it steals tasks from other threads, ensuring that all threads are working efficiently and that no computer resources are wasted.

ForkJoinPool is widely used in Java's Parallel Streams and CompletableFutures, allowing developers to easily execute tasks concurrently. Additionally, other JVM languages ​​such as Kotlin and Akka also use this framework to build message-driven applications that require high concurrency and resilience.

Use ForkJoinPool to build a thread pool

The ForkJoinPool stores workers, which are processes running on each CPU core of the machine. Each of these processes is stored in a deque ( Deque). Once a worker thread runs out of tasks, it starts stealing tasks from other worker threads.

First, there will be a process of forking tasks. This means that a large task will be broken down into smaller tasks that can be executed in parallel. Once all subtasks are complete, they are rejoined. Finally, the ForkJoinPool class provides an output result through Join, as shown in the figure below.

Untitled-2023-06-15-1917 (1).png

When a task is submitted in the ForkJoinPool, the process is split into smaller processes and pushed onto the shared queue.

Once fork()the method is called, the tasks will be called in parallel until the base condition is true. Once processing is forked, join()the method ensures that the threads wait for each other until the process completes.

All tasks will initially be submitted to a main queue which will push tasks to worker threads. Meanwhile, same as the stack data structure, tasks are LIFOinserted using last-in-first-out ( ) strategy, as shown in the figure below.

Untitled-2023-06-15-1917 (2).png

Work-stealing algorithm

Work-stealing algorithm is a strategy for parallel computing and load balancing. It is commonly used in distributed systems and multi-core processors to efficiently distribute and balance computing tasks.

The advantage of the Work-stealing algorithm is that it can achieve efficient load balancing and parallel computing while reducing the waiting time of tasks. When a thread finishes its task and becomes free, it tries to "steal" the task from another thread's queue end, same as queue data structure, it follows FIFO policy. This strategy will allow the idle thread to pick up the longest waiting task, reducing overall wait time and increasing throughput.

In the diagram below, thread 2 steals a task from thread 1 by polling the last element in thread 1's queue, and then executes the task. The stolen tasks are usually the longest waiting tasks in the queue, which ensures that the workload is evenly distributed among all threads in the pool.

Untitled-2023-06-15-1917 (3).png

Overall, ForkJoinPool's work-stealing algorithm is a powerful feature that can significantly improve the performance of parallel programs by ensuring that all available computing resources are efficiently utilized.

ForkJoinPool main class

Let's take a quick look at the main class that supports processing with ForkJoinPool.

  • ForkJoinPool creates a thread pool to use ForkJoin: it works similarly to other thread pools. The most important method in this class is commonPool()that it is used to create the ForkJoin thread pool.
  • RecursiveAction: The main function of this class is to calculate recursive operations. In compute()the method, we have no return value because the recursion happens in compute()the method.
  • RecursiveTask: This class works similarly RecursiveAction, except that compute()the method will return a value.

Use RecursiveAction

To use the functionality of RecursiveAction, we need to inherit from it and override its compute()methods.

In the following code example, we will calculate the doubling of each number in an array in parallel and recursively.

We see that in code, fork()methods call compute()methods. Once the entire array has been summed per element, the recursive call stops. At the same time, we display the result once the recursive sum of all the elements of the array is performed.

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;

public class ForkJoinDoubleAction {
    
    

  public static void main(String[] args) {
    
    
    ForkJoinPool forkJoinPool = new ForkJoinPool();
    int[] array = {
    
    1, 5, 10, 15, 20, 25, 50};
    DoubleNumber doubleNumberTask = new DoubleNumber(array, 0, array.length);

    // 调用compute方法
    forkJoinPool.invoke(doubleNumberTask);
    System.out.println(DoubleNumber.result);
  }
}

class DoubleNumber extends RecursiveAction {
    
    

  final int PROCESS_THRESHOLD = 2;
  int[] array;
  int startIndex, endIndex;
  static int result;

  DoubleNumber(int[] array, int startIndex, int endIndex) {
    
    
    this.array = array;
    this.startIndex = startIndex;
    this.endIndex = endIndex;
  }

  @Override
  protected void compute() {
    
    
    if (endIndex - startIndex <= PROCESS_THRESHOLD) {
    
    
      for (int i = startIndex; i < endIndex; i++) {
    
    
        result += array[i] * 2;
      }
    } else {
    
    
      int mid = (startIndex + endIndex) / 2;
      DoubleNumber leftArray = new DoubleNumber(array, startIndex, mid);
      DoubleNumber rightArray = new DoubleNumber(array, mid, endIndex);

      // 递归地调用compute方法
      leftArray.fork();
      rightArray.fork();

      // Joins
      leftArray.join();
      rightArray.join();
    }
  }
}

The resulting output of the calculation is 252.

The important thing to remember from RecursiveAction is that it does not return a value. Performance can also be improved by breaking down the process using a divide and conquer strategy.

Also note that RecursiveAction is most effective when used for tasks that can be efficiently broken down into smaller subproblems.

Therefore, RecursiveAction and ForkJoinPool should be used for computationally intensive tasks where parallelization of work can significantly improve performance. Otherwise, performance will be worse due to thread creation and management.

RecursiveTask

In this example, we use the RecursiveTask class to see if there is any difference.

The difference between RecursiveAction and RecursiveTask is that with RecursiveTask we can compute()return a value in the method.

import java.util.List;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;

public class ForkJoinSumArrayTask extends RecursiveTask<Integer> {
    
    

  private final List<Integer> numbers;

  public ForkJoinSumArrayTask(List<Integer> numbers) {
    
    
    this.numbers = numbers;
  }

  @Override
  protected Integer compute() {
    
    
    if (numbers.size() <= 2) {
    
    
      return numbers.stream().mapToInt(e -> e).sum();
    } else {
    
    
      int mid = numbers.size() / 2;
      List<Integer> list1 = numbers.subList(0, mid);
      List<Integer> list2 = numbers.subList(mid, numbers.size());
 
      ForkJoinSumArrayTask task1 = new ForkJoinSumArrayTask(list1);
      ForkJoinSumArrayTask task2 = new ForkJoinSumArrayTask(list2);

      task1.fork();

      return task1.join() + task2.compute();
    }
  }

  public static void main(String[] args) {
    
    
    ForkJoinPool forkJoinPool = new ForkJoinPool();

    List<Integer> numbers = List.of(1, 3, 5, 7, 9);
    int output = forkJoinPool.invoke(new ForkJoinSumArrayTask(numbers));

    System.out.println(output);
  }
}

In the above code, we recursively decompose the array until it reaches the base condition.

Once we destroy the main array, we send list1 and list2 to ForkJoinSumArrayTask, then we fork task1, which executes compute()the method and the rest of the array in parallel.

Once the recursive process reaches the base condition, the method is called join, concatenating the results.

The final output is 25.

When to use ForkJoinPool

ForkJoinPool should not be used in all cases. As mentioned, it's best used for highly intensive concurrent processes. Let's look at each of these situations in detail:

  • Recursive tasks: ForkJoinPool is ideal for performing recursive algorithms such as quicksort, mergesort or binary search . These algorithms can be broken down into smaller sub-problems and executed in parallel, significantly improving performance.
  • Parallel problem: If your problem can be easily divided into independent subtasks, such as image processing or numerical simulation, then use ForkJoinPool to execute subtasks in parallel.
  • High-concurrency scenarios: In high-concurrency scenarios, such as web servers, data processing pipelines, or other high-performance applications, ForkJoinPool can be used to execute tasks in parallel across multiple threads, which can help improve performance and throughput.

end

In this article, we saw how to use the all-important ForkJoinPool feature to perform heavy operations in CPU cores. Finally let's summarize the main points of this article:

  • ForkJoinPool is a thread pool that recursively executes tasks using a divide and conquer strategy.
  • JVM languages ​​such as Kotlin and Akka use ForkJoinPoolto build message-driven applications.
  • ForkJoinPool executes tasks in parallel, thus efficiently utilizing computer resources.
  • Work-stealing algorithms optimize resource utilization by allowing idle threads to steal tasks from busy threads.
  • The task is stored in the double-ended queue, the storage adopts the last-in-first-out strategy, and the stealing adopts the first-in-first-out strategy .
  • The main classes in the ForkJoinPool framework include ForkJoinPool, RecursiveAction and RecursiveTask:
    • RecursiveAction is used to evaluate recursive actions, it does not return any value.
    • RecursiveTask is used to compute recursive operations, but returns a value.
    • The compute() method is overridden in both classes to implement custom logic.
    • The fork() method calls the compute() method and breaks down the task into smaller subtasks.
    • The join() method waits for subtasks to complete and merges their results.
    • ForkJoinPool is commonly used with parallel streams and CompletableFuture.

Guess you like

Origin blog.csdn.net/ImagineCode/article/details/132384777