二维数组求和:ForkJoinPool比单线程运行更慢的原因排查
我尝试用ForkJoinPool对二维数组求和,结果运行速度反而比单线程实现慢。测试arr[15][10]时,单线程耗时402200纳秒,ForkJoin耗时3241900纳秒,完全搞不懂原因。
单线程实现代码
private static void calc(int[][] arr) { int sum = 0; int row = arr.length; int column = arr[0].length; for (int i = 0; i < row; i++) { for (int j = 0; j < column; j++) { sum += arr[i][j]; } } System.out.print("sum: " + sum + "\n"); }
性能测试代码
long startTime = System.nanoTime(); calc(arr); long stopTime = System.nanoTime(); System.out.println("one thread: " + (stopTime - startTime) + "\n");
ForkJoin实现代码
import java.util.concurrent.ForkJoinPool; import java.util.concurrent.RecursiveAction; class ForkJoin extends RecursiveAction { int maxCall; int[] data; int start, end; public static int sum = 0; ForkJoin(int[] nums, int s, int e, int t) { data = nums; start = s; end = e; maxCall = t; } protected void compute() { if ((end - start) < maxCall) { for (int i = start; i < end; i++) { sum += data[i]; } } else { int middle = (start + end) / 2; invokeAll(new ForkJoin(data, start, middle, maxCall), new ForkJoin(data, middle, end, maxCall)); } } public ForkJoin(int[][] nums) { int threads = 32; int maxCall = 10000; long beginT, endT; ForkJoinPool fjp = new ForkJoinPool(threads); int row = nums.length; int column = nums[0].length; for (int i = 0; i < row; i++) { ForkJoin task = new ForkJoin(nums[i], 0, column, maxCall); fjp.invoke(task); } System.out.println("sum: " + sum); } }
核心原因分析
任务粒度太小,线程开销远超计算收益
你的测试数组总共只有150个元素,这点计算量单线程瞬间就能完成。但ForkJoin的任务拆分、线程调度、上下文切换都有额外开销,这些开销对于这么小的计算量来说,完全盖过了多线程并行的收益。哪怕你设置的maxCall=10000远大于单行列数10,每个行任务根本不会拆分,循环提交15个小任务到线程池依然会产生调度成本。错误的任务提交方式导致串行执行
在ForkJoin的构造方法里,你循环调用fjp.invoke(task)——这个方法会阻塞当前线程直到任务完成,相当于你拿着多线程池却在做单线程的事,平白多了线程调度的成本,完全没用到并行。线程池设置过大引发上下文切换
你创建了32线程的ForkJoinPool,但普通CPU核心数一般在4-16之间,过多的线程会导致频繁的上下文切换,进一步增加不必要的开销。共享静态变量的潜在开销
你用public static int sum作为累加变量,虽然当前任务是串行提交的,但静态变量的访问可能存在隐含的同步开销,而且如果真的并行执行还会有线程安全问题。
优化建议
只在大数据量时使用并行
ForkJoin的优势只在数据量足够大(比如百万级以上元素)时才会体现,小数据量直接用单线程更高效。正确提交并行任务
不要循环串行调用invoke,应该把整个二维数组拆分成大的并行任务,或者将所有行任务一次性提交到线程池,用invokeAll批量处理后等待结果。合理设置线程池大小
直接用new ForkJoinPool()即可,它默认会根据CPU核心数设置线程数,手动设置过大的线程数只会增加切换开销。用任务返回结果替代共享变量
改用RecursiveTask<Integer>代替RecursiveAction,让每个子任务返回自己的求和结果,最后在父任务中合并结果,既避免线程安全问题,又消除共享变量的开销。
优化后的ForkJoin示例代码
import java.util.concurrent.ForkJoinPool; import java.util.concurrent.RecursiveTask; class ArraySumTask extends RecursiveTask<Integer> { private final int[][] arr; private final int startRow; private final int endRow; // 阈值根据实际数据量调整,数据越大阈值可以设越高 private static final int THRESHOLD = 100; public ArraySumTask(int[][] arr, int startRow, int endRow) { this.arr = arr; this.startRow = startRow; this.endRow = endRow; } @Override protected Integer compute() { int rowCount = endRow - startRow; // 小粒度任务直接单线程计算 if (rowCount <= THRESHOLD) { int sum = 0; for (int i = startRow; i < endRow; i++) { for (int num : arr[i]) { sum += num; } } return sum; } // 大粒度任务拆分为两个子任务并行执行 int midRow = startRow + rowCount / 2; ArraySumTask leftTask = new ArraySumTask(arr, startRow, midRow); ArraySumTask rightTask = new ArraySumTask(arr, midRow, endRow); leftTask.fork(); // 异步执行左任务 int rightSum = rightTask.compute(); // 同步执行右任务 int leftSum = leftTask.join(); // 等待左任务结果 return leftSum + rightSum; } public static void main(String[] args) { // 用大数据量测试才能体现并行优势 int[][] arr = new int[10000][1000]; // 初始化数组(示例:填充随机数) for (int i = 0; i < arr.length; i++) { for (int j = 0; j < arr[i].length; j++) { arr[i][j] = (int) (Math.random() * 100); } } ForkJoinPool pool = new ForkJoinPool(); long startTime = System.nanoTime(); int totalSum = pool.invoke(new ArraySumTask(arr, 0, arr.length)); long stopTime = System.nanoTime(); System.out.println("sum: " + totalSum); System.out.println("ForkJoin耗时: " + (stopTime - startTime) + "纳秒"); } }
内容的提问来源于stack exchange,提问作者Lex

