Synchronize的底层优化(CAS,重量级锁,轻量级锁,偏向锁)
Java 对象头 (重点)
对象头
对象头包含两部分: 运行时元数据(Mark Word)和类型指针(Klass Word)
运行时元数据:
-
哈希值(HashCode),可以看出是 堆中对象的地址
-
GC分代年龄(年龄计数器) (用于新生代from/to区晋升老年代的标准, 阈值为15)
-
锁状态标志(用于JDK1.6对synchronize的优化->轻量级锁)
-
线程持有的锁
-
偏向线程ID(用于JDK1.6对synchronize的优化->偏向锁)
-
偏向时间戳
类型指针
- 指向
类元数据InstanceKlass,确定该对象所属的类型
。指向的其实是方法区中存放的类元信息
说明:如果对象是数组,还需要记录数组的长度
- 以 32 位虚拟机为例,普通对象的对象头结构如下,其中的
Klass Word
为类型指针
,指向方法区
对应的Class对象
;
数组对象
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Rf2jdMLK-1617765491139)(C:\Users\dell\AppData\Roaming\Typora\typora-user-images\image-20210404202934931.png)]
其中 Mark Word 结构为: 无锁(001)、偏向锁(101)、轻量级锁(00)、重量级锁(10)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2Q23Hrt4-1617765491141)(C:\Users\dell\AppData\Roaming\Typora\typora-user-images\image-20210404202942355.png)]
所以一个对象的结构如下:
64 位虚拟机 Mark Word
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-s3LHkiky-1617765491143)(C:\Users\dell\AppData\Roaming\Typora\typora-user-images\image-20210404203040902.png)]
原理之Monitor(锁)
Monitor被翻译成 *监视器或 管程
每个Java对象
都可以关联一个(操作系统的)Monitor
,如果使用synchronized
给对象上锁(重量级)
,该对象头的MarkWord
中就被设置为指向Monitor对象
的指针
下图原理解释:
- 当Thread1访问到synchronized(obj)中的共享资源的时候
首先会将synchronized中的锁对象中对象头的MarkWord去尝试指向操作系统的Monitor对象. 让锁对象中的MarkWord和Monitor对象相关联. 如果关联成功, 将obj对象头中的MarkWord的对象状态从01改为10。
因为Monitor没有和其他的obj的MarkWord相关联, 所以Thread1就成为了该Monitor的Owner(所有者)。
又来了个Thread1执行synchronized(obj)代码, 它首先会看看能不能执行该临界区的代码; 它会检查obj是否关联了Montior, 此时已经有关联了, 它就会去看看该Montior有没有所有者(Owner), 发现有所有者了(Thread2); Thread1也会和该Monitor关联, 该线程就会进入到它的EntryList(阻塞队列);
当Thread2执行完临界区代码后, Monitor的Owner(所有者)就空出来了. 此时就会通知Monitor中的EntryList阻塞队列中的线程, 这些线程通过竞争, 成为新的所有者
-
刚开始时
Monitor
中的Owner为null
-
当Thread-2 执行synchronized(obj){}代码时就会将Monitor的所有者Owner 设置为 Thread-2,上锁成功,Monitor中同一时刻只能有一个Owner
-
当Thread-2 占据锁时,如果线程Thread-3,Thread-4也来执行synchronized(obj){}代码,就会进入
EntryList
中变成BLOCKED状态
-
Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,
竞争时是非公平的 (仍然是抢占式)
-
图中 WaitSet 中的Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲wait-notify 时会分析
- synchronized 必须是进入同一个锁对象的monitor 才有上述的效果; —> 也就要使用同一把锁
- 不加 synchronized的锁对象不会关联监视器,不遵从以上规则
它加锁就是依赖底层操作系统的
mutex
相关指令实现, 所以会造成用户态和内核态之间的切换
, 非常耗性能 !
- 在JDK6的时候, 对synchronized进行了优化, 引入了
轻量级锁, 偏向锁
, 它们是在JVM的层面上进行加锁逻辑, 就没有了切换的消耗~
原理之synchronize(字节码)
public class Test {
static final Object lock =new Object();
static int count = 0;
public static void main(String[] args) throws InterruptedException {
synchronized (lock){
count++;
}
}
}
字节码如下:
0 getstatic #2 <com/yc/Test.lock> //-<lock 引用(synchronize开始)
3 dup
4 astore_1 //lock引用 ->slot 1(槽1)
5 monitorenter //将lock对象MarkWord置为Monitor指针
6 getstatic #3 <com/yc/Test.count>
9 iconst_1
10 iadd
11 putstatic #3 <com/yc/Test.count>
14 aload_1 // lock引用
15 monitorexit //将lock对象MarkWord重置,唤醒EntryList
16 goto 24 (+8) //正常执行直接到24行
19 astore_2 //检测异常
20 aload_1
21 monitorexit //将lock对象MarkWord重置,唤醒EntryList
22 aload_2
23 athrow
24 return
Exception table: //异常表
from to target type
6 16 19 any
19 22 19 any
注意:方法级别的 synchronized 不会在字节码指令中有所体现
synchronized 原理进阶
- 小故事
什么是CAS
CAS:Compare and Swap,即比较再交换。
CAS算法理解
对CAS的理解,CAS是一种无锁算法,CAS有3个操作数,***内存值V,旧的预期值A,要修改的新值B。当且***仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
伪代码如下:
do{
备份旧数据;
基于旧数据构造新数据;
}while(!CAS( 内存地址,备份的旧数据,新数据 ))
案例:
注:t1,t2线程是同时更新同一变量56的值
因为t1和t2线程都同时去访问同一变量56,所以他们会把主内存的值完全拷贝一份到自己的工作内存空间,所以t1和t2线程的预期值都为56。
假设t1在与t2线程竞争中线程t1能去更新变量的值,而其他线程都失败。(失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次发起尝试)。t1线程去更新变量值改为57,然后写到内存中。此时对于t2来说,内存值变为了57,与预期值56不一致,就操作失败了(想改的值不再是原来的值)。
(上图通俗的解释是:***CPU去更新一个值,但如果想改的值不再是原来的值,操作就失败,因为很明显,有其它操作先改变了这个值。***)
就是指当两者进行比较时,如果相等,则证明共享数据没有被修改,替换成新值,然后继续往下运行;如果不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作。
代码案例
class MyLock {
private boolean locked = false;
public boolean lock() {
if(!locked) {
locked = true;
return true;
}
return false;
}
}
上面这段代码,如果用在多线程的程序会出现很多错误,不过现在请忘掉它。
因为***CAS操作必须是原子性的。***但是这些方法的操作并不是原子性
class MyLock {
private boolean locked = false;
public synchronized boolean lock() {
if(!locked) {
locked = true;
return true;
}
return false;
}
}
改良
可以把之前的操作变成一个原子性(synchronize)
CAS用作原子操作
public static class MyLock {
private AtomicBoolean locked = new AtomicBoolean(false);
public boolean lock() {
return locked.compareAndSet(false, true);
}
}
locked变量不再是boolean类型而是AtomicBoolean。这个类中有一个compareAndSet()方法,它使用一个期望值和AtomicBoolean实例的值比较,和两者相等,则使用一个新值替换原来的值。
轻量级锁(用于优化Monitor这类的重量级锁)
通过
锁记录
的方式, 场景 : 多个线程交替进入临界区
- 轻量级锁的使用场景: 如果一个对象虽然有很多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用 轻量级锁来进行优化
- 轻量级锁对使用者是透明的,即语法仍然是
synchronize
(jdk6对synchronize的优化),假设有两个方法同步块,利用同一个对象加锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
eg:
线程A来操作临界区的资源,给资源加锁,到执行完临界区代码,释放锁
的过程,没有线程来竞争,此时就可以使用轻量级锁
;如果这期间有线程来竞争的话,就会 升级为重量级锁- 每次指向到
synchronized代码块
时,都会在栈帧中
创建锁记录(Lock Record)对象
,每个线程都会包括一个锁记录的结构
,锁记录内部可以储存对象的MarkWord
和锁对象引用reference
- 让
锁记录
中的Object reference指向锁对象的地址
,并且尝试用CAS(Compare and sweep)
将栈帧中的锁记录(lock record 地址00)
替换Object对象的Mark Word
,将 Mark Word的值(01)存入锁记录(lock record地址中) ----相互替换
- 01代表
无锁(看 Mark Word结构,数字的含义)
- 00代表
轻量级锁
- 01代表
重点:
- 如果
cas替换成功
, 获得了轻量级锁,那么对象
的对象头储存的就是锁记录的地址和状态00
,如下所示- 线程中锁记录, 记录了锁对象的锁状态标志; 锁对象的对象头中存储了锁记录的地址和状态, 标志哪个线程获得了锁
- 此时
栈帧
中存储了对象的对象头
中的锁状态标志
,年龄计数器,哈希值等;对象的对象头中
就存储了栈帧中锁记录的地址和状态00
, 这样的话对象
就知道了是哪个线程锁住自己
。
-
如果
cas替换失败,有两种情况
: ① 锁膨胀 ② 重入锁失败1、如果是
其它线程
已经持有了该Object的轻量级锁
,那么表示有竞争,将进入锁膨胀阶段
- 此时
对象Object
对象头中已经存储了别的线程的锁记录地址 00
,指向了其他线程;
- 此时
-
2、如果是
自己的线程已经执行了synchronized进行加锁
,那么再添加一条 Lock Record 作为重入锁的计数
– 线程多次加锁, 锁重入- 在上面代码中,
临界区中
又调用了method2
, method2中又进行了一次synchronized加锁操作
, 此时就会在虚拟机栈
中再开辟一个method2方法对应的栈帧(栈顶), 该栈帧中又会存在一个独立
的Lock Record
, 此时它发现对象的对象头中指向的就是自己线程中栈帧的锁记录
; 加锁也就失败了. 这种现象就叫做锁重入
; 线程中有多少个锁记录, 就能表明该线程对这个对象加了几次锁 (锁重入计数)
- 在上面代码中,
轻量级锁解锁流程 :
- 当
线程退出synchronize代码块
的时候, 如果获取的是取值为null的锁记录
,表示有锁重入
,这是重置锁记录,表示重入计数减一
-
当线程退出synchronize代码块的时候,如果
获取的锁记录取值不为null
,那么 使用cas将Mark Word的值恢复给对象,将直接替换的内容还原。- 成功则解锁成功(轻量级锁解锁成功)
- 失败,表示有竞争,则
说明轻量级锁进行了锁膨胀
或已经升级为重量级锁
, 进入重量级锁解锁流程(Monitor流程)
锁膨胀
- 如果在尝试
加轻量级锁
的过程中,cas替换操作无法成功
,这是 有一种情况就是其他线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀(有竞争)
,将轻量级锁变成重量级锁
- 当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁,此时发生
锁膨胀
-
这时Thread-1加轻量级锁失败, 进入锁膨胀流程
-
因为
Thread-1
**线程加轻量锁失败,轻量级锁没有阻塞队列的概念,**所以此时就要为对象申请Monitor锁(重量级锁)
,让Object指向重量级锁地址10
,然后自己进入Monitor的EntryList变成BLOCKED状态
-
当
Thread-0线程
执行完synchronize同步块时
,**使用cas将Mark Word的值回复给对象头,**肯定恢复失败,因为对象的对象头中存储的是重量级锁的地址,状态变成10了
之前的是00,肯定恢复失败。那么会进入重量级锁的解锁过程,
即按照Monitor的地址找到Monitor对象,将Owner设置为null,唤醒EntryList中的Thread-1线程
-
自旋锁优化(优化重量锁竞争)
- 当发生
重量级锁竞争的时候
,还可以使用自旋来进优化(不加入Monitor的阻塞队列EntryList中)
,如果当前线程自旋成功(即在自旋的时候持锁的线程是否了锁),**那么当前线程就可以不用进行上下文切换(持锁线程执行完synchronize同步块中,释放锁,Owner为空,唤醒阻塞队列来竞争,胜出的线程得到CPU执行权的过程)
**就获得了锁
- 优化的点: 不用将
线程
加入到阻塞队列,减少cpu切换。
-
自旋重试成功的情况
- 自旋会
会占用CPU时间
,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。 - 在
Java6之后自旋锁是自适应
的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性很高,就多自旋几次;反之,就减少自旋甚至不自旋,总之,比较智能。Java7之后不能控制是否开启自旋功能。
偏向锁(biased lock)(用于优化轻量级锁重入)
场景:没有竞争的时候,一个线程中多次使用
synchronize
需要冲入加锁的情况;(只有一个线程进入临界区)
- 在经常需要竞争的情况下就不使用偏向锁,因为偏向锁默认是开启的,我们可以通过JVM的配置,将偏向锁给关闭
- 将进入临界区的线程的ID,直接设置给锁对象的Mark word,下次该线程又获取锁,发现线程ID是自己,就不需要CAS了
- 在
轻量级的锁
中,我们可以发现,如果同一个线程对同一个对象进行重入锁
时, 也需要执行CAS替换操作,这是有点耗时的。 - 那么java6开始引入了
偏向锁
,将进入临界区的线程ID,直接设置给锁对象的Mark Word,下次该线程又获取锁,发现线程ID是自己,就不需要CAS了- ***升级为轻量级锁的情况(会进行偏向锁的撤销)***:获取偏向锁的时候,发现线程ID不是自己的,此时通过CAS替换操作,操作成功了,此时该线程就获得了锁对象。(
此时是交替访问临界区,撤销偏向锁,升级为轻量级锁
) - ***升级为重量级锁的情况(会进行偏向锁撤销):***获取偏向锁的时候,发现线程ID不是自己的,此时通过CAS替换操作,操作失败了,此时说明发生了锁竞争。(
此时是多线程访问的临界区,撤销偏向锁,升级为重量级锁
)
- ***升级为轻量级锁的情况(会进行偏向锁的撤销)***:获取偏向锁的时候,发现线程ID不是自己的,此时通过CAS替换操作,操作成功了,此时该线程就获得了锁对象。(
例如:
public class Test {
static final Object obj = new Object();
public static void m1() {
synchronized (obj) {
// 同步块A
m2();
}
}
public static void m2() {
synchronized (obj) {
// 同步块B
m3();
}
}
public static void m3() {
synchronized (obj) {
// 同步块C
}
}
}
偏向锁状态 (了解)
运行时元数据(Mark Word)
的结构如下:
Normal:一般状态,没有加任何锁
**,前面62位保存的是对象的信息,**最后2位为状态(01),倒数第三位表示是否使用偏向锁(未使用:0)Biased:偏向状态,使用偏向锁
**,前面54位保存的当前线程的ID,**最后2位为状态(01),倒数第三位表示是否使用偏向锁(使用:1)Lightweight:使用轻量级锁
**,前62位保存的是锁记录的指针,**最后2位为状态(00)Heavyweight:使用重量级锁
**,前62位保存的是Monitor的地址指针,**最后2位为状态(10)
- 如果开启了偏向锁(默认开启),在创建对象时,对象的Mark Word后三位应该是101
- 但是偏向锁默认是有延迟的,不会再程序一启动就生效,而是会在程序运行一段时间(几秒之后),才会对创建的对象设置为偏向状态
- 如果没有开启偏向锁,对象的Mark Word后三位应该是001
一个对象的创建过程
如果开启了偏向锁(默认是开启的)
,那么对象刚创建之后,Mark Word 最后三位的值101,并且这是它的ThreadId
,epoch
,age(年龄计数器)
都是0
*,**在加锁的时候进行设置这些的值
偏向锁默认是延迟
的,不会在程序启动的时候立刻生效,如果想避免延迟,可以添加虚拟机参数来禁用延迟:-XX:BiasedLockingStartupDelay=0
来禁用延迟
- 注意 :
处于偏向锁的对象解锁后,线程id仍存储于对象头中
输出结果:
- 测试
禁用偏向锁
:如果没有开启偏向锁
,那么对象创建后最后三位的值为001
,这时候它的hashcode,age都为0,hashcode是第一次用到hashcode
时才赋值的。在上面测试代码运行时在添加 VM 参数-XX:-UseBiasedLocking
禁用偏向锁(禁用偏向锁则优先使用轻量级锁)
,退出synchronized
状态变回 001- 禁止偏向锁, 虚拟机参数
-XX:-UseBiasedLocking
; 优先使用轻量级锁
- 输出结果: 最开始状态为001,然后加轻量级锁变成00,最后恢复成001
- 禁止偏向锁, 虚拟机参数
撤销偏向锁-hashcode方法 (了解)
- 测试
hashCode
:当调用对象的hashcode方法
的时候就会撤销这个对象的偏向锁
,因为使用偏向锁时没有位置存hashcode
的值了
撤销偏向锁-发生锁竞争 (升级为重量级锁)
小故事: 线程A门上刻了名字, 但此时线程B也要来使用房间了, 所以要将偏向锁升级为轻量级锁. (线程B要在线程A使用完房间之后
(执行完synchronized代码块)
,再来使用; 否则就成了竞争获取锁对象, 此时就要升级为重量级锁
了)
偏向锁、轻量级锁的使用条件, 都是在于多个线程没有对同一个对象进行
锁竞争
的前提下, 如果有锁竞争
,此时就使用重量级锁。
- 这里我们演示的是
偏向锁
撤销, 变成轻量级锁
的过程,那么就得满足轻量级锁的使用条件,就是没有线程对同一个对象进行锁竞争
,我们使用wait
和notify
来辅助实现 - 虚拟机参数
-XX:BiasedLockingStartupDelay=0
确保我们的程序最开始使用了偏向锁
输出结果,最开始使用的是偏向锁
,但是第二个线程尝试获取对象锁时(前提是: 线程一已经释放掉锁了,也就是执行完synchroized代码块),发现本来对象偏向的是线程一
,那么偏向锁就会失效,加的就是轻量级锁
撤销偏向锁 - 调用 wait/notify (只有重量级锁才支持这两个方法)
(调用wait方法会导致锁膨胀而使用重量级锁)
- 会使对象锁变成重量级锁,因为
wait/notify方法之后重量级锁才支持
批量重偏向
- 如果
对象被多个线程访问,但是没有竞争 (上面撤销偏向锁就是这种情况: 一个线程执行完, 另一个线程再来执行, 没有竞争)
, 这时偏向T1的对象仍有机会重新偏向T2
- 重偏向会重置Thread ID
- 当
撤销偏向锁101 升级为 轻量级锁00
超过20次后(超过阈值)
,JVM会觉得是不是偏向错了,这时会在给对象加锁时,重新偏向至加锁线程 (T2)。
批量撤销偏向锁
- 当 撤销偏向锁的阈值超过40以后 ,就会将整个类的对象都改为不可偏向的
同步省略 (锁消除)
同步省略
- 线程同步的代价是相当高的,同步的后果是
降低并发性和性能
。 - 在动态编译同步块的时候,
JIT编译器
可以借助逃逸分析
来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。 - 如果没有,
那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。
这个取消同步的过程就叫同步省略,也叫锁消除。
- 例如下面的智障代码,
根本起不到锁的作用
public void f() {
Object hellis = new Object();
synchronized(hellis) {
System.out.println(hellis);
}
}
- 代码中对hellis这个对象加锁,但是hellis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉,优化成:
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}
字节码分析
- 代码
public void f() {
Object hellis = new Object();
synchronized(hellis) {
System.out.println(hellis);
}
}
- 注意:字节码文件中并没有进行优化,可以看到加锁和释放锁的操作依然存在,
同步省略操作是在解释运行时发生的