参考链接:
深入理解synchronized底层原理,一篇文章就够了!
Synchronized详解(可重入、Monitor原理等)
Java中的偏向锁,轻量级锁, 重量级锁解析
浅谈Java里的三种锁:偏向锁、轻量级锁和重量级锁
偏向锁
阿里面试:跟我死磕Synchronized底层实现,我满分回答拿了Offer
什么是锁?
我把它理解为,在多线程环境下保证数据安全的一种方案。
为什么不是只能由一个线程访问,因为乐观锁的存在,它是允许线程同时访问的。
实现有很多种,比如java的sync(原谅我一直这么念这个单词),比如乐观锁CAS,比如Lock。
那么,我们先从关键字synv开始,了解一下sync的原理和特性。
Synchronized 原理
首先,sync作为java中的关键字。它是基于JVM层面实现的一种机制。
那么它是怎么实现的呢?
我们先从对象在内存中的形态开始:
内存中的对象
【对象头】
- Mark Word标记字段:默认储存
HashCode
,分代年龄和锁信息
,GC标志
等信息。 - Class Metadata Address类型指针:指向它的类元数据,将
对象和类映射
。
【实例数据】数据
,父类信息。
【对齐填充】虚拟机要求对象起始地址是8的整倍数,用来自动填充
。
由上可知,在每个对象中,都有一个记录锁信息的位置。
记录在每个对象的对象头
中的MarkWord
的锁信息
位置中。
那么,记录锁信息的位置记录些什么呢?
锁状态:
无锁状态——>偏向锁——>轻量级锁——>重量级锁
注意:(此部分是自己理解,存疑)
没锁也是一种锁状态,因为sync是基于JVM的,即使一个对象没有锁,也会有锁状态,只不过锁状态是无锁
;
并且:
因为锁升级问题,锁状态是可变的。不是固定的。
有锁状态 <—关联—> monitor:
当我们代码中使用sync关键字后,就会关联一个monitor。
对象头都关联着monitor,每一个监视器和一个对象引用相关联。
在虚拟机中,monitor是由ObjectMonitor实现的。
参考代码:
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0; //线程重入次数
_object = NULL; // 存储Monitor对象
_owner = NULL; //_owner指向持有ObjectMonitor对象的线程
_WaitSet = NULL; //被wait()的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //单向列表
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
通过上图,我们梳理一下获得锁的过程:
当多个线程同时访问一段同步代码时:
1
.当一个线程进入方法时,得嘞,您先执行monitorenter。进入_EntryList 集合。
1
.如果_Owner 区域不为null,说明有锁,线程就在_EntryList 中阻塞着。
【旁边:这里的线程要么获得锁,要么继续阻塞,不可以中断】
2
.轮到这个线程时,进入_Owner 区域并把monitor中的owner变量设置为当前线程。
2
.同时,monitor中的计数器count加1。
3
.若此线程调用wait()
,线程释放_Owner,并进入WaitSe t集合中等待被 唤醒。
3
.此时由于_Owner中没有线程,owner变量恢复为null,count自减1。
【旁白:wait()
把监视器释放了,其他线程可以进入。而sleep()
只是单纯的休眠,并不会释放锁,它会继续持有锁对象。】
4
.直到有线程调用notify/notifyAll
方法后方能继续执行,重新获得锁对象继续跑。
5
.执行monitorexit,并释放锁。owner变量恢复为null,count自减1。
锁升级、锁膨胀
刚刚我们大概模拟了一下线程获得锁到释放锁的基本流程。
现在我们回到上文的锁状态:(对象头中MorkWord标记的)
无锁状态——>偏向锁——>轻量级锁——>重量级锁
注意,这里的四种状态我用了四个箭头表示,意在表达锁的状态的转化。
为什么锁状态要转化? 思考几个问题:
假如我这个锁每次只有同一个线程访问的情况下,我们可以让获得锁的效率变高吗?
假如只有多个线程在获取这个锁呢,你获得锁的时候我已经执行完了,多其乐融融不是吗?这里可以让效率变高吗
OK,这些问题我们不用写。因为JDK也想到了。给它升级了,优化了。
现在我们看看JDK对sync的优化——锁升级:
当有一个线程初次获取锁时:
偏向锁:
【注意:其他博文这里写的都是统一锁,我理解为了同一锁。存疑】
在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,而线程的阻塞和唤醒需要CPU从用户态
转为核心态
,频繁的阻塞
和唤醒
对CPU来说是一件负担很重的工作,为了让线程获得锁的代驾更低而引入了偏向锁。
当有一个线程初次获取锁时,锁进入偏向模式。
此时MarkWord
修改为 1+锁ID
当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程ID等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。
当多个线程和谐的获取锁时:
轻量级锁:
轻量级锁是由偏向锁升级而来,当存在多个个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。
注意,这里说的是申请锁,也就是不存在竞争关系。什么意思呢?
前一个线程执行完跑了,第二个线程申请执行。这是申请。
前一个线程正在执行呢,第二个线程也要抢,还要等你执行完,这是竞争。
当多个线程竞争一个耗时很短的锁时:
自旋锁:
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。(减少内核态用户态切换)
通过让线程执行循环
等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起。
自适应自旋锁:
这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。
如果自旋失败,则挂起。
当多个线程竞争一个锁时:
重量级锁:
重量级锁,也就是我们常说的sync,在上文中锁的获得流程中。描述的就是这种重量级锁的 复杂的 获得锁的过程。
锁消除
了解了上面的锁膨胀后,我们可以思考还有没有其他优化锁的方案呢?有
比如:当一些代码加了锁和不加锁没区别时。锁就会被消除。
public void method1(){
Cat cat =new Cat();
sychhronized(cat){
//同步代码
}
}//本方法中,锁的是cat对象,者是一个局部变量,不存在竞争关系。
//对cat加锁后,跑完cat也被释放了,monitor随着对象的释放而释放。
public void method2(){
Cat cat=new Cat();
//同步代码
}
在JIT编译时,会对运行上下文进行扫描,去除不可能存在竞争的锁。
锁粗化
锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁
和释放锁
。
public void method1(){
for(int i=0; i<10000;++i){
synchronized(this){
//同步代码
}
}
}
//为了避免反复加锁,和释放锁。直接粗化。因此上下两个方法效率一样。
public void method1(){
synchronized(this){
for(int i=0; i<10000;++i){
//同步代码
}
}
}