Java高级:条件队列与同步器Synchronizer的原理+AQS的应用(1)

acquire lock object state

while (precondition does not hold) {

release lock

wait until precondition might hold

optionally fail if interrupted or timeout expires

reacquire lock

}

perform action

release lock

程序清单 14-1 的这种加锁模式有些不同寻常,因为锁是在操作执行过程中被释放与重新获取的。构成前提条件的状态变量必须由对象的锁保护起来 ,这样它们能在测试前提条件的过程中保持不变。如果前提条件尚未满足 ,就必须释放锁 ,让其他线程可以修改对象的状态。否则 ,前提条件就永 远无发成真了。再次测试前提条件之前 ,必须要重新获得锁 .

接下来以有届缓存的实现为例,介绍一下采用不同方式来处理前提条件失败。在每种实现中都扩展了程序清单 14-2中的 BaseBoundedBuffer ,在该类中实现了一个基于数组的循环缓存,其中各个缓存状态变量( buf、 head、 tail、 count )均有缓存的内置锁来保护。同时还提供了同步的 doPut和 doTake方法,并在子类中通过这些方法来实现 put和 take操作,底层状态对子类隐藏。

程序清单 14-2 有届缓存实现的基类

public class BaseBoundedBuffer {

private final V[] buf;

private int tail;

private int head;

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 ;

}

}

1.2 通过 " 轮询加休眠 " 实现拙劣的阻塞

程序清单 14-3 使用简单阻塞实现的有届缓存

public class SleepyBoundedBuffer extends BaseBoundedBuffer {

protected SleepyBoundedBuffer(int capacity) {

super(capacity);

}

public void put(V v) throws InterruptedException {

//无限尝试将v添加入集合

while (true) {

//获得锁

synchronized (this) {

//如果不空,就添加进集合,退出循环

if (!isFull()) {

doPut(v);

return;

}

}

//否则释放锁,休眠一段时间,给其他线程一些修改的机会.

Thread.sleep(1000);

}

}

public V take() throws InterruptedException {

while (true) {

synchronized (this) {

if (!isEmpty())

return doTake();

}

Thread.sleep(1000);

}

}

}

另外,除了阻塞休眠等待的方式,还可以将前提条件的失败传递给调用者,由调用者控制是否进入休眠。如果调用者不进入休眠而直接重新调用的方式成为忙等待或者自旋等待。

如果缓存的状态在很长一段时间内不会发生变化,那么使用这个方式就会消耗大量的 CPU时间。但是,在进入休眠的情况下,如果缓存的状态在刚调完 sleep后就立即发生变化,那么将不必要地休眠一段时间。因此我们必须要在这两者中做出选择:要么容忍自旋导致的 CPU始终周期浪费,要么容忍由于休眠而导致的低响应性。

1.3 条件队列

通过轮询与休眠来实现阻塞操作的过程需要付出大量的努力。如果存在某中挂起线程的方法,并且这种方法能够确保当某个条件成真时线程立即醒来,那么将极大地简化实现工作。这正是条件队列的功能。

“条件队列 ”这个名字的来源于:它使得一组线程(称之为等待线程集合)能够通过某种方式来等待特定的条件变成真。传统队列的元素是一个个数据,而与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。

正如每个 Java对象都可以作为一个锁,每个对象同样可以作为一个条件队列,并且 Object中的wait、 notify和 notifuAll方法就构成了内部条件队列的 API。对象的内置锁与其内部条件队列是相互联系的,要调用对象 X中条件队列的任何一个方法,必须持有对象 X上的锁。这是因为 “等待由状态构成的条件 ”与 “维护状态一致性 ”这两种机制必须被紧密地绑定在一起:只有能对状态进行检查时,才能在某个条件上等待,并且只有能修改状态时,才能从条件等待中释放另一个线程。

Object.wait会自动释放锁,并请求操作系统挂起当前的线程,从而使其他线程能够获得这个锁并修改对象的状态。当被挂起的线程醒来时,他将在返回之前重新获取锁。程序清单 14-6中使用了 wait和 notifyAll实现了一个有届缓存。

程序清单 14-6 使用条件队列实现的有届缓存

public class BoundedBuffer extends BaseBoundedBuffer {

// 条件谓词:not-full (!isFull)

// 条件谓词:not-empty (!isEmpty)

protected BoundedBuffer(int capacity) {

super(capacity);

}

public synchronized void put(V v) throws InterruptedException {

while(isFull())

wait();

doPut(v);

notifyAll();

}

public synchronized V take() throws InterruptedException {

while (isEmpty())

wait();

V v = doTake();

notifyAll();

return v;

}

}

最终这比使用休眠的有届缓存更加简单,并且更加高效(线程醒来的次数更少),响应性也更高(当发生特定状态变化时将立即醒来)。

注意:与使用休眠的有届缓存相比,条件多列并没有改变原来的语义。他只是在多个方面进行了优化: CPU效率,上下文切换开销和响应性等。如果某个功能无法通过 “轮询 +休眠 ”来实现,那么使用条件队列也无法实现,但条件队列是的在表达和管理状态依赖时更加的简单和高效。

14.2 使用条件队列

条件队列使构建高效以及高可响应性的状态依赖类变得更容易,但同时也很容易被不正确的使用。

虽然许多规则都能确保正确地使用条件队列,但在编译器或系统平台上却并没有强制要求遵循这些规则。(这也是为什么要尽量基于 LinkedBlockingQueue、 Latch、 Semaphore 和 FutureTask 等类来构造程序的原因之一,如果能避免使用条件队列,那么实现起来将容易许多)。

2.1 条件谓词

要想正确地使用条件队列,关键是找出对象在哪个条件谓词上等待。如果没有条件谓词,条件等待机制将无法发挥作用。条件谓词是使某个操作成为状态依赖操作的前提条件。对 take 方法来说,它的条件谓词就是 "缓存不为空 " , take 方法在执行之前必须首先测试该条件谓词。

在条件等待中存在一种重要的三元关系,包括:

  • 加锁

  • wait 方法

  • 条件谓词

在条件谓词中包含多个状态变量,而状态变量由一个锁来保护,因此在测试条件谓词之前必须先持有这个锁。锁对象与条件队列对象(即调用 wait 和 notify 等方法所在的对象)必须是同一个对象。

每一次 wait 调用都会隐式地与特定的条件谓词关联起来。当调用某个特定条件谓词的 wait 时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护着构成条件谓词的状态变量。

注意,当线程从 wait方法中被唤醒时,他在重新请求锁时不具有任何特殊的优先级,而要与任何其他尝试进入同步代码块的线程一起正常的在锁上竞争。

2.2 过早唤醒

wait的返回并不一定意味着线程正在等待的条件谓词已经变成真了。内置条件队列可以与多个条件谓词一起使用,这是一种常见的情况,如在 BoundedBuffer 中使用的条件队列与 "非满 " 和"非空 "两个条件谓词相关。

当一个线程由于调用 notifyAll 而醒来时,并不意味该线程正在等待的条件谓词已经变成真了。这就像烤面包机和咖啡机共用一个铃声,当响铃后,你必须查看是哪个设备发出的铃声。另外, wait 方法还可以 "假装 " 返回,而不是由于某个线程调用了

notify。

当执行控制重新进入调用

wait 的代码时,它已经重新获取了与条件队列相关联的锁。现在条件谓词是不是已经变为真了?或许。在发出通知的线程调用 notifyAll 时,条件谓词可能已经变成真,但在重新获取锁时将再次变为假。在线程被唤醒到 wait 重新获取锁的这段时间里,可能有其他线程已经获取了这个锁,并修改了对象的状态。或者,条件谓词从调用 wait 起根本就没有变成真。你并不知道另一个线程为什么调用 notify 或 notifyAll ,也许是因为与同一条件队列相关的另一个条件谓词变成了真。

基于所有这些原因,每当线程从 wait 中唤醒时,都必须再次测试条件谓词,如果条件谓词不为真,那么就继续等待(或者失败)。由于线程在条件谓词不为真的情况下也可以反复地醒来,因此必须在一个循环中调用 wait ,并在每次迭代中都测试条件谓词。

程序清单 14-7 状态依赖方法的标准形式

void stateDependentMethod() throws InterruptedException {

// 必须通过一个锁来保护条件谓词

synchronized (lock) {

while (!conditionPredicate())

lock.wait();

// 现在对象处于合适的状态

}

}

当使用条件等待时(Object.wait 或者Condition.await):

  • 永 远设置一个条件谓词 —一些对状态的测试 ,线程执行前必须满足它 ;

  • 永 远在调用 wait前测试条件谓词 ,并且从 wait中返回后再次测试 ;

  • 永远在循环中调用 wait;

  • 确保构成条件谓词的状态变量被锁保护 ,而这个锁正是与条件队列相关联的 ;

  • 当调用wait 、notify 或者notifyAll 时, 要持有与条件队列相关联的锁; 并且,

  • 在检查条件谓词之后、开始执行被保护的逻辑之前 ,不要释放锁 .

2.3 丢失信号

之前我们讨论活跃性故障,有死锁和活锁。另一种形式的活跃性故障是丢失信号。

丢失的信号是指:线程必须等待一个已经为真的条件,但在开始等待之前没有检查条件谓词。现在,线程将等待一个已经发生过的事件。这就好比在启动了烤面包机出去拿报纸,当你还在屋外时烤面包机的铃声响了,但你没有听到,因此还会坐在厨房的桌子前等着烤面包机的铃声。你可能会等待很长的时间(为了摆脱等待,其他人也不得不开始烤面包,从而使得情况变得糟糕,当铃声响起时,还要与别人争论这个面包是属于谁的。)如果线程 A 通知了一个条件队列,而线程 B 随后在这个条件队列上等待,那么线程 B 将不会立即醒来,而是需要另一个通知来唤醒它。编码错误(例如没有在调用 wait 之前检测条件谓词)会导致信号的丢失。如果按照程序清单 14-7 的方式来设计条件等待,则不会发生信号丢失的问题。

注意:保证 notify一定在 wait之后!!!

2.4 通知

到目前为止,我们介绍了条件等待的前一半内容:等待。另一半内容是:通知。在有界缓存中,如果缓存为空,在调用 take 时将阻塞。在缓存变为非空时,为了使 take 解除阻塞,必须确保在每条使缓存变为非空的代码路径中都发出一个通知。

在条件队列 API 中有两个发出通知的方法,即:

  • 单次通知 : notify

  • 全部通知: notifyAll

无论调用哪个,都必须持有与条件队列对象相关联的锁。在调用 notify 时, JVM 会从这个条件队列上等待的多个线程中选择一个来唤醒,而调用 notifyAll 则会唤醒所有在这个条件队列上等待的线程。由于在调用 notify 或 notifyAll 时必须持有条件队列对象的锁,因此发出通知的线程应该尽快地释放锁,从而确保正在等待的线程尽可能快地解除阻塞。

在 BoundedBuffer 中很好的说明了为什么在大多数情况下应该优先选择 notifyAll。 这里的条件队列用于两个不同的条件谓词: "非空 " 和 "非满 "。假设线程 A 在条件队列上等待条件谓词 PA,同时线程 B 在同一个条件队列上等待条件谓词 PB。现在,假设 PB 变成真,并且线程 C 执行一个 notify : JVM 将从它拥有的众多线程中选择一个并唤醒。如果选择了线程 A ,那么它被唤醒,并且看到 PA 尚未变成真,因此将继续等待。同时,线程 B 本可以开始执行,却没有被唤醒。这并不是严格意义上的 "丢失信号 " ,更像一种 "被劫持的 " 信号,但导致的问题时相同的:线程正在等待一个已经(或本应该)发生过的信号。

只有同时满足以下两个条件时,才能用单一的 notify 而不是 notifyAll :

  • 所有等待线程的类型都相同:只有一个条件谓词与条件队列相关,并且每个线程在从 wait 返回后将执行相同的操作。

  • 单进单出:在条件变量上的每次通知,最多只能唤醒一个线程来执行。

由于大多数类并不满足这些需求,因此普遍认可的做法是优先使用 notifyAll 而不是 notify。虽然notifyAll 可能比 notify 更低效,但却更容易确保类的行为是正确的。这种低效情况带来的影响有时候很小,但有时候却非常大。当只有一个线程可以执行时,如果有 10 个线程在一个条件队列上等待,那么调用 notifyAll 将唤醒每个线程,并使得它们在锁上发生竞争。然后,它们中的大多数或者全部又都回到休眠状态。因而,在每个线程执行一个事件的同时,将出现大量的上下文切换操作以及发生竞争的锁获取操作。(最坏的情况是,在使用 notifyAll 时将导致 O(n^2) 次唤醒操作,而实际上只需要 n 次唤醒操作就足够了)这是 "性能考 虑因素与安全性考虑因素相互矛盾 " 的另一种情况。

在 BoundedBuffer 的 put 和 take 方法中采用的通知机制是保守的,可以对其进行优化:首先,仅当缓存从空变为非空,或者从满转为非满时,才需要释放一个线程。并且,仅当 put 或 take 影响到这些状态转换时,才发出通知。这也被称为 " 条件通知 "。虽然可以提升性能,但却很难正确地实现(而且还会使子类的实现变得复 杂),因此在使用时需谨慎。

程序清单 14-8 使用条件通知

public synchronized void put(V v) throws InterruptedException {

while (isFull())

wait();

boolean wasEmpty = isEmpty();

doPut(v);

if (wasEmpty)

notifyAll();

}

单次通知和条件通知都属于优化措施。通常,在使用这些优化措施时,应该遵循 “首先使程序正确地执行,然后才使其运行的更快 ”这个原则。如果不正确地使用这些优化措施,很容易在程序中引入奇怪的活跃性故障。

2.5 示例

通过使用条件等待, ThreadGate实现了一个可打开和重新关闭的阀门,并提供了一个 await方法,该方法能一直阻塞知道阀门被打开。这 open中使用了 notifyAll ,因为这个类的语义不满足单次通知的条件。

程序清单 14-9 实现一个可重新关闭的阀门

@ThreadSafepublic

class ThreadGate {

// CONDITION-PREDICATE: opened-since(n) (isOpen || generation>n)

@GuardedBy(“this”) private boolean isOpen;

@GuardedBy(“this”) private int generation;

public synchronized void close() {

isOpen = false;

}

public synchronized void open() {

++generation;

isOpen = true;

notifyAll();

}

// BLOCKS-UNTIL: opened-since(generation on entry)

public synchronized void await() throws InterruptedException {

int arrivalGeneration = generation;

while (!isOpen && arrivalGeneration == generation)

wait();

}

}

await中的使用的条件谓词比较复 杂,这种条件谓词时必需的,因为如果当阀门打开时有 N 个线程正在等待它,那么这些线程都应该被允许执行。然而,如果阀门在打开后又非常快速地关闭了,并且 await 方法只检查 isOpen ,那么所有线程都可能无法释放:当所有线程收到通知时,将重新请求锁并退出 wait ,而此时的阀门可能已经再次关闭了。因此,在 ThreadGate 中使用了一个更复 杂的条件谓词:每次阀门关闭时,递增一个 Generation 计数器,如果阀门现在是打开的,或者阀门自从该线程到达后就一直是打开的,那么线程就可以通过 await。

由于 ThreadGate 只支持等待打开阀门,因此它只在 open 中执行通知。要想既支持等待打开又支持等待关闭,那么必须在 open 和 close 中都进行通知。这很好地说明了为什么在维护状态依赖的类时是非常困难的 ——当增加一个新的状态依赖操作时,可能需要对多条修改对象的代码路径进行改动,才能正确地执行通知。

可见在使用条件队列时,除了使用起来比较复杂且易出错,还有面临诸如子类安全问题以及条件队列封装问题等。

14.3 显示的 Condition 对象

正如在某些情况下,当内置锁过于不灵活时,可以使用显式锁。在内置条件队列不满足需求时,可以使用显示条件队列 Condition。注意: Lock 是一种广义的内置锁, Condition 也是一种广义的内置条件队列。

程序清单 14-10 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();

}

内置条件队列存在一些缺陷,每个内置锁都只能有一个相关联的条件队列,因而在像 BoundedBuffer 这种类中,多个线程可能在同一个条件队列上等待不同的条件谓词,并且在最常见的加锁模式下公开条件队列对象。这些因素都使得无法满足在使用 notifyAll 时所有等待线程为同一类型的需求。如果想编写一个带有多个条件谓词的并发对象,或者想获得除了条件队列可见性之外的更多控制权,就可以使用显式的 Lock 和 Condition 而不是内置锁和条件队列,这是一种更灵活的选择。

一个 Condition 和一个 Lock 关联在一起,就像一个条件队列和一个内置锁相关联一样。要创建一个 Condition ,可以在相关联的 Lock 上调用 Lock.newCondition 方法。 Condition 同样比内置条件队列提供更丰富的功能:在每个锁上可存在多个等待、条件等待可以使可中断的或不可中断的、基于时限的等待,以及公平的或非公平的队列操作。

与内置条件队列不同的是,对于每个 Lock ,可以有任意数量的 Condition 对象。Condition 对象继承了相关的 Lock 对象的公平性,对于公平的锁,线程会依照 FIFO 顺序从 Condition.await 中释放。

有界缓存的另一种实现,即使用两个 Condition ,分别为 notFull 和 notEmpty ,用于表示 " 非满" 与 " 非空" 两个条件谓词。当缓存为空时,take 将阻塞并等待 notEmpty ,此时 put 向 notEmpty 发送信号,可以解除任何在 take 中阻塞的线程。

程序清单 14-11 使用显示条件队列的有届缓存

public class ConditionBoundedBuffer {

protected final Lock lock = new ReentrantLock();

// 条件谓词:not-full (count < items.length)

private final Condition notFull = lock.newCondition();

// 条件谓词:not-empty (count > 0)

private final Condition notEmpty = lock.newCondition();

@GuardedBy(“lock”)

private final T[] items = (T[]) new Object[100];

@GuardedBy(“lock”)

private int tail, head, count;

// 阻塞并指导not-full

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();

}

}

// 阻塞并直到not-empty

public T take() throws InterruptedException {

lock.lock();

try {

while (count == 0)

notEmpty.await();

T x = items[head];

items[head] = null;

if (++head == items.length)

head = 0;

–count;

notFull.signal();

return x;

} finally {

lock.unlock();

}

}

}

注意:在 Condition 对象中,与 wait 、notify 和 notifyAll 方法对应的分别是 await 、signal 和 signalAll 。但是,Condition 对 Object 进行了扩展,因而它也包含 wait 和 notify 方法。一定要确保使用正确的版本—— await 和 signal 。

ConditionBoundedBuffer的行为与 BoundedBuffer相同,但它对条件队列的使用方式更容易理解 ——在分析使用多个 Condition 的类时,比分析一个使用单一内部队列加多个条件谓词的类简单得多。通过将两个条件谓词分开并放到两个等待线程集中, Condition 使其更容易满足单次通知的需求。 signal 比 signalAll 更高效,它能极大地减少在每次缓存操作中发生的上下文切换与锁请求次数。

与内置锁和条件队列一样,当使用显式的 Lock 和 Condition 时,也必须满足锁、条件谓词和条件变量之间的三元关系。在条件谓词中包含的变量必须由 Lock 来保护,并且在检查条件谓词以及调用 await 和 signal 时,必须持有 Lock 对象。

在显示 condition与内置条件队列进行选择时,与在 ReentrantLock和 synchronized之间选择是一样的:如果需要一些高级功能,例如使用公平的队列操作或者在每个锁上对应多个等待线程集,那么应该优先使用 Condition。但如果需要 ReentrantLock 的高级功能,并且已经使用了它,那么就应该选择 Condition。

最后

这份清华大牛整理的进大厂必备的redis视频、面试题和技术文档

祝大家早日进入大厂,拿到满意的薪资和职级~~~加油!!

感谢大家的支持!!

image.png

Object 进行了扩展,因而它也包含 wait 和 notify 方法。一定要确保使用正确的版本—— await 和 signal 。

ConditionBoundedBuffer的行为与 BoundedBuffer相同,但它对条件队列的使用方式更容易理解 ——在分析使用多个 Condition 的类时,比分析一个使用单一内部队列加多个条件谓词的类简单得多。通过将两个条件谓词分开并放到两个等待线程集中, Condition 使其更容易满足单次通知的需求。 signal 比 signalAll 更高效,它能极大地减少在每次缓存操作中发生的上下文切换与锁请求次数。

与内置锁和条件队列一样,当使用显式的 Lock 和 Condition 时,也必须满足锁、条件谓词和条件变量之间的三元关系。在条件谓词中包含的变量必须由 Lock 来保护,并且在检查条件谓词以及调用 await 和 signal 时,必须持有 Lock 对象。

在显示 condition与内置条件队列进行选择时,与在 ReentrantLock和 synchronized之间选择是一样的:如果需要一些高级功能,例如使用公平的队列操作或者在每个锁上对应多个等待线程集,那么应该优先使用 Condition。但如果需要 ReentrantLock 的高级功能,并且已经使用了它,那么就应该选择 Condition。

最后

这份清华大牛整理的进大厂必备的redis视频、面试题和技术文档

祝大家早日进入大厂,拿到满意的薪资和职级~~~加油!!

感谢大家的支持!!

[外链图片转存中…(img-x3Yt9LLX-1714404805380)]

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

  • 12
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值