参考视频
疑问
我们都是知道synchronized保证了临界区代码的安全性。那么我有以下几个疑问。
-
Synchronized为什么要绑定一个对象作为锁呢?
-
Synchronized他底层是怎么来保证一次只能有一个线程访问的呢?
-
JDK对Synchronized做了一个锁升级的优化,那这个优化的过程又是怎么样的呢?
Synchronized的简单介绍,加锁方式
上面我们不难看出,我们使用synchronized就必须要有一个被锁的对象。那么我们就来看下这个锁对象到底起到什么作用?
对象头信息
普通对象头信息
数组对象
Mark Word 信息
这张图很好的展示了不同锁状态的取值。
在大概知道对象头与锁的关系后,我们在来介绍下什么是Monitor 管程。
Monitor
在早期的时候,synchronized是一把很重的锁。这也是一部分同学知道的,synchronized是一把重量级的锁。那么这是为什么呢?
这里我们就要介绍一下Monitor了。
在jdk1.5之后对synchronized进行了改进升级。使得synchronized的锁根据线程的并发激烈程度的变化自动对锁进行一个升级的过程(锁升级不可逆)。而不是一上来就给锁对象分配一个Monitor管程。1.5之前的时候就是不管三七二十一,只要使用了synchronized关键字,就给当前锁对象分配一个Monitor对象。
Monitor 对象内部结构
底层是基于JVM内部的C++语言实现的。
ObjectMonitor() {
_header = NULL; //对象头 markOop
_count = 0;
_waiters = 0,
_recursions = 0; // 锁的重入次数
_object = NULL; //存储锁对象
_owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程)8 _WaitSet = NULL; // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
FreeNext = NULL ;
_EntryList = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程)
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
我们再用一张更简单的图来描述,线程、对象锁于Monitor的关系
1.WaitSet
这就是持有锁的对象调用wait()方法后,Monitor给Waiting的线程提供的一个等待的休息室。等待被唤醒。(Look锁中有Condition多个等待休息室)
2.EntryList
并发情况下,没有争抢到锁而进行阻塞的线程,存放的地方。
3.Owner
当前持有锁的线程。(后面进行比对,CAS等操作)
Monitor 流程简介(重量级锁)
简单的熟悉了线程、对象锁于Monitor的关系后,我们在来一个三者代码层面的程序运行流程。
工作流程(简图,这里介绍的就是重量级锁的加锁过程):
- 开始时 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj),先进行Owner 的CAS 比对操作,如果是null, 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner,obj 对象的 Mark Word 指向 Monitor,把对象原有的 MarkWord 存入线程栈中的锁记录中。
- 在 Thread-2 上锁的过程,Thread-3、Thread-4、Thread-5 也执行 synchronized(obj),但是此时的Owner已经存放了Thread-2。他们就会进入 EntryList BLOCKED(双向链表)。
- Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord。
- 唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞。(非公平锁设计)
- WaitSet 中的 Thread-0,是以前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制)
以上我们算是对,synchronized底层有了一个大概的认识了,接下来就是 锁升级、锁优化是怎么一回事了。
轻量级锁
虽然是多个线程在使用同一把锁对象来上锁,但是上锁的时机是错开的。比如 Thread1 使用期间没有其他线程来上锁,另一个线程 Thread2 来上锁也不需要等待。这种场景下就不需要申请Monitor对象来进行多个线程间的协调(也就是不需要使用重量级锁)。有什么好的方法来进行优化呢?这时候Synchronized就提供了一种优化后的锁 轻量级锁。
我们来举例,说明下轻量级锁的加锁、解锁、和升级的过程。
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
- 同一个线程加锁两次的情况。Thread0 调用method1上了一把锁,method2又上了一把锁。
- JVM会给线程开辟一块栈内存,给线程调用的方法在栈内存中又分配一个栈帧。如图:
- 创建锁记录(Lock Record)对象,每个线程都为栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word。(我们可是上了两次锁的哦,别忘记了。后面还有图,慢慢看)
- 第一次上锁的过程是这样的。让锁记录中 Object reference 指向锁对象,并尝试用 cas (由于这里比较耗费性能,后面会经过偏向锁优化)替换 Object 的 Mark Word,将 Mark Word 的值存起来。如图:
- (只考虑交换成功,如果失败就是重量级锁了。后面讲)这个时候你就应该回忆起来,上面我们介绍的对象头信息了。交换完之后,对象头重的锁标志为 00,好了第一次上锁成功,且是轻量级锁。
- 我们再来上第二次锁,看看会怎么样。
- 只有第一次的锁记录,记录了对象头重的Mark Work 信息。不用重复记录。
- 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
- 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头
思考问题:上述的流程中肯定会有失败的,我们不可能保证在Thread0 执行过程中没有别的线程来对同一个锁对象加锁。那么会发生什么呢?
- 第一处失败:Thread1来上锁,发现此时对象头已经是轻量级锁了。那么这时候Thread1会尝试自璇CAS上锁(这里是出于性能优化,不要一下子就升级重量级锁),几次失败后。会申请为重量级锁,这时候我们的Monitor就会登场了,把锁对象头Mark Word设置为重量级锁10,且重新指向Monitor。 并且把Thread1 存放到EntryList集合中等待唤醒,此时的Owner 为Thread0
- 第二处失败:Thread0释放锁的时候,想通过CAS交换Mark Wokd信息,这时候发现。Mark Word 已经指向 Monitor了,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程。
锁膨胀
思考问题就是锁碰撞啊兄弟!
再总结一下锁膨胀吧:当前是轻量级锁情况下,一旦发生锁竞争了。就会进入到锁膨胀过程,将轻量级锁升级为重量级锁。
自旋优化
兄弟啊,这个在上面也讲过了哦。
再总结一下吧:重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。避免上下文切换,优先自旋几次尝试上锁。上锁失败后,在入队列。提升性能。
-
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
-
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
-
Java 7 之后不能控制是否开启自旋功能
偏向锁
其实在上述的轻量级锁中,还是有一部分性能问题的,就是每一次轻量级锁的上锁和解锁都是需要进行CAS操作的,这个也是比较消耗CPU性能的。且经过大量的官方实践,大部分的项目也都是单线程就可以解决的。所以就有了更加优化的手段 偏向锁
偏向锁是如何做的呢?
偏向锁是直接将Thread ID 设置到Mark Work中,避免每次都上锁了。直接比对锁对象头中的线程ID与当前线程ID就可以了。
我们来对比下 轻量级锁与偏向锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
偏向锁之锁撤销
偏向锁是撤销是什么情况呢?就是JVM判断当前的锁不适合做偏向锁,因此将对象头中Thread Id 和偏向锁状态取消升级成轻量级锁或重量级锁。
那么哪些情况会出现锁撤销呢?
- 调用了锁对象的HashCode方法,因为锁对象中存储了HashCode值就无法存储线程ID了。其他两种锁都有地方存的。(轻量级锁在锁记录中存放HashCode,重量级锁在Monitor中存HashCode)
- 当另外一个线程使用同一把锁的时候(如果是发生竞争的话就是重量级锁了),这时候会使用轻量级锁。
- 调用 wait/notify 方法。这两个方法 是要绑定Monitor对象的,升级成重量级锁。
偏向锁之批量重偏向
什么情况下会发生批量重偏向呢?我们来看下代码吧。
Vector<Dog> list = new Vector<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 30; i++) {//线程1 连续给三十个对象上了偏向锁
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
for (int i = 0; i < 30; i++) {//给上面30个已经上了偏向锁 t1的对象,由t2重新上了轻量级锁
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}
}, "t2");
t2.start();
第二次循环三十次,重新给锁对象升级为轻量级锁。但是一旦超过20次,JVM就会怀疑是偏向错误,这时候从第21个对象开始,又重新偏向t2.这就是批量重偏向。
偏向锁之批量撤销
当上述的撤销次数达到40次的时候,JVM就会认为。根本就不该偏向,于是整个类的所有对象都不会偏向。
锁消除
public void b() throws Exception {
Object o = new Object();
synchronized (o) {
x++;
}
}
上述中的这种加锁,是无意义的。在JIT 阶段会做优化处理。不加锁处理,这就是锁消除。
锁升级总结
锁的升级过程是这样的:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 //过程不可逆