面试必问的AQS,你真的会了么?(Java Lock实现篇-1)

最近又再研究了一下AQS,颇有所感,做个简单的介绍,望对各位求知若渴的同僚有所帮助

JDK版本:1.8.0_171

AQS是嘛呀?

  • AbstractQueuedSynchronizer
  • JUC里的同步并发工具,基本上JUC所有的并发工具类(CountDownLautch、Semaphore、CyclicBarrier)都依据了它来进行实现

怎么来真正理解AQS呢?

  • 客官别急,我们慢慢来.
  • 先自己设计一个简单的独占锁方案实现


    简易锁实现 (1).png
实现思路(叫兽勉为其难的牺牲一下吧)
  1. 有个叫红本本的东西可以来记录下叫兽的♀
  2. 根据相关政策规定,红本本上只能有一个人噢(独占性质)
  3. 如果红本本上已经有人了,只有红本本上的那位才能强行占有叫兽,其他的小伙伴只能去排队等待机会了(队列排队)
  4. 物是人非事事休,欲语泪先流,叫兽的那个ta竟然出柜了!(粉丝团 : 你还有我们呢!),排队的小伙伴里面的头号粉丝同学叫兽是你的人了!(唤醒队列的头号玩家)
我们再初略的看下AQS的整体结构
image.png
  • 先简略有个印象,然后稍微介绍一下里面的几个点,重点关注我标红的几个字段.
    1. exclusiveOwnerThread
      • 用来记录当前获得独占锁的线程
    2. head、tail
      • 记录我们同步队列的头结点、尾节点
    3. state
      • 状态值标记,不同的同步工具类对它有不同的用法(CountDownLautch,Lock等)
    4. Node
      • 封装的队列节点元素
      • prev
        • 记录当前节点的上一个节点
      • next
        • 记录当前节点的下一个节点
      • thread
        • 记录当前节点的线程
用AQS的里面的字段来实现一波看看(版本1)
基于AQS的简易实现独占锁-版本1.png
  • 嗯,基于上面的设计,貌似实现了简单的独占锁,而且还支持重入(重入指当前线程可以多次获取锁)
  • 不过细心的小伙伴有注意到,state字段貌似没见你用呀?
    • 实际上ReentrantLock中是通过state字段来达到可重入的效果的.
    • state > 0 代表已有线程抢占了锁,加锁了N次,则state = N
来我们再来一版加入state的新版本吧(版本2)
基于AQS的简易实现独占锁-版本2 (3).png
  • 可以看到我们根据state来判断当前锁是否被抢占
  • 而释放锁的条件变了,只有当state变为0时才会释放锁并通知等待队列中的头结点去尝试抢占锁.
我们将借助Java并发包下的ReentrantLock进行分析
image.png
  • 可以看到ReentrantLock(可重入锁)内部有一个Sync静态内部类就是继承自我们的AQS
  • 同样我们可以看到在底层还有一个FairSync(公平锁)、NonfairSync(非公平锁),这两种类型又是继承自内部的Sync对象,公平和非公平我们先暂且不谈(后续会分析)
    /**
     * 可以看到默认构造是非公平的实现
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }
下面将真正开始秋名漂移时刻!
  • 关键方法
    • lock
    • unlock
   public void lock() {
     // 哦豁,这里的lock实际上就是调用的我们初始化的时候NonfairSync了   
     sync.lock();
    }

接着往下瞅瞅看

    /**
     * 非公平锁实现
     */
    static final class NonfairSync extends Sync {

        final void lock() {
            // 这里的CAS操作相当于两个操作组合 
            // 1. 当前的state = 0 ?
            // 2.等于0的情况将state设置为1
            if (compareAndSetState(0, 1))
                //登记当前线程
                setExclusiveOwnerThread(Thread.currentThread());
            else
                //尝试失败了,说明之前state的值不是0,有人先抢到锁了
                acquire(1);
        }
    }

细心的小伙伴发现了,这个实现思路不就是和我们上面的设计如出一辙?
那么我们可以大胆的猜测,是不是else里的acquire(1)将会出现我们设计里的排队操作呢?
咋们拭目以待:

    public final void acquire(int arg) {
      // tryAcquire顾名思义,就是再试试能抢得到锁不
      // addWaiter其实就是往队列里加入新的节点
      if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

接下来摆在我们面前的有三个点

  • tryAcquire
  • addWaiter
  • acquireQueued

先来看下tryAcquire

       //回到了我们的NonfailSync类中
        protected final boolean tryAcquire(int acquires) {
              //下面这个操作实际是定义在Sync抽象类中的
              return nonfairTryAcquire(acquires);
        }

----------------------华丽的分割线(下面部分是Sync类中)-----------------------
 
       final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            //c==0意味着锁被空出来了
            if (c == 0) {
                //又尝试去抢锁(下面的操作是否似曾相识?)
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            //state!=0,判断当前线程是不是就是抢到锁的那个
            else if (current == getExclusiveOwnerThread()) { 
                //哦豁,是自己本身就把state值增加
                int nextc = c + acquires;
                //这里是考虑到int类型溢出了,变成了负数
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            //尝试抢锁失败
            return false;
        }

我们看上面这部分代码是否感到似曾相识的感觉呢?
可以看到tryAcquire其实就是一次尝试抢锁的过程.

接下来再看看addWaiter是干嘛呢
    private Node addWaiter(Node mode) {
        //新创建了个节点,mode其实就是标明节点的模式,我们当前是Node.EXCLUSIV模式
        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;
            }
        }
        //尾部节点为空(说明队列还没初始化呢),以及CAS设置尾部失败了(存在并发竞争)会走enq的入队操作
        enq(node);
        return node;
    }

    private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // 尾部节点为空时会初始化一个头节点,这个头节点是一个dummy节点
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {//自旋循环第二次的时候,因为第一次设置了头尾节点,会走到else逻辑
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

可以看到addWaiter其实就是将我们的线程封装成了Node节点,并且加入到了链表队列的尾部.

接下来看看 acquireQueued,这个就要画重点了!
    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            //中断标志
            boolean interrupted = false;
            //自旋尝试获取锁
            for (;;) {
                //取得当前节点的上一个节点
                final Node p = node.predecessor();
                //上一个节点是头节点并且尝试获取锁成功;tryAcquire上面有分析过是尝试获取锁的过程
                if (p == head && tryAcquire(arg)) {
                    //抢到锁了会把头节点替换成自己本身
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                // 没抢到锁的线程在自旋里会在这一步阻塞挂起
                // 如果队列里的头个等待节点后面被唤醒了,它又会再次自旋去尝试抢锁
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            //异常情况下,会将node节点置位取消状态
            if (failed)
                cancelAcquire(node);
        }
    }

初步看下来,可以看到acquireQueued是队列中的等待节点自旋尝试获取锁的一个过程,中间会存在线程挂起阻塞

shouldParkAfterFailedAcquire 这个方法的逻辑需要一点前置知识的铺垫.

  • 我们再来回忆下之前的结构图


    image.png
  • 上面可以看到Node里有一个 waitStatus的状态字段,这个字段非常重要,我们马上就可以看到它的出场
  • waitStatus 一共有五种值,默认为0,其他四种值如下
    1. CANCELLED = 1; (取消状态)
    2. SIGNAL = -1; (这个状态表示该节点下个节点是需要unpark的)
    3. CONDITION = -2; (这个状态用在等待队列中)
    4. PROPAGATE = -3; (这个状态意味着节点是带传播属性的)
  • CONDITION、PROPAGATE两种状态分别是等待队列CondtionObject中、共享模式的锁实现中会使用到,本次暂不做过多分析.
接下来完成我们上面未完成的工作,shouldParkAfterFailedAcquire
    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        //获取当前的节点Node的上一节点pred的状态值
        int ws = pred.waitStatus;
        //哦豁,如果上一节点status已经是SIGNAL
        //即表示当前节点是可以支持做unpark操作的,所有可以放心挂起线程了,返回true
        if (ws == Node.SIGNAL)
            return true;
        
        // 什么情况下ws会大于0?
        // 回忆了一下,CANCELLED,只有这个状态值为1是大于0的,说明节点被取消了
        if (ws > 0) {
            //这个循环操作其实就是过滤掉被取消的节点,直到找到一个正常节点
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {//如果是正常节点的话,就把上一节点打上SIGNAL标记
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

有了上面的状态值分析后,再来看该方法,是不是清晰了许多
可以看到shouldParkAfterFailedAcquire其实就是检查当前即将要挂起的节点的上一节点确保上一节点的状态值是SIGNAL状态

此時再回到之前的這個if判斷
    //shouldParkAfterFailedAcquire 确保SIGNAL状态
    if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
          interrupted = true;

    private final boolean parkAndCheckInterrupt() {
           //只是一个工具类,将当前线程进行挂起
           LockSupport.park(this);
            //检查了下线程是否发生过中断,并中断复位
           return Thread.interrupted();
      }

可以看到之前的这一个if条件其实就是首先确保当前线程的节点上一节点的状态值为SIGNAL,然后将线程进行挂起操作.

看到这里,我们的一个加锁操作的分析就已经结束了,为大伙儿鼓个掌!
下回会进行锁释放的一个分析,有兴趣的小伙伴可以提前自己了解下锁释放的实现!

我们最后再来总结一下,先上个图


AQS基本图1.png
  1. state用来表明有锁、无锁状态
    • state = 0 ,无锁状态
    • state > 0, 表明加锁次数
  2. exclusiveOwnerThread来记录获取到锁的线程,主要是为了支持可重入.
  3. 未获取到锁的线程封装成Node节点组成双向链表排队等待
  4. Node节点有一个waitStatus状态值,默认为0,线程Node节点挂起前会将其上一个节点状态设置为SIGNAL
彩蛋

之前有提到过公平锁、非公平锁,默认的可重入锁是非公平的,那么不公平体现在哪呢?

  • 非公平锁
    • 每次lock会自己先尝试下能否抢到锁,抢不到才会加入同步队列挂起
    • 这对已经在队列等着抢锁的小伙伴无疑是不公平的,总有人想先插个队试试
  • 公平锁
    • 即便现在是无锁状态,这位公平的小青年也会看下同步队列里是否已经有人在等着,有的话自己乖乖去排队.
      有兴趣的小伙伴可以自己研读下公平锁的实现方式!
尾声

好了!就先到这吧!
后续会持续更新相关的锁释放环节、Condition条件等待队列、CountDownLautch、Semaphore、CyclicBarrier、Future、ArrayBlockingQueue、线程池的原理等,有兴趣的同学可以敬请期待!

作者: 沃尔特叫兽

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,287评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,346评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,277评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,132评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,147评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,106评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,019评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,862评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,301评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,521评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,682评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,405评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,996评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,651评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,803评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,674评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,563评论 2 352

推荐阅读更多精彩内容