带着问题看文章
- synchronized 是什么,怎么用?对象锁,类锁有什么区别
- synchronized 有什么缺陷?
- synchronized 怎么保证线程安全?
- jdk1.6 对 synchronized 做了哪些优化?
- synchronized修饰实例方法,抛出异常后会释放锁吗?
- 聊一聊锁升级的过程
- 什么是自旋锁,自适应自旋锁,偏向锁,轻量级锁,重量级锁
- JVM是怎么知道是哪个线程持有的锁呢?对象头结构
- synchronized 与 Lock 区别
概念
synchronized 是 Java 提供的一个内置锁,也叫做监视器锁。线程的代码在执行 synchronized 代码块前会自动获取内部锁,这时其他线程访问该同步代码块会阻塞挂起。拿到锁的线程在正常退出代码块或者抛出异常后会释放锁。
synchronized 是可重入的互斥锁。同一个时间只能有一个线程可以获取同一个对象的锁,能保证线程安全
可重入避免了死锁的发生
基本用法
Java中的每一个对象都可以作为锁,具体表现为以下3种形式
- 修饰普通方法,锁的是当前实例对象
public synchronized void method(){}
- 修饰静态方法,锁的是当前类的Class对象
public synchronized static void method(){}
- 修饰代码块,锁的 synchronized 括号里配置的对象
// 锁的是当前实例对象
public void method(){
synchronized(this){
}
}
// 锁的是类对象A,A的所有实例对象共用1把锁
public void method(){
synchronized(A.class){
}
}
// 锁的是实例对象obj
Object obj = new Object();
public void method(){
synchronized(obj.class){
}
}
实现原理
JVM基于进入和退出 Monitor 对象来实现方法同步和代码块同步。每个对象都有一个Monitor对象与之对应,一个线程去获取对象锁时,其实是获取对象对应的Monitor的所有权,当Monitor对象被持有后,它将处于锁定状态
同步代码块
synchronized 作用在同步代码块,经过编译后,会在同步代码块前后分别形成 monitorenter
和 monitorexit
这两个字节码指令。
monitorenter
:尝试去获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1;monitroexit
:将锁的计数器减1,当减到0时,锁就被释放了。
如果获取锁失败,那当前线程就需要阻塞等待,直到对象锁被另一个线程释放为止。
同步方法
方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法的调用和返回操作之中。
如果一个方法被 synchronized 修饰,那么在方法常量池的方法表结构中有一个 ACC_SYNCHRONIZED
访问标志。
当持有 ACC_SYNCHRONIZED
标志的方法被访问时,执行线程就需要先尝试去持有 Monitor 对象,成功持有 Monitor 对象后,才能执行方法,方法执行结束,释放 Monitor 对象。如果方法执行过程中抛了异常,则 Monitor 在方法抛出异常之后自动释放。
对象头
在HotSpot虚拟机中,对象在内存中存储的布局分为3个区域:对象头、实例数据和对齐填充。
对象头:普通对象包含2部分数据,Mark Word和 Class Pointer(类型指针),数组对象还包含数组长度
- Mark Word:存储对象自身运行时数据,如hashcode,gc分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等
- Class Pointer:对象指向它的类元数据的指针,通过这个指针来确定这个对象是哪个类的实例,开启指针压缩的情况下占4字节32位,关闭指针压缩
-XX:-UseCompressedOops
占8字节64位
指针压缩:
- 数组长度:如果是数组对象,还有一块区域记录数组长度
实例数据:对象真正存储的有效信息
对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍,因此当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
8字节对齐,是为了效率的提升,以空间换时间的一种方案
对齐填充不是必然存放的,它仅仅起着占位符的作用。HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍
Mark Word
在32位虚拟机中,Mark Word 数据长度为 32 bit,如果对象未处于锁定状态,那么25bit用于存储对象hashCode,4bit存储分代年龄,2bit存储锁标志位,1bit固定为0
在64位虚拟机中,Mark Word 数据长度为 64 bit,无锁状态下,前25位未使用,31位存储hashCode,cms_free从字面意思猜测可能和cms垃圾回收有关,有读者知道的话,可以留言告知。
Monitor
在HotSpot虚拟机中,Monitor由C++中的ObjectMonitor实现
synchronized的运行机制,就是当JVM监测到对象不同的竞争状态时,会自动切换到合适的锁实现,这种切换就是锁升级和降级
偏向锁,轻量级锁,重量级锁对应的就是三种Monitor的实现,当一个Monitor被某个线程持有后,它就处于锁定状态
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0; // 线程重入次数
_object = NULL; // 存储 Monitor 对象
_owner = NULL; // 持有当前线程的 owner
_WaitSet = NULL; // 处于wait状态的线程,会被加入到 _WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 单向列表
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
- _count:记录 owner 线程获取锁的次数
- _owner:指向持有ObjectMonitor对象的线程
- _EntryList:存放处于等待锁 block 状态的线程队列
- _WaitSet:存放处于wait 状态的线程队列
- 想要获取 Monitor 对象的线程,先进入 EntryList
- 当某个线程获取到 Monitor 对象后,进入到 Owner区,设置为当前线程,计数器_count + 1
- 当线程调用wait() 方法时,将 owner 设置为 null,线程进入 WaitSet 队列,释放锁,计数器 _count - 1
- 如果其他线程调用 notify() 或 notifyAll() ,唤醒 WaitSet 队列中的某个线程,该线程尝试去获取 Monitor对象,成功则进入 owner 区
- 同步方法执行完毕,线程退出临界区,将monitor的owner设置为null,并释放监视器锁
锁优化
jdk1.6 对 synchronized 进行了优化,为了减少加解锁带来的性能消耗,提出了 自旋锁,锁消除,锁粗化,偏向锁,轻量级锁等优化策略,来提高并发性能。
- 自旋锁
尝试获取锁的线程不会阻塞,而是采用循环的方式去获取锁。在很多应用上面,对象的锁状态只会持续很短一段时间,为了这很短的时间频繁的阻塞唤醒线程非常不值,所以引入了自旋锁
好处:不会造成上下文切换
坏处:循环会消耗CPU
-XX:+UseSpinning
开启自旋锁
-XX:PreBlockSpin=n
设置自旋次数,默认10 - 适应性自旋
自旋的次数不再固定,由上一次在同一个锁上的自旋时间及锁等待拥有者的状态来决定。
自旋成功,增加下一次自旋次数
自旋失败,减少下一次自旋次数 - 锁消除
虚拟机检测到不可能存在共享数据竞争情况,则会对同步操作进行锁消除,依据逃逸分析的数据支持。
public void vectorTest(){
// Vector add方法存在加锁操作,JVM检测到变量 vector 没有逃逸到方法外面去,就会进行一个锁消除
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}
- 锁粗化
将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁
如上面的一段代码,vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外
锁膨胀过程
对象锁状态:无锁,偏向锁,轻量级锁,重量级锁
偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是有同一线程多次获得,那么对于同一个线程反复加解锁的过程中其实并没有发生竞争,但是带来了很多上下文切换和不必要的性能开销
- 这个锁会偏向第一个获取它的线程,如果在接下来的执行过程中,锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。
- 目的:消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。
- 配置命令:
-XX:+UseBiasedLocking
开启偏向锁
-XX:-UseBiasedLocking
禁止偏向锁
-XX:BiasedLockingStartupDelay=0
进行关闭偏向锁延迟 - 偏斜锁并不适合所有应用场景,撤销操作(revoke)是比较重的行为,只有当存在较多不会真正竞争的 synchronized 块儿时,才能体现出明显改善
获取锁 - 检查对象是否为可偏向状态,即偏向状态标识为1,锁标志为01
- 如果是可偏向状态,校验当前对象头Mark Word中的线程ID是否为当前线程,如果是则执行代码块,否则通过CAS修改Mark Wrod的线程ID
- CAS修改成功,将线程ID修改为当前线程ID
- CAS修改失败,表示当前存在多线程竞争,当到达安全点后,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续执行代码块
释放锁
偏向锁采用了一种等到竞争出现才会释放锁的机制,当其他线程尝试竞争偏向锁的时候,持有偏向锁的线程才会释放锁。偏向锁的撤销需要等待全局安全点(这个时间点上是没有正在执行的代码),步骤如下: - 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态;
- 撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态
轻量级锁
轻量级锁并不是用来替代重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能消耗。
获取锁
- 线程在执行同步代码块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为Displaced Mark Word。
- 然后,线程尝试使用CAS操作将对象的Mark Word更新为指向Lock Record 的指针。如果更新成功,当前线程获得该对象的锁,并且将对象Mark Word的锁标志位变为 00。如果失败,表示其他线程竞争锁,当前线程尝试使用自旋来获取锁。
释放锁
- 使用CAS操作将Displaced Mark Word替换回到对象头,如果成功,表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁
因为自旋会消耗CPU,为了避免无用的自旋,一旦锁升级成了重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程会进入下一轮锁争夺。
重量级锁
通过对象内部的监视器(Monitor)实现。
需要向操作系统申请互斥量 mutex,线程阻塞,出让时间片,进行上下文切换(用户态和内核态的切换),切换成本高
各种锁对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使得自旋会消耗CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
synchronized 与 Lock 区别
synchronized缺点:
- 效率低:锁的释放情况较少,只有执行完代码块或抛异常才能释放锁,获取锁的时候无法设置超时,不能中断一个正在使用锁的线程
- 不够灵活:加锁和释放锁的时机单一,每个锁仅有一个单一的条件(某个对象)
- 无法知道是否成功获得锁
Lock能解决的问题
- 常用的方法:
lock
、unlock
,tryLock
、tryLock(long,Timeutil)
- 获取锁时可以设置超时,可以中断一个正在使用锁的线程
锁膨胀完整流程
总结
1、对象与Monitor的关系
- 每个对象都有对象头
- 对象头里有Mark Word,不同虚拟机 Mark Word 结构不一样
- Mark Word 有指针指向 Monitor
2、对象锁有四种状态:无锁、偏向锁、轻量级锁、重量级锁,随着竞争激烈逐渐升级
3、重量级锁需要申请操作系统互斥量资源,线程竞争需要阻塞,进行上下文切换
4、线程获取锁通过CAS进行操作,CAS底层通过 lock cmpxchg 指令实现