线程池ForkJoinPoolg工作原理

122 阅读11分钟

如何快速高效的对千万大小数据进行排序

归并排序(Merge Sort)是一种基于分治思想的排序算法。归并排序的基本思想是将一个大数组分成 两个相等大小的子数组,对每个子数组分别进行排序,然后将两个子数组合并成一个有序的大数组。 因为常常使用递归实现(由先拆分后合并的性质决定的),所以我们称其为归并排序。

归并排序的步骤包括以下几个方面:

  • 将数组分成两个子数组
  • 对每个子数组进行排序
  • 合并两个有序的子数组

归并排序的时间复杂度为O(nlogn),空间复杂度为O(n),其中n为数组的长度。 分治思想是将一个规模为N的问题分解为K个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可得到原问题的解。

分治思想的步骤如下:

分解:将要解决的问题划分成若干规模较小的同类问题;

求解:当子问题划分得足够小时,用较简单的方法解决;

合并:按原问题的要求,将子问题的解逐层合并构成原问题的解

单线程和多线程并行场景下归并排序性能对比

单线程实现归并排序

单线程归并算法的实现,它的基本思路是将序列分成两个部分,分别进行递归排序,然后将排序好的 子序列合并起来。

` public class MergeSort {

ini
复制代码
private final int[] arrayToSort; //要排序的数组
private final int threshold;  //拆分的阈值,低于此阈值就不再进行拆分

public MergeSort(final int[] arrayToSort, final int threshold) {
    this.arrayToSort = arrayToSort;
    this.threshold = threshold;
}

/**
 * 排序
 * @return
 */
public int[] mergeSort() {
    return mergeSort(arrayToSort, threshold);
}

public static int[] mergeSort(final int[] arrayToSort, int threshold) {
    //拆分后的数组长度小于阈值,直接进行排序
    if (arrayToSort.length < threshold) {
        //调用jdk提供的排序方法
        Arrays.sort(arrayToSort);
        return arrayToSort;
    }

    int midpoint = arrayToSort.length / 2;
    //对数组进行拆分
    int[] leftArray = Arrays.copyOfRange(arrayToSort, 0, midpoint);
    int[] rightArray = Arrays.copyOfRange(arrayToSort, midpoint, arrayToSort.length);
    //递归调用
    leftArray = mergeSort(leftArray, threshold);
    rightArray = mergeSort(rightArray, threshold);
    //合并排序结果
    return merge(leftArray, rightArray);
}

public static int[] merge(final int[] leftArray, final int[] rightArray) {
    //定义用于合并结果的数组
    int[] mergedArray = new int[leftArray.length + rightArray.length];
    int mergedArrayPos = 0;
    // 利用双指针进行两个数的比较
    int leftArrayPos = 0;
    int rightArrayPos = 0;
    while (leftArrayPos < leftArray.length && rightArrayPos < rightArray.length) {
        if (leftArray[leftArrayPos] <= rightArray[rightArrayPos]) {
            mergedArray[mergedArrayPos] = leftArray[leftArrayPos];
            leftArrayPos++;
        } else {
            mergedArray[mergedArrayPos] = rightArray[rightArrayPos];
            rightArrayPos++;
        }
        mergedArrayPos++;
    }

    while (leftArrayPos < leftArray.length) {
        mergedArray[mergedArrayPos] = leftArray[leftArrayPos];
        leftArrayPos++;
        mergedArrayPos++;
    }

    while (rightArrayPos < rightArray.length) {
        mergedArray[mergedArrayPos] = rightArray[rightArrayPos];
        rightArrayPos++;
        mergedArrayPos++;
    }

    return mergedArray;
}

}

`

Java并行框架Fork/Join使用详解

Fork/Join并行归并排序

并行归并排序是一种利用多线程实现的归并排序算法。它的基本思路是将数据分成若干部分,然后在 不同线程上对这些部分进行归并排序,最后将排好序的部分合并成有序数组。在多核CPU上,这种算 法也能够有效提高排序速度。

可以使用Java的Fork/Join框架来实现归并排序的并行化

` public class MergeSortTask extends RecursiveAction {

java
复制代码
private final int threshold; //拆分的阈值,低于此阈值就不再进行拆分
private int[] arrayToSort; //要排序的数组

public MergeSortTask(final int[] arrayToSort, final int threshold) {
	this.arrayToSort = arrayToSort;
	this.threshold = threshold;
}

@Override
protected void compute() {
	//拆分后的数组长度小于阈值,直接进行排序
	if (arrayToSort.length <= threshold) {
		// 调用jdk提供的排序方法
		Arrays.sort(arrayToSort);
		return;
	}

	// 对数组进行拆分
	int midpoint = arrayToSort.length / 2;
	int[] leftArray = Arrays.copyOfRange(arrayToSort, 0, midpoint);
	int[] rightArray = Arrays.copyOfRange(arrayToSort, midpoint, arrayToSort.length);

	MergeSortTask leftTask = new MergeSortTask(leftArray, threshold);
	MergeSortTask rightTask = new MergeSortTask(rightArray, threshold);

	//调用任务,阻塞当前线程,直到所有子任务执行完成
	invokeAll(leftTask,rightTask);
	//提交任务

// leftTask.fork(); // rightTask.fork(); // //合并结果 // leftTask.join(); // rightTask.join();

csharp
复制代码
	// 合并排序结果
	arrayToSort = MergeSort.merge(leftTask.getSortedArray(), rightTask.getSortedArray());
}

public int[] getSortedArray() {
	return arrayToSort;
}

} `

在这个示例中,我们使用Fork/Join框架实现了归并排序算法,并通过递归调用实现了并行化。使用 Fork/Join框架实现归并排序算法的关键在于将排序任务分解成小的任务,使用Fork/Join框架将这些小 任务提交给线程池中的不同线程并行执行,并在最后将排序后的结果进行合并。这样可以充分利用多 核CPU的并行处理能力,提高程序的执行效率。

测试结果对比

测试代码

` import java.util.Random;

public class Utils {

arduino
复制代码
/**
 * 随机生成数组
 * @param size 数组的大小
 * @return
 */
public static int[] buildRandomIntArray(final int size) {
	int[] arrayToCalculateSumOf = new int[size];
	Random generator = new Random();
	for (int i = 0; i < arrayToCalculateSumOf.length; i++) {
		arrayToCalculateSumOf[i] = generator.nextInt(100000000);
	}
	return arrayToCalculateSumOf;
}

} `

` import com.tuling.learnjuc.forkjoin.util.Utils;

import java.util.Arrays; import java.util.concurrent.ForkJoinPool;

public class ArrayToSortMain {

ini
复制代码
public static void main(String[] args) {
    //生成测试数组  用于归并排序
    int[] arrayToSortByMergeSort = Utils.buildRandomIntArray(20000000);
    //生成测试数组  用于forkjoin排序
    int[] arrayToSortByForkJoin = Arrays.copyOf(arrayToSortByMergeSort, arrayToSortByMergeSort.length);
    //获取处理器数量
    int processors = Runtime.getRuntime().availableProcessors();


    MergeSort mergeSort = new MergeSort(arrayToSortByMergeSort, processors);
    long startTime = System.nanoTime();
    // 归并排序
    mergeSort.mergeSort();
    long duration = System.nanoTime()-startTime;
    System.out.println("单线程归并排序时间: "+(duration/(1000f*1000f))+"毫秒");

    //利用forkjoin排序
    MergeSortTask mergeSortTask = new MergeSortTask(arrayToSortByForkJoin, processors);
    //构建forkjoin线程池
    ForkJoinPool forkJoinPool = new ForkJoinPool(processors);
    startTime = System.nanoTime();
    //执行排序任务
    forkJoinPool.invoke(mergeSortTask);
    duration = System.nanoTime()-startTime;
    System.out.println("forkjoin排序时间: "+(duration/(1000f*1000f))+"毫秒");

}

} `

根据测试结果可以看出,数组越大,利用Fork/Join框架实现的并行化归并排序比单线程归并排序的效 率更高

Fork/Join处理递归任务和阻塞任务注意事项

处理递归任务注意事项 对于一些递归深度较大的任务,使用Fork/Join框架可能会出现任务调度和内存消耗的问题。 当递归深度较大时,会产生大量的子任务,这些子任务可能被调度到不同的线程中执行,而线程的创 建和销毁以及任务调度的开销都会占用大量的资源,从而导致性能下降。 此外,对于递归深度较大的任务,由于每个子任务所占用的栈空间较大,可能会导致内存消耗过大, 从而引起内存溢出的问题。 因此,在使用Fork/Join框架处理递归任务时,需要根据实际情况来评估递归深度和任务粒度,以避免 任务调度和内存消耗的问题。如果递归深度较大,可以尝试采用其他方法来优化算法,如使用迭代方 式替代递归,或者限制递归深度来减少任务数量,以避免Fork/Join框架的缺点。

处理阻塞任务

在ForkJoinPool中使用阻塞型任务时需要注意以下几点:

  1. 防止线程饥饿:当一个线程在执行一个阻塞型任务时,它将会一直等待任务完成,这时如果没有其他线程可以窃取任务,那么该线程将一直被阻塞,直到任务完成为止。为了避免这种情况,应该避免ForkJoinPool中提交大量的阻塞型任务。
  2. 使用特定的线程池:为了最大程度地利用ForkJoinPool的性能,可以使用专门的线程池来处理阻塞型任务,这些线程不会被ForkJoinPool的窃取机制所影响。例如,可以使用ThreadPoolExecutor来创建一个线程池,然后将这个线程池作为ForkJoinPool的执行器,这样就可以使用ThreadPoolExecutor来处理阻塞型任务,而使用ForkJoinPool来处理非阻塞型任务。
  3. 不要阻塞工作线程:如果在ForkJoinPool中使用阻塞型任务,那么需要确保这些任务不会阻塞工作线程,否则会导致整个线程池的性能下降。为了避免这种情况,可以将阻塞型任务提交到一个专门的线程池中,或者使用CompletableFuture等异步编程工具来处理阻塞型任务。

下面是一个使用阻塞型任务的例子,这个例子展示了如何使用CompletableFuture来处理阻塞型任 务: ` public class BlockingTaskDemo { public static void main(String[] args) { //构建一个forkjoin线程池 ForkJoinPool pool = new ForkJoinPool();

//创建一个异步任务,并将其提交到ForkJoinPool中执行 CompletableFuture future = CompletableFuture.supplyAsync(() -> { try { // 模拟一个耗时的任务 TimeUnit.SECONDS.sleep(5); return "Hello, world!"; } catch (InterruptedException e) { e.printStackTrace(); return null; } }, pool);

try { // 等待任务完成,并获取结果 String result = future.get();

System.out.println(result); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } finally { //关闭ForkJoinPool,释放资源 pool.shutdown(); } } }

`

在这个例子中,我们使用了CompletableFuture来处理阻塞型任务,因为它可以避免阻塞 ForkJoinPool中的工作线程。另外,我们也可以使用专门的线程池来处理阻塞型任务,例如 ThreadPoolExecutor等。不管是哪种方式,都需要避免在ForkJoinPool中提交大量的阻塞型任务,以 免影响整个线程池的性能。

ForkJoinPool工作原理分析

ForkJoinPool 内部有多个任务队列,当我们通过 ForkJoinPool 的 invoke() 或者 submit() 方法提 交任务时,ForkJoinPool 根据一定的路由规则把任务提交到一个任务队列中,如果任务在执行过程中 会创建出子任务,那么子任务会提交到工作线程对应的任务队列中。 如果工作线程对应的任务队列空了,是不是就没活儿干了呢?不是的,ForkJoinPool 支持一种叫 做“任务窃取”的机制,如果工作线程空闲了,那它可以“窃取”其他工作任务队列里的任务。如此 一来,所有的工作线程都不会闲下来了。

工作线程ForkJoinWorkerThread

ForkJoinWorkerThread是ForkJoinPool中的一个专门用于执行任务的线程。 当一个ForkJoinWorkerThread被创建时,它会自动注册一个WorkQueue到ForkJoinPool中。这个 WorkQueue是该线程专门用于存储自己的任务的队列,只能出现在WorkQueues[]的奇数位。在 ForkJoinPool中,WorkQueues[]是一个数组,用于存储所有线程的WorkQueue。

工作队列WorkQueue

WorkQueue是一个双端队列,用于存储工作线程自己的任务。每个工作线程都会维护一个本地的 WorkQueue,并且优先执行本地队列中的任务。当本地队列中的任务执行完毕后,工作线程会尝试从 其他线程的WorkQueue中窃取任务。 注意:在ForkJoinPool中,只有WorkQueues[]奇数位的WorkQueue是属于ForkJoinWorkerThread 线程的,因此只有这些WorkQueue才能被线程本身使用和窃取任务。偶数位的WorkQueue是用于外 部线程提交任务的,而且是由多个线程共享的,因此它们不能被线程窃取任务。

工作窃取

ForkJoinPool与ThreadPoolExecutor有个很大的不同之处在于,ForkJoinPool存在引入了工作窃 取设计,它是其性能保证的关键之一。工作窃取,就是允许空闲线程从繁忙线程的双端队列中窃取任 务。默认情况下,工作线程从它自己的双端队列的头部获取任务。但是,当自己的任务为空时,线程 会从其他繁忙线程双端队列的尾部中获取任务。这种方法,最大限度地减少了线程竞争任务的可能 性。 ForkJoinPool的大部分操作都发生在工作窃取队列(work-stealing queues ) 中,该队列由内部 类WorkQueue实现。它是Deques的特殊形式,但仅支持三种操作方式:push、pop和poll(也称为 窃取)。在ForkJoinPool中,队列的读取有着严格的约束,push和pop仅能从其所属线程调用,而 poll则可以从其他线程调用。 通过工作窃取,Fork/Join框架可以实现任务的自动负载均衡,以充分利用多核CPU的计算能力,同时 也可以避免线程的饥饿和延迟问题

总结

Fork/Join是一种基于分治思想的模型,在并发处理计算型任务时有着显著的优势。其效率的提升主要 得益于两个方面:

  • 任务切分:将大的任务分割成更小粒度的小任务,让更多的线程参与执行;
  • 任务窃取:通过任务窃取,充分地利用空闲线程,并减少竞争。

在使用ForkJoinPool时,需要特别注意任务的类型是否为纯函数计算类型,也就是这些任务不应该关 心状态或者外界的变化,这样才是最安全的做法。如果是阻塞类型任务,那么你需要谨慎评估技术方 案。虽然ForkJoinPool也能处理阻塞类型任务,但可能会带来复杂的管理成本。