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框架,在实际项目中提升程序的性能和效率。
超级会员免费看
886

被折叠的 条评论
为什么被折叠?



