概述
synchronized能够实现线程同步。无论怎么使用,最终都是对对象加锁。
- 锁class对象、静态方法 都是锁类对象
- 锁普通对象、普通方法,都是锁实例对象
为什么synchronized最终都是作用在对象上呢? 因为对象在堆中除了除了有字段属性外,还有固定的对象头,通过对象头最终可以得知这个对象是否被加过锁,以及持有锁的线程是谁。(第2节 对象结构)
仔细研究对象头后,发现其中记录了多种锁的状态。锁的升级与其息息相关。(第3节 锁优化策略)
许多线程长时间阻塞,说明锁已经升级到了重量级锁,JVM使用Monitor处理阻塞线程,如wait、notify等。(第1节 Monitor阻塞机制)
AQS也是参照jvm底层Monitor处理方式实现的,但是性能上更优一些。(第4节 与AQS的对比)
一、理解
synchronized是非公平、可重入、独占锁
wait和notify使用时线程必须持有锁
1. synchronized对MESA管程模型的实现
底层逻辑:
- cxq、EntryList都是先进后出队列FILO
- 争抢锁失败的线程会进入cxq
- 获取锁的线程调用wait后进入waitSet
- waitSet中被notify唤醒的线程会进入cxq
- 持有锁的线程释放锁后
- 唤醒EntryList最后入队的线程
- 如果EntryList没有节点,则会将cxq的节点移动过来,再唤醒最后入队的线程
下面是代码证明。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
/**
* <p> Description:Application</p>
* <p> CreationTime: 2022/1/20 10:39
*
* @author dreambyday
* @since 1.0
*/
public class Application {
public synchronized static void show(int seconds) {
try {
TimeUnit.MILLISECONDS.sleep(seconds);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName());
}
public static void testSync() throws InterruptedException {
// 0->200ms 1获取锁 234blocked -> 234在cxq
new Thread(()->Application.show(200),"1").start();
// 保证1线程先获取锁
TimeUnit.MILLISECONDS.sleep(10);
// 200->300ms 1释放锁 234从cxq进入EntryList,先进后出,4获取锁,23继续blocked
new Thread(()->Application.show(400),"2").start();
new Thread(()->Application.show(400),"3").start();
new Thread(()->Application.show(400),"4").start();
// 300->(200+400)ms
// 4继续获取锁,23blocked,处于EntryList。
// 567线程启动,获取锁失败,进入cxq
TimeUnit.MILLISECONDS.sleep(300);
new Thread(()->Application.show(400),"5").start();
new Thread(()->Application.show(400),"6").start();
new Thread(()->Application.show(400),"7").start();
// EntryList先被唤醒,EntryList空了后,将cxq整体移动到EntryList继续唤醒
// 200->(200+400*3)ms 处于EntryList的线程依次被唤醒,顺序为432
// (200+400*3)ms 时,EntryList为空,cxq移动到EntryList,内容为567
// (200+400*3)ms到结束,处于EntryList的线程依次被唤醒,顺序为765
}
public static void main(String[] args) throws InterruptedException {
// 最终顺序为1432765
testSync();
}
}
2. 为什么用cxq和EntryList两个队列存放线程
为了降低cxq尾部并发竞争
假如只有cxq先进后出队列,队列尾部面临的操作有
- 增加新的竞争锁失败的线程
- 尾部线程被唤醒
- 增加从waitSet中被notify的线程
拆分两个队列后,唤醒操作发生在EntryList上
二、对象结构
new Object()在内存中的字节数为16 = 8(Mark Word)+4(开启压缩指针的klass pointer) + 0(不是数组,不占字节) + 0(对象体,无属性,不占字节) + 4(对齐填充,保证整体是8的倍数)
1. Mark Word
占1个字宽大小(32位机则为32位长,64位机为64位长)。是实现轻量级锁和偏向锁的关键。
包括: hashcode、分代年龄、是否偏向、锁的标志、GC标记、指向monitor的指针、指向持有锁线程的lockRecord的指针、偏向锁线程ID、epoch(第几代偏向锁)
Mark Word存在5种状态。如下图。
图片来源
轻量级锁的MarkWord指向栈中lockRecord的指针
虚拟机栈保存lockRecord: 记录了持有锁的线程、无锁状态下的Mark Word信息(为了备份还原,以及记录hashcode等信息)
锁重入情况: 栈新压入Mark Word记录为null的lockRecord,释放一次锁就从栈弹出一次lockRecord
重量级锁的MarkWord指向堆中Monitor的指针
monitor对象和java对象同生共死,存储在堆中。重量级锁状态下,monitor同样保存了无锁状态的MarkWord信息。
2. Klass Pointer
- 32位机则为32位长,64位机默认指针压缩开启,也占32位。不开启则64位。
- 指向元空间(jdk8)或方法区(jdk7)中的类的元数据,用于标识当前实例对象属于哪个类
为什么指向元数据而不是类对象,我只能靠一个例子理解:如果锁的对象是类对象,那么klass pointer相当于自己指向自己,会令人奇怪吧
3. 数组长度(可选)
32位机和64位机都是32位长,因此数组长度理论上不能超过 2 32 2^{32} 232 。实际上,根据JVM对对象头的处理,目前数组的最大长度是 2 31 − 1 2^{31}-1 231−1 ,即Integer最大值
三、锁优化策略
锁级别: 无锁->偏向锁->轻量级锁->重量级锁
锁只能升级不能降级。
1. 偏向锁
偏向锁是加锁操作的优化手段。为了消除数据无竞争下的锁重入开销引入偏向锁。在无锁竞争场合,偏向锁性能较好。
偏向锁使用了一种等到竞争出现才释放锁的机制,消除偏向锁的开销比较大
自JDK6,JVM默认启动偏向锁模式,但是会在虚拟机启动后延迟4s左右才会开启
在启动偏向锁后创建的对象,对象头的Mark Word的锁状态默认变成偏向锁状态,并且Thread ID为0 。此时处于 可偏向但未偏向任何线程 ,也叫匿名偏向状态
为什么要有延迟偏向: 虚拟机启动过程中,许多后台线程可能会争抢锁,导致对象头的锁状态从偏向锁撤销,再升级到 轻量级锁或重量级锁。
- 无锁时在MarkWord存储hashcode。
- 偏向锁无位置存储hashcode。
- 轻量级锁在栈的锁记录中记录hashcode
- 重量级锁在Monitor中记录hashcode
对象可偏向或已偏向时,调用hashcode会使对象无法偏向。
- 可偏向时,调hashcode,偏向锁撤销,并只能升级为轻量级锁
- 已偏向时,调hashcode,偏向锁撤销并升级为重量级锁
几种情况:
- 创建对象默认无锁->synchronized加锁->无锁变轻量级锁
- 创建对象默认偏向锁->调用hashcode->撤销偏向锁变为无锁->synchronized加锁->无锁变轻量级锁
- 创建对象默认偏向锁->synchronized加锁->调用hashcode->偏向锁撤销变重量级锁
偏向锁状态,执行
- notify会升级为轻量级锁
- wait会升级为重量级锁
2. 轻量级锁
轻量级锁适用于锁竞争不激烈的场景。 如两个线程交替运行
多线程竞争同一把锁(偏向锁或轻量级锁)时,竞争失败的线程若在短暂的自旋后获取到锁,则不会导致锁膨胀为重量级锁。
3. 重量级锁
重量级锁适用于锁竞争激烈或同步块执行时间长的场景
重量级锁基于Monitor实现,用户态转内核态耗时较长。
4. 锁升级过程
上图为锁竞争对于锁状态变化的影响。
参考
5. 几种锁状态的总结
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加解锁无额外消耗,单线程下加锁基本无消耗 | 线程竞争产生额外的锁撤销成本 | 单线程访问同步代码块 |
轻量级锁 | 竞争线程不阻塞,基于cas自旋在用户态实现 | 长时间锁自旋获取不到锁还是会锁膨胀,并且消耗CPU | 同步块执行速度快、锁竞争不激烈 |
重量级锁 | 锁不自旋,不消耗cpu | 线程阻塞,用户态转内核态效率低 | 追求吞吐量、竞争激烈、同步代码块执行时间长 |
6. 其他的锁优化
自旋锁和适应性自旋锁
- 用于避免轻量级锁直接升级为重量级锁,用户态转内核态带来的损耗。 轻量级锁发生竞争时会先进行一段时间自旋, 超过一定自旋次数后才会升级
- 适应性自旋锁是为了动态调节锁升级自旋次数的阈值。 虚拟机认为上次自旋成功了,这次也可能成功,允许的自旋次数会增加。反之亦然。
锁消除
锁消除的依据是逃逸分析的数据支持。如果JVM检测同步内容不会逃逸,则会直接消除加锁操作
锁粗化
连续的加锁、解锁操作合并为一个加锁解锁操作可能提升性能,因此有了锁粗化的概念。
JVM检测到对同一个对象连续的加解锁,并且可以合并时,会将加解锁操作移动到循环之外。
for x : xx
synchronized(obj) {
}
四、与AQS体系锁的对比
1. LockSupport.park和synchronized的重量级锁一样吗
结论: 是。LockSupport也是基于mutex实现的,有用户态和内核态的切换
ReentrantLock最终会使用LockSupport.park阻塞线程,因此最终和synchronized的重量级锁一样
2. 为什么ReentrantLock比synchronized性能高
ReentrantLock基于AQS实现。AQS在Java代码层面管理阻塞队列,synchronized由jvm层面管理阻塞队列。AQS使用CAS较多,阻塞队列操作逻辑比jvm实现的好,因此性能高一些。
3. 和ReentrantLock的对比
区别 | synchronized | ReentrantLock |
---|---|---|
修饰位置不同 | 静态方法、普通方法、代码块 | 代码块 |
自动与非自动释放锁 | 自动释放锁 | 手动释放锁 |
锁类型不同 | 非公平锁 | 默认非公平锁,也可以创建公平锁 |
响应中断不同 | 不可以响应中断 | 能够响应中断 |
底层实现不同 | 基于JVM的Monitor | 基于AQS |
阻塞后线程状态不同 | 阻塞进入BLOCKED状态 | 阻塞进入WAITING状态 |
同步队列实现方式不同 | 类似栈,有两个,先进后出 | 队列且只有一个,先进先出 |
参考
- 偏向锁、轻量锁升级对对象头、哈希码的影响
- 调用hash计算时锁的变化
- 再谈阻塞(3):cxq、EntryList与WaitSet
- synchronized读书笔记
- Java精通并发-通过openjdk源码分析ObjectMonitor底层实现
- 深入分析Synchronized原理(阿里面试题)
转载:https://blog.csdn.net/dreambyday/article/details/128767442