本文节选自 Effective Java by Joshua Bloch 和 Concurrent Programming in Java by Doug Lea.
1.7 使用锁工具
1.7.1 synchronized 的限制
内部的synchronized方法和块可以满足很多基于锁的应用,但是它有以下限制:
- 如果某个线程试图获得锁,而这个锁已经被其他线程持有,那么没有办法回退,也没有办法在等待一段时间后放弃等待,或者在某个中断之后取消获取锁的企图,这些使得线程很难从活性问题中恢复。
- 没有办法改变锁的语义形式,例如重入性、读何写保护或者公平性等方面。
- 没有同步的访问控制,任何一个方法都可以对其可访问的对象执行synchronized(obj)操作,这样导致由于所需要的锁已经被占用而引起拒绝服务的问题。
- 方法和块内的同步,使得只能够够对严格的块结构使用锁。例如:不能在一个方法中获得锁,而在另外一个方法中释放锁。
1.7.2 util.concurrent工具包
util.concurrent工具包是Doug Lea在基本的Java同步工具(synchronization tools)之上,编写的高质量、高效率、语义上准确的线程控制结构工具包。下面简要介绍几个接口和实现。
1.7.2.1 ReentrantLock
ReentrantLock具有与内部锁相同的互斥、重入性和内存可见性的保证,它必须被显式地释放。ReentrantLock是可中断的、可定时的,非块结构锁。在Java5中,ReentrantLock的性能要远远高于内部锁。在Java6中,由于管理内部锁的算法采用了类似于 ReentrantLock使用的算法,因此内部锁和ReentrantLock之间的性能差别不大。
ReentrantLock的构造函数提供了两种公平性选择:创建非公平锁(默认)或者公平锁。在公平锁中,如果锁已被其它线程占有,那么请求线程会加入到等待队列中,并按顺序获得锁;在非公平锁中,当请求锁的时候,如果锁的状态是可用,那么请求线程可以直接获得锁,而不管等待队列中是否有线程已经在等待该锁。公平锁的代价是更多的挂起和重新开始线程的性能开销。在多数情况下,非公平锁的性能高于公平锁。Java内部锁也没有提供确定的公平性保证, Java语言规范也没有要求JVM公平地实现内部锁,因此ReentrantLock并没有减少锁的公平性。下面是关于ReentrantLock的一个例子:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BoundedBuffer<T> {
//
private int head;
private int tail;
private int count;
private final T buffer[];
//
private final Lock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();
@SuppressWarnings("unchecked")
public BoundedBuffer(int capacity) {
this.buffer = (T[]) new Object[capacity];
}
public T take() throws InterruptedException {
lock.lock();
try {
while(isEmpty()) {
notEmpty.await();
}
T t = doTake();
notFull.signal();
return t;
} finally {
lock.unlock();
}
}
public void put(T t) throws InterruptedException {
lock.lock();
try {
while(isFull()) {
notFull.await();
}
doPut(t);
notEmpty.signal();
} finally {
lock.unlock();
}
}
private boolean isEmpty() {
return count == 0;
}
private boolean isFull() {
return count == buffer.length;
}
private T doTake() {
T t = buffer[head];
buffer[head] = null;
if(++head == buffer.length) {
head = 0;
}
--count;
return t;
}
private void doPut(T t) {
buffer[tail] = t;
if(++tail == buffer.length) {
tail = 0;
}
++count;
}
}
1.7.2.2 Mutex
一个Mutex类(互斥独占锁mutual exclusion lock)的所写可以定义为
public class Mutex implemets Sync
{
public void acquire() throws InterruptedException;
public void release();
public boolean attempt(long msec) throws InterruptedException;
}
acquire和同步块的入口操作相似,release和同步块的释放锁操作相似。attempt操作只有在规定的时间内得到锁才返回true。0是合法的,这表明如果得不到锁的话则不需要等待。和内建的同步机制不同的是,如果当前的线程在试图获得锁的过程中被中断,acquire和attempt方法会抛出InterruptedException异常,这一点增加了使用的复杂性,但是提供了编写响应良好的健壮代码的来处理取消操作的机制。和synchronized方法或块不同的是,标准的Mutex类不能重入。如果锁已经被执行acquire的线程持有,如果这个线程继续调用acquire,那么它会被阻塞。ReentrantLock是可重入的锁。
1.7.2.3 Semaphore
信号量(Semaphore) 是并发控制中的经典构件。同其他工具类一样,它们也遵守获得-释放协议。从概念上说,一个信号量维护着一组在构造方法中初始化了的许可证。如果必要的话,每次acquire操作都会阻塞直到有一个许可证可用,然后占用这个许可证。attempt方法执行类似的操作,但是它可以在超时的时候失败并退出。每一次release都会添加一个许可证。不过事实上并没有使用真实的许可证对象,信号量只需要知道当前可用的许可证的数量并执行相关的操作即可。Mutex可以看成许可数是1的Semaphore。下面是关于信号量的一个典型例子:
public class SyncQueue implements Queue
{
private final Queue mQueue;
private final int mCapacity;
private final Semaphore mSemProducer;
private final Semaphore mSemConsumer;
public SyncQueue(Queue queue)
{
this(queue, Integer.MAX_VALUE);
}
public SyncQueue(Queue queue, int capacity)
{
mQueue = queue;
mCapacity = capacity;
mSemProducer = new Semaphore(capacity);
mSemConsumer = new Semaphore(0);
}
public Object get()
{
// Accquire consumer's semaphore
try
{
mSemConsumer.acquire();
}
catch(InterruptedException ie)
{
Thread.currentThread().interrupt();
return null;
}
// Get the item
Object item = null;
synchronized(mQueue)
{
item = mQueue.get();
}
//
mSemProducer.release();
return item;
}
public boolean put(Object item)
{
// Precondition checking
if(item == null)
{
return false;
}
// Accquire producer's semaphore
try
{
mSemProducer.acquire();
}
catch(InterruptedException ie)
{
Thread.currentThread().interrupt();
return false;
}
// Add the item
synchronized(mQueue)
{
mQueue.put(item);
}
// Release consumer's semaphore
mSemConsumer.release();
return true;
}
}
1.7.2.4 Latch
闭锁(latch)是指那些一旦获得某个值就再不变化的变量或者条件。二元闭锁变量或者条件(通常就被成为闭锁)的值只能改变一次,即从其初始化状态到其最终状态。和闭锁相关的并发控制技术封装在Latch类中,并遵守通用的获得-释放协议。但是它的语义为:一个release操作将使得所有之前或者之后的acquire操作都恢复执行。
闭锁的扩展之一就是倒数计数器(countdown),其acquire操作在release操作执行了固定的次数,而不仅仅是一次后恢复执行。闭锁,倒数计数器以及建立在他们基础之上的简单工具类可以被用于处理一下这些条件的响应操作。
- 完成指示器。例如,强制某些线程直到某些操作执行完毕后才能继续执行。
- 定时阀值。例如,在某个时期触发一组线程。
- 事件指示。例如,触发那些只有收到特定报文或者特定按钮被按下后才能继续的操作。
- 错误指示。例如,触发在全局性的关闭人去执行时才可以运行的一组线程
1.7.2.5 Barrier
Barrier能够阻塞一组线程,其与闭锁的区别在于:闭锁等待的是事件,barrier等待的是线程。CyclicBarrier允许给定数量的线程多次集中在一个barrier point。当某个线程调用await方法时会被阻塞,当所有的线程都调用await方法时,barrier被突破,所有的线程都可以继续执行,barrier也被reset以备下一次使用。如果await调用超时,或者阻塞中的线程被中断,那么barrier就认为是失败的,所有未完成的await调用都通过BrokenBarrierException终止。如果await调用成功,那么它返回一个唯一的到达索引号。CyclicBarrier也允许你向构造函数中传递一个Runnable型的barrier action,当成功通过barrier的时候会被执行。下面是关于CyclicBarrier的一个例子:
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
public class Solver {
//
private final String[][] data;
private final CyclicBarrier barrier;
private final CountDownLatch latch;
public Solver(String[][] data) {
this.data = data;
this.barrier = new CyclicBarrier(data.length, new BarrierAction());
this.latch = new CountDownLatch(data.length);
}
public void start() {
//
for (int i = 0; i < data.length; ++i) {
new Thread(new Worker("worker" + i, this.data[i])).start();
}
//
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String args[]) {
String[][] data = new String[][]{{"a1", "a2", "a3"}, {"b1", "b2", "b3"}, {"c1", "c2", "c3"}};
Solver solver = new Solver(data);
solver.start();
}
private class BarrierAction implements Runnable {
public void run() {
System.out.println(Thread.currentThread().getName() + " is processing barrier action");
}
}
private class Worker implements Runnable {
//
private String name;
private String[] row;
Worker(String name, String[] row) {
this.name = name;
this.row = row;
}
public void run() {
for(int i = 0; i < row.length; i++) {
System.out.println(name + " is processing row[" + i +"]" + row[i]);
try {
barrier.await();
} catch (InterruptedException ex) {
break;
} catch (BrokenBarrierException ex) {
break;
}
}
//
latch.countDown();
}
}
}
1.8 并发处理实践
假设你设计了一个集合类,现在想提供一个多线程环境下的遍历方法。最于这个设计问题一般有三种解决方法:同步聚集操作、索引化遍历和版本化迭代变量,每种方法都有设计的利弊。
1.8.1 同步聚集操作
一种安全使用枚举的方法就是吧作用于每个元素的操作抽取出来,这样可以把它作为synchronized applyToAll方法的参数(比如C/C++中的函数指针(function pointer),java中的接口或者闭包(colsure))。例如:
public interface Procedure
{
void apply(Object obj);
}
public class Vector
{
public syncronized void applyToAll(Procedure p)
{
for(int i = 0; i < size; i++)
{
p.apply(data[i]);
}
}
}
这种方法消除了在遍历过程中其它线程是否增加或者减少集合元素可能带来的干扰,但是代价是拥有集合的锁的时间太长。
1.8.2 索引化遍历和客户端锁
这种遍历策略是要求客户端使用索引的访问方法来遍历,例如:
for(int i = 0; i < v.size(); i++)
{
System.out.println(v.get(i));
}
size(), get(int)方法都是同步的,但是为了处理有细锁类度产生的潜在冲突,比如像v.size()方法可能成功,但是之后,另一个线程可能删除了最后一个元素,如果这时调用v.get(i)可能就会出错。解决这个问题的一个办法就是使用客户端锁,来保证大小检查和访问的原子性。
这种方法使用起来比较灵活,但是是在破会封装为代价的前提下,而且正确与否依赖于对Vector内部实现的了解程度。
1.8.3 版本化迭代变量
这用遍历方法是涉及的集合类支持失败即放弃的迭代变量,如果在遍历的过程中集合元素被修改,迭代操作就会抛出一个异常。实现这种策略的最简单的方法就是维护一个迭代操作的版本号,这个版本号在每次更新集合时都会增长。每当迭代变量访问下一个元素的时候,都会先看一下这个版本号,如果它已经改变了,则抛出一个异常。这个版本号应该足够大,使得在一次遍历的过程中版本号不会循环。一般来讲,整形(int)就足够了。
Java语言集合框架中的java.util.Iterator使用的就是这用策略。ConcurrentModificationException经常说明了在线程之间存在无计划而且不希望看到的交互,然而这些问题的修正仅靠异常处理代码往往是不够的。对于集合类来说,版本化迭代变量还是一个比较好的选择,部分因为可以在这些迭代化变量之上使用聚合遍历或客户端锁。