java大厂面试题整理(十一)AQS详解
请说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虚拟机 【java大厂面试题整理(十一)AQS详解】
谈谈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,才能获得锁。
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会无限制等下去(不设置时间的.).
- 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全称是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的体系和大致类结构属性。下面一步一步源码解读:
还是从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(抽象类与接口的区别&重载与重写&内存泄漏)
- PMSJ寻平面设计师之现代(Hyundai)
- 杜月笙的口才
- 事件代理
- Java|Java OpenCV图像处理之SIFT角点检测详解
- java中如何实现重建二叉树
- Linux下面如何查看tomcat已经使用多少线程
- 皮夹克
- 数组常用方法一
- 【Hadoop踩雷】Mac下安装Hadoop3以及Java版本问题