java的并行基础模块主要包括线程安全的容器类和各种用于协调多个相互协作的线程控制流的同步工具类(synchronizer)。
- 线程安全的容器:同步容器类,并发容器类
- 同步工具类: 闭锁,FutureTask,信号量,Barrier (栅栏)
同时我们还需要了解下生产者和消费者模式,但是首先,我们讨论下关于线程安全的容器,也就是同步容器类和并发容器类。
- 同步容器类
同步容器类包括Vector和Hashtable,这个都是早期的JDK中的产物,他们实现线程安全的方式是:将他们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能够访问容器的状态。他们的实现都比较简单,但是与此同时也带来了问题:
- 性能问题:
因为使用的是对象锁,因此对该容器的对象的所有操作都是串行的,性能代价开销比较大。
- 复合操作的问题:
同步容器类只是同步了单一的操作,如果客户端是一组复合操作,它就没有办法同步了,必须需要客户端自己做同步,如下面的代码:
1. public static Object getLast(Vector list) {
2. int lastIndex = list.size() - 1;
3. return list.get(lastIndex);
4. }
5. public static void deleteLast(Vector list) {
6. int lastIndex = list.size() - 1;
7. list.remove(lastIndex);
8. }
getLast和deleteLast都是复合操作,由先前对原子性的分析可以判断,这依然存在线程安全问题,有可能会抛出ArrayIndexOutOfBoundsException的异常,错误产生的逻辑如下所示:
解决的办法只能是通过对这些复合操作加锁来实现。
- 迭代器并发问题
java collection进行迭代的标准时使用Iterator,无论是使用老的方式迭代循环,还是java5 提供的for-each循环,都需要对整个迭代的过程加锁,不然就会跑出concurrentmodificationexception异常抛出,这样如果容器的规模很大,或者在每个元素上执行操作的时间很长,那么这些线程将长时间等待,很可能产生死锁。即使不存在饥饿或者死锁等风险,长时间的对容器加锁也会降低程序的可伸缩性。当然如果不希望在迭代期间对容器加锁,那么一种替代的方式就是“克隆”容器,并且在副本上进行迭代,但是存在显著的性能开销,需要平衡诸多因素。并且有些迭代是隐含的,比如容器的toString,hashcode,equals,containsAll,removeAll,retainall以及把容器作为参数的构造函数都会对容器进行迭代。
- 并发容器类
由于上面的同步容器类所存在的问题,现在一般很少使用了,java5 推出了并发容器类,比如concurrenthashmap,以及copyonwritelist,java6推出了concurrentskiplistmap和concurrentskiplistset分别作为同步的sortedmap和sortedset的并发替代品,与同步容器类相比,存在如下的特性:
- 更加细粒度的加锁机制:同步容器直接把容器对象作为锁,这样的话所有的操作串行化,过于悲观。并发容器类采用分段锁机制
- 提供了一些原子性的复合操作:比如putifabsent,removeifequal,replaceifequal等操作。
- 弱一致性:在迭代过程中不再抛出concurrentmodificationexception,但是可能在高并发的情况下size和isEmpty方法获得的结果不准确,不过在真正的并发环境下,这些方法也没什么用。
值得一提的是copyonwritelist采用写入时复制的方式,是通过冗余和不可变性来避开了并发问题,在性能上开销比较大,但是如果写入的操作远远小于迭代和读操作,那么性能就差别不大了。
下面我们讲继续讨论同步工具类,宽泛的来说,其实后面的生产者消费者问题中出现的blockingqueue就是一种同步工具类,它是一个对象,封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待,并且提供了一些方法对状态进行操作。
- 闭锁
闭锁可以延迟线程的进度直到其到达终止状态,闭锁的作用相当于一扇门:在闭锁状态到达结束状态之前,这一扇门是一直关闭的,当闭锁状态到达结束状态后,将不会改变状态,因此这扇门将一直打开,闭锁可以用来确保某些活动直到其他活动都完成后菜继续执行,例如:
- 确保某个计算在其所需要的所有资源都被初始化之后才继续执行。二元闭锁(包括两个状态)可以用来表示”资源R已经被初始化“,而所有需要R的操作都必须先在这个闭锁上等待。
- 确保某个服务在其依赖的所有其他服务都已经启动之后才启动。每个服务都有一个相关的二元闭锁。当启动服务S的时候,将首先在S依赖的其他服务的闭锁上等待,在所有依赖的服务都启动后会释放闭锁S,这样依赖S的服务才能够继续执行。
- 等待直到某个操作的所有参与者(例如,Dota天梯中需要达到10个游戏玩家才能开始游戏)都就绪再继续执行。在这种情况下,当所有的玩家都准备就绪时,闭锁将到达结束状态。
我们以魔兽世界游戏为例:在进入战斗之后,魔法点心与魔法水是不能被使用的,这里的闭锁状态可以对应为战斗状态,因此如果脱离战斗了,也就是说闭锁状态到达结束状态,这个时候魔法点心与魔法水是可以被使用的。(唯一的不同是闭锁是不能够重置的,而这里的战斗状态和脱战状态是可以重置的)
CountDownLaunch是一个灵活的闭锁实现,可以在上述各种情况下使用,它通过一个内部的计数器来标示状态,如下的程序给出了闭锁的两种常见用法:
public class TestHarness{
public long timeTasks(int nThreads, final Runnable task) throws InterruptedException{
final CountDownLatch startGate =new CountDownLatch(1);
final CountDownLatch endGate =new CountDownLatch(nThreads);
for(int i=0;i<nThreads;i++){
Thread t=new Thread(){
public void run(){
try{
startGate.await();
try{
task.run();
}finally{
endGate.countDown();
}
}catch(InterruptedException ignored){}
}
};
t.start();
}
long start=System.nanoTime();
startGate.countDown();
endGate.await();
long end=System.nanoTime();
return end-start;
}
该代码可以用来测试n个线程并发执行某个任务时需要的时间。
- FutureTask
FutureTask也可以用作闭锁。(FutureTask实现了Future语义,表示一种抽象的可生成结果的计算)。FutureTask表示的结果是通过Callable来实现的,相当于一种可以生成结果的Runnable,并且可以处于一下三种状态:等待运行,正在运行和运行完成。Future.get行为取决于任务的状态,如果任务已经完成,那么get会立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果或者抛出异常。FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能够实现结果的安全发布。
其实简单的说,你可以把Future的作用想象成这样:你想和同学A一起打Dota,所以你需要 A来一起新开一盘,如果A打的那局已经结束,那么你就可以立即跟A重新开一盘(获得返回值)或者直接不打(抛出异常),否则你就得等待A直到A把之前打的那局打完。
FutureTask在Executor框架中表示异步任务,此外还可以用来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。下面的程序就使用FutureTask来执行一个高开销的计算,并且计算结果将在稍后使用。通过提前启动计算,可以减少在等待结果时需要的时间。
public class Test { private final FutureTask<ProductInfo> future = new FutureTask<PorductInfo>(
new Callable<ProductInfo>() {
public ProductInfo call() throws DataLoadException {
return loadProductInfo();
}
});
private final Thread thread = new Thread(future);
public void start() {
thread.start();
}
public ProductInfo get() throws DataLoadException, InterruptedException {
try {
return future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof DataLoadException) {
throw (DataLoadException) cause;
} else {
throw launderThrowable(cause);
}
}
}
}
- 信号量(semahore)
这里的信号量类与操作系统的实现是类似的,计数信号量用来控制同时访问某个特定资源的操作数量,或者执行某个指定操作的数量。计数信号量可以用来实现某种资源池,或者对容器加边界。
semaphore中管理着一组虚拟的许可,许可的初始数量可通过构造函数来指定,在执行操作时可以首先获得许可(只要还有剩余的许可),并且在使用之后释放许可。如果许可,那么acquire将阻塞直到有许可(或者直到被中断或者操作超时)。release方法将返回一个许可给信号量。(初始值为1,那么就是一个mutex)。可以将acquire看做消费一个许可,而release操作时创建一个许可。
我们以魔兽世界中人物的背包作为例子:背包中假设有80个格子(80个许可),如果往格子中放一种物品,那么就会消费掉一个许可,如果我丢掉一个物品,就会创建一个许可(因为此时背包中的对应格子空置了),下面是一个代码示例:
public class BoundedHashSet<T>{
private final Set<T> set;
private final Semaphore sem;
public BoundedHashSet(int bound){
this.set=Collections.synchronizedSet(new HashSet<T>)();
sem=new Semaphore(bound);
}
public boolean add(T o) throws InterruptedException{
sem.acquire();
boolean wasadded=false;
try{
wasadded=set.add(o);
return wasadded;
}finally{
if(!wasadded)
sem.release();
}
}
public boolean remove(Object o){
boolean wasRemoved=set.remove();
if(wasRemoved)
sem.release();
return wasRemoved;
}
}
- 栅栏(Barrier)
我们从上面看到闭锁是一次性对象,一旦进入终止状态,就不能被重置。栅栏类似于闭锁,都能阻塞一组线程直到某个事件发生,栅栏与闭锁的关键区别在于,所有线程都必须同时到达栅栏,才能够继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。栅栏用于实现一些协议,例如几个家庭决定在某个地方集合:”所有人6:00在麦当劳碰头,到了以后要等其他人,之后再讨论下一步要做的事情“。
CyclicBarrier可以使一定数量的参与方反复的在栅栏位置汇集,它可以在打开之后再重置以便下次使用。另外一种形式的栅栏式Exchanger,它是一种two-party栅栏,各方在栅栏位置交换数据,当两方执行不对称的操作时,exchanger会非常有用,例如当一个线程向缓冲区写数据,而另一个线程从缓冲区读数据。这些线程可以使用exchanger来汇合,并且将满的缓冲区与空的缓冲区交换。当两个线程通过exchanger交换对象时,这种交换就把这两个对象安全的发布给另一方。
最后我们了解下生产者,消费者模式以及阻塞队列,blockingqueue简化了生产者消费者设计的实现过程,它支持任意数量的生产者和消费者。一种最常见的生产者-消费者模式就是线程池与工作队列的组合,在executor任务执行框架中就体现了这种模式。我们以下面的代码来介绍(linkedBlockingqueue的具体实现):
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
int c = -1;
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
try {
while (count.get() == capacity)
notFull.await();
} catch (InterruptedException ie) {
notFull.signal(); // propagate to a non-interrupted thread
throw ie;
}
insert(e);
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
}