文档章节

扒一扒ReentrantLock以及AQS实现原理

Float_Luuu
 Float_Luuu
发布于 2016/04/03 11:45
字数 2661
阅读 5152
收藏 23

摘要

提到JAVA加锁,我们通常会想到synchronized关键字或者是Java Concurrent Util(后面简称JCU)包下面的Lock,今天就来扒一扒Lock是如何实现的,比如我们可以先提出一些问题:当我们通实例化一个ReentrantLock并且调用它的lock或unlock的时候,这其中发生了什么?如果多个线程同时对同一个锁实例进行lock或unlcok操作,这其中又发生了什么?


什么是可重入锁?

ReentrantLock是可重入锁,什么是可重入锁呢?可重入锁就是当前持有该锁的线程能够多次获取该锁,无需等待。可重入锁是如何实现的呢?这要从ReentrantLock的一个内部类Sync的父类说起,Sync的父类是AbstractQueuedSynchronizer(后面简称AQS)。


什么是AQS?

AQS是JDK1.5提供的一个基于FIFO等待队列实现的一个用于实现同步器的基础框架,这个基础框架的重要性可以这么说,JCU包里面几乎所有的有关锁、多线程并发以及线程同步器等重要组件的实现都是基于AQS这个框架。AQS的核心思想是基于volatile int state这样的一个属性同时配合Unsafe工具对其原子性的操作来实现对当前锁的状态进行修改。当state的值为0的时候,标识改Lock不被任何线程所占有。


ReentrantLock锁的架构

ReentrantLoc的架构相对简单,主要包括一个Sync的内部抽象类以及Sync抽象类的两个实现类。上面已经说过了Sync继承自AQS,他们的结构示意图如下:

上图除了AQS之外,我把AQS的父类AbstractOwnableSynchronizer(后面简称AOS)也画了进来,可以稍微提一下,AOS主要提供一个exclusiveOwnerThread属性,用于关联当前持有该所的线程。另外、Sync的两个实现类分别是NonfairSync和FairSync,由名字大概可以猜到,一个是用于实现公平锁、一个是用于实现非公平锁。那么Sync为什么要被设计成内部类呢?我们可以看看AQS主要提供了哪些protect的方法用于修改state的状态,我们发现Sync被设计成为安全的外部不可访问的内部类。ReentrantLock中所有涉及对AQS的访问都要经过Sync,其实,Sync被设计成为内部类主要是为了安全性考虑,这也是作者在AQS的comments上强调的一点。


AQS的等待队列

作为AQS的核心实现的一部分,举个例子来描述一下这个队列长什么样子,我们假设目前有三个线程Thread1、Thread2、Thread3同时去竞争锁,如果结果是Thread1获取了锁,Thread2和Thread3进入了等待队列,那么他们的样子如下:

AQS的等待队列基于一个双向链表实现的,HEAD节点不关联线程,后面两个节点分别关联Thread2和Thread3,他们将会按照先后顺序被串联在这个队列上。这个时候如果后面再有线程进来的话将会被当做队列的TAIL。

1)入队列

我们来看看,当这三个线程同时去竞争锁的时候发生了什么?

代码:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

解读:

三个线程同时进来,他们会首先会通过CAS去修改state的状态,如果修改成功,那么竞争成功,因此这个时候三个线程只有一个CAS成功,其他两个线程失败,也就是tryAcquire返回false。

接下来,addWaiter会把将当前线程关联的EXCLUSIVE类型的节点入队列:

代码:

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

解读:

如果队尾节点不为null,则说明队列中已经有线程在等待了,那么直接入队尾。对于我们举的例子,这边的逻辑应该是走enq,也就是开始队尾是null,其实这个时候整个队列都是null的。

代码:

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

解读:

如果Thread2和Thread3同时进入了enq,同时t==null,则进行CAS操作对队列进行初始化,这个时候只有一个线程能够成功,然后他们继续进入循环,第二次都进入了else代码块,这个时候又要进行CAS操作,将自己放在队尾,因此这个时候又是只有一个线程成功,我们假设是Thread2成功,哈哈,Thread2开心的返回了,Thread3失落的再进行下一次的循环,最终入队列成功,返回自己。

2)并发问题

基于上面两段代码,他们是如何实现不进行加锁,当有多个线程,或者说很多很多的线程同时执行的时候,怎么能保证最终他们都能够乖乖的入队列而不会出现并发问题的呢?这也是这部分代码的经典之处,多线程竞争,热点、单点在队列尾部,多个线程都通过【CAS+死循环】这个free-lock黄金搭档来对队列进行修改,每次能够保证只有一个成功,如果失败下次重试,如果是N个线程,那么每个线程最多loop N次,最终都能够成功。

3)挂起等待线程

上面只是addWaiter的实现部分,那么节点入队列之后会继续发生什么呢?那就要看看acquireQueued是怎么实现的了,为保证文章整洁,代码我就不贴了,同志们自行查阅,我们还是以上面的例子来看看,Thread2和Thread3已经被放入队列了,进入acquireQueued之后:

  1. 对于Thread2来说,它的prev指向HEAD,因此会首先再尝试获取锁一次,如果失败,则会将HEAD的waitStatus值为SIGNAL,下次循环的时候再去尝试获取锁,如果还是失败,且这个时候prev节点的waitStatus已经是SIGNAL,则这个时候线程会被通过LockSupport挂起。

  2. 对于Thread3来说,它的prev指向Thread2,因此直接看看Thread2对应的节点的waitStatus是否为SIGNAL,如果不是则将它设置为SIGNAL,再给自己一次去看看自己有没有资格获取锁,如果Thread2还是挡在前面,且它的waitStatus是SIGNAL,则将自己挂起。

如果Thread1死死的握住锁不放,那么Thread2和Thread3现在的状态就是挂起状态啦,而且HEAD,以及Thread的waitStatus都是SIGNAL,尽管他们在整个过程中曾经数次去尝试获取锁,但是都失败了,失败了不能死循环呀,所以就被挂起了。当前状态如下:


锁释放-等待线程唤起

我们来看看当Thread1这个时候终于做完了事情,调用了unlock准备释放锁,这个时候发生了什么。

代码:

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

解读:

首先,Thread1会修改AQS的state状态,加入之前是1,则变为0,注意这个时候对于非公平锁来说是个很好的插入机会,举个例子,如果锁是公平锁,这个时候来了Thread4,那么这个锁将会被Thread4抢去。。。

我们继续走常规路线来分析,当Thread1修改完状态了,判断队列是否为null,以及队头的waitStatus是否为0,如果waitStatus为0,说明队列无等待线程,按照我们的例子来说,队头的waitStatus为SIGNAL=-1,因此这个时候要通知队列的等待线程,可以来拿锁啦,这也是unparkSuccessor做的事情,unparkSuccessor主要做三件事情:

  1. 将队头的waitStatus设置为0.

  2. 通过从队列尾部向队列头部移动,找到最后一个waitStatus<=0的那个节点,也就是离队头最近的没有被cancelled的那个节点,队头这个时候指向这个节点。

  3. 将这个节点唤醒,其实这个时候Thread1已经出队列了。

还记得线程在哪里挂起的么,上面说过了,在acquireQueued里面,我没有贴代码,自己去看哦。这里我们也大概能理解AQS的这个队列为什么叫FIFO队列了,因此每次唤醒仅仅唤醒队头等待线程,让队头等待线程先出。


羊群效应

这里说一下羊群效应,当有多个线程去竞争同一个锁的时候,假设锁被某个线程占用,那么如果有成千上万个线程在等待锁,有一种做法是同时唤醒这成千上万个线程去去竞争锁,这个时候就发生了羊群效应,海量的竞争必然造成资源的剧增和浪费,因此终究只能有一个线程竞争成功,其他线程还是要老老实实的回去等待。AQS的FIFO的等待队列给解决在锁竞争方面的羊群效应问题提供了一个思路:保持一个FIFO队列,队列每个节点只关心其前一个节点的状态,线程唤醒也只唤醒队头等待线程。其实这个思路已经被应用到了分布式锁的实践中,见:Zookeeper分布式锁的改进实现方案。


总结

这篇文章粗略的介绍一下ReentrantLock以及锁实现基础框架AQS的实现原理,大致上通过举了个三个线程竞争锁的例子,从lock、unlock过程发生了什么这个问题,深入了解AQS基于状态的标识以及FIFO等待队列方面的工作原理,最后扩展介绍了一下羊群效应问题,博主才疏学浅,还请多多指教。

© 著作权归作者所有

Float_Luuu
粉丝 220
博文 47
码字总数 104674
作品 0
长宁
高级程序员
私信 提问
加载中

评论(11)

何轩
何轩
图片看不了
a
antony橙子
感谢博主的文章,文中应该有一处是笔误了,“如果是非公平锁,这个时候来了Thread4,那么这个锁将会被Thread4抢去”
望尘
望尘

引用来自“MrHandsomeLI”的评论

我想问一个问题,既然ReentrantLock是基于AQS来实现锁的,由于AQS是FIFO的队列,当一个线程释放锁后,只会去唤醒它的后继结点去竞争锁,那ReentrantLock的非公平锁是怎么实现的呢,队列后面的锁根本参与不了竞争啊?
非公平锁是lock时就直接尝试获取锁,获取不到再入队
公平锁是先判断是否有队列里是否有前任节点,没有才尝试获取锁
w
wannshan
你说的羊群效应,有人叫惊群。
w
web工作者
线程放入等待队列后,如果是公平锁的话,队列中的线程也是按照fifo方式获取锁的?那跟非公平锁有什么区别?
努力的程又青
你好,我想问一下,unparkSuccessor主要做三件事情:其一为什么要把head的waitStaus设置为0?另一个是你说“”将这个节点唤醒,其实这个时候Thread1已经出队列了“”,我的问题是Thread1本身就不在队列中的啊何来出队列,你指的是Thread2吗?
Float_Luuu
Float_Luuu 博主

引用来自“MrHandsomeLI”的评论

我想问一个问题,既然ReentrantLock是基于AQS来实现锁的,由于AQS是FIFO的队列,当一个线程释放锁后,只会去唤醒它的后继结点去竞争锁,那ReentrantLock的非公平锁是怎么实现的呢,队列后面的锁根本参与不了竞争啊?

当锁被释放的时候会唤醒队列线程去竞争锁 这个时候如果有新线程进来 且非公平锁的话 会去直接竞争锁,公平锁在这个点上有点区别 它会直接进入队列
M
MrHandsomeLI
我想问一个问题,既然ReentrantLock是基于AQS来实现锁的,由于AQS是FIFO的队列,当一个线程释放锁后,只会去唤醒它的后继结点去竞争锁,那ReentrantLock的非公平锁是怎么实现的呢,队列后面的锁根本参与不了竞争啊?
Seven-2012-7-11
Seven-2012-7-11
学习了,还要再看一遍
Float_Luuu
Float_Luuu 博主

引用来自“原来如此”的评论

非常棒,每篇都是经典,学到了很多

谢谢支持 13
源码|并发一枝花之ReentrantLock与AQS(2):lockInterruptibly

上次分析了ReentrantLock#lock()与ReentrantLock#unlock()的实现原理,并初步讨论了AQS的等待队列模型,参考源码|并发一枝花之ReentrantLock与AQS(1):lock、unlock。 本文继续分析Reentra...

猴子007
2017/12/25
0
0
阿里P7架构师带你深入分析AQS实现原理

内容导航 ReentrantLock重入锁的使用引入AQS 什么是AQS AQS的源码分析 简单解释一下J.U.C,是JDK中提供的并发工具包, java.util.concurrent。里面提供了很多并发编程中很常用的实用工具类,比...

Java-飞鱼
06/12
65
0
Java 重入锁 ReentrantLock 原理分析

1.简介 可重入锁自 JDK 1.5 被引入,功能上与关键字类似。所谓的可重入是指,线程可对同一把锁进行重复加锁,而不会被阻塞住,这样可避免死锁的产生。ReentrantLock 的主要功能和 synchroniz...

Java-老刘
2018/05/08
71
0
透过ReentrantLock窥探AQS

背景 JDK1.5引入的并发包提供了一系列支持中等并发的类,这些组件是一系列的同步器,几乎任一同步器都可以实现其他形式的同步器,例如,可以用可重入锁实现信号量或者用信号量实现可重入锁。...

01/29
0
0
ReentrantLock与公平锁、非公平锁实现

前言 最近开始读JDK源码,所有心得准备总结成一个专栏,JDK Analysis系列的第一篇,就从万众瞩目的ReentrantLock开始吧,而谈到ReentrantLock,就不得不说AQS,它是AbstractQueuedSynchroni...

rickiyeat
2017/10/22
0
0

没有更多内容

加载失败,请刷新页面

加载更多

IT兄弟连 HTML5教程 HTML5表单 小结及习题

小结 HTML表单提交的方法有get方法和post方法,get方法的作用是从指定的资源请求数据,post方法的作用是向指定的资源提交要被处理的数据。HTML表单一直都是Web的核心技术之一,有了它我们才能...

老码农的一亩三分地
23分钟前
13
0
向maven工程中导入自己封装好的jar包方法

1.打开cmd窗口 输入并执行:mvn install:install-file -DgroupId=com.test   -DartifactId=ptest -Dversion=0.1  -Dfile=E:\test\test-0.1.0.jar    -Dpackaging=jar注:Dgr......

gantaos
25分钟前
3
0
【jQuery基础学习】09 jQuery与前端(这章很水)

本文转载于:专业的前端网站➨【jQuery基础学习】09 jQuery与前端(这章很水) 这章主要是将如何将jQuery应用到网站中,或者说其实就是一些前端知识,对于我这种后端程序来说其实还是蛮有用的...

前端老手
37分钟前
11
0
深度科技与金山云完成兼容互认证 共同促进我国软件生态发展

近日,深度科技与金山云完成兼容互认证工作,经双方共同严格测试,深度操作系统ARM服务器版软件V15与金山云分布式数据库软件DragonBase V1.0相互兼容、稳定运行,可以为企业级应用提供全面保...

后浪涛涛
37分钟前
7
0
Less导入选项

Less 提供了CSS @import CSS规则的几个扩展,以提供更多的灵活性来处理外部文件。 语法: @import (keyword) "filename"; 以下是导入指令的相关详情: reference,使用较少的文件但不输出。 ...

凌兮洛
53分钟前
15
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部