7. 高级别并发对象
到目前为止,本课程介绍了一些Java平台初学者必须的低级别API。这些API对于简单的任务足够了,但是负责的任务需要一些高级别的构建块。这对于利用现在的多处理器和多核系统的大规模并发应用更加正确。
在本节,我们将要介绍Java平台5.0引入的一些高级别并发特性。他们大部分在java.util.concurrent包中实现。现在在Java Collections框架中也包括了一些新的数据结构。
- Lock 对象支持锁机制,其简化了许多并发程序。
- Executors 定义了启动和管理线程的高级别API。java.util.concurrent提供了Executor类的实现,Executor类提供了一个适合大规模程序的线程池管理。
- 并发集合(Concurrent collections)使得管理大规模的集合变得简单,并且大大降低了同步的需要。
- 原子(Atomic)变量降低了同步的需要,并且帮助避免内存一致性错误。
- ThreadLocalRandom(JDK 7)提供了多线程中伪随机序列的有效产生器。
7.1 Lock对象
同步代码依赖于一种简单的可重入锁。该锁使用起来很简单,但是存在一些限制。大部分复杂的锁方法都是由java.util.concurrent.locks包提供的。我们不详细介绍该包,而着重介绍一个基本的接口,Lock。
Lock对象和隐式锁工作非常像。和隐式锁一样,只有一个线程可以得到Lock对象。Lock对象同时也通过相对应的Condition对象提供了wait/notify机制。
Lock对象相对于隐式锁最大的优势就是,其可以退出尝试获取锁的行为。当锁不可用是,tryLock 方法会立即退出或者在超时前(如果指定的话)。如果另一个线程在得到锁之前发出了一个中断,lockInterruptibly 方法会退出。
让我们使用Lock对象解决在活跃度小节出现的死锁问题。Alphonse和Gaston受过训练,知道朋友何时会鞠躬。我们要求Friend对象在鞠躬之前必须获得两个参与者的锁。这是改进后模型Safelock的源代码。为了展示该方法的多用行,我们假设Alphonse和Gaston痴情于他们的新发现,即不能对彼此停止鞠躬。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.Random;
public class Safelock {
static class Friend {
private final String name;
private final Lock lock = new ReentrantLock();
public Friend(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public boolean impendingBow(Friend bower) {
Boolean myLock = false;
Boolean yourLock = false;
try {
myLock = lock.tryLock();
yourLock = bower.lock.tryLock();
} finally {
if (! (myLock && yourLock)) {
if (myLock) {
lock.unlock();
}
if (yourLock) {
bower.lock.unlock();
}
}
}
return myLock && yourLock;
}
public void bow(Friend bower) {
if (impendingBow(bower)) {
try {
System.out.format("%s: %s has"
+ " bowed to me!%n",
this.name, bower.getName());
bower.bowBack(this);
} finally {
lock.unlock();
bower.lock.unlock();
}
} else {
System.out.format("%s: %s started"
+ " to bow to me, but saw that"
+ " I was already bowing to"
+ " him.%n",
this.name, bower.getName());
}
}
public void bowBack(Friend bower) {
System.out.format("%s: %s has" +
" bowed back to me!%n",
this.name, bower.getName());
}
}
static class BowLoop implements Runnable {
private Friend bower;
private Friend bowee;
public BowLoop(Friend bower, Friend bowee) {
this.bower = bower;
this.bowee = bowee;
}
public void run() {
Random random = new Random();
for (;;) {
try {
Thread.sleep(random.nextInt(10));
} catch (InterruptedException e) {}
bowee.bow(bower);
}
}
}
public static void main(String[] args) {
final Friend alphonse =
new Friend("Alphonse");
final Friend gaston =
new Friend("Gaston");
new Thread(new BowLoop(alphonse, gaston)).start();
new Thread(new BowLoop(gaston, alphonse)).start();
}
}
7.2 Executors
在之前的例子中,由Runnable对象定义的任务,和由Thread定义的线程之前有紧密的联系。在小程序中会工作地很好,但是在大规模程序中,有必要将线程的管理和创建从程序的其他部分分离。封装了这些功能的类就是执行者(executors)。接下来的几个小节将详细介绍该类。
- 执行者接口(Executor Interfaces )定义了三种执行者对象类型。
- 线程池(Thread Pools)是最常见的执行者实现。
- Fork/Join是利用多处理器实现的框架(JDK 7新增)
7.2.1 执行者接口
java.util.concurrent包定义了三个执行者接口,
- Executor,一种最简单的支持启动新任务的接口。
- ExecutorService,Executor的子接口,新增了一些特性,来帮助管理任务和执行者本身的生命周期。
- ScheduledExecutorService,ExecutorService的子接口,支持任务的延期或周期执行。
典型地,指向执行者对象的变量被声明成这三种类型之一,而不是executor类型。
1. Executor接口
Executor接口只提供了一个方法,execute,用来代替线程创建方法。如果r是Runnable对象,e是一个执行者对象,你可以将
(new Thread(r)).start();
替换成
e.execute(r);
然而,execute的定义却不是很具体。该低级别方法新建一个线程并立即启动。根据Executor的实现,execute可能会做相同的工作,然而更可能在一个现存的工作线程中执行r,或者将r放到一个执行队列里等待执行。(我们将在线程池中介绍工作线程)
java.util.concurrent包中的执行者实现,充分利用了更加高级的ExecutorService和ScheduledExecutorService接口,虽然这两个也是基于Executor实现的。
2. ExecutorService接口
ExecutorService接口使用相似的、当更加多功能的submit方法补充了execute方法。和execute方法类似,submit方法接收Runnable对象,同时也接受Callable对象,Callable对象允许任务返回一个数值。submit方法返回一个Future对象,用来获取Callable的返回值和管理Runnable与Callable任务。
ExecutorService还提供了提交大量Callable对象的方法。最后,ExecutorService提供了一系列关闭执行者的方法。为了支持立即关闭,任务必须正确处理中断。
3. ScheduledExecutorService接口
ScheduledExecutorService用schedule方法扩充了父类ExecutorService的方法。schedule方法延时执行Runnable或Callable任务。另外,定义了scheduleAtFixedRate 和scheduleWithFixedDelay方法,用来周期重复执行任务。
7.2.2 线程池
大部分java.util.concurrent包实现的执行者都使用了线程池,线程池包含了工作线程。这种线程和它要执行的Runnable和Callable对象分离开,并通常被用于执行多任务。
使用工作线程降低了线程创建导致的开销。线程对象使用一定量的内存,并且在大规模程序中,分配和释放线程对象会导致明显的内存管理开销。
一种常见的线程池是固定线程池(fixed thread pool)。这类线程池包含指定数量的线程;如果一个线程终止但是它还在使用中,它将会被新的线程代替。任务通过内部队列被提交到线程池,队列包含了超过线程数量的额外任务。
固定线程池的一个重要优势就是使用它的应用可以巧妙地降级。为了理解这,考虑一个web服务器应用,每一个HTTP请求被一个单独的线程处理。如果该应用只是简单地给每个请求新建一个线程,当系统收到的请求超过它能立即处理的数量时,它会向所有的请求停止响应如果这些线程的开销超过了系统的能力。由于新建线程的数量有限,当大量请求过来的时候,该应用无法响应,但是如果系统可以维持的话,就能相应。
一个使用固定线程池创建执行者的简单方法是调用java.util.concurrent.Executors类中的newFixedThreadPool工厂模式方法,该类也提供了一下的工厂模式方法:
- newCachedThreadPool方法创建一个数量可扩充的线程池。该执行者适用于执行短期任务的程序。
- newSingleThreadExecutor方法创建一个单一线程的线程池。
- 其他一些方法是上述执行者的ScheduledExecutorService版本。
如果上述工厂模式提供的执行者无法满足你的需求,创建java.util.concurrent.ThreadPoolExecutor或者java.util.concurrent.ScheduledThreadPoolExecutor实例可以给你提供额外的选择。
7.2.3 Fork/Join
Fork/Join框架是ExecutorService接口的一个实现,来帮助你利用多处理器。它为那些可以分解为小片递归的任务设计。目标是所有处理器的能力来提高你应用的性能。
和所有ExecutorService的实现,fork/join框架将任务分发给线程池中的工作线程。fork/join框架很特别,因为其采用的是工作窃取算法(work-stealing algorithm)。执行完任务的工作线程可以从其他还繁忙的工作线程中“窃取”任务。
fork/join框架的中心是ForkJoinPool类,该类是AbstractExecutorService的一个扩展。ForkJoinPool实现了工作窃取算法(work-stealing algorithm),并且可以执行ForkJoinTask进程。
1. 基本用法
使用fork/join框架的第一步就是编写执行部分工作的代码。你的代码需要和下面的伪代码类似:
if (my portion of the work is small enough)
do the work directly
else
split my work into two pieces
invoke the two pieces and wait for the results
将这些代码包装在ForkJoinTask的子类中,典型地,可以使用更加具体的类型,RecursiveTask(可以返回一个结果)或者RecursiveAction。
当你的ForkJoinTask子类准备好后,创建一个代表所有任务执行完的对象,并将它传递给ForkJoinPool实例的invoke()方法。
2. Blurring for Clarity
为了帮助你理解fork/join框架如何工作,考虑以下的例子。假设你想要一张图片混乱。原始图片保存在整型数组中,每个整数代表了一个像素点的颜色值。混乱后的目标图片也保存在相同大小的整型数组中。
通过逐一遍历原始数组来完成混淆。每个像素值计算它周围的像素值(计算红,绿,蓝的平均值),并将结果存放在目标数组中。由于每个图片是一个数组,这个过程可能得消耗一段时间。你可以通过fork/join框架实现该算法,来利用多处理器系统的并行计算。这是一个可能的例子:
public class ForkBlur extends RecursiveAction {
private int[] mSource;
private int mStart;
private int mLength;
private int[] mDestination;
// Processing window size; should be odd.
private int mBlurWidth = 15;
public ForkBlur(int[] src, int start, int length, int[] dst) {
mSource = src;
mStart = start;
mLength = length;
mDestination = dst;
}
protected void computeDirectly() {
int sidePixels = (mBlurWidth - 1) / 2;
for (int index = mStart; index < mStart + mLength; index++) {
// Calculate average.
float rt = 0, gt = 0, bt = 0;
for (int mi = -sidePixels; mi <= sidePixels; mi++) {
int mindex = Math.min(Math.max(mi + index, 0),
mSource.length - 1);
int pixel = mSource[mindex];
rt += (float)((pixel & 0x00ff0000) >> 16)
/ mBlurWidth;
gt += (float)((pixel & 0x0000ff00) >> 8)
/ mBlurWidth;
bt += (float)((pixel & 0x000000ff) >> 0)
/ mBlurWidth;
}
// Reassemble destination pixel.
int dpixel = (0xff000000 ) |
(((int)rt) << 16) |
(((int)gt) << 8) |
(((int)bt) << 0);
mDestination[index] = dpixel;
}
}
...
现在你开始实现抽象compute()方法,该方法会直接结算混淆结果或者将其分解为两个较小的任务。可以使用数组长度阈值判断任务是否需要分解为小任务。
protected static int sThreshold = 100000;
protected void compute() {
if (mLength < sThreshold) {
computeDirectly();
return;
}
int split = mLength / 2;
invokeAll(new ForkBlur(mSource, mStart, split, mDestination),
new ForkBlur(mSource, mStart + split, mLength - split,
mDestination));
}
如果这些方法在RecursiveAction类的子类中,那么最直接的一个方法就是在ForkJoinPool执行任务,步骤如下:
1. 创建任务
// source image pixels are in src
// destination image pixels are in dst
ForkBlur fb = new ForkBlur(src, 0, src.length, dst);
2. 创建执行任务的ForkJoinPool
ForkJoinPool pool = new ForkJoinPool();
3. 执行任务
pool.invoke(fb);
所有的代码,包括生成目标图像文件的代码,请参考ForkBlur例子。
3. 标准实现
除了在多处理器系统上使用fork/join框架来实现并发执行任务的算法外(例如上节的ForkBlur.java例子),Java SE中还有一些使用fork/join框架实现的特性。Java SE 8 引进的一个例子,使用在java.util.Arrays类的parallelSort()方法中。这些方法和sort()方法很像,但是通过fork/join使用了并发。在多处理器系统上,大数据的并行排序比串行排序要快许多。然而,本教程不介绍这些方法是如何使用fork/join框架的。这些信息可以参考Java API文档。
fork/join的另一个实现,使用在java.util.streams包中,该包是Java SE 8 Lamba项目的一部分。更多的信息,请参考Lambda Expressions。
7.3 并发集合(Concurrent Collections)
java.util.concurrent包包括了一系列对Java集合框架的扩充。根据提供的集合接口,可以如下分类:
- BlockingQueue定义了一个先进先出的数据结构,当你往饱和的队列中添加数据或者从一个空队列中取数据会导致阻塞或者超时。
- ConcurrentMap,java.util.Map的子类,定义了一些有用的原子操作。这些方法仅当key存在的时候,会删除或替换一个key-value对,或者仅当key不存在的时候,会添加一个key-value对。将这些操作原子化避免了同步。ConcurrentHashMap是ConcurrentMap标准的实现,是一个并发的HashMap。
- ConcurrentNavigableMap,ConcurrentMap的子接口,执行近似匹配。ConcurrentSkipListMap是ConcurrentNavigableMap的标准实现,是一个并发的TreeMap。
这些所有的集合,通过向集合添加对象的操作和后续访问或删除该对象的操作之间定义happens-before关系,来帮助避免内存一致性错误。
7.4 原子变量(Atomic Variables)
java.util.concurrent.atomic包定义了执行单个变量执行原子操作的类。所有的类都有get和set方法,类似于在volatile对象上的读写。也就是,set,在后续的相同变量上的get方法有happens-before关系。原子的compareAndSet方法也有这些内存一致性特性,正如整数原子变量上的原子计算方法。
为了示例如何使用该类,让我们看我们已经实现的Counter类,
class Counter {
private int c = 0;
public void increment() {
c++;
}
public void decrement() {
c--;
}
public int value() {
return c;
}
}
一个避免线程冲突的方法是让Counter的方法同步, SynchronizedCounter所示:
class SynchronizedCounter {
private int c = 0;
public synchronized void increment() {
c++;
}
public synchronized void decrement() {
c--;
}
public synchronized int value() {
return c;
}
}
对于该简单的类,同步是一个可以接受的方案。但是对于一个复杂的类,我们得避免非必要同步带来的活跃度影响。使用AtomicInteger代替int域,使得我们不使用同步而避免线程冲突,AtomicCounter所示:
import java.util.concurrent.atomic.AtomicInteger;
class AtomicCounter {
private AtomicInteger c = new AtomicInteger(0);
public void increment() {
c.incrementAndGet();
}
public void decrement() {
c.decrementAndGet();
}
public int value() {
return c.get();
}
}
7.5 并发随机数字(Concurrent Random Numbers)
在JDK 7中,java.util.concurrent包括了一个类,ThreadLocalRandom,该类方便程序在多线程或者ForkJoinTasks中使用随机数。为了并发访问,使用ThreadLocalRandom而不是Math.random(),会带来更少的冲突、更好的性能。你需要做的只是调用ThreadLocalRandom.current(),然后调用它的方法来获得随机数,这是一个例子:
int r = ThreadLocalRandom.current() .nextInt(4, 77);
文章翻译自High Level Concurrency Objects,翻译难免会有纰漏,欢迎读者讨论指正。