欢迎分享,原创不易,请带上作者信息,谢谢
掘金栏目地址: https://juejin.cn/collection/6845244260057366535
哇咔咔,接着上回的继续来聊,接下来咱们聊聊你非常熟悉(基本不用)的东西,Synchronized。
Synchronized 是什么
来咱们先聊聊 Synchronized 是什么,你用过吗?
Synchronized 用过的,简单的说是用来控制线程同步的。
为了解决多线程下线程对资源的读写发生混乱,例如不同线程读写发生不一致的情况,所以需要给这些资源加上锁,保证资源读写的原子性。
Synchronized 大致可以分为 2 类,一种是锁住对象,一种是锁住类:
- 1.锁对象:
- 同步代码块,Synchronized(o)
- 同步方法,相当于 Synchronized(this)
- 2.锁类:
- Synchronized(Object.class)
- 同步静态方法,相当于 Synchronized(Class)
最好不要不要锁基础类型以及其包装类
那 Synchronized 可以用来锁 Integer 类型吗?
最好不要这样使用。
因为 JDK1.5 之后 Integer 范围在[-128-127]之间的数据,会直接使用 IntegerCache 缓存起来,而不会产生新的对象,这样其他人如果要使用这个数字的时候需要等待你释放锁,可能降低效率,也可能和你预期的结果不一样。 同样的 Long、String 也是一样的道理,Long 也有缓存,String 是直接存在常量池中的,实际使用的时候最好别用。
Synchronized 锁升级
你前面说了 Synchronized 可以锁对象,锁类,确定他一定会发生锁这个操作吗?
要看怎么理解“锁”这个概念,如果说发生在操作系统级别的话,Synchronized 不是直接进行锁定的,他有一个锁升级的过程。 简单的说就是四个阶段:普通对象(匿名偏向)、偏向锁、轻量锁、重量锁。
锁升级过程
你能详细的说一下他们的锁升级过程吗?
首先在未开启偏向锁的时候,刚刚创建出来的对象是普通对象。
如果进入被 Synchronized 修饰的代码中,对象会变成偏向锁。
当发生线程竞争的时候,偏向锁升级成为轻量锁。
当竞争加剧,出现十次自旋或者竞争线程数超过二分之一 CPU 核数时,轻量锁升级成为重量锁。
当然如果在 JVM 已经开启偏向锁的情况下,创建出来的对象是匿名偏向对象。
JVM 默认会设置一个偏向锁延迟开启,默认延迟 4 秒,通过-XX:BiasedLockingStartupDelay=0 这个参数来调节。
这样吧,我画一个锁升级的流程图:
markword
那你刚刚那张流程图,那些 CAS,自旋之类的操作具体是指什么?
这个就需要看一下对象头信息(markword)的结构了,因为 JVM 是通过更新 markword 信息实现 Synchronized 的。 我来画一张图,看下对象头包含了哪些东西。
1.markword 三位控制锁状态
简单的来说对象头中,最后三位用来控制锁的状态,其中 1 位用来表示是否偏向锁,2 位用来表示是什么状态的锁,当发生锁升级的时候会更新对象头信息。
2.一般来说进入同步区域就是偏向锁
我们结合上面那个流程图,当普通对象进入同步区的时候(未调用 hashCode),对象头会保存一个当前线程的地址指针(相当于打了个标记,这条线程正在使用这个锁对象),并且在当前线程的 LR 栈中压入一条记录,此时进入偏向锁状态。
3.二次进入同步区域,原线程重入使用,发生竞争升级轻量锁
第二次有线程进入此同步区域的时候,取出 markword 中的线程指针和当前线程对比,如果是同一个,重入次数+1,继续往 LR 栈中压入一条 LR 记录(退出的时候弹出,数量为 0 时说明完全释放了锁),执行同步区域内的代码。
如果第二次 markword 中的线程指针和当前线程地址不同,那么进行偏向锁撤销并升级为轻量锁。 使用 CAS 方式将 markword 中的头 62 位更新为指向当前线程中的 LR 地址,谁更新成功了,谁就获得了锁,其他的线程则采用自旋的方式继续尝试获取锁。
4.竞争激烈升级为重量级锁
如果自旋的次数超过 10 次(JDK1.6 可以开启自适应自旋,统计代码的执行过程,自动调整自旋频次)或者竞争的线程数超过了 1/2CPU 核数,则升级为重量级锁,向操作系统申请。
当然如果调用了 wait 方法,会直接升级为重量级锁发生阻塞。
5.调用 hashCode 方法的对象进入同步区域直接升级成为轻量锁
前面说了,是未调用 hashCode 方法的对象会升级为偏向锁, 如果计算过对象的 hashCode,则对象无法进入偏向状态!
此处的 hashCode 是指没有被覆盖实现的 hashCode 方法,产生的 hashCode 是系统唯一编号 identityHashCode,同个对象每次调用一定是相同的,自己覆盖实现的 hashCode 不在此列。 原因是偏向锁的 markword 需要存储,线程指针以及 Epoch,hashcode 就没地方放了。
轻量级锁 hashCode 存在线程栈的 LR 中,不需要保存在对象头内,所以调用过 hashCode 方法会直接升级成为轻量锁。
重量级锁的 hashCode 存在 ObjectMonitor 的成员中
总结一下重点:
- 1.偏向锁、轻量锁都会生成 LockRecord
- 2.轻量锁是 CAS+自旋的过程实现的
- 3.自旋超过 10 次、二分之一 CPU 核数或者调用了 wait 方法升级为重量级锁
- 4.偏向、轻量锁的重入会创建新的 LR 记录并压入 LR 栈内,重量级锁的重入应该是记录在 ObjectMonitor 中
底层实现
既然你说了这么多,那你知道 Synchronized 底层到底怎么实现的吗?
这个要看是从哪个层面说了。
- 代码层面自然不用说,使用 Synchronized 关键字。
- JAVA 字节码层面的话,同步方法用的是 monitor_acc 修饰的方法定义,同步代码块使用 monitor_enter,monitor_exit 两个指令实现。
- JVM 层面就是之前说的那个锁升级过程,当中使用了 Atomic::cmpxchg_ptr 之类的 CAS 操作调用了一些操作系统的指令 LOCK_IF_MP。
- 操作系统层面是调用了汇编指令 lock cmpxchg,通过 lock 保证后面的命令(cmpxchg)只能一个线程执行。
- 硬件层面,是锁住了一个北桥的信号量。
其他关键点
小伙子有两把刷子阿,到了这里我觉得这个小伙子 Synchronized 掌握的还不错,所以我觉得再考验他一下。
锁消除
你知道锁消除吗?
说白了就是,绝对不需要用锁的情况,就把他消除掉。
锁消除是 JVM 在即时编译器在运行期间,检测到当前锁只会在线程安全的情况下执行,JVM 会自动消除该代码的锁。
定义一个 StringBuffer 为局部变量,不能被其他线程引用,JVM 会自动消除 Stringbuffer 内部的锁。
public void add(String str1,String str2){
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
锁粗化
那锁粗化听过吗?
说白了就是,连续多次使用同个对象锁,直接扩大范围,加一次锁就够了。
JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。
public String test(String str){
int i = 0;
StringBuffer sb = new StringBuffer():
while(i < 100){
sb.append(str);
i++;
}
return sb.toString():
}
Epoch
你刚刚说偏向锁 markword 中有一个 Epoch,这个用来干嘛的?
面试者皱着眉头,我看得出他很不舒服,估计是佩服我能问出如此刁钻的问题吧。
简单的说,Epoch 是用来定义当前偏向锁属于哪一代的,发生批量重偏向的时候 class 中的 epoch + 1,淘汰老一代的 epoch。
额,还有更加清楚的说法吗?
偏向锁撤销
这个就要从偏向锁撤销开始聊了。
一条线程,对一个 class 创建了大量的对象来作为偏向锁,当他退出同步代码块或者升级为轻量锁时,需要对这一大堆对象进行撤销,这个时候非常的浪费资源。
能想到解决办法有 2 个,1 是不撤销,2 是直接使用轻量锁。
这两个办法对应了 2 个概念,1 批量重偏向,2 批量撤销:
- 批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,升级为轻量锁,这样会导致大量的偏向锁撤销操作。
- 批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。
批量重偏向
class 中维护了一个统计撤销次数的属性(不是 epoch),每次对象发生一次锁撤销,统计数量累加 1。 当这个累计数量达到了 20 的时候,说明这个锁有大量的撤销对象,需要进行批量重偏向。
class 中也有一个 epoch,假设此时为 0,发生批量重偏向的时候,将 class 中的 epoch 加 1,此时 markword 中的 epoch 仍然为 0,不需要再对锁进行撤销了。
下一次有线程需要获取偏向锁,只要对比当前对象的 epoch 是否等于 class 中的 epoch,如果不相等,说明 class 升级了一代,旧一代被淘汰了,可以直接更新 markword 中的线程指针为当前线程的指针,从而获得锁。(节约了大量锁撤销的资源)
备注:当有新的对象产生时,epoch(代数)会与 class 中的 epoch 保持一致。
但是此时该 class 还有一些正在使用中的对象,所以 JVM 会遍历每一个线程栈,找到该 class 的实例,将实例中 markword 的 epoch 也加 1。
批量撤销(不可偏向)
当达到重偏向阈值后,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认 40),JVM 就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向,之后,对于该 class 的锁,直接走轻量级锁的逻辑。
CAS
前面你说了其中使用了 CAS,说一下你对 CAS 怎么理解的。
CAS 英文全称是 Compare And Swap (Compare And Exchange)
简单的说是比较然后交换。
cas 一般有三个参数 cas(v,a,b)
- v:需要更改的变量
- a:期望值(读取的旧值)
- b:当前值(当前内存中的值)
读取 a,计算之后得到 v, 再把内存中的 a 更新成为 v
先拿 a 去与内存中的值(b)比较,如果相等说明,a 没有被其他线程更新过,可以更新为 v。
如果 a 与 b 不等,则说明内存中的值已经发生变化,更新失败,重新拿到内存中的 b 再更新。
## ABA 问题 CAS 会发生什么问题吗?比如 ABA 问题了解过吗?
ABA 是指,中间某个线程,把 a 的值改变了,另外一个线程又把他改回成为 a 了,这个时候当前线程去比较 a 与 内存中的值的时候,也是相等的,可以更新成功。
在 integer 之类的基础类型,其实不需要关心,更新值成功就行了。
某些场景下,需要解决 ABA 问题,加上版本号就行了。(不要比较值,比较版本号)
##CAS 底层实现 前面也提到过 CAS 底层实现
- java 源码层 使用了 Unsafe 类中的 compareAndSwap 方法
- JVM 层 使用了 LOCK_IF_MP,多线程下加锁
- 操作系统汇编层: lock cmpxchg
- 硬件层面: 锁北桥信号
- 原创不易,英雄点个赞再走。
- 可以关注微信公众号:面试官收题
- 领取一些我收集的 JAVA 学习和面试资料
- 有兴趣讨论问题也可以加我微信:gk6688225