一、并行流
Java 8 流的一个显著优点是,在某些情况下,它们可以很容易地并行化。这来自库的仔细设计,特别是流使用内部迭代的方式 - 也就是说,它们控制着自己的迭代器,这是一种特殊的迭代器,称为 Spliterator,它被限制为易于自动分割。使用.parallel()
会产生魔法般的结果-流中的所有内容都作为一组并行任务运行。如果你的代码是使用 Streams 编写的,那并行化以提高速度似乎是一种琐事。
例如,考虑来自 Streams 的 Prime.java。查找质数可能是一个耗时的过程,我们可以看到该程序的计时:
// concurrent/ParallelPrime.java
import java.util.*;
import java.util.stream.*;
import static java.util.stream.LongStream.*;
import java.io.*;
import java.nio.file.*;
public class ParallelPrime {
static final int COUNT = 100000;
public static boolean isPrime(long n){
return rangeClosed(2, (long)Math.sqrt(n)).noneMatch(i -> n % i == 0);
}
public static void main(String[] args) throws IOException {
long startMili=System.currentTimeMillis();
List<String> primes =
iterate(2, i -> i + 1)
.parallel()
.filter(ParallelPrime::isPrime)
.limit(COUNT)
.mapToObj(Long::toString)
.collect(Collectors.toList());
long endMili=System.currentTimeMillis();
System.out.println("并行的执行时间为:"+(endMili-startMili));
startMili=System.currentTimeMillis();
primes =
iterate(2, i -> i + 1)
.filter(ParallelPrime::isPrime)
.limit(COUNT)
.mapToObj(Long::toString)
.collect(Collectors.toList());
endMili=System.currentTimeMillis();
System.out.println("执行时间为:"+(endMili-startMili));
}
}
输出结果:
并行的执行时间为:679
执行时间为:1613
可以看到19行的.parallel()
被注释掉后,运行时间大概是未被注释时的三倍。
虽然我们可以将编程问题转换为流,然后插入 parallel() 以加快速度。但实际上,仅在一部分情况下这很容易,在大多数情况下,使用.parallel()
存在着许多陷阱。
二、创建和运行任务
如果无法通过并行流实现并发,则必须创建并运行自己的任务。运行任务的理想 Java 8 方法是 CompletableFuture,但在这里我们将使用更基本的工具介绍概念。
Java 并发的历史始于非常原始和有问题的机制,并且充满了各种尝试的改进。这些主要归入附录:低级并发 (Appendix: Low-Level Concurrency)。在这里,我们将展示一个规范形式,表示创建和运行任务的最简单,最好的方法。与并发中的所有内容一样,存在着各种变体,但这些变体要么降级到该附录,要么超出本书的范围。
2.1 创建线程并执行
在 Java 的早期版本中,是通过直接创建自己的Thread对象来使用线程的,并且需要手动调用构造函数并自己启动线程。但是现在不鼓励采用手动操作方法,在 Java 5 中,添加了类来帮助处理线程池。你可以将任务创建为单独的类(比如实现Runnable接口),然后将其交给 ExecutorService(即线程池) 以运行该类中定义的任务,ExecutorService 为我们管理线程。
下面是一个代码例子,首先,我们将创建一个几乎不执行任务的任务(通过实现Runnable接口)。它“sleep”(暂停执行)100 毫秒,显示其标识符和正在执行任务的线程的名称,然后完成:
// concurrent/NapTask.java
import onjava.Nap;
public class NapTask implements Runnable {
final int id;
public NapTask(int id) {
this.id = id;
}
@Override
public void run() {
new Nap(0.1);// Seconds
System.out.println(this + " " +
Thread.currentThread().getName());
}
@Override
public String toString() {
return"NapTask[" + id + "]";
}
}
其中调用的onjava.Nap实现如下:
// onjava/Nap.java
package onjava;
import java.util.concurrent.*;
public class Nap {
public Nap(double t) { // Seconds
try {
TimeUnit.MILLISECONDS.sleep((int)(1000 * t));
} catch(InterruptedException e){
throw new RuntimeException(e);
}
}
public Nap(double t, String msg) {
this(t);
System.out.println(msg);
}
}
对 TimeUnit.MILLISECONDS.sleep() 的调用获取“当前线程”并在参数中将其置于休眠状态,这意味着该线程被挂起。但并不意味着底层处理器停止工作,操作系统将切换到其他任务。OS 任务管理器定期检查 sleep() 是否超时。当sleep结束时,线程被“唤醒”并给予处理时间。
你可以看到 sleep() 抛出一个受检的 InterruptedException ;这是原始 Java 设计中的一个部分,它通过突然断开它们来终止任务。因为它往往会产生不稳定的状态,所以后来不鼓励终止。但是,我们必须在需要或仍然发生终止的情况下捕获异常。
要执行任务,我们将从最简单的方法–SingleThreadExecutor 开始:
//concurrent/SingleThreadExecutor.java
import java.util.concurrent.*;
import java.util.stream.*;
import onjava.*;
public class SingleThreadExecutor {
public static void main(String[] args) {
ExecutorService exec =
Executors.newSingleThreadExecutor();
IntStream.range(0, 10)
.mapToObj(NapTask::new)
.forEach(exec::execute);
System.out.println("All tasks submitted");
exec.shutdown();
while(!exec.isTerminated()) {
System.out.println(
Thread.currentThread().getName()+
" awaiting termination");
new Nap(0.1);
}
}
}
输出结果:
All tasks submitted
main awaiting termination
NapTask[0] pool-1-thread-1
main awaiting termination
main awaiting termination
NapTask[1] pool-1-thread-1
NapTask[2] pool-1-thread-1
main awaiting termination
main awaiting termination
NapTask[3] pool-1-thread-1
main awaiting termination
NapTask[4] pool-1-thread-1
main awaiting termination
NapTask[5] pool-1-thread-1
main awaiting termination
NapTask[6] pool-1-thread-1
main awaiting termination
NapTask[7] pool-1-thread-1
main awaiting termination
NapTask[8] pool-1-thread-1
main awaiting termination
NapTask[9] pool-1-thread-1
首先请注意,没有 SingleThreadExecutor 类。newSingleThreadExecutor() 是 Executors 中的一个工厂方法,它创建特定类型的 ExecutorService。
我创建了十个 NapTasks 并将它们提交给 ExecutorService,这意味着它们开始自己运行。然而,在此期间,main() 也在继续执行。当我运行 callexec.shutdown() 时,它告诉 ExecutorService 完成已经提交的任务,但不接受任何新任务。此时,这些任务仍然在运行,因此我们必须等到它们在退出 main() 之前完成。这是通过检查 exec.isTerminated() 来实现的,这在所有任务完成后变为 true。
请注意,main() 中线程的名称是 main,并且只有一个其他线程 pool-1-thread-1。此外,交错输出显示两个线程确实同时运行。
如果你只是调用 exec.shutdown(),程序将完成所有任务。也就是说,不需要 while(!exec.isTerminated()) :
// concurrent/SingleThreadExecutor2.java
import java.util.concurrent.*;
import java.util.stream.*;
public class SingleThreadExecutor2 {
public static void main(String[] args)throws InterruptedException {
ExecutorService exec
=Executors.newSingleThreadExecutor();
IntStream.range(0, 10)
.mapToObj(NapTask::new)
.forEach(exec::execute);
exec.shutdown();
}
}
输出结果:
NapTask[0] pool-1-thread-1
NapTask[1] pool-1-thread-1
NapTask[2] pool-1-thread-1
NapTask[3] pool-1-thread-1
NapTask[4] pool-1-thread-1
NapTask[5] pool-1-thread-1
NapTask[6] pool-1-thread-1
NapTask[7] pool-1-thread-1
NapTask[8] pool-1-thread-1
NapTask[9] pool-1-thread-1
当我们执行了callexec.shutdown()
后,尝试提交新任务将抛出RejectedExecutionException:
// concurrent/MoreTasksAfterShutdown.java
import java.util.concurrent.*;
public class MoreTasksAfterShutdown {
public static void main(String[] args) {
ExecutorService exec
=Executors.newSingleThreadExecutor();
exec.execute(new NapTask(1));
exec.shutdown();
try {
exec.execute(new NapTask(99));
} catch(RejectedExecutionException e) {
System.out.println(e);
}
}
}
输出结果:
java.util.concurrent.RejectedExecutionException: Task NapTask[99] rejected from java.util.concurrent.ThreadPoolExecutor@1540e19d[Shutting down, pool size = 1, active threads = 1, queued tasks = 0, completed tasks = 0]
NapTask[1] pool-1-thread-1
exec.shutdown()
的替代方法是 exec.shutdownNow()
,它除了不接受新任务外,还会尝试通过中断任务来停止任何当前正在运行的任务。同样,中断是错误的,容易出错并且不鼓励。
2.2 使用更多线程
使用线程的重点是(几乎总是)更快地完成任务,那么我们为什么要限制自己使用 SingleThreadExecutor 呢?查看执行 Executors 的 Javadoc,你将看到更多选项。例如 CachedThreadPool:
// concurrent/CachedThreadPool.java
import java.util.concurrent.*;
import java.util.stream.*;
public class CachedThreadPool {
public static void main(String[] args) {
ExecutorService exec
=Executors.newCachedThreadPool();
IntStream.range(0, 10)
.mapToObj(NapTask::new)
.forEach(exec::execute);
exec.shutdown();
}
}
输出:
NapTask[4] pool-1-thread-5
NapTask[7] pool-1-thread-8
NapTask[9] pool-1-thread-10
NapTask[3] pool-1-thread-4
NapTask[6] pool-1-thread-7
NapTask[5] pool-1-thread-6
NapTask[2] pool-1-thread-3
NapTask[8] pool-1-thread-9
NapTask[1] pool-1-thread-2
NapTask[0] pool-1-thread-1
当你运行这个程序时,你会发现它完成得更快。这是有道理的,每个任务都有自己的线程,所以它们都并行运行,而不是使用相同的线程来顺序运行每个任务。这似乎没毛病,很难理解为什么有人会使用 SingleThreadExecutor。
要理解这个问题,我们需要一个更复杂的任务:
// concurrent/InterferingTask.java
public class InterferingTask implements Runnable {
final int id;
private static Integer val = 0;
public InterferingTask(int id) {
this.id = id;
}
@Override
public void run() {
for(int i = 0; i < 100; i++)
val++;
System.out.println(id + " "+
Thread.currentThread().getName() + " " + val);
}
}
每个任务都增加val一百次,所以我们最后得到的结果应该是1000,接下来我们使用CachedThreadPool来验证下我们的结果:
// concurrent/CachedThreadPool2.java
import java.util.concurrent.*;
import java.util.stream.*;
public class CachedThreadPool2 {
public static void main(String[] args) {
ExecutorService exec=Executors.newCachedThreadPool();
IntStream.range(0, 10).mapToObj(InterferingTask::new).forEach(exec::execute);
exec.shutdown();
}
}
运行结果如下:
2 pool-1-thread-3 211
1 pool-1-thread-2 311
3 pool-1-thread-4 311
0 pool-1-thread-1 132
4 pool-1-thread-5 411
6 pool-1-thread-7 511
7 pool-1-thread-8 645
5 pool-1-thread-6 675
8 pool-1-thread-9 775
9 pool-1-thread-10 875
可以看到,输出并不是我们所期望的,并且如果我们再次运行,得到的结果和上次还不一样:
0 pool-1-thread-1 100
1 pool-1-thread-2 300
2 pool-1-thread-3 200
3 pool-1-thread-4 400
4 pool-1-thread-5 506
5 pool-1-thread-6 585
8 pool-1-thread-9 685
6 pool-1-thread-7 785
7 pool-1-thread-8 885
9 pool-1-thread-3 985
这就是多线程带来的问题,我们称上面的例子为线程不安全。将CachedThreadPool换成SingleThreadExecutor,再观察下会发生什么:
// concurrent/SingleThreadExecutor3.java
import java.util.concurrent.*;
import java.util.stream.*;
public class SingleThreadExecutor3 {
public static void main(String[] args)throws InterruptedException {
ExecutorService exec
=Executors.newSingleThreadExecutor();
IntStream.range(0, 10)
.mapToObj(InterferingTask::new)
.forEach(exec::execute);
exec.shutdown();
}
}
不论执行几次,最后得到的结果都如下所示:
0 pool-1-thread-1 100
1 pool-1-thread-1 200
2 pool-1-thread-1 300
3 pool-1-thread-1 400
4 pool-1-thread-1 500
5 pool-1-thread-1 600
6 pool-1-thread-1 700
7 pool-1-thread-1 800
8 pool-1-thread-1 900
9 pool-1-thread-1 1000
这是 SingleThreadExecutor 的主要好处 - 因为它一次运行一个任务,这些任务不会相互干扰,因此强加了线程安全性。这种现象称为线程封闭,因为在单线程上运行任务限制了它们的影响。线程封闭限制了加速,但可以节省很多困难的调试和重写。
2.3 产生结果
因为 InterferingTask 是一个 Runnable ,它没有返回值,因此只能使用副作用产生结果 - 操纵缓冲值而不是返回结果。副作用是并发编程中的主要问题之一,因为我们看到了 CachedThreadPool2.java 。InterferingTask 中的 val 被称为可变共享状态,这就是问题所在:多个任务同时修改同一个变量会产生竞争。结果取决于首先在终点线上执行哪个任务,并修改变量(以及其他可能性的各种变化)。
避免竞争条件的最好方法是避免可变的共享状态。我们可以称之为自私的孩子原则:什么都不分享。
使用 InterferingTask ,最好删除副作用并返回任务结果。为此,我们创建 Callable 而不是 Runnable :
// concurrent/CountingTask.java
import java.util.concurrent.*;
public class CountingTask implements Callable<Integer> {
final int id;
public CountingTask(int id) { this.id = id; }
@Override
public Integer call() {
Integer val = 0;
for(int i = 0; i < 100; i++)
val++;
System.out.println(id + " " +
Thread.currentThread().getName() + " " + val);
return val;
}
}
call() 完全独立于所有其他 CountingTasks 生成其结果,这意味着没有可变的共享状态
ExecutorService 允许你使用 invokeAll() 启动集合中的每个 Callable:
// concurrent/CachedThreadPool3.java
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;
public class CachedThreadPool3 {
public static Integer extractResult(Future<Integer> f) {
try {
return f.get();
} catch(Exception e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args)throws InterruptedException {
ExecutorService exec =
Executors.newCachedThreadPool();
List<CountingTask> tasks =
IntStream.range(0, 10)
.mapToObj(CountingTask::new)
.collect(Collectors.toList());
List<Future<Integer>> futures =
exec.invokeAll(tasks);
Integer sum = futures.stream()
.map(CachedThreadPool3::extractResult)
.reduce(0, Integer::sum);
System.out.println("sum = " + sum);
exec.shutdown();
}
}
输出结果:
0 pool-1-thread-1 100
2 pool-1-thread-3 100
3 pool-1-thread-4 100
1 pool-1-thread-2 100
4 pool-1-thread-5 100
6 pool-1-thread-7 100
5 pool-1-thread-6 100
7 pool-1-thread-8 100
9 pool-1-thread-10 100
8 pool-1-thread-9 100
sum = 1000
只有在所有任务完成后,invokeAll() 才会返回一个 Future 列表,每个任务一个 Future 。Future 是 Java 5 中引入的机制,允许你提交任务而无需等待它完成。在这里,我们使用 ExecutorService.submit() :
// concurrent/Futures.java
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;
public class Futures {
public static void main(String[] args)throws InterruptedException, ExecutionException {
ExecutorService exec
=Executors.newSingleThreadExecutor();
Future<Integer> f =
exec.submit(new CountingTask(99));
System.out.println(f.get()); // [1]
exec.shutdown();
}
}
输出结果:
99 pool-1-thread-1 100
100
注意:当你的任务在尚未完成的 Future 上调用 get() 时,调用会阻塞(等待)直到结果可用。
但这意味着,在 CachedThreadPool3.java 中,Future 似乎是多余的,因为 invokeAll() 甚至在所有任务完成之前都不会返回。但是,这里的 Future 并不用于延迟结果,而是用于捕获任何可能发生的异常。
还要注意在 CachedThreadPool3.java.get() 中抛出异常,因此 extractResult() 在 Stream 中执行此提取。
因为当你调用 get() 时,Future 会阻塞,所以它只能解决等待任务完成才暴露问题。最终,Futures 被认为是一种无效的解决方案,现在不鼓励,我们推荐 Java 8 的 CompletableFuture ,这将在本章后面探讨。当然,你仍会在遗留库中遇到 Futures。
我们可以使用并行 Stream 以更简单,更优雅的方式解决这个问题:
4 ForkJoinPool.commonPool-worker-5 100
1 ForkJoinPool.commonPool-worker-3 100
2 ForkJoinPool.commonPool-worker-1 100
8 ForkJoinPool.commonPool-worker-2 100
6 main 100
0 ForkJoinPool.commonPool-worker-6 100
7 ForkJoinPool.commonPool-worker-4 100
9 ForkJoinPool.commonPool-worker-7 100
3 ForkJoinPool.commonPool-worker-3 100
5 ForkJoinPool.commonPool-worker-5 100
1000
输出结果:
1 ForkJoinPool.commonPool-worker-3 100
8 ForkJoinPool.commonPool-worker-2 100
0 ForkJoinPool.commonPool-worker-6 100
2 ForkJoinPool.commonPool-worker-1 100
4 ForkJoinPool.commonPool-worker-5 100
9 ForkJoinPool.commonPool-worker-7 100
6 main 100
7 ForkJoinPool.commonPool-worker-4 100
5 ForkJoinPool.commonPool-worker-2 100
3 ForkJoinPool.commonPool-worker-3 100
1000
这不仅更容易理解,而且我们需要做的就是将 parallel() 插入到其他顺序操作中,然后一切都在同时运行。
2.4 Lambda 和方法引用作为任务
在 java8 有了 lambdas 和方法引用,你不需要受限于只能使用 Runnable 和 Callable 。因为 java8 的 lambdas 和方法引用可以通过匹配方法签名来使用(即,它支持结构一致性),所以我们可以将非 Runnable 或 Callable 的参数传递给 ExecutorService :
// concurrent/LambdasAndMethodReferences.java
import java.util.concurrent.*;
class NotRunnable {
public void go() {
System.out.println("NotRunnable");
}
}
class NotCallable {
public Integer get() {
System.out.println("NotCallable");
return 1;
}
}
public class LambdasAndMethodReferences {
public static void main(String[] args)throws InterruptedException {
ExecutorService exec =
Executors.newCachedThreadPool();
exec.submit(() -> System.out.println("Lambda1"));
exec.submit(new NotRunnable()::go);
exec.submit(() -> {
System.out.println("Lambda2");
return 1;
});
exec.submit(new NotCallable()::get);
exec.shutdown();
}
}
输出结果:
Lambda1
NotCallable
NotRunnable
Lambda2
这里,前两个 submit() 调用可以改为调用 execute() 。所有 submit() 调用都返回 Futures ,你可以在后两次调用的情况下提取结果。