美文网首页Java Concurrencyjava学习之路
java大厂面试题整理(十一)AQS详解

java大厂面试题整理(十一)AQS详解

作者: 唯有努力不欺人丶 | 来源:发表于2021-05-25 21:09 被阅读0次

    请说intern方法。和猜测下面代码的运行结果。

        public static void main(String[] args) {
            String str1 = new StringBuffer("58").append("tongcheng").toString();        
            String str2 = new StringBuffer("ja").append("va").toString();
            System.out.println(str1);
            System.out.println(str1.intern());
            System.out.println(str1 == str1.intern());
            System.out.println(str2);
            System.out.println(str2.intern());
            System.out.println(str2 == str2.intern());
        }
    

    首先这个intern方法是一个native本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用。否则将此String对象包含的字符串添加到常量池中,并返回此String对象的引用。
    而上面的代码其实考点就是这两个等于的结果。答案是第一个是true,第二个是false。
    而且这里只有java会false。因为在jdk中有个类sun.misc.Version.加载这个类的时候会自动把一个launcher_num的静态变量注入。这个变量的值就是java。
    所以说上面代码执行之前,常量池中已经存在java字符串了。所以str2的指向和java字符串的指向是不一样的。所以false。
    这个题是深入理解JVM虚拟机第三版的原题:

    深入理解JVM虚拟机

    谈谈AQS和LockSupport

    这里可以先聊聊可重入锁:可重入锁又叫递归锁。是指在同一个线程的外层方法中获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁的是同一个对象)。不会因为之前已经过去过锁还没释放而阻塞。
    java中ReentrantLock和Synchronized都是可重入锁。可重入锁的一个优点是一定程度避免死锁。

    Synchronized

    下面是一个简单的可重入demo:


    synchronized可重入

    上面代码中m1,m2都是由synchronized锁住的。在进入m1的时候持有锁了,方法里调用m2直接进入m2了。这个时候m1的锁还没释放。因为m2也是这个锁,所以能靠这把未释放的锁进入m2。证明了可重入性。
    同理,其实这里同步代码块更明了:


    同步代码块重入
    没有死锁本身就说明了synchronized的同步块可重入。下面贴上完整demo代码:
    
    public class LockDemo {
        public synchronized void m1() {
            System.out.println("进入到m1方法!"+new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
            m2();
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (Exception e) {         
                e.printStackTrace();
            }       
        }
        public synchronized void m2() {
            System.out.println("进入到m2方法!"+new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (Exception e) {
                e.printStackTrace();
            }       
        }
        public void m3() {
            synchronized (this) {
                System.out.println("进入m3的一层同步块");
                synchronized (this) {
                    System.out.println("进入m3的二层同步块");
                    synchronized (this) {
                        System.out.println("进入m3的三层同步块");
                    }
                }
            }
        }
        public static void main(String[] args) {
            LockDemo lockDemo = new LockDemo();
            lockDemo.m3();
            new Thread(()->lockDemo.m1()).start();
            new Thread(()->lockDemo.m2()).start();
        }
    }
    

    可重入锁实现原理:每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
    当执行monitor-enter的时候,如果该对象计数器为0说明当前锁没有被其他线程锁占有,java虚拟机会将该锁对象的持有线程设置为当前线程。并将计数器+1.
    在目标锁对象不为0的情况下:

    • 如果锁的持有者是当前线程。则锁对象的计数器+1.
    • 如果锁对象不是当前线程则要等待锁对象的计数器归0,才能获得锁。

    当执行monitor-exit的时候,java虚拟机将锁对象的计数器-1.计数器归0代表锁已经被释放。

    ReentrantLock

    测试可重入的demo如下:

    public class LockDemo {
        Lock lock = new ReentrantLock();
        public String getTime() {
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
        }
        public void m1() {
            lock.lock();
            try {           
                System.out.println("进入m1方法"+getTime());
                m2();
                TimeUnit.SECONDS.sleep(5);
            } catch (Exception e) {
            }finally {
                lock.unlock();
            }
        }
        public void m2() {
            lock.lock();
            try {
                System.out.println("进入m2方法"+getTime());
                TimeUnit.SECONDS.sleep(5);
            } catch (Exception e) {
            }finally {
                lock.unlock();
            }
        }
        public static void main(String[] args) {
            LockDemo lockDemo = new LockDemo();
            lockDemo.m1();
    
        }
    }
    

    如上测试,这两个打印语句是同时调用的。说明不用等m1的锁释放,就能进入到m2的方法中,也说明了ReentrantLock的可重入。而lock和synchronized最主要的区别的synchronized是隐式获取锁和释放锁。而reentrantLock的是要手动lock,unlock的。而lock和unlock就是syncronized原理是+1,-1的操作。记住加几次锁就要放几次锁。否则会导致锁无法释放。这个我就不测试了。毕竟是很基础的知识点。
    说完了可重入锁,下面直接说LockSupport.

    LockSupport

    如果觉得这个类很陌生,我们学习的最佳方式就是官网:所以可以去jdk文档中找找看:

    手册上对lockSupport介绍
    一句话总结:是线程等待唤醒机制(wait/notify)的改良加强版.
    LockSupport中park()和unpark()的作用分别是阻塞线程和解除阻塞线程.
    LockSupport方法

    这块其实之前学juc的时候就学到过.下面一张图表示java线程中等待唤醒机制的发展进步:


    等待唤醒机制发展

    我们目前知道的三个版本的等待唤醒:

    • Object的wait/notify
      这种做法的局限性:两者必须在synchronized方法/同步块中执行.wait和notify的顺序必须严格执行.如果先notify再wait,那么会无限制等待下去.
    • Condition的await和signal
      这种做法的局限性:必须和lock/unlock之间使用.同时也必须先await在signal.否则await会无限制等下去(不设置时间的.).

    这两种情况都有两个约束:1.先持有锁.2.先等待再唤醒.

    • LockSupport的park和unpark
      这两个方法底层调用的unsafe类.具体用法如下demo:
    public class LockDemo {
        public static void main(String[] args) {
            Thread a = new Thread(()->{
                System.out.println("进入到a线程");
                LockSupport.park();
                System.out.println("a线程被唤醒");
            },"a");
            a.start();
            new Thread(()->{
                System.out.println("进入到b线程");
                LockSupport.unpark(a);
            },"b") .start();
        }
    }
    

    首先看这个代码很明显:阻塞唤醒不需要先获取锁.
    其次我们可以代码测试一下顺序:

    先唤醒再等待也可以正常运行
    先唤醒后等待也支持.当然了这个要一对一的.就是先unpark一次,就可以解park一次,如果unpark一次,park两次,也还是要等待的:
    park两次卡死
    接下来我们简单看下LockSupport源码:
    LockSupport源码
    归根结底LockSupport是一个线程阻塞工具类.所有的方法都是静态方法.可以让线程在任意位置阻塞.阻塞之后也有对应的唤醒方法.其本质上调用的是Unsafe类的native方法.
    其实现原理是这样的:每个使用它的线程都有一个许可(permit)关联.permit相当于1,0的开关.默认是0.
    调用一次unpark就加1变成1.
    调用一次park就把1变成0.同时park立即返回.
    注意的是只有0,1两种状态.也就是连续调用unpark多次,也只能让许可证变成1.能解一次park而已.
    形象点理解:
    • 线程阻塞需要消耗凭证permit.这个凭证最多只有一个.
    • 调用park时:
      • 如果有凭证.则消耗这个凭证并且正常退出
      • 如果没凭证,则要等待有凭证才可以退出
    • 调用unpark时,会增加一个凭证,但是凭证的上限是1.

    AQS详解

    AQS全称是AbstractQueuedSynchronizer。中文翻译过来其实就是三个单词:抽象的,队列,同步器。
    AQS是用来构建锁或者其他同步器组件的重量级基础框架以及整个JUC体系的基石。通过内置的先进先出(first in first out,简称FIFO)队列来完成资源获取线程的排队工作。并通过一个int类型变量表示持有锁的状态。
    为什么说AQS是juc体系的基石呢?
    简单来说,ReentrantLock,CountDownLatch,ReentrantReadWriteLock,Semaphore这些类,都用到了抢锁放锁等。这些都用到了AQS,如下源码截图:

    这四个类都用到了AQS

    锁和同步器的关系?
    锁-面向锁的使用者。定义了使用层的api,隐藏了实现细节,调用即可。
    同步器-面向锁的实现者。提出了统一规范并简化了锁的实现。屏蔽了同步状态管理,阻塞线程排队和通知,唤醒机制等。

    有阻塞就需要排队,而实现排队必然需要有某种形式的队列来进行管理。
    一堆线程抢一个锁的时候,抢到资源的线程处理业务逻辑,抢不到的排队。但是等待线程仍然保留着获取锁的可能并且获取锁的流程还在继续。这就是排队等候机制。
    如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现。将暂时获取不到锁的线程加入到队列中,这个队列就是AQS的抽象表现。
    它将请求共享资源的线程封装成队列的节点。通过CAS,自旋以及LockSupport.park()的方式,维护state变量的状态,使并发达到同步的控制效果。
    AQS底层使用了一个volatile的int类型的成员变量来表示同步状态。通过内置的FIFO队列来完成资源获取的排队工作。将每条要抢占资源的线程封装成一个Node结点来实现锁的分配。通过CAS完成对state值的修改。

    AQS源码截图
    Node节点中Thread类型
    由上面两个代码说明了AQS的等待队列的数据类型是Node,而Node中装的是Thread。
    AQS类中有个volatile修饰的state变量。当state是0的时候说明没线程占有资源。大于等于1的时候说明有线程占有资源。再有后来的线程是要排队的。我们继续看AQS是源码会发现虽然方法很多,看似挺复杂的。但是AQS本身最外层的属性就是一个state变量和一个clh变种的双端队列。如下截图:
    AQS源码-外层属性

    而我们之所以说Node是双端队列也很容易看出来,我们可以看Node的源码:

    Node源码中有头指针和尾指针的指向
    Node其实可以看成一个单独的类。虽然是内部的。然后其属性也都是很有用的。除了上面简单说的头尾节点,还有别的,首先作为双向链表,上一个下一个元素的指针必有的,其次首尾节点上面就说了,都是链表的基本知识,就不说了。还有一个属性比较有用:waitStatus:表示的是排队的每一个节点的状态。
    • 0是初始化Node的时候的默认值。
    • 1 表示线程获取锁的请求已经取消了
    • -2 表示节点在等待队列中,等着唤醒
    • -3 当前线程处于shared情况下该字段才会使用
    • -1表示线程已经准备好就等着资源释放了


      源码中状态注释

    AQS源码解读

    现在为止简单的理解了下AQS的体系和大致类结构属性。下面一步一步源码解读:
    还是从ReentrantLock说起,Lock接口的实现类,基本都是通过聚合了一个队列同步器的子类完成线程访问控制的。
    这句话我们看着代码更好理解:

    ReentrantLock的lock方法
    sync的本质
    上面两段代码就可以看出来:lock.lock本质上是在lock类中聚合一个AQS的实现类,然后调用lock和unlock都是调用这个AQS的实现类的方法来实现(unlock是调用sysn.release(1);)的。

    然后注意,我们知道ReentrantLock默认是非公平锁的,可以创建的时候传参设置为公平锁,那么这个公平还是非公平对于AQS的实现类有什么区别呢?


    AQS源码-公平锁与非公平锁

    讲真,这个就好像是在套娃。根据传参的不同去实现不同的配置的Sync类型,Sync类型又是AQS的实现类。其实这些只要看代码虽然不一定能理解人家为什么这么写,但是还挺好看懂的。这两种实现有什么不同呢?继续在源码中找答案:


    两种方式尝试获取锁方法

    因为显示原因就这么看,明显是两个方法,我们去对比这两个方法的区别:


    两个方法就一个区别
    很容易能看出来两个方法只有一个区别:公平锁多了一个判断,方法如下:
    image.png

    很明显这个方法是判断当前队列是不是有元素。如果这个锁的等待队列中已经有了线程,则方法放回true,在尝试获取锁的时候条件是非true也就是false。因为这里用的是&&,一个false则全部false,所以不往下走了,直接返回false,当前线程要进入等待队列去排队。
    而非公平锁则不用进行这个判断,直接尝试获取锁。获取到了就true,获取不到走false。
    下面我们用debug的方式一步一步走一下代码:
    从lock开始:


    reentrantLock.lock()
    默认非公平锁
    cas比较state
    也就是如果当前是0.state是0说明当前资源没人占用,这个时候设置值为1并且返回true,调用设置当前线程为资源拥有者:
    设置当前线程为资源拥有者
    而如果当前资源有线程占有了,则cas返回false,所以走else分支,调用acquire方法:
    acquire方法
    继续往下走这个方法:
    tryAcquire方法

    这个方法直接看是就抛了个异常,我们可以点进实现类里看,因为我们最开始就是非公平锁,所以看NonfairSync的实现:


    tryAcquire的实现
    其实代码逻辑也挺简单的,先看看资源状态是不是0,是0则试图用cas抢资源。不是0判断线程是不是当前资源持有线程,是的话返回true(这里证明了锁的可重入)。不是返回false。
    现在如果资源被占用且不是当前线程占用的,这个方法肯定是返回false,这个时候继续往下走代码:因为在acquire方法tryAcquire是取反的,返回false,!false是true,则继续下一个判断:
    进入队列
    这个方法也分两步:一个是acquireQueued方法,一个是addWaiter方法。addWaiter其实很明显,因为acquireQueued方法的两个参数第一个是Node,第二个是int。而addWaiter方法的结果作为第一个参数。所以我们可以合理的猜测addWaiter方法是将当前线程转化为Node方法。猜测完毕下面我们用代码去确定:
    acquireQueued方法
    addWaiter方法
    addWaiter中的参数是一个null
    分析代码,addWaiter第一行代码就是创建了一个Node对象,并且把当前线程当参数构建的Node。然后把这个Node对象挂到双端队列上去。挂的逻辑是pre指向之前的末尾元素。而tail指向的是当前元素。因为是双向队列,所以之前的最后一个节点的下一个指向新添加的。就是一个很简单的逻辑。然后这里有两个分支:一个是添加到队列,还有一个是单独的enq(node)方法,区别是如果队列存在则添加。如果队列不存在则先创建再添加。
    然后重点就是入队的方法:
    阻塞队列
    这个方法的重点其实是要看下红框里的两个方法:前面的比较眼熟了应该,还是尝试获取锁。问题是获取不到锁会走下面的两个方法:
    第一个方法
    这个比较容易理解,就是判断当前这个线程还是不是在等着呢。其实重点在第二个方法:
    阻塞当前线程
    这个线程想抢锁,但是没抢到,所以阻塞了。(注意之前的方法是自旋的。也就是这个线程被唤醒以后还会重复这个方法的操作)。
    至于什么时候会被唤醒。其实猜也能猜到,肯定是获取这个资源的线程释放锁以后唤醒所有队列中的线程。我们也可以顺着代码去找一下唤醒的步骤:
    unlock方法调用的也是AQS是方法
    tryRelease方法实现
    解锁时候state从1-1变成0
    而且这个方法中如果是正常情况下,state会变成0.并且这里还有个判断:
    if (Thread.currentThread() != getExclusiveOwnerThread())
                    throw new IllegalMonitorStateException();
    

    如果当前线程未持有锁则不可以释放。所以说如果没有lock先unlock会报错。就是这句代码起的作用。
    继续往下说这个tryRelease方法如果c等于0说明锁彻底被释放,会返回true,然后代码往下走,走到了另一个方法:

    unparkSuccessor
    这里的两点就是unpark。之前所有等待的线程都被park了,现在在解锁以后,这个unpark就把之前挂起来的等待线程叫醒了。
    并且注意入队我们知道了,但是出队的过程是在一个很意想不到的地方:也就是挂起线程的哪个自旋结束后。因为自旋中有两个分支:一个是抢占成功了还有一个是抢占失败了,而如果抢占成功了的话会直接将当前线程出队的。
    出队流程
    至此,所有的逻辑都串起来了。AQS的大部分逻辑都是这样的。中间可能有一些方法略过了或者没说,但是总体流程就是这样。
    非公平锁是每次tryAcquire时如果当前资源处于0,没有被占有的状态,每个线程都有机会去获取锁,而公平锁在tryAcquire中哪怕资源没被占有,也只有队首的元素有资格去获取锁。
    本篇笔记就记到这里,如果稍微帮到你了记得点个喜欢点个关注,也祝大家工作顺顺利利,生活健健康康!其实偶尔我会觉得看源码是种很有意思的事,去看人家的逻辑,流程走向,代码的书写等。我记得都说看一本好书开拓视野,回味无穷。其实一个好的源码也可以如此。愿我们在求索的路上一往无前吧!

    相关文章

      网友评论

        本文标题:java大厂面试题整理(十一)AQS详解

        本文链接:https://www.haomeiwen.com/subject/hsmyjltx.html