Java并发——构建自定义的同步工具
状态依赖性的管理#
状态依赖性指某种操作必须依赖于指定的状态才可以执行。比如一个阻塞队列的take
方法依赖于这个阻塞队列中有至少一个元素这个状态。
如果一个状态依赖性操作所依赖的状态不满足,通常有几种处理办法:
- 抛出异常
- 使用某种约定的错误返回值
- 阻塞,直到依赖的状态被满足
在并发程序设计中,第三种办法比较优雅且经常使用。在这一节中,通过自己构建一个具有状态依赖性的缓冲队列来讨论在Java中如何自己设计具有状态依赖性操作的类,我们所有的缓冲队列实现基于下面这个基类:
@ThreadSafe
public abstract class BaseBoundedBuffer <V> {
@GuardedBy("this") private final V[] buf;
// tail指向当前队列中最后一个元素,初始情况下,tail = buf.length - 1
@GuardedBy("this") private int tail;
// head指向当前队列中第一个元素,初始情况下,head = buf.length - 1, 当tail == head时,队列空
@GuardedBy("this") private int head;
@GuardedBy("this") private int count;
public BaseBoundedBuffer(int capacity) {
this.buf = (V[]) new Object[capacity];
this.tail = buf.length - 1;
this.head = buf.length - 1;
}
protected synchronized void doPut(V v) {
tail = (tail + 1) % buf.length;
buf[tail] = v;
count++;
}
public abstract void put(V v);
protected synchronized V doTake() {
V v = buf[head];
head = (head + 1) % buf.length;
count--;
return v;
}
public abstract V take();
public synchronized boolean isFull() {
return count == buf.length;
}
public synchronized boolean isEmpty() {
return count == 0;
}
}
这是一个基于数组构建的循环缓冲队列,读者不用纠结于算法和数据结构层面的知识,只需要知道该类目前是一个有界队列,put
和take
方法分别用于向队列中放入内容和从队列中取出内容。这个队列并没有任何状态依赖性,它只是提供了doPut
和doTake
来完成基本的队列操作,至于put
和take
方法要依赖什么状态,状态不满足时是否要实现成可阻塞的,如何阻塞,这都是子类需要考虑的。
该类使用了客户端加锁方式,子类和调用者都可以通过锁定队列对象参与到该类的同步策略中。
依赖的状态不满足时传递给调用者#
public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
@Override
public synchronized void put(V v) {
if (isFull())
throw new BufferFullException();
doPut(v);
}
@Override
public synchronized V take() {
if (isEmpty())
throw new BufferEmptyException();
return doTake();
}
}
这种实现简单的将状态不满足传递给调用者,而在并发程序中,一般情况下都是需要重试的,它把重试的逻辑留给了调用者,调用者代码会很难看。
while (true) {
try {
buffer.take();
break;
} catch (BufferEmptyException e) {
// 调用者通常还需要考虑这里sleep的中断该如何处理
Thread.sleep(RETRY_INTERVAL);
}
}
在接收到BufferEmptyException
后,调用者需要Thread.sleep
来将当前线程挂起一段时间后重试,这样可能会降低程序的活跃性,但如果不这样CPU会疯狂的进入没用的自旋等待,在活跃性和CPU占用之间需要一些权衡。你也可以使用Thread.yield
来向JVM表示该线程现在需要让出CPU给其它线程执行一会儿。
通过轮询与休眠实现简单阻塞#
public class SleepyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
private static final int RETRY_INTERVAL = 50;
@Override
public void put(V v) throws InterruptedException {
while (true) {
synchronized (this) {
if (!isFull()) {
doPut(v);
break;
}
}
Thread.sleep(RETRY_INTERVAL);
}
}
@Override
public V take() throws InterruptedException {
while (true) {
synchronized (this) {
if (!isEmpty())
return doTake();
}
Thread.sleep(RETRY_INTERVAL);
}
}
}
第二种,将轮询和休眠的逻辑放到缓冲队列类内部,这样一来外部的调用就会很简单。
条件队列#
现在为止,我们都是主动的去查询一个操作所依赖的条件是否满足,这让我们陷入不断的尝试中。
如果有一种机制,能够在依赖的状态发生时通知我们,而非让我们去做又消耗CPU(或影响活跃性)又会经常引入线程上下文切换的主动轮询,那岂不美哉?条件队列可以满足这种需求。如果学过操作系统,可能就知道有一种同步机制是锁和条件变量相配合使用的,而且它也是用来完成当某种条件满足时来通知某些挂起线程的操作的工具。
Java中的条件队列使用wait
和notify
完成,每个对象都可以作为一个条件队列,因为一个条件要和一个锁绑定,所以对一个对象调用wait
和notify
时要先具有该对象的锁。
wait
操作会进入阻塞并暂时释放该线程持有的锁
wait
:wait的语义是,当前线程执行过程中碰到了某些没法满足的前置依赖条件,所以现在要挂起了,等一会儿条件满足时再通知我notify
:notify的语义是,当前某些线程依赖的前置依赖条件已经满足,现在要通知一个线程结束它的挂起状态notifyAll
:notifyAll的语义是,当前某些线程的前置依赖条件已经满足,现在要通知所有已经阻塞的,等待该条件的线程
下面是使用条件队列实现的缓冲队列
public class ConditionBoundedBuffer<V> extends BaseBoundedBuffer<V> {
// not-full : 条件谓词,队列未满
// not-empty : 条件谓词,队列非空
@Override
public synchronized void put(V v) throws InterruptedException {
while (isFull())
wait();
doPut(v);
notifyAll();
}
@Override
public synchronized V take() throws InterruptedException {
while (isEmpty())
wait();
V v = doTake();
notifyAll();
return v;
}
}
这里面有两个条件,put
需要依赖队列未满的条件,take
需要依赖队列非空的条件。
有一些需要注意的点,如果你熟悉传统的锁,条件变量,那么你可能会觉得疑惑。传统的锁,条件变量中一个锁可以有多个条件变量,所以我们可以提供两个条件notFull
和notEmpty
,但Java中,一个锁只能有一个条件队列,这个条件队列就是该锁锁定的对象。所以这里我们用一个条件队列模拟了两个条件变量时的操作,这带来的问题就是,无论是put
和take
中的wait
,当它们被唤醒时,它们都不知道是哪个前置条件满足了,所以我们需要用while
让它在被唤醒后重新判断下是否是属于自己的前置依赖条件被满足了,如果不是继续挂起。
还有需要注意的是为什么这里使用notifyAll
而非notify
,假设你的缓冲队列容量为1,有若干个生产者线程向其中添加数据,有若干个消费者线程从其中拿取数据,假设现在队列为空,只有一个生产者线程处于工作状态,其它的都在wait
,与传统的条件变量不同的是,Java语言的限制导致它们都只能wait
在一个条件队列上,如果该工作中的生产者填满队列后使用notify
唤醒一个wait
的线程,你如何确保你能唤醒一个消费者线程而非另一个生产者线程。如果你唤醒了另一个生产者线程,那么系统中所有的线程都将陷入wait
状态等待唤醒,但不会再有线程能够唤醒它们了。
使用notifyAll
是将所有的线程从wait
状态转换成就绪状态去抢占锁,虽然也是只有一个线程能抢占到锁,其它的线程依旧会阻塞,但当该锁被释放,这些线程会继续进行锁的抢占。这是Java线程中WATING
状态和BLOCKING
状态的区别,可以看下这篇文章:图解Java线程的6种状态及切换。
读者需要记住,如果使用Java的
wait
和notify
机制,当有多个条件谓词时,在你的线程从wait
状态出来时一定要重新判断已经满足的谓词是不是自己需要的,通常使用一个while
来完成。并且,你需要用与条件队列相关的锁来保护构成谓词条件的各个状态变量。如果你有多个谓词,请使用notifyAll
代替notify
。只有当你只有一个条件谓词,并且wait
返回后的操作都相同,并且在条件变量上的每次通知只能唤醒一个线程来执行时才使用notify
。
条件队列的一些其它示例#
条件通知#
上面的代码中,每次向队列中放置一个值,或从队列中取出一个值时,都会执行一次notifyAll
唤醒所有线程,其实大可不必这样,因为只有当条件谓词改变时才需要唤醒线程。下面的代码通过对放置前的状态进行一个判断,如果本次放置导致条件谓词改变才发起通知,这种方式叫条件通知。
条件通知和notify
都能获得比notifyAll
更好的性能,可是它们也往往更容易出错,只有当确保能够正确的使用这些优化措施时才应该去使用它们。
示例:阀门类#
阀门是这样一种并发工具,阀门具有开启和关闭的状态,初始情况下是关闭的,当在关闭情况下调用await
时,线程将阻塞在这里,当调用open
打开阀门时,所有的阻塞的线程都将取消其阻塞状态。
阀门一般是不能关闭的,如果你打开了就不能关闭它,这里我们需要设计一个可重新关闭的阀门,close
方法关闭阀门。
下面是一个不可关闭的阀门
@ThreadSafe
public class ThreadGate {
private boolean isOpen;
public synchronized void open() {
isOpen = true;
notifyAll();
}
public synchronized void await() throws InterruptedException {
while (!isOpen)
wait();
}
}
下面是一个可关闭的阀门:
@ThreadSafe
public class ThreadGate {
private int generation;
private boolean isOpen;
public synchronized void close() {
isOpen = false;
}
public synchronized void open() {
isOpen = true;
generation++;
notifyAll();
}
public synchronized void await() throws InterruptedException {
int arrivalGen = this.generation;
while (!isOpen && arrivalGen == this.generation)
wait();
}
}
子类的安全问题#
对于状态依赖的类,要么将其等待和通知的协议完全向子类公开,公开底层状态变量,并且将其详尽的写入文档,要么就完全不公开(如使用final禁止继承或使用private隐藏锁、条件队列和状态变量)
不过这和常见的线程安全设计模式格格不入,比如上面的ConditionBoundedBuffer
就不符合上面所说。
显式的Condition对象#
Java中的显式锁,Lock
,提供了显式的Condition对象。之前我一直在说Java的条件队列和传统的锁、条件变量的不一致的问题,Lock
对象提供了与之一致的操作。
Lock
对象的newCondition
方法创建一个新的条件变量,而且并没有创建数量的限制,Condition
接口如下:
它提供了可中断的await
,可设置超时的await
和signal
、signalAll
。
下面是我之前使用Condition
写的一个缓冲队列,和本书中的例子的思想不同,由于我当时希望数据结构方面的糟心事留给子类实现,所以我提供了putE
和takeE
,即把put
操作和take
操作具体怎么保存数据的难题交给子类管理,我只提供阻塞、唤醒和线程安全:
public abstract class ReentrantLockBlockQueue<E> implements BlockQueue<E> {
protected final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;
public ReentrantLockBlockQueue() {
lock = new ReentrantLock();
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
@Override
public void put(E e) throws InterruptedException {
lock.lockInterruptibly();
// 防止`take`调用`signal`并释放锁,该方法的调用者线程未被唤醒之前,有人已经put数据导致`isFull() == true`
// 这里用了一个while
while (isFull()) notFull.await();
putE(e);
notEmpty.signal();
lock.unlock();
}
/**
* 该方法用于实际向队列中添加一个值,由于`ReetrantLockBlockQueue`只实现了take和put方法的线程安全、阻塞和唤醒,
* 并未规定队列具体的实现逻辑,所以这里需要子类实现`putE`来存入数据
*
* 需要注意的是`putE`中不需要额外的同步机制,因为它只会被同步的`put`方法调用。
* @param e
*/
protected abstract void putE(E e);
@Override
public E take() throws InterruptedException {
lock.lockInterruptibly();
// 防止`put`调用`signal`并释放锁,该方法的调用者线程未被唤醒之前,有人已经take走数据导致`isEmpty() == true`
// 这里用了一个while
while (isEmpty()) notEmpty.await();
E e = takeE();
notFull.signal();
lock.unlock();
return e;
}
/**
* 该方法用于实际从队列中取出一个值,由于`ReetrantLockBlockQueue`只实现了take和put方法的线程安全、阻塞和唤醒,
* 并未规定队列具体的实现逻辑,所以这里需要子类实现`takeE`来取出数据
*
* 需要注意的是`takeE`中不需要额外的同步机制,因为它只会被同步的`take`方法调用。
* @return
*/
protected abstract E takeE();
}
AbstractQueuedSynchronizer#
在学习Java的一些并发基础构件时,我觉得它们都差不多。
CountDownLatch
等待计数器到达某个数后释放所有正在等待的线程,Semaphore
则是好像是CountDownLatch
的反操作,它等待计数器到达某个数后阻塞后面进来的线程,当某个线程release
后,计数器+1,当计数器大于0时唤醒一个阻塞的线程,甚至ReentrantLock
也类似,它像一个计数器为1的Semaphore
。
大多数并发基础构件都是这样,就像我们之前构建的具有状态依赖性的类一样,它们管理一些状态,并且它们其中的某些操作在状态未处于特定条件下阻塞,当状态处于特定条件下会被唤醒。
AbstractQueuedSynchronizer
是Java中实现这些并发基础构件的一个基本框架,称为同步器,它维护一个整数类型的状态,子类可以设置它的状态,获取它的状态,并且重写其中的方法来告诉AbstractQueuedSynchronzier
,对于一个操作,处于何种状态才放行,处于何种状态会阻塞,当子类的某种方法需要改变某个状态时,如何进行改变。如下是使用该框架实现的一个OneshotLatch
:
public class OneshotLatch {
// 使用一个同步器,同步器状态为1时打开,0关闭
private final Sync sync = new Sync();
public void signal() {
sync.releaseShared(0); // 释放,这里的参数会把传递给`tryToReleaseShared`,不过不用关心它,因为我们都没使用这个参数
}
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(0); // 获取,一样,不用关心参数
}
private class Sync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryReleaseShared(int arg) {
setState(1); // Release时设置状态为1
return true;
}
@Override
protected int tryAcquireShared(int arg) {
return (getState() == 1) ? 1 : -1; // 状态为1时放行否则阻塞
}
}
}
AbstractQueuedSynchronizer
具有我们熟悉的acquireXXX
操作用于获取某种东西,releaseXXX
用于表示释放某种东西。它们被调用时会调用tryAcquire
和tryRelease
,这是子类用来决定处于什么状态下可以获取(返回正数代表可以获取,负数代表失败,线程会进入阻塞状态)和决定释放时状态应该进行何种改变的位置。
acquire
和release
都有shared
和普通版本,shared
被支持共享获取操作的类来使用(如信号量)。
Java程序员并不需要直接使用同步器,而是使用基于同步器构建的一系列Java并发构件。
ReentrantLock#
这是ReentrantLock
中实现的tryAcquire
(因为它是独占锁,所以它并不需要实现shared版本):
这里的状态可以很明显看出:
- 0:锁未被持有
- 1:锁被持有
- 1+:用于支持可重入锁,记录当前线程进入了多少次该锁
Semaphore#
由于它们都不是独占的,所以需要实现tryAcquireShared
和tryReleaseShared
:
下面是Semaphore
的实现:
明显,Semaphore
将状态视为当前还剩余的计数器数量。它在acquire
中不断地尝试设置减少后的计数器数,直到成功。而release
几乎是acquire
的反操作。
作者:Yudoge
出处:https://www.cnblogs.com/lilpig/p/16170177.html
版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。
欢迎按协议规定转载,方便的话,发个站内信给我嗷~
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· winform 绘制太阳,地球,月球 运作规律
· 上周热点回顾(3.3-3.9)