synchronized原理
1. Monitor 介绍
Monitor 被成为锁或者是管程,是操作系统底层为了控制线程,进程同步时使用的一个对象。在Monitor中有三个管家的属性见下图:
分别是:
- waitSet —— 条件等待队列
- EntryList —— 锁等待队列
- Owner —— 线程的持有者
这里只需要知道Monitor 是操作系统提供的一个用于进程线程控制的一个对象,以及对象中的三个属性就好了。
2. MarkWord
说到MarkWord,就不得不提到java中一个对象的组成,java中 一个对象包含三部分分别是: 对象头,实例数据,对齐补充。
- 对齐补充的作用就是,java中规定每个对象的大小必须是8整数倍,对齐补充就是用来补充的。
- 实例数据就是类中实际定义的属性。
- 对象头则比较特殊,其中两部分:MarkWord 和 类型指针, 类型指针指向的就是当前对象的class对象,大小为4 字节。MarkWord 在32为和 64 为的虚拟机下是不同的。32 位的虚拟机下大小位 32b,64 位下 大小为64 b。
以下是32 位虚拟机下的markWord
32 比特下的markWord 中,在默认的情况下是有25 比特用于存储对象的哈希码,4 比特用于存储对象的分代年龄,2 比特用于存储锁标志位 1 比特固定位0.注意在其他不同的状态下时,MarkWord 中记录的信息也会改变:具体看下图:
从上到下依次是: 最后三位是 - 正常状态 001
- 偏向锁状态 101
- 轻量级锁状态 000
- 重量级锁状态 010
- GC状态 011
这里重点注意三种不同锁状态记录线程的方式:
* 偏向锁: 使用 23b 的threadId 来记录当前对象的偏向状态,是偏向哪一个线程(不懂的可以看完后面的再来看这里)
* 轻量级锁: 使用 30B 大小的lock record 对象来记录线程信息(该对象是有每个线程的栈帧中创建)
* 重量级锁: 使用Monitor 来管理线程
2. Synchronized 原理 (重量级锁)
Thread2 进入synchronized 代码块,可以看到这里使用的obj对象作为锁。
本质上在执行到Synchronized (obj) 的时候,jvm会将Obj
对象和操作系统提供的Monitor对象进行关联,并使将MarkWord中前30 B 大小的数据保存起来,替换位30B 大小的一个引用,该引用执行Monitor对象,然后剩下的两位 锁标志位 设置为10 表示重量级锁。结果如下图:
此时的MarkWord 如下图:
由于此时Monitor的ower 为空,所以Thread2 可以加锁成功,就线程中的一个所记录对象Lock record 对象 指向 owner 。
接着又来了一个线程Thread 1
在进入synchronized 代码块的时候也会尝试获取锁,但是由于此时Monitor的Onwer 对象 已被占有,所以就会被加入到Monitor 的 EntryList 链表上进行等待,
继续由来了一个线程,过程也是类似的:
最后当Thread2 在离开synchronized 代码块时,操作系统就会唤醒在EntyrList 中的一个线程,注意这里唤醒是随机的,和进入链表的顺序没有关系。比如这里唤醒了Thread1:结果如下图
到这里Synchronized 的基础原理就介绍完了,这里还需要注意的一个点是,一个obj对象就可以关联一个Monitor,所以如果使用不同的obj对象作为锁,那么是不会相互阻塞的。如下图:
其次是关于 另一个等待集合WaitSet ,该集合中存储的是使用了Object.wait()方法等待的线程.wait / notify 的和synchronized 类似,只不过它使用的是WaitSet 集合来存放等待线程。
2.1 synchronized 在字节码中 的体现
一道面试题:
在使用synchronized 关键字时,什么时候会执行三条Monitor指令? 什么时候会执行两条Monitor指令?
答: 正常执行时,执行两条指令分别是monitorenter和monitorexit。 出现异常时,可能执行三条指令,monitorenter,两条monitorexit指令
3. Synchronized进阶原理
3.1 轻量级锁
轻量级锁的使用场景:就是一个对象是多个线程可以共享的,但是访问的时间却是错开的,也就是没有冲突。 我比较好奇的是既然不会冲突为什么还要加锁?本质上轻量级锁synchronized 的优化,实现,对使用者来说是透明的,语法依然是synchronized。我们知道重量级锁的加锁和释放锁机制是和操作系统提供的Monitor对象相关的,就是说每一次的线程加锁,线程释放锁,都是由操作系统底层来实现的,并不是java 内部实现,所以整个的调用过程是比较慢的。
所以JDK6 开始 jvm采取了使用轻量级锁的优化策略:
轻量级锁在加锁的时候,部需要使用到Monitor,而是和线程中的Lock record 对想关联。
分析:
在线程即将进入synchronized代码块时,jvm会检擦该obj对象的markword的最后两位,如果时01 表示未加锁状态,那么jvm就会在当前的栈帧中创建一个lock record 对象,用于存储Markword的拷贝。Lock Record 包含两部分:
- lock record 00 轻量级锁状态
- owner 指向Obj 对象
接着jvm就会尝试使用cas操作 把对象头的MarkWord 更新为执行该Lock Record 对象的指针,以及修改后两位 为00 表示轻量级锁。Lock Record 的 lock record 存储对象头的原来的信息。owner 指向 obj对象。
如果cas操作失败那么失败的原因有两个:
- 1.当前obj对象的锁以及被使用
- 2.但线程已经占有了该obj对象
如果有其他线程已经占有了对象的锁,那么就表示当前有锁的竞争,那么就需要进行锁膨胀,升级为重量级锁。如果是自己占有了当前的对象的锁,那么就再加一条锁记录对象再栈帧中
- 轻量级锁线程退出时:
如果Lock Record 对象的lock record 值为null ,则表示当前线程有锁重入,则清楚掉但钱的这个Lock Record 对象。 如果不为null ,则表示当前已经最后一个锁,jvm再次使用cas操作 减缓lock record 和 mark word 中的信息。- 成功 : 退出
- 失败: 进入重量级锁的解锁流程
3.2 锁膨胀
锁膨胀发生在线程竞争锁的时候,如果当前对象锁使用的是偏向锁,如果出现竞争就会升级为轻量级锁,如果当前为轻量级锁,出现了竞争就会升级为重量级锁。
以轻量级锁升级为重量级锁为例子:
当thread 0 已经对该对象加了轻量级锁时,线程Thead-1 使用cas操作尝试加锁,但是会失败,所以就会进入锁膨胀。接着Obj对象就会申请一个Monitor对象,然后将自己的markword 中的前30 字节 引用指向 该monitor 对象 后 2b 设置围殴 10 表示重量级锁。注意这里的markword 中的引用已经不是指向战争中的 Lock Record 对象了 ,所以在退出synchronized 代码块时,因为 栈帧中的Lock Record 对象依然存在,那么线程首先就会尝试使用cas来 释放锁,母庸置疑肯定会失败,然后就会进入重量级锁的解锁流程。即按照markword 中指向的monitor 对象 ,设置 owner 引用为null,唤醒entyrList 中等待的线程。
代码测试:
public class Main5 {
public static void main(String[] args) {
Dog dog = new Dog();
Thread t1 = new Thread(()->{
System.out.println(ClassLayout.parseInstance(dog).toPrintable());
System.out.println("================== 加锁前");
synchronized (dog){
// 打印对象头信息
System.out.println("出现冲突前");
System.out.println(ClassLayout.parseInstance(dog).toPrintable());
synchronized (Main5.class){
Main5.class.notifyAll();
}
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
Thread t2 = new Thread(()->{
synchronized (Main5.class){
try {
Main5.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (dog){
// 打印对象头信息
System.out.println("出现冲突后");
System.out.println(ClassLayout.parseInstance(dog).toPrintable());
}
});
t2.start();
}
}
测试结果:
最开始为001 表示为正常状态
没有竞争时 为 000 表示轻量级锁状态
最后出现竞争时为 010 表示 重量级锁
3.3 偏向锁
锁偏向是对在对象锁无竞争下的进一步优化,注解把整个的同步都消除掉,连cas都不用做。
当线程第一次获取到锁对象时,尝试把对象头的偏向模式设置为1,表示进入偏向模式,同时仅使用一次cas操作把得到该锁对象的线程的id设置到对象的markword 中。此后持有该锁对象的线程,在进入和该锁有关的同步代码块时,无需做任何的同步操作。
但是一旦当有另外一个线程尝试获取该锁对象时,那么偏向模式就会结束,将升级为轻量级锁,后续的同步操作就按照轻量级锁执行。
3.31 hashcode问题
观察下图可以知道(64 位虚拟机),原来hashcode值占用的位置被 线程id占用了,
那么原来的的hashcode值怎么处理? 在java中的hashcode 的值如果一经计算就不应该改变,所以如果一个对象计算了hashcode 值就需要永久的保存在markword 中,所以如果计算了hashcode值 就会导致 线程id 没有可以放置的位置,所以jvm中,如果计算了hashcode 值,就直接偏向失效。
4. 自旋优化
自选优化在jdk6 中引入,目的也是为了提高程序并发性能,由于现在计算机基本都是 多核,就可以尝试一种机制,就是如果一个线程请求锁失败,并不立即将该线程挂起等待,而是在一定的时间段内不断的尝试获取锁。
频繁的挂起和恢复工作,涉及到线程的上下文切换,开销比较大,自旋可以有效的减少上下文切换的次数。jdk6中还引入了自适应的自旋,就是每次自旋的时间和上一次自旋的结果相关,如果上一次自旋成功,那么jvm就推测本次自旋成功的几率也比较高,就会运行更长时间的自旋。
5. 锁消除
即时编译器通过逃逸分析,分析出如果数据不会逃离出被其他线程执行,那么就会直接消除当代码中的同步块。