《Java并发编程实践》五(2):自定义同步器

这一章介绍基于状态条件同步的一般场景,以及通过条件队列来构建自定义同步机制的方法。最后介绍JDK同步器的公共抽象—AQS的基本原理,以及它在Java并发库内的应用。

状态条件

一般来说,线程之间的同步都是围绕状态条件展开的,以”容量有限队列“为例,take操作必须在队列满足“nonempty“状态条件下才能执行;如果不满足条件,take操作要么失败,要么阻塞。构建基于状态、具备并发同步功能的类,最容易的方式是使用现有的同步装置,下使用CountDownLatch构建一个二元的同步装置ValueLatch,用来同步一个value的初始化。

@ThreadSafe
public class ValueLatch<T> {
	@GuardedBy("this") private T value = null
	private final CountDownLatch done = new CountDownLatch(1);
	public boolean isSet() {
		return (done.getCount() == 0);
	}
	public synchronized void setValue(T newValue) {
		if (!isSet()) {
			value = newValue;
			done.countDown();
		}
	}
	
	public T getValue() throws InterruptedException {
		done.await();
		synchronized (this) {
			return value;
		}
	}
}

synchronized锁,也可被认为是一种特殊的0/1状态条件的同步器,所以上面的代码实际上用了两个同步器,CountDownLatch用来同步“初始化”,synchronized用来同步字段读写。

如果现有的同步器不满足需求,可以尝试使用条件队列、AbstractQueuedSynchronizer等更底层的同步装置,本章介绍实现“基于状态条件的同步器”的各种可选技术方案。

状态条件阻塞

在单线程程序中,类在执行操作时如果发现条件不满足,只能失败返回;但在一个并发程序中,还可以选择等待,其他线程会改变状态并使条件满足。”阻塞线程以等待条件满足“通常是一个更好的选择,否则使用者需要处理失败并进行重试;而如何进行重试对使用者来说是一个进退两难的问题。

使用基于状态的阻塞机制采用以下伪码所展示的工作模式:

1: acquire lock on object state
2:	while (precondition does not hold) {
3:		release lock
4:		wait until precondition might hold
5:		optionally fail if interrupted or timeout expires
6:		reacquire lock
7:	}
8: perform action
9: release lock

这段伪码展示了基于状态的阻塞的基本机制,值得仔细琢磨:

  • line 1:获取保护状态的锁,因为需要读写状态,为了线程安全,需要先持有锁;
  • line 2:while循环的条件是:条件尚未满足,之所有用循环而不是简单的if,是当线程从阻塞状态唤醒时,有可能有其他线程又修改了状态使得条件不满足;
  • line 3:在线程挂起前释放锁,如果不释放锁,其他线程就无法修改状态以使条件发生变化;
  • line 4:线程挂起直到条件满足,一般是其他线程修改状态后唤醒该线程;
  • line 5:按选项,阻塞可能由于线程中断或超时而失败(直接返回失败,跳出循环);
  • line 6:重新获取锁,因为要回到第二行;
  • line 7:执行业务操作;
  • line 8:释放锁,结束同步代码块。

Bounded Buffer示例

接下来用一个类似ArrayBlockingQueue的容量有限队列,展示各种同步实现方式。这个类有两个主要操作:take和put,在队列满的情况下,put操作需要阻塞;反之,在队列空的情况下,take操作要阻塞。

为了方便各种同步方式的实现,先编写了一个基类如下,由子类来编写可阻塞的put&take方法:

@ThreadSafe
public abstract class BaseBoundedBuffer<V> {
	@GuardedBy("this") private final V[] buf;
	@GuardedBy("this") private int tail;
	@GuardedBy("this") private int head;
	@GuardedBy("this") private int count;
	
	protected BaseBoundedBuffer(int capacity) {
		this.buf = (V[]) new Object[capacity];
	}
	protected synchronized final void doPut(V v) {
		buf[tail] = v;
		if (++tail == buf.length)
		tail = 0;
		++count;
	}
	protected synchronized final V doTake() {
		V v = buf[head];
		buf[head] = null;
		if (++head == buf.length)
			head = 0;
		--count;
		return v;
	}
	public synchronized final boolean isFull() {
		return count == buf.length;
	}
	public synchronized final boolean isEmpty() {
		return count == 0;
	}
}

非阻塞版本

第一个实现版本是非阻塞的:

@ThreadSafe
public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
	public GrumpyBoundedBuffer(int size) { super(size); }
	
	public synchronized void put(V v) throws BufferFullException {
		if (isFull())
			throw new BufferFullException();
		doPut(v);
	}
	
	public synchronized V take() throws BufferEmptyException {
		if (isEmpty())
			throw new BufferEmptyException();
		return doTake();
	}
}

它使用与基类一致的锁策略(synchronized),实现非常简单,但是很难用。如果使用者需要实现等待状态的功能,大体只能按以下方式:

while (true) {
	try {
		V item = buffer.take();
		// use item
		break;
	} catch (BufferEmptyException e) {
		Thread.sleep(SLEEP_GRANULARITY);
	}
}

在捕获BufferEmptyException之后,线程不知道该等待多久,SLEEP_GANULARITY过小会导致忙等待,浪费CPU;SLEEP_GANULARITY过大,则损害程序响应性。

条件队列(Condition Queue)

条件队列为”状态条件的等待、通知“建立了一个标准的模式,它之所以称之为条件队列,是因为它将等待某个条件的线程组织成一个队列。条件队列总是与一个锁关联,因为条件状态需要锁的保护;每个java对象可被视为一个条件队列,叫内置条件队列,它与监视器锁相关联。相关的接口是Object类的wait,notify,notifyAll等方法。

下面是使用内置条件队列实现的BoundedBuffer版本:

@ThreadSafe
public class BoundedBuffer<V> extends BaseBoundedBuffer<V> {

	// CONDITION PREDICATE: not-full (!isFull())
	// CONDITION PREDICATE: not-empty (!isEmpty())
	public BoundedBuffer(int size) { super(size); }
	
	// BLOCKS-UNTIL: not-full
	public synchronized void put(V v) throws InterruptedException {
		while (isFull())
			wait();
		doPut(v);
		notifyAll();
	}
	// BLOCKS-UNTIL: not-empty
	public synchronized V take() throws InterruptedException {
		while (isEmpty())
			wait();
		V v = doTake();
		notifyAll();
		return v;
	}
}

上面的代码是一个生产环境可用的阻塞队列,put方法进入方法体之后已经持有内置锁,wait操作内部会暂时释放锁;doPut成功之后,ifEmpty谓词可能发生变化,因此调用notifyAll方法来通知状态队列内的所有等待线程恢复执行(重新计算条件谓词)。

条件谓词

示例代码中的isEmpty、isFull是”条件谓词“,用于判断某个状态条件是否满足。对开发者来说,清晰的条件谓词,是正确使用条件队列的关键,这也是内置条件队列经常令人迷惑的原因,因为诸如Object.wait、Object.notify这样的方法,完全没有表达出“状态”,“”条件谓词“”到底是什么。

使用条件队列,一定要通过文档或注释,讲清楚谓词逻辑,就像上面的BoundedBuffer一样。

waiting

Object.wait方法返回,并不代表条件谓词一定成立。首先,多个条件谓词可能共享一个条件队列(BoundedBuffer的isEmpty和isFull共享内置条件队列),等待条件A的线程可能由于条件B的状态改变而被唤醒,;其次,由于并发,在线程被唤醒到重新获得锁之间的间隙,其他线程可能已经修改了条件状态。

因此在使用条件队列的等待操作,要注意遵循以下规则:

  • 有明确的条件谓词(判断对象状态是否满足条件);
  • 在wait之前执行条件谓词,wait之后也立即执行条件谓词;
  • 将上面两个操作组成一个循环体;
  • 条件谓词涉及的状态变量,应当被对应的锁保护;
  • 调用wait,notify,notifyAll的时候,必须持有锁;(Java编译器已经保证了这一点)
  • 在条件谓词检查成功之后,不要立即释放锁,要等状态变量使用完之后再释放。

上面伪码和BoundedBuffer遵循了上面的规则,用while循环来检查条件谓词和wait

notification

任何操作,只要修改状态使得条件谓词成立,必须调用条件队列的通知方法,内置条件队列是Object.notify或Object.notifyAll,来唤醒等待线程。BoundedBuffer的put和take都可能导致条件谓词发生变化,因此调用了Object.notifyAll。

notify和notifyAll方法区别在于,前者让JVM自动选择一个等待线程唤醒,后者唤醒所有等待线程。执行notify和notifyAll需要持有锁,而被唤醒的线程需要重新获得锁来执行,因此方法在调用notify和notifyAll之后要尽快释放锁。

BoundedBuffer不能使用notify,必须使用notifyAll,因为条件队列有两个条件谓词,如果条件谓词C1成立,却唤醒了等待条件谓词C2的线程,可能导致死锁。要使用notify必须满足两个条件:

  • 一致的等待者:所有的wait线程都在等待同一个条件谓词,并且在条件满足后,执行相同的逻辑;
  • 一进一出:一个通知只能满足一个等待线程。

绝大多数类都不满足以上这两个条件,因此流行的建议是一律使用notifyAll。不过使用notifyAll确实可能导致更多的上下文切换、锁竞争,这是安全性和可伸缩性发生矛盾的一个案例。

可以从另一个角度对通知方式进行优化:仅在条件谓词从false变为true的时候才发出通知,这叫做"conditional notification"。BoundedBuffer.put方法可以按这个思想进行优化如下。

public synchronized void put(V v) throws InterruptedException {
	while (isFull())
		wait()
	boolean wasEmpty = isEmpty();
	doPut(v);
	if (wasEmpty)
		notifyAll();
}

无论是使用notify,还是"conditional notification",都增加了编码、维护的难度,仅在测试证明确实需要优化时,才有必要采取这样的手段。

Condition对象

就像Lock类型是内置锁的一般化,Condition类是内置条件队列的一般化,提供了更丰富的功能。对于内置条件队列,每个锁只能有一个队列,如果有多个条件谓词,只能共享这个队列,影响性能。

Lock.newCondition方法创建与该锁关联的Condition对象,Condition的接口定义如下:

public interface Condition {
	void await() throws InterruptedException;
	boolean await(long time, TimeUnit unit) throws InterruptedException;
	long awaitNanos(long nanosTimeout) throws InterruptedException;
	void awaitUninterruptibly();
	boolean awaitUntil(Date deadline) throws InterruptedException;
	void signal();
	void signalAll();
}

一个Lock可以创建多个关联的Condition对象,Condition队列的公平性由锁的公平性来决定,Condition的await,signal,signalAll方法分别相当于内置条件队列的wait,notify,notifyAll方法。

Condition也是Object,也支持wait,notify,notifyAll方法,使用时要注意。

下面用Condition来改进BoundedBuffer如下(省略了take方法):

@ThreadSafe
public class ConditionBoundedBuffer<T> {
	protected final Lock lock = new ReentrantLock();
	
	// CONDITION PREDICATE: notFull (count < items.length)
	private final Condition notFull = lock.newCondition();
	
	// CONDITION PREDICATE: notEmpty (count > 0)
	private final Condition notEmpty = lock.newCondition();
	
	@GuardedBy("lock") private final T[] items = (T[]) new Object[BUFFER_SIZE];
	@GuardedBy("lock") private int tail, head, count;
	
	// BLOCKS-UNTIL: notFull
	public void put(T x) throws InterruptedException {
		lock.lock();
		try {
			while (count == items.length)
				notFull.await();
			items[tail] = x;
			if (++tail == items.length)
				tail = 0;
			++count;
			notEmpty.signal();
		} finally {
			lock.unlock();
		}
	}
}

ConditionBoundedBuffer与BoundedBuffer的功能完全一致,它通过两个Condition对象分别对应两个条件谓词,满足了用signal取代signalAll的条件。此外,通过变量命名(notFull&notEmpty),表达出Condition对象代表的条件谓词,比使用内置条件队列有更好的代码可读性、可维护性。

与Java监视器锁被推荐使用不同,内置条件队列由于其模糊性,不被推荐使用,建议用Condition来取代。

解密同步器(AQS)

ReentrantLock和Semaphore有很多的共同点,它们都起到类似”门禁“的作用,在同一时刻只允许有限数量的线程通过;而且二者都提供不可中断、可中断、限时的加锁(获取)操作,甚至都可以指定公平或不公平策略。这样一来,你或许认为Semaphore是基于ReentrantLock实现的,或者反过来ReentrantLock是基于二元Semaphore实现的;实际上,这都是完全可行的,下面的SemaphoreOnLock展示了如何通过ReentrantLock实现Semaphore。

@ThreadSafe
public class SemaphoreOnLock {
	private final Lock lock = new ReentrantLock();
	// CONDITION PREDICATE: permitsAvailable (permits > 0)
	private final Condition permitsAvailable = lock.newCondition();
	@GuardedBy("lock") private int permits;
	
	SemaphoreOnLock(int initialPermits) {
		lock.lock();
		try {
			permits = initialPermits;
		} finally {
			lock.unlock();
		}
	}
	// BLOCKS-UNTIL: permitsAvailable
	public void acquire() throws InterruptedException {
		lock.lock();
		try {
			while (permits <= 0)
				permitsAvailable.await();
			--permits;
		} finally {
			lock.unlock();
		}
	}
	public void release() {
		lock.lock();
		try {
			++permits;
			permitsAvailable.signal();
		} finally {
			lock.unlock();
		}
	}
}

不过真实的情况是:ReentrantLock和Semaphore都有一个叫做AbstractQueuedSynchronizer(AQS)公共基类。AQS是一个用来构建锁和同步器的框架,它有广泛的应用,CountDownLatch、ReentrantReadWriteLock也是基于AQS实现的;很多第三方库也基于AQS来实现满足特定需求的同步器。

AQS概述

AQS实现了同步器的通用功能,比如它内含一个FIFO阻塞线程队列,处理了线程阻塞和恢复的细节等;具体同步器实现只需要关注什么情况下线程通过、什么情况下阻塞、什么情况下唤醒阻塞的线程。AQS是一个高度抽象的类,直接理解它比较困难,我们可以通过一些具体的同步器来剖析它的工作方式。

基于AQS同步器的基本操作是”acquire“和”release“,线程调用acquire试图占据(或者通过)这个同步器,它是一个基于条件状态的操作,在条件不满足时可能会阻塞。 不同的同步器,acquire含义有所不同,对CountDownLatch来说,acquire成功的条件是”latch到达它的终结状态”;而对与FutureTask,acquire意味着“任务到达完成状态”。release是一个非阻塞操作,它修改状态使的阻塞于acquire操作之上的线程得以恢复运行。

AQS内部管理一个int类型的状态值,所有阻塞和同步都是围绕这个状态进行的。AQS提供了操作状态的方法:getState,setState和compareAndSetState。状态所代表的含义由具体同步器来决定,比如ReentrantLock的状态值代表当前线程加锁的次数,而Semaphore的状态值代表剩余的许可数量,FutureTask的状态则是任务的状态(未开始、运行中、完成、取消)。 当然同步器也可以管理额外的状态,比如ReentrantLock会维护当前持有锁的线程,以区分重入的加锁请求。

AQS也是基于状态条件的同步机制,只不它使用更底层的技术(CAS指令、volatile、线程操作)来实现的。本质上,我们只要拥有一个状态同步器,我们就可以用它实现各式各样的状态同步器。而AQS就可以被看做一个状态同步器。

acquire操作

AQS的acquire操作的基本形式如下:

boolean acquire() throws InterruptedException {
	while (state does not permit acquire) {
		if (blocking acquisition requested) {
			enqueue current thread if not already queued
			block current thread
		}
		else
			return failure
	}
	possibly update synchronization state
	dequeue thread if it was queued
	return success
}

上面的代码在形式上与条件队列的wait操作有点类似,包含的步骤如下:

  • 判断当前的状态是否允许acquire成功,由于需要反复计算该判断,所以用while包裹
  • 如果不符合条件
    • 再看本次acquire是否可阻塞,可阻塞的话将当前线程加入AQS的阻塞队列,并挂起;当其他线程修改状态后,会唤醒该线程,回到while循环,重新竞争该同步器;
    • 如果是非阻塞操作,失败返回
  • 如果符合条件
    • 可能需要修改状态,独占该同步器;
    • 如果当前线程在阻塞队列里,删除它;
    • 返回成功

acquire成功的条件状态是什么?是否某些状态下允许多个线程同时acquire成功?都由具体的同步器来决定,但它们的工作模式大体如此,我们应当记住这个模式并在学习具体同步器时不断对照这个模式。

release操作

AQS的release操作,表示线程要释放同步器,它不会导致阻塞:

void release() {
	update synchronization state
	if (new state may permit a blocked thread to acquire)
		unblock one or more queued threads
}

release操作首先修改AQS的状态,如果状态变化使得某些阻塞在acquire操作上的线程有机会成功,那么唤醒这些线程。

继承AQS

AQS为子类提供了getState, setState和compareAndSetState方法来访问状态值,并定义了一些可重写的方法。

如果要实现一个完全互斥的(类似ReentrantLock)的同步器,需要覆盖以下几个方法:

  • protected boolean tryAcquire(int arg):尝试占据AQS,成功返回true,arg参数代表修改状态的量,它的含义由子类解释;
  • protected boolean tryRelease(int arg):尝试释放AQS;

如果该是一个支持Condition的Lock,还需要实现:

  • protected boolean isHeldExclusively():该同步器是否被当前线程独占?

如果实现可共享的同步器,覆盖以下方法:

  • protected int tryAcquireShared(int arg):尝试以共享方式占据AQS,返回值负数代表失败,0代表成功且后续tryAcquireShared不会成功,>0代表成功且后续tryAcquireShared仍可能成功;这个特性使得AQS可支持有限共享的同步器(Semaphore);
  • protected int tryReleaseShared(int arg):尝试以共享方式释放AQS;

线程调用AQS的acquireXXX和releaseXXX方法时,后者会调用上面这些方法,依据返回值来决定来执行同步逻辑,如阻塞、唤醒等。

基于AQS的SimpleLatch

现在基于AQS实现一个简单的同步器OneShotLatch(二元状态的CountdownLatch),它的初始状态是关闭,调用await方法会阻塞,直到其他线程调用signal打开它。

@ThreadSafe
public class OneShotLatch {
	private final Sync sync = new Sync();
	
	public void signal() { 
		sync.releaseShared(0); 
	}
	public void await() throws InterruptedException {
		sync.acquireSharedInterruptibly(0);
	}
	private class Sync extends AbstractQueuedSynchronizer {
		protected int tryAcquireShared(int ignored) {
			// Succeed if latch is open (state == 1), else fail
			return (getState() == 1) ? 1 : -1;
		}
		protected boolean tryReleaseShared(int ignored) {
			setState(1); // Latch is now open
			return true; // Other threads may now be able to acquire
		}
	}
}

OneShotLatch没有继承自AQS,而是通过一个内部类来继承AQS,这样做是值得推荐的:首先AQS提供的功能并不是所有的同步器都需要,将AQS封闭在同步器内,能够屏蔽不需要的功能,避免被误用;其次AQS的方法语义过于抽象,具体同步器可以重新定义更加契合其功能的操作方法给使用者。实际上,java所有的同步器都是用这样的模式来使用AQS的。

OneShotLatch用state1代表打开,state0代表关闭,默认就是关闭;OneShotLatch是可共享的,所以实现了tryAcquireShared和tryReleaseShared;tryAcquireShared只要state==1即成功,它不独占同步器,所以该操作不修改state;tryReleaseShared将state改为1,该方法总会成功,因为Latch无论打开多少次,都保持打开状态。

OneShotLatch的await调用了sync.acquireSharedInterruptibly(参数无意义),后者会调用tryAcquireShared,;signal调用sync.releaseShared(参数无意义),后者会调用tryReleaseShared。

Sync.tryAcquireShared的实现,并不需要担心并发场景导致程序错误,即使tryAcquireShared返回失败的一刻,另外一个线程修改了state(),同步器也不会出错。如果tryAcquireShared返回false,AQS会尝试将当前线程加入等待队列,创建一个阻塞线程Node;在插入队列之前,会再调用一次tryAcquireShared,此后,如果有其他线程调用了tryReleaseShared,AQS保证此线程Node会得到唤醒机会。有兴趣的同学可以看看AQS源码,它真正的复杂之处其实是内部的阻塞线程队列。

AQS in java.util.concurrent

这一部分我们来介绍一下,java.util.concurrent包里面的同步器是如何使用AQS来实现其功能的;不过我们不会深入它的源码细节,仅限于原理。

ReentrantLock

ReentrantLock仅支持互斥的工作方式,所以它内部实现了tryAcquire, tryRelease, 和isHeldExclusively(支持Condition);tryAcquire的工作方式类似以下伪代码:

protected boolean tryAcquire(int ignored) {
	final Thread current = Thread.currentThread();
	int c = getState();
	if (c == 0) {
		if (compareAndSetState(0, 1)) {
			owner = current;
			return true;
		}
	} else if (current == owner) {
		setState(c+1);
		return true;
	}
	return false;
}

AQS的状态为0时,表示没有线程占据这个锁,ReentrantLock通过compareAndSetState来加锁(compareAndSetState是一个原子操作,下一章介绍),加锁成功记住锁的拥有者(owner);如果状态为非0,有两种情况,一种是owner就是当前线程,表明这是一次重入加锁,将state+1即可(此处无并发风险,不需要使用原子操作);否则返回false表示加锁失败。

Semaphore

Semaphore是用可共享的方式工作,它用AQS的状态来表示当前剩余的许可数量,它内部实现tryAcquireShared类似以下伪代码:

protected int tryAcquireShared(int acquires) {
	while (true) {
		int available = getState();
		int remaining = available - acquires;
		if (remaining < 0
			|| compareAndSetState(available, remaining))
			return remaining;
	}
}

tryAcquireShared计算剩余许可数量与需求的差值,若不足(remaining<0)返回失败;否则使用原子compareAndSetState来更新状态值;如果compareAndSetState由于并发失败,那就重试(while)。

tryReleaseShared的实现类似以下伪代码,使用compareAndSetState增加状态值,直至成功为止:

protected boolean tryReleaseShared(int releases) {
	while (true) {
		int p = getState();
		if (compareAndSetState(p, p + releases))
			return true;
	}
}

CountDownLatch

CountDownLatch的tryReleaseShared类似Semaphore,tryAcquireShared类似OneShotLatch,不再赘述。

总结

如果你想构建一个基于状态的同步机制,最好的方式是直接使用现有的类,如Semaphore、BlockingQueue或CountDownLatch;如果这些类不满足需求,那么可以考虑使用条件队列(Condition Queue);如果还不满足需求,可基于AbstractQueuedSynchronizer(AQS)构建自定义的同步器,AQS实现了一个高度抽象的基于状态的线程同步机制(阻塞、唤醒),java并发库内的同步器基本都是基于AQS实现的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章