1、Java 中的锁的类别划分

Java 中的锁有很多种,在笔者学习的时候经常被这些概念搞混,目前通过参考一些其他资源和以及自己学习,总结了一些锁的种类和分类,供读者有个大致的认知。

image.png

2、Lock 接口的范式以及Synchronized的区别

Lock 接口是Java SE 5.0 提供的接口,用于描述定义Java中的锁。一个锁能够防止多个线程同时访问共享的资源(一些特殊的锁除外,比如读写锁)。在Lock接口出现之前,Java程序一般使用Synchronized关键字实现同步操作,但使用synchronized关键字实现同步操作虽然其具有隐式的获取和释放锁的便捷性,但是一些场景下总是不够灵活,比如获取A对象锁之后获取B对象锁,获取B对象锁成功之后释放A对象的锁。

Lock 与 Synchronized相比具有以下的特点:

  • 非阻塞的获取锁,如果没有获取到则直接立刻返回获取失败,而 Synchronized则会一直阻塞(线程处于BLOCKED状态)
  • Lock锁支持超时获取,如果到达指定时间仍然获取失败,则返回获取失败
  • 在等待获取Lock锁的同时,该线程能够响应中断,中断异常将被抛出,同时释放锁,而Synchronized在等待获取锁的时候不可以响应中断

这种场景下使用Lock接口实现就非常方便了,Lock接口的定义以及使用范式如下:

  1. // JAVA 中锁的定义
  2. public interface Lock {
  3. // 获取锁,若获取失败,则会阻塞状态
  4. void lock();
  5. // 可中断的获取锁,即在获取锁的过程中,可以中断当前线程
  6. void lockInterruptibly() throws InterruptedException;
  7. // 尝试获取锁,获取失败,直接返回false 不阻塞
  8. boolean tryLock();
  9. // 在时间范围内尝试获取锁,若超时,则直接返回false
  10. boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
  11. // 释放锁
  12. void unlock();
  13. // 获取等待通知组件,该组件和当前锁绑定
  14. Condition newCondition();
  15. }
  16. // Lock 使用的范式
  17. public static void main(String[] args){
  18. Lock lock = xxxx;
  19. lock.lock(); // tryLock() 或者 tryLock(long time, TimeUnit unit)
  20. try{
  21. // do something();
  22. }finally{
  23. lock.unlock();
  24. }
  25. }

3、 抽象队列同步器 AbstractQueueSynchronizer

队列同步器AbstractQueueSynchronizer (以下简称AQS)是并发编程大佬 Doug Lea 实现的一个并发编程的基础组件。Java中一些锁是通过AQS实现的,比如 ReentrantLock(可重入锁),CountDownLatch等。Doug Lea 也希望AQS能够成为并发编程的基本。

AQS 使用了int类型的成员变量state表达同步状态,通过内置FIFO的队列来完成资源获取的排队工作。同步器的主要使用是通过继承AQS类,并实现或者重写其方法来实现管理同步状态,在抽象方法实现的过程中,免不了要对int类型的状态进行判断和更新,AQS提供了三个方法 getState()setState(int newState) 以及 compareAndSet(int expect,int update) 来实现获取以及更新状态。基于此,我们们可以定义state的语义,在不同的实现中state 有不同的语义,这里后面的内容会涉及到。根据state不同的语义我们可以实现可重入锁,读写锁以及信号等等。
这里需要明确的是Lock接口面向的使用者,它隐藏了实现细节,而AQS面对的这是同步组件的底层实现,其简化了锁的实现方法,对外屏蔽了同步的状态管理、线程队列等等。锁和AQS很好地隔离了使用者和实现着关注的领域。

  • 这里需要说明的是,Lock接口是面向线程的,线程通过Lock接口提供的方法实现加锁,释放锁等等操作,单不涉及同步队列,队列排队等底层操作。AQS实现了队列同步,队列排队,等待唤醒等底层操作。锁和同步器很好的隔离了使用者和实现者所关注的领域。

3.1 队列同步器的接口与实例

队列同步器是基于模板方法模式的(模板方法模式)也就是说使用者需要继承AQS,并重写指定方法,同步器内部的逻辑会调用这些重写的方法。如上文所说,实现同步器需要使用三个方法来设置state.

  1. getState() 获取当前状态
  2. setState(int newState) 设置当前同步状态
  3. compareAndSetState(int expect,int newState) 使用CAS设置同步状态,可保证状态设置的原子性

同步器可重写的方法如下所示,主要分为有5种方法,三类: 1. 独占式获取与释放 2. 共享式获取与释放 3. 查询状态 ,这种方法在AQS中的实现全部是抛出 UnsupportedOperationException 异常。我们需要在自定的同步组件实现相应的方法。

  1. // 自定义一个同步器
  2. public static class Sync extends AbstractQueuedSynchronizer {
  3. /** 判断当前同步器是否在在独占模式下被占用 */
  4. @Override
  5. protected boolean isHeldExclusively() { }
  6. /** 独占式获取同步状态,需要使用CAS 设置同步状态 */
  7. @Override
  8. protected boolean tryAcquire(int arg) { }
  9. /** 独占式释放同步状态 */
  10. @Override
  11. protected boolean tryRelease(int arg) { }
  12. /** 共享式获取同步状态 */
  13. @Override
  14. protected int tryAcquireShared(int arg) { }
  15. /** 共享式释放同步状态 */
  16. @Override
  17. protected boolean tryReleaseShared(int arg) { }
  18. }

依据此,我们可以实现一个独占锁的锁Mutex, 它只允许同时一个线程占用这个锁,Mutex定义了内部静态类Sync,实现了独占式的获取和释放锁。通过CAS设置state为1 标识获取当前线程获取同步状态成功,标记为0 标识获释放同步状态成功。用于在使用Metux 的同时不需要了解Sync的实现过程,仅需要调用Metux的方法即可,这进步的降低了自定义同步组件的门槛。

  1. public class Mutex implements Lock {
  2. private final Sync sync = new Sync();
  3. // 阻塞性获取锁
  4. @Override
  5. public void lock() {
  6. sync.acquire(1);
  7. }
  8. // 中断锁
  9. @Override
  10. public void lockInterruptibly() throws InterruptedException {
  11. sync.acquireInterruptibly(1);
  12. }
  13. // 非阻塞性获取锁
  14. @Override
  15. public boolean tryLock() {
  16. return sync.tryAcquire(1);
  17. }
  18. // 超时模式下获取锁
  19. @Override
  20. public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
  21. return sync.tryAcquireNanos(1, unit.toNanos(time));
  22. }
  23. // 释放锁
  24. @Override
  25. public void unlock() {
  26. sync.release(1);
  27. }
  28. // Condition
  29. @Override
  30. public Condition newCondition() {
  31. return null;
  32. }
  33. public static class Sync extends AbstractQueuedSynchronizer {
  34. /** 判断当前同步器是否在在独占模式下被占用 */
  35. @Override
  36. protected boolean isHeldExclusively() {
  37. return getState() == 1;
  38. }
  39. /** 独占式获取同步状态,需要使用CAS 设置同步状态 */
  40. @Override
  41. protected boolean tryAcquire(int arg) {
  42. if (compareAndSetState(0, 1)) { // CAS 设置同步状态
  43. Thread currentThread = Thread.currentThread();
  44. setExclusiveOwnerThread(currentThread); // 设置当前获取到同步状态的线程
  45. return true;
  46. }
  47. return false;
  48. }
  49. /** 独占式释放同步状态 */
  50. @Override
  51. protected boolean tryRelease(int arg) {
  52. if (getState() == 0) {
  53. throw new IllegalMonitorStateException("当前状态不允许释放锁");
  54. }
  55. // 设置当前持有线程为NULL
  56. setExclusiveOwnerThread(null);
  57. setState(0);
  58. return true;
  59. }
  60. }
  61. }

3.2 AQS 中的同步队列

在AQS中,主要依赖一个同步队列来实现同步状态的管理。同步状态队列是一个FIFO的双向队列。

  • 每个节点会持有获取同步状态的线程
  • 当线程获取同步状态失败时候,会生成新的Node节点并添加到同步队列的尾部,同时会阻塞当前线程
  • 当同步状态释放时,会把头节点的后继节点的线程唤醒,使其再次尝试获取同步状态

同步队列的基本结构如下:

image.png
图 2.2.1 同步队列的基本结构

在上图中,同步器持有两个节点的引用,分别头结点 Head以及尾结点 Tail 。当一个节点获取到同步状态,其他线程无法获取到同步状态时,转为会创建一个Node加入到同步队列中,而加入同步队列必须保证线程安全,所以同步器提供了一个基于CAS的设置尾结点的方法 compareAndSetTail(Node expect,Node update) 。同步器添加到尾结点的模式图如下:

image.png
图 2.2.2 CAS 模式设置同步队列的尾结点

其代码实现如下

    /**
     * 创建一个Node节点并添加到同步队列的尾部
     * 
     */
    private Node addWaiter(Node mode) {
        // 构建新的Node
        Node node = new Node(Thread.currentThread(), mode);
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            // CAS 同步设置到同步队列的尾部
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }

同步队列遵循FIFO的原则,头结点HeadNode 是成功获取到同步状态的节点,头结点的线程在释放同步状态后后继节点会被唤醒尝试获取同步状态,后继节点成功获取同步状态之后会将自己设置为头节点。

image.png
图 2.2.3 后继节点成为新的头节点