致命的なJavaコンカレントプログラミング(6):AQSが詳細に説明しました。今回は、Javaコンカレントパッケージのロックの原理を完全に理解するため、インタビューごとに繰り返す必要はありません。

インタビュー中にJavaのロックについてよく聞かれますか?インターネット上のブログ投稿から関連する概念や知識を見たことがあるかもしれませんが、深く理解していない場合は、この知識を整理して独自の知識脳マップを作成できます。すぐに忘れてしまいます。その結果、すべての面接を最初から見直す必要があり、時間と手間がかかります。

今日は、Java並行性のロックについて学び始めました。主な目的は、Java並行性パッケージのロックに関連するAPIとコンポーネントを整理することです。目標は、その使用方法と実装方法を知ることです。それが何で何故なのかを知ることによってのみ、それを正しく使用し、インタビューに対処することができます。

2

読者の負担を軽減するために、この記事では主にAQS AbstractQueuedSynchronizerについて説明します。つまりAQS がロック音声を実装する方法を確認します。

ロックインターフェース

ロックと言えば、間違いなく同期キーワードを思い浮かべますが、jdk1.5以前のロック機能を実現するためにJavaプログラムで使用されていました。jdk1.5以降、ロックインターフェイスをコンカレントパッケージに追加してロック機能を実装しています。その機能は同期化と似ていますが、ロックを取得および解放するには表示する必要があります。

次のデモに示すように、Lockの使用も非常に簡単です。

Lock lock = new ReentrantLock();
lock.lock();
try {
} finally {
    lock.unlock();
}

ここでは、Lockインターフェースによって提供される同期されたものにはない主な機能を説明する必要があります。

  • ロックの試行取得:現在のスレッドがロックを取得しようとしますが、他のスレッドが現在のロックを取得していない場合は、結果が取得されて保持されます。

  • 割り込み可能なロックの取得:同期とは異なり、ロックを取得したスレッドは割り込みに応答できます。ロックを取得したスレッドが他のスレッドに割り込まれると、割り込み例外がスローされ、同時にロックが解除されます。

  • 時間をかけてロック取得する:指定した時間より前にロックを取得し、タイムアウトを取得できない場合は戻ります。

ロックは、ロックの取得と解放の基本操作を定義するインターフェースです。

APIの意味を上から下に説明します。

  1. ロックを取得します。現在のスレッドがロックを取得すると、このメソッドから戻り、ロックを取得している間、メソッドはブロックします。

  2. 和lock() 的区别在于该方法会响应中断;

  3. 非阻塞尝试获取锁,方法立即返回,获取到返回true,否则返回false;

  4. 超时的获取锁,当超时、中断、获取未超时获取到了锁这三种场景都会返回;

  5. 释放锁,唤醒后继节点;

  6. 获取等待通知组件,该组件与当前锁绑定,必须获取到锁才能调用该组件的 wait 方法;

队列同步器(AQS)

队列同步器 AbstractQueuedSynchronizer 这个可以说是Java并发包中构建锁和各种安全容器实现的基石,比如 ReentrantLock、ReadWriteLock、CountDownLatch 等的实现中都少了AQS的身影。

AQS本身使用了一个int成员变量来表示同步状态,通过内置的FIFO队列,来完成资源获取线程的排队工作。 java并发包的作者 Doug Lea 大神在设计的时候就希望它成为实现大部分同步需求的基础。

同步器是实现锁的关键,当然也可以是任意的同步组件,在锁的实现中聚合同步器,利用同步器实现锁的语义。两者的关系可以理解为,锁是面向程序员的即Lock接口中定义的API,它定义了程序员使用的交互接口,隐藏了实现细节。而同步器则是面向锁的实现者的,它简化了锁的实现方式,屏蔽了同步状态管理、线程排队、等待与通知等底层实现细节。 这个设计是非常牛的,很好的隔离了使用者和实现者所需要关注的领域。

AQS的使用示例

同步器AQS的设计时基于模板方法的,即使用者需要继承同步器并重写指定的方法,然后将同步器组合在自定义同步组件中,并调用同步器提供的模板方法,这些模板方法会调用使用者的重写方法。

同步器为了让使用者重写指定的方法,提供了三个基础方法:

  1. getState(),获取当前同步状态

  2. setState(int newState):设置当前同步状态

  3. compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态 设置的原子性

同步可重写的方法分为独占式获取锁和共享式获取锁,这里为了不给读者增加负担,只列出独占式获取锁的可重写方法。下面列出简化的源码

protected boolean tryAcquire(long arg) {    
    throw new UnsupportedOperationException();
}
protected boolean tryRelease(long arg) {
    throw new UnsupportedOperationException();
}
protected boolean isHeldExclusively() {
    throw new UnsupportedOperationException();
}

可以看到这些需要重写的方法都是没有具体实现的,所以在使用的时候需要我们去实现。

上面列出需要需要自定义同步组件实现的方法,接下来我们看看同步器提供了哪些模板方法,由于篇幅原因,为了不给读者的阅读带来压力,所以只列出几个核心的方法,具体的大家可以看到JDK源码中 AbstractQueuedSynchronizer 的具体实现。

独占式获取锁

可响应中断的获取锁

释放锁

总的来说,同步器提供的模板方法基本上分为3类:独占式获取与释放同步状态、共享式获取与释放同步状态和查询同步队列中的等待线程情况。自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。

说了这么多,接下来,我们自己实现一个独占锁,采用组合自定义同步器AQS的方式,帮助大家掌握同步器的工作原理,只有搞懂了AQS才能更加深入的去学习理解 并发包中的其它同步组件。

示例如下如下:

如示例代码所示,大家可以看到实现一个简单的独占锁利用AQS是非常容易的。Mutex中定义了一个静态内部类,它继承了同步器实现了独占式获取和释放同步状态。

tryAcquire(int acquires) 方法中,如果经过CAS设置成功(同步状态设置为1),则代表获 取了同步状态,而在 tryRelease(int releases) 方法中只是将同步状态重置为0。用户使用Mutex时并不会直接和内部同步器的实现打交道,而是调用Mutex提供的方法,在Mutex的实现中,以获 取锁的 lock() 方法为例,只需要在方法实现中调用同步器的模板方法acquire(int args) 即可,这样大大简化了实现一个可靠自定义同步组件的门槛。

AQS实现源码分析

AQS结构

先来看下AQS中都有哪些属性,看了这个你基本就知道AQS实现锁的套路了。

看了之后你会发现很简单吧,就只有三个核心属性。

シンクロナイザは、内部同期キューを使用して同期状態の管理を完了します。プロセスは次のとおりです。スレッドが同期状態の取得に失敗すると、シンクロナイザは現在のスレッドと待機状態をノード(ノード)に構築し、同時にキューに追加します。現在のスレッドをブロックします。同期状態が解放されると、最初のノードのスレッドが呼び起こされ、ロック同期状態を再度取得しようとします。

同期キューのノードは、参照、待機状態、および同期状態の取得に失敗したスレッドの先行ノードと後続ノードを保存するために使用されます。コードを見てみましょう。

Nodeのデータ構造は実際には複雑ではなく、thread + waitStatus + pre + next + nextWaiter5つの属性にすぎないため、最初にこの概念を念頭に置く必要があります。

ノードは同期キューの基礎です。シンクロナイザには最初と最後のノードがあります。同期を取得できなかったスレッドはノードになり、キューの最後に加わります。同期キューの基本構造は次のとおりです。

上記の紹介を通して、AQSがロックを取得および解放する方法を知りたいと思うかもしれません。心配しないで、原則を学んでください。遅いのが速いです!

その後、特定の実装コードに従ってください。

ロックを取得

上記のように、取得ロックは排他型と共有型に分かれています。読みやすくするために、ここでは排他型取得ロックのみを見ていきます。排他型取得ロックモードをマスターしていると思いますので、共有取得には問題ありません。の。

上記のコードはほとんどなく、ロジックは比較的明確です。まず、tryAcquire(arg)メソッドが呼び出されます前述のように、このメソッドは、上記で実装したMutexロックなどの同期コンポーネント自体によって実装する必要があります。このメソッドは、同期状態のスレッドセーフな取得を保証します。tryAcquire(arg)は、取得が成功して正常に終了することを示すためにtrueを返します。そうでない場合は、同期ノード(排他的なNode.EXCLUSIVE)を構築し、addWaiter(Node mode)メソッドによってキュー同期の末尾に追加されacquireQueued(final Node node, int arg)ます。これは、「死サイクル」アプローチによって同期ステータスを取得するための最後の呼び出しです。利用できない場合、ノード内の対応するスレッドがブロックされ、ブロックされた後の覚醒は、前駆ノードをデキューするか、ブロックされたスレッドを中断することによってのみ達成できます。

ノードの構造を見て、同期キューに追加します。

上記のコードでは、compareAndSetTail(pred、node)メソッドを使用して、構築されたノードを同期キューの最後に追加するときに、ノードをスレッドセーフに追加できるようにしています。

下面我们来看下当上面的快速加入同步队列末尾不满足条件时(即上面代码中显示的队列为空或者有多线程并发入队),走到了 enq(node) 方法,即采用自旋的方式入队。

具体就不啰嗦了,上述代码已经写得很清晰了,就是 enq(final Node node) 方法,在死循环中通过CAS将节点设置为尾结点之后,线程才从该方法返回。否则当前线程不断的尝试。 可以看到这个方法使用场景本来不是线程安全的,因为同时可能有很多 调用 tryAcquire 方法获取同步状态失败的线程要进行入队操作。此处巧妙的用自旋加CAS将并发请求变得 “串行化了”。

经过上述方法,节点就进入了同步队列中后,就进入到了一个下一个自旋的过程,每个节点(即获取锁失败的线程)都在自省的观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则就苦逼的滞留在这个自旋过程中,并且阻塞节点线程。具体代码如下:

如上,假如当前node本来就不是队头或者就是 tryAcquire(arg) 没有抢赢别人,就是走到下一个分支判断:shouldParkAfterFailedAcquire(p, node) 当前线程没有抢到锁,是否需要挂起当前线程

上面的代码你一定要自己的理解,如果思路断了希望从上面在顺一遍,以免浪费时间。

这里我们分析下private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) 这个方法返回值的情况:

  1. 如果返回true, 说明前驱节点的 waitStatus==-1,是正常情况,那么当前线程需要被挂起,等待以后被唤醒,就等着前驱节点拿到锁,然后释放锁的时候叫你好了;

  2. 如果返回false, 说明当前不需要被挂起,为什么呢?往后看

shouldParkAfterFailedAcquire(Node pred, Node node) 这个方法返回后,是true 则执行 parkAndCheckInterrupt() 方法:

接下来说说如果 shouldParkAfterFailedAcquire(p, node) 返回false的情况:仔细看shouldParkAfterFailedAcquire(p, node),我们可以发现,其实第一次进来的时候,一般都不会返回true的,原因很简单,前驱节点的 waitStatus=-1 是依赖于后继节点设置的。也就是说,我都还没给前驱设置-1呢,怎么可能是true呢,但是要看到,这个方法是套在循环里的,所以第二次进来的时候状态就是-1了。

如果看到这里思路还是比较清晰的话,那么这里我们再来解释下为什么shouldParkAfterFailedAcquire(p, node) 返回false的时候不直接挂起线程?

这是因为经过这个方法后,当前节点的前一个节点有可能因为超时或者中断而取消阻塞退出同步队列因此设置了新的父节点,这个父节点有可能就已经是head了,这里有没有恍然大悟的感觉。。。

说到这里也就明白了 AQS同步器获取锁的过程,还是希望你能多看几遍 acquireQueued(final Node node, int arg) 方法。 代码不多,花时间推演下各个分支进入的原因,这个时间是值得投入的。

释放锁

当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态。通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。

唤醒的代码还是比较简单的,你如果上面加锁的都看懂了,下面都不需要看就知道怎么回事了!

唤醒线程以后,被唤醒的线程将从以下代码中继续往前走:

好了,能看完到这里的你肯定已经对于AQS同步器独占式获取锁和解锁流程有了一定的了解,这篇文章就不继续怼源码了。 相信你看懂了上面的,如果还有问题或者想看下非独占式获取释放锁流程,自己去老老实实仔细看看代码吧。

总结

总结一下吧。

Java并发包中提供了锁的另一种实现Lock接口,它定义了锁的获取和释放基本操作。

队列同步器 AbstractQueuedSynchronizer 这个可以说是Java并发包中构建锁和各种安全容器实现的基石,比如 ReentrantLock、ReadWriteLock、CountDownLatch 等的实现中都少了AQS的身影。

在并发环境下,并发包中提供了实现了Lock接口的各种锁,他们依赖AQS同步器完成加锁解锁操作。而AQS的实现主要需要下面三个组件协调:

  1. 锁状态。我们要知道锁是不是被别的线程占有了,这个就是 state 的作用,它为 0 的时候代表没有线程占有锁,可以去争抢这个锁,用 CAS 将 state 设为 1,如果 CAS 成功,说明抢到了锁,这样其他线程就抢不到了,如果锁重入的话,state进行 +1 就可以,解锁就是减 1,直到 state 又变为 0,代表释放锁,所以 lock() 和 unlock() 必须要配对啊。然后唤醒同步队列中的第一个线程,让其来占有锁。

  2. 线程的阻塞和解除阻塞。AQS 中采用了 LockSupport.park(thread) 来挂起线程,用 unpark 来唤醒线程。

  3. 阻塞队列。因为争抢锁的线程可能很多,但是只能有一个线程拿到锁,其他的线程都必须等待,这个时候就需要一个 queue 来管理这些线程,AQS 用的是一个 FIFO 的队列,就是一个链表,每个 node 都持有后继节点的引用。

示例图

这幅图用来回顾下获取锁的流程,如果看完还是有点蒙圈的话,这里还有一次机会帮你梳理思路,结合这幅图仔细思考,脑中有这个思路流程,再去看一遍源码。

(本文完)


参考资料

  1. 周志明:《深入理解Java虚拟机》

  2. 方腾飞:《Java并发编程的艺术》

おすすめ

転載: blog.csdn.net/taurus_7c/article/details/105760231