Skip to Content
Course content

200: Fork/Join Framework for Parallel Tasks

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When I first started working with the Fork/Join framework, I fell into a trap that almost every developer does: I thought that since the goal was "parallelism," I should split my tasks as small as humanly possible. I figured that if I had a 10,000-element array, splitting it down until every single element was its own task would maximize the use of my CPU cores. I was wrong. Dead wrong.

Thinking More Tasks Always Equals More Speed

Here is why that logic fails. Every time you "fork" a task, you aren't just magically splitting a number; you are creating an object on the heap and pushing it onto a queue. If you split a task down to a single element, the overhead of managing those millions of ForkJoinTask objects completely dwarfs the actual work (like adding two numbers) being done. You end up spending 99% of your CPU time managing the framework and 1% doing the actual math.

// DON'T DO THIS: The "Naive" approach
protected Long compute() {
    if (array.length <= 1) { // Splitting too far!
        return array[0];
    }
    // Splitting into tiny fragments creates massive overhead
    SumTask left = new SumTask(leftHalf);
    SumTask right = new SumTask(rightHalf);
    left.fork();
    return right.compute() + left.join();
}


If you ran the code above on a massive dataset, you'd likely find it's actually slower than a simple for loop. It's a humbling realization, but in parallel computing, "granularity" is everything.

The Threshold and the Art of Work-Stealing

To fix this, we use a threshold. The idea is simple: split the task recursively until the workload is "small enough" to be handled efficiently by a single thread sequentially. Only then do you stop forking and just do the work. I usually pick a threshold based on the size of the data, but a good rule of thumb is to ensure each leaf task does a significant enough amount of work to justify the cost of the fork.

But the real secret sauce of the ForkJoinPool isn't just the splitting—it's work-stealing. In a standard ThreadPoolExecutor, if one thread finishes its queue, it just sits there idling while another thread might be slammed with 1,000 tasks. In a ForkJoinPool, an idle thread will literally "steal" a task from the back of another busy thread's deque. This keeps all your cores humming at maximum efficiency without you having to manually balance the load.

// DO THIS: The "Balanced" approach
protected Long compute() {
    // We only split if the workload is larger than our threshold
    if (array.length <= 10_000) { 
        return computeSequentially(); 
    }

    int mid = array.length / 2;
    SumTask left = new SumTask(Arrays.copyOfRange(array, 0, mid));
    SumTask right = new SumTask(Arrays.copyOfRange(array, mid, array.length));

    // fork() pushes the task to the queue; compute() does it immediately
    invokeAll(left, right); 

    return left.join() + right.join();
}


I prefer invokeAll(left, right) over calling fork() and join() manually because it's more concise and handles the execution order more efficiently. Note that we call join() at the end to gather the results. Remember: fork() is asynchronous, while join() is a blocking call that waits for the result.

  • RecursiveTask<V>: Use this when your parallel task needs to return a value (like our sum).
  • RecursiveAction: Use this when you just need to perform an operation (like sorting an array in place) and don't need a return value.
  • The Pool: While you can create your own ForkJoinPool, Java provides ForkJoinPool.commonPool(), which is usually sufficient for most applications and reduces resource waste.



📋 Practical Task

Implementing a Parallel Large-Scale Image Brightness Analyzer

You are building a tool to analyze high-resolution images. You have a simulated image represented as a massive 1D array of integers (where each integer is a pixel brightness value from 0 to 255). Your goal is to calculate the average brightness of the entire image using the Fork/Join framework.

Requirements:

  • Create a class BrightnessAnalyzer that extends RecursiveTask<Double>.
  • Implement a threshold of 50,000 pixels. If the segment is smaller than this, calculate the sum of brightness sequentially.
  • Use invokeAll() to split the array into two halves recursively.
  • In your main method, generate an array of 1,000,000 random integers, run the task using the ForkJoinPool.commonPool(), and print the final average brightness.
  • Ensure you are returning the sum of brightness in the recursive steps, and only calculate the final average (sum / total pixels) in the main method to avoid precision errors during recursion.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.