88、Java Fork/Join框架深入解析与应用

Java Fork/Join框架深入解析与应用

1. 公共池的使用

在Java中,使用Fork/Join框架时,有时无需显式获取公共池的引用。当对一个尚未加入池的任务调用 ForkJoinTask invoke() fork() 方法时,该任务会自动在公共池中执行。例如,在之前的程序里,可以完全去掉 fjp 变量,直接使用 task.invoke(); 来启动任务。公共池是JDK 8对Fork/Join框架的一项改进,它提高了框架的易用性。在很多不需要与JDK 7兼容的场景中,公共池是首选的方式。

2. 并行级别对性能的影响

在深入学习之前,理解并行级别对fork/join任务性能的影响以及并行性和阈值之间的相互作用至关重要。下面的程序可以让你尝试不同的并行度和阈值,在多核计算机上运行时,你可以直观地观察这些值的影响。

在之前的示例中,使用的是默认的并行级别。不过,你可以通过 ForkJoinPool 的构造函数 ForkJoinPool(int pLevel) 来指定所需的并行级别,其中 pLevel 必须大于零且小于实现定义的限制。

以下是一个创建fork/join任务的程序,用于转换一个双精度数组:

// A simple program that lets you experiment with the effects of
// changing the threshold and parallelism of a ForkJoinTask.
import java.util.concurrent.*;

// A ForkJoinTask (via RecursiveAction) that performs a
// a transform on the elements of an array of doubles.
class Transform extends RecursiveAction {

  // Sequential threshold, which is set by the constructor.
  int seqThreshold;

  // Array to be accessed.
  double[] data;

  // Determines what part of data to process.
  int start, end;

  Transform(double[] vals, int s, int e, int t ) {
    data = vals;
    start = s;
    end = e;
    seqThreshold = t;
  }

  // This is the method in which parallel computation will occur.
  protected void compute() {

    // If number of elements is below the sequential threshold,
    // then process sequentially.
    if((end - start) < seqThreshold) {
      // The following code assigns an element at an even index the
      // square root of its original value. An element at an odd
      // index is assigned its cube root. This code is designed
      // to simply consume CPU time so that the effects of concurrent
      // execution are more readily observable.
      for(int i = start; i < end; i++) {
        if((data[i] % 2) == 0)
          data[i] = Math.sqrt(data[i]);
        else
          data[i] = Math.cbrt(data[i]);
      }
    }
    else {
      // Otherwise, continue to break the data into smaller pieces.

      // Find the midpoint.
      int middle = (start + end) / 2;

      // Invoke new tasks, using the subdivided data.
      invokeAll(new Transform(data, start, middle, seqThreshold),
                new Transform(data, middle, end, seqThreshold));
    }
  }
}

// Demonstrate parallel execution.
class FJExperiment {

  public static void main(String args[]) {
    int pLevel;
    int threshold;

    if(args.length !=  2) {
      System.out.println("Usage: FJExperiment parallelism threshold ");
      return;
    }

    pLevel = Integer.parseInt(args[0]);
    threshold = Integer.parseInt(args[1]); 

    // These variables are used to time the task.
    long beginT, endT;

    // Create a task pool. Notice that the parallelism level is set.
    ForkJoinPool fjp = new ForkJoinPool(pLevel);

    double[] nums = new double[1000000];

    for(int i = 0; i < nums.length; i++)
      nums[i] = (double) i;

    Transform task = new Transform(nums, 0, nums.length, threshold);

    // Starting timing.
    beginT = System.nanoTime();

    // Start the main ForkJoinTask.
    fjp.invoke(task);

    // End timing.
    endT = System.nanoTime();

    System.out.println("Level of parallelism: " + pLevel);
    System.out.println("Sequential threshold: " + threshold);
    System.out.println("Elapsed time: " + (endT - beginT) + " ns");
    System.out.println();
  }
}

使用该程序时,需要在命令行指定并行级别和阈值。建议尝试不同的值并观察结果。要注意,程序要在至少有两个处理器的计算机上运行才会有明显效果。而且,由于系统中其他进程会占用CPU时间,两次运行可能会产生不同的结果。

为了让你了解并行性带来的差异,我们做个实验。首先,使用以下命令运行程序:

java FJExperiment 1 1000

这会请求并行级别为1(基本是顺序执行),阈值为1000。在双核计算机上的示例输出如下:

Level of parallelism: 1
Sequential threshold: 1000
Elapsed time: 259677487 ns

然后,指定并行级别为2:

java FJExperiment 2 1000

同一台双核计算机上的示例输出为:

Level of parallelism: 2
Sequential threshold: 1000
Elapsed time: 169254472 ns

显然,增加并行性可以显著减少执行时间,提高程序的运行速度。你可以在自己的计算机上尝试不同的阈值和并行级别,结果可能会让你惊讶。

此外,在实验fork/join程序的执行特性时,还有两个有用的方法:
- ForkJoinPool getParallelism() 方法可以获取当前的并行级别。对于自己创建的池,默认情况下,该值等于可用处理器的数量。JDK 8还添加了 getCommonPoolParallelism() 方法,用于获取公共池的并行级别。
- Runtime 类的 availableProcessors() 方法可以获取系统中可用的处理器数量。由于系统需求的变化,每次调用返回的值可能不同。

3. 使用RecursiveTask 的示例

前面的两个示例基于 RecursiveAction ,这类任务并发执行但不返回结果。若要创建返回结果的任务,可使用 RecursiveTask 。一般来说,解决方案的设计方式与之前类似,但关键区别在于 compute() 方法会返回结果,因此需要聚合结果,确保第一次调用完成时返回整体结果。另外,通常会显式调用 fork() join() 来启动子任务。

以下是一个使用 RecursiveTask 的程序示例,它创建了一个名为 Sum 的任务,用于计算双精度数组中元素的总和:

// A simple example that uses RecursiveTask<V>.
import java.util.concurrent.*;

// A RecursiveTask that computes the summation of an array of doubles.
class Sum extends RecursiveTask<Double> {

  // The sequential threshold value.
  final int seqThresHold = 500;

  // Array to be accessed.
  double[] data;

  // Determines what part of data to process.
  int start, end;

  Sum(double[] vals, int s, int e ) {
    data = vals;
    start = s;
    end = e;
  }

  // Find the summation of an array of doubles.
  protected Double compute() {
    double sum = 0;

    // If number of elements is below the sequential threshold,
    // then process sequentially.
    if((end - start) < seqThresHold) {
      // Sum the elements.
      for(int i = start; i < end; i++) sum += data[i];
    }
    else {
      // Otherwise, continue to break the data into smaller pieces.

      // Find the midpoint.
      int middle = (start + end) / 2;

      // Invoke new tasks, using the subdivided data.
      Sum subTaskA = new Sum(data, start, middle);
      Sum subTaskB = new Sum(data, middle, end);

      // Start each subtask by forking.
      subTaskA.fork();
      subTaskB.fork();

      // Wait for the subtasks to return, and aggregate the results.
      sum = subTaskA.join() + subTaskB.join();
    }
      // Return the final sum.
      return sum;
  }
}

// Demonstrate parallel execution.
class RecurTaskDemo {
  public static void main(String args[]) {
    // Create a task pool.
    ForkJoinPool fjp = new ForkJoinPool();

    double[] nums = new double[5000];

    // Initialize nums with values that alternate between
    // positive and negative.
    for(int i=0; i < nums.length; i++)
      nums[i] = (double) (((i%2) == 0) ? i : -i) ;

    Sum task = new Sum(nums, 0, nums.length);

    // Start the ForkJoinTasks.  Notice that, in this case,
    // invoke() returns a result.
    double summation = fjp.invoke(task);

    System.out.println("Summation " + summation);
  }
}

程序的输出为:

Summation -2500.0

在这个程序中,有几个值得注意的地方。首先,通过调用 fork() 方法启动两个子任务:

subTaskA.fork();
subTaskB.fork();

这里使用 fork() 是因为它启动任务但不等待任务完成,即异步运行任务。通过调用 join() 方法获取每个任务的结果:

sum = subTaskA.join() + subTaskB.join();

该语句会等待每个任务结束,然后将结果相加并赋值给 sum 。最后, compute() 方法返回 sum ,这将是第一次调用完成时的最终总和。

处理子任务的异步执行还有其他方式,例如:
- 使用 fork() 启动 subTaskA ,使用 invoke() 启动并等待 subTaskB

subTaskA.fork();
sum = subTaskB.invoke() + subTaskA.join();
  • subTaskB 直接调用 compute()
subTaskA.fork();
sum = subTaskB.compute() + subTaskA.join();
4. 异步执行任务

之前的程序通过在 ForkJoinPool 上调用 invoke() 来启动任务。当调用线程必须等待任务完成时,这种方法很常用,因为 invoke() 会在任务终止后才返回。不过,也可以异步启动任务,这样调用线程会继续执行,调用线程和任务会同时执行。要异步启动任务,可使用 ForkJoinPool execute() 方法,它有两种形式:

void execute(ForkJoinTask<?> task)
void execute(Runnable task)

在这两种形式中, task 指定要运行的任务。第二种形式允许指定 Runnable 而不是 ForkJoinTask ,它在Java传统多线程方法和新的Fork/Join框架之间架起了桥梁。需要注意的是, ForkJoinPool 使用的线程是守护线程,当主线程结束时,它们也会结束。因此,可能需要确保主线程在任务完成之前保持活动状态。

5. 取消任务

可以通过调用 ForkJoinTask cancel() 方法取消任务,其一般形式如下:

boolean cancel(boolean interuptOK)

如果任务被成功取消,该方法返回 true ;如果任务已结束或无法取消,则返回 false 。目前,默认实现不使用 interuptOK 参数。通常, cancel() 方法应从任务外部的代码调用,因为任务自身可以通过返回轻松取消。

可以通过调用 isCancelled() 方法判断任务是否已被取消:

final boolean isCancelled( )

如果调用的任务在完成前被取消,该方法返回 true ,否则返回 false

6. 确定任务的完成状态

除了 isCancelled() 方法, ForkJoinTask 还提供了另外两个方法来确定任务的完成状态:
- isCompletedNormally()

final boolean isCompletedNormally( )

如果调用的任务正常完成(即没有抛出异常且没有通过调用 cancel() 方法取消),该方法返回 true ,否则返回 false
- isCompletedAbnormally()

final boolean isCompletedAbnormally( )

如果调用的任务因被取消或抛出异常而完成,该方法返回 true ,否则返回 false

7. 重启任务

通常,任务完成后不能重新运行。但可以在任务完成后重新初始化其状态,使其可以再次运行。这可以通过调用 reinitialize() 方法实现:

void reinitialize( )

该方法会重置调用任务的状态,但不会撤销任务对持久数据所做的任何修改。例如,如果任务修改了数组,调用 reinitialize() 不会撤销这些修改。

8. Fork/Join框架的其他特性

Fork/Join框架还有许多其他特性,下面是一些示例:
- ForkJoinTask的其他特性
- inForkJoinPool() :可用于确定代码是否在任务内部执行。
- adapt() :可以将 Runnable Callable 对象转换为 ForkJoinTask ,有三种形式,分别用于转换 Callable 、不返回结果的 Runnable 和返回结果的 Runnable
- getQueuedTaskCount() :获取调用线程队列中任务的近似数量。
- getSurplusQueuedTaskCount() :获取调用线程队列中比池里其他线程可能“窃取”的任务数量多出的近似数量。
- quietlyJoin() quietlyInvoke() :这两个方法是 join() invoke() 的变体,前缀为 quietly ,它们不返回结果也不抛出异常。
- tryUnfork() :尝试“取消调用”(即取消调度)任务。
- JDK 8添加的 getForkJoinTaskTag() setForkJoinTaskTag() 等方法支持标签,标签是与任务关联的短整数值,在特定应用中可能有用。
- ForkJoinTask 实现了 Serializable 接口,可进行序列化,但在执行过程中不使用序列化。
- ForkJoinPool的其他特性
- toString() :重写的 toString() 方法可以显示池状态的“用户友好”摘要。例如,在之前的 FJExperiment 类中,可以按以下方式使用:

// Asynchronously start the main ForkJoinTask.
fjp.execute(task);

// Display the state of the pool while waiting.
while(!task.isDone()) {
  System.out.println(fjp);
}

运行程序时,会在屏幕上看到一系列描述池状态的消息,示例如下:

java.util.concurrent.ForkJoinPool@141d683[Running, parallelism = 2,
size = 2, active = 0, running = 2, steals = 0, tasks = 0, submissions = 1]
- `isQuiescent()`:判断池当前是否空闲,如果池没有活动线程,返回`true`,否则返回`false`。
- `getPoolSize()`:获取当前池中的工作线程数量。
- `getActiveThreadCount()`:获取池中活动线程的近似数量。
- `shutdown()`:关闭池,当前活动的任务仍会执行,但不能启动新任务。
- `shutdownNow()`:立即停止池,尝试取消当前活动的任务。需要注意的是,这两个方法都不影响公共池。
- `isShutdown()`:判断池是否已关闭。
- `isTerminated()`:判断池是否已关闭且所有任务都已完成。
9. Fork/Join框架使用技巧

使用Fork/Join框架时,有几个技巧可以帮助你避免一些常见的陷阱:
- 避免使用过低的顺序阈值。一般来说,阈值设置高一些比低一些好。如果阈值过低,生成和切换任务所消耗的时间可能会超过处理任务的时间。
- 通常最好使用默认的并行级别。如果指定的并行级别过小,可能会显著降低使用Fork/Join框架的优势。
- 一般来说, ForkJoinTask 不应使用同步方法或同步代码块, compute() 方法通常也不应使用其他类型的同步,如信号量。不过,新的 Phaser 在适当情况下可以使用,因为它与fork/join机制兼容。
- 避免会导致大量I/O阻塞的情况。因为ForkJoinTask的核心思想是分治策略,这种方法通常不适合需要外部同步的场景。

Java Fork/Join框架深入解析与应用(续)

10. 操作流程总结

为了更清晰地理解Fork/Join框架的使用,下面对关键操作的流程进行总结:

10.1 创建并执行Fork/Join任务
  • 定义任务类 :继承 RecursiveAction RecursiveTask ,重写 compute() 方法。
    • 若任务不返回结果,继承 RecursiveAction
    • 若任务返回结果,继承 RecursiveTask
  • 创建任务实例 :根据任务类的构造函数,传入所需的参数。
  • 创建任务池 :使用 ForkJoinPool 的构造函数创建任务池,可指定并行级别。
  • 执行任务
    • 同步执行:调用 ForkJoinPool invoke() 方法。
    • 异步执行:调用 ForkJoinPool execute() 方法。

以下是一个简单的流程图,展示了创建并执行Fork/Join任务的流程:

graph TD;
    A[定义任务类] --> B[创建任务实例];
    B --> C[创建任务池];
    C --> D{执行方式};
    D -- 同步 --> E[调用invoke()];
    D -- 异步 --> F[调用execute()];
10.2 任务拆分与合并

compute() 方法中,根据任务的规模(阈值)决定是直接处理任务还是拆分任务:
- 任务规模小于阈值 :直接处理任务。
- 任务规模大于阈值 :将任务拆分为多个子任务,递归调用 compute() 方法处理子任务,最后合并子任务的结果。

以下是任务拆分与合并的流程图:

graph TD;
    A[compute()方法开始] --> B{任务规模 < 阈值};
    B -- 是 --> C[直接处理任务];
    B -- 否 --> D[拆分任务为子任务];
    D --> E[递归调用compute()处理子任务];
    E --> F[合并子任务结果];
    C --> G[任务结束];
    F --> G;
11. 性能优化建议

在使用Fork/Join框架时,为了获得更好的性能,可以参考以下优化建议:

优化点 建议
顺序阈值 避免设置过低,一般设置高一些,防止生成和切换任务的时间过长。
并行级别 通常使用默认并行级别,避免指定过小的并行级别,以充分发挥Fork/Join框架的优势。
同步操作 尽量避免在 ForkJoinTask 中使用同步方法、同步代码块和其他类型的同步(如信号量),但 Phaser 在适当情况下可使用。
I/O操作 避免会导致大量I/O阻塞的情况,因为分治策略不适合需要外部同步的场景。
12. 总结

Fork/Join框架是Java中用于并行计算的强大工具,它通过分治策略将大任务拆分为小任务,利用多核处理器的优势提高程序的执行效率。通过本文的介绍,我们了解了Fork/Join框架的基本概念、使用方法、任务的执行方式、取消和状态判断等操作,以及一些高级特性和使用技巧。

在实际应用中,我们可以根据具体的需求选择合适的任务类( RecursiveAction RecursiveTask ),合理设置顺序阈值和并行级别,避免常见的陷阱,以充分发挥Fork/Join框架的性能优势。同时,通过掌握框架的高级特性,我们可以更灵活地控制任务的执行,满足不同场景的需求。

希望本文能帮助你更好地理解和使用Java的Fork/Join框架,在实际项目中提升程序的性能和效率。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值