一、synchronized基础
synchronized关键字在需要原子性、可见性和有序性这三种特性的时候都可以作为其中一种解决方案,看起来是“万能”的。的确,大部分并发控制操作都能使用synchronized来完成。在多线程并发编程中Synchronized一直是元老级角色,很多人都会称呼它为重量级锁,但是随着Java SE1.6对Synchronized进行了各种优化之后,有些情况下它并不那么重了,本文详细介绍了Java SE1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。
1.1、synchronized的使用
修饰目标 | 锁 | |
方法 | 实例方法 | 当前实例对象(即方法调用者) |
静态方法 | 类对象 | |
代码块 | this | 当前实例对象(即方法调用者) |
class对象 | 类对象 | |
任意Object对象 | 任意示例对象 |
1.1.1、示例
1 public class Synchronized {
2
3 public static volatile boolean flag = true;
4 public static volatile int num = 0;
5
6 //synchronized关键字可放于方法返回值前任意位置,本示例应当注意到sleep()不会释放对监视器的锁定
7 //实例方法
8 public synchronized void instanceMethod() {
9 for (int i = 0; i < 5; i++) {
10 System.out.println(Thread.currentThread().getName() + " ==== instanceMethod" + i);
11 try {
12 Thread.sleep(100);
13 } catch (InterruptedException e) {
14 e.printStackTrace();
15 }
16 }
17 }
18
19 //静态方法
20 public synchronized static void staticMethod() {
21 for (int i = 0; i < 5; i++) {
22 System.out.println(Thread.currentThread().getName() + " ==== staticMethod" + i);
23 try {
24 Thread.sleep(100);
25 } catch (InterruptedException e) {
26 e.printStackTrace();
27 }
28 }
29 }
30
31 public void thisMethod() {
32 //this对象
33 synchronized (this) {
34 for (int i = 0; i < 5; i++) {
35 System.out.println(Thread.currentThread().getName() + " ==== thisMethod" + i);
36 try {
37 Thread.sleep(100);
38 } catch (InterruptedException e) {
39 e.printStackTrace();
40 }
41 }
42 }
43 }
44
45 public void classMethod() {
46 //class对象
47 synchronized (Synchronized.class) {
48 for (int i = 0; i < 5; i++) {
49 System.out.println(Thread.currentThread().getName() + " ==== classMethod" + i);
50 try {
51 Thread.sleep(100);
52 } catch (InterruptedException e) {
53 e.printStackTrace();
54 }
55 }
56 }
57 }
58
59 public void anyObject() {
60 //任意对象
61 synchronized ("anything") {
62 for (int i = 0; i < 5; i++) {
63 System.out.println(Thread.currentThread().getName() + " ==== anyObject" + i);
64 try {
65 Thread.sleep(100);
66 } catch (InterruptedException e) {
67 e.printStackTrace();
68 }
69 }
70 }
71 }
72 }
1.1.2、验证
- 普通方法和代码块中使用this是同一个监视器(锁),即某个具体调用该代码的对象
1 public static void main(String[] args) {
2 final Synchronized syn = new Synchronized();
3 for (int i = 0; i < 10; i++) {
4 new Thread() {
5 @Override
6 public void run() {
7 syn.thisMethod();
8 }
9 }.start();
10 new Thread() {
11 @Override
12 public void run() {
13 syn.instanceMethod();
14 }
15 }.start();
16 }
17 }
发现输出结果总是以5个为最小单位交替出现,证明sychronized(this)和在实例方法上使用synchronized使用的是同一监视器。如果去掉任一方法上的synchronized或者全部去掉,则会出现instanceMethod和thisMethod无规律的交替输出。
- 静态方法和代码块中使用该类的class对象是同一个监视器,任何该类的对象调用该段代码时都是在争夺同一个监视器的锁定
1 public static void main(String[] args) {
2 final Synchronized syn = new Synchronized();
3 for (int i = 0; i < 10; i++) {
4 new Thread() {
5 @Override
6 public void run() {
7 syn.staticMethod();
8 }
9 }.start();
10 new Thread() {
11 @Override
12 public void run() {
13 syn.classMethod();
14 }
15 }.start();
16 }
17 }
输出以5个为最小单位交替出现,证明两段代码是同一把锁,如果去掉任一synchronnized则会无规律交替出现。
1.2、synchronized的特点
- 可重入性
- 当代码段执行结束或出现异常后会自动释放对监视器的锁定
- 是非公平锁,在等待获取锁的过程中不可被中断
- synchronized的内存语义,把在synchronized块内对共享变量的修改刷新到主内存
- 互斥性,被synchronized修饰的方法同时只能由一个线程执行
二、synchronized进阶
2.1、synchronized实现原理
synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低(要在用户态与内核态切换)。当然,JVM内置锁在1.5 之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与 Lock持平。
synchronized关键字被编译成字节码后会被翻译成monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置 与结束位置。
每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如下图所示:
写个demo看下,使用javap命令,查看JVM底层是怎么实现synchronized,代码如下:
1 public class TestSynMethod {
2
3 synchronized void hello() {
4
5 }
6
7 public static void main(String[] args) {
8 String anything = "anything";
9 synchronized (anything) {
10 System.out.println("hello word");
11 }
12 }
13 }
使用javap -v 反编译字节码文件
命令:java -v TestSynMethod.class
同步块的jvm实现,可以看到它通过monitorenter
和monitorexit
实现锁的获取和释放。通过图片中的注解可以很好的解释synchronized的特性2,当代码段执行结束或出现异常后会自动释放对监视器的锁定。
注意,如果synchronized在方法上,那就没有上面两个指令,取而代之的是有一个ACC_SYNCHRONIZED修饰,表示方法加锁了。然后可以在常量池中获取到锁对象,实际实现原理和同步块一致,后面也会验证这一点
2.2、对象的内存布局
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充 (Padding)。
对象头
HotSpot虚拟机的对象头包括两部分信息
第一部分是“Mark Word”,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关 键。,这部分数据的长度在32位和64位的虚拟机(暂 不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。
对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序代码中定义的各种类型的字段内容。
oop.hpp
中这样定义
HotSpot通过markOop类型实现Mark Word,具体实现位于markOop.hpp文件中。
Java对象头的Mark Word里默认存储对象的HashCode、分代年龄和锁标志位。
Mark Word可能变化为存储以下4种数据,如表所示
- age: 保存对象的分代年龄
- biased_lock: 偏向锁标识位
- lock: 锁状态标识位
- JavaThread*: 保存持有偏向锁的线程ID
- ptr: monitor的指针
- epoch: 保存偏向时间戳
锁状态 | 25bit | 4bit | 1bit | 2bit | |
23bit | 2bit | 是否是偏向锁 | 锁标志位 | ||
轻量级锁 | 指向栈中所记录的指针 | 00 | |||
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | |||
GC标志 | 空 | 11 | |||
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 |
无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中定义的各种类型的字段内容。
对齐填充
起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的整数倍(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对其填充来补全。
使用JOL工具验证
- 项目引入依赖
1 <dependency>
2 <groupId>org.openjdk.jol</groupId>
3 <artifactId>jol-core</artifactId>
4 <version>0.9</version>
5 </dependency>
- 代码
1 public class TestInitial {
2 public static void main(String[] args) throws InterruptedException {
3 Object obj = new Object();
4 //打印对象头
5 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
6 }
7 }
- 结果如下:
2.3、锁升级
《java并发编程的艺术》的描述(引用)
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状 态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏 向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高 获得锁和释放锁的效率,下文会详细分析。
2.3.1、偏向锁
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并 获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出 同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否 存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需 要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
(1)偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正 在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着, 如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈 会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他 线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。图2-1中的线 程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。
(2)关闭偏向锁
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动4秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0
。如果你确定应用程 序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:UseBiasedLocking=false
,那么程序默认会进入轻量级锁状态。
2.3.2、轻量级锁
(1)轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用 CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失 败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
(2)轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。图2-2是 两个线程同时争夺锁,导致锁膨胀的流程图。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时, 都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮 的夺锁之争。
到此,我们可以看到一个锁升级的轮廓了,但是看完之后有一些细节却让我更加迷惑,最后经过思考后,我发现作者给出的图片和描述适用的是当两个线程拥有同样锁等级同时竞争时的状况。 下面是我关于锁升级的一些思考
2.4、验证锁升级
2.4.1、验证默认开启偏向锁
我们首先验证一下java6以后默认开启偏向锁,它在应用程序启动4秒钟之后才激活。
- 实例代码
1 public class TestInitial {
2 public static void main(String[] args) throws InterruptedException {
3 Object obj = new Object();
4 //打印对象头
5 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
6 }
7 }
- 运行结果如下:
- 64bit环境下红框内位置对应的分布如下:
- 可以看到此时对象头处于轻量级锁的无锁状态(如下图示意,重点关注后三位),但是我们的偏向锁明明是开启的,这是因为由4s中的延时开启,这一设计的目的是因为程序在启动初期需要初始化大量类,此时会发生大量锁竞争,如果开启偏向锁,在冲突时锁撤销要耗费大量时间。
- 修改
TestInitial
程序,第一行添加延时5s
1 public class TestInitial {
2 public static void main(String[] args) throws InterruptedException {
3 TimeUnit.SECONDS.sleep(5);
4 Object obj = new Object();
5 //打印对象头
6 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
7 }
8 }
- 运行结果如下:
- 可以发现过了偏向锁延时启动时间后,我们再创建对象,对象头锁状态变成了偏向锁
2.4.1、验证锁的释放与获取
解释器执行monitorenter时会进入到InterpreterRuntime.cpp
的InterpreterRuntime::monitorenter
函数,具体实现如下:
synchronizer.cpp
文件的ObjectSynchronizer::fast_enter
函数:
BiasedLocking::revoke_and_rebias
函数过长,下面就简单分析下(着重分析一个线程先获得锁,下面会通过实验来验证结论)
1. 当线程访问同步块时首先检查对象头中是否存储了当前线程(和java中的ThreadId不一样),如果有则直接执行同步代码块。
即此时JavaThread*
指向当前线程
2. 如果没有,查看对象头是否是允许偏向锁且指向线程id为空
测试代码
1 public class TestBiasedLock {
2 public static void main(String[] args) throws InterruptedException {
3 TimeUnit.SECONDS.sleep(5);
4 Object object = new Object();
5
6 System.out.println("同步块前\n" + ClassLayout.parseInstance(object).toPrintable());
7
8 synchronized (object) {
9 System.out.println("同步块中\n" + ClassLayout.parseInstance(object).toPrintable());
10 }
11
12 TimeUnit.SECONDS.sleep(1);
13 System.out.println("出同步块\n" + ClassLayout.parseInstance(object).toPrintable());
14
15 }
16 }
测试结果
结合初始化的测试,我们可以得知偏向锁的获取方式。CAS设置当前对象头指向自己,如果成功,则获得偏向锁(t1获得了偏向锁)开始执行代码。并且知道了拥有偏向锁的线程在执行完成后,偏向锁JavaTherad*
依然指向第一次的偏向。
3. t2尝试获取偏向锁,此时对象头指向的不是自己(指向t1,而不是t2),开始撤销偏向锁, 升级为轻量级锁。
偏向锁的撤销,需要等待全局安全点,然后检查持有偏向锁的线程(t1)是否活着。
(1). 如果存活:让该线程(t1)获取轻量级锁,将对象头中的Mark Word替换为指向锁记录的指针,然后唤醒被暂停的线程。
也就是说将当前锁升级为轻量级锁,并且让之前持有偏向锁的线程(t1)继续持有轻量级锁。
(2). 如果已经死亡:将对象头设置成无锁状态
之前尝试获取偏向锁失败引发锁升级的线程(t2)会尝试获取轻量级锁,在当前线程的栈桢中然后创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用 CAS将对象头中的Mark Word替换为指向锁记录的指针,如果失败,开始自旋(即重复获取一定次数),在自旋过程中过CAS设置成功,则成功获取到锁对象。java中采用的是自适应自旋锁,即如果第一次自旋获取锁成功了,那么在下次自旋时,自旋次数会适当增加。 采用自旋的原因是尽量减少内核用户态的切换。也就是说t2尝试获取偏向锁失败,导致偏向锁的撤销,撤销后,线程(t2)继续尝试获取轻量级锁。
测试代码
t1获取了偏向锁,JavaThread*
指向t1。t2在thread1执行完毕后尝试获取偏向锁,发现该偏向锁指向t1,因此开始撤销偏向锁,然后尝试获取轻量级锁
1 public class TestLightLock {
2
3
4
5 public static void main(String[] args) throws InterruptedException {
6 TimeUnit.SECONDS.sleep(5);
7 Object obj = new Object();
8
9 Thread t1 = new Thread() {
10 @Override
11 public void run() {
12 synchronized (obj) {
13 System.out.println("thread1 获取偏向锁成功");
14 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
15 }
16 }
17 };
18
19 Thread t2 = new Thread() {
20 @Override
21 public void run() {
22 synchronized (obj) {
23 System.out.println("thread2 获取偏向锁失败,升级为轻量级锁,获取轻量级锁成功");
24 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
25 }
26 }
27 };
28
29 t1.start();
30 //让t1死亡
31 t1.join();
32 System.out.println("thread1执行结束");
33 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
34
35 t2.start();
36 //t2死亡
37 t2.join();
38 System.out.println("thread2执行结束,释放轻量级锁");
39 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
40
41
42 synchronized (obj) {
43 System.out.println("=====主线程中,获取锁=====");
44 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
45 }
46 }
47 }
测试结果
1)t1先执行获取偏向锁成功,开始执行。
2)t2获取偏向锁失败,升级为轻量级锁
3) t2获取轻量级锁成功,执行同步代码块
4. 如果t2在自旋过程中成功获取了锁,那么t2开始执行。此时对象头格式为
在t2执行结束后,释放轻量级锁,锁状态为
5. 如果t2在自旋过程中未能获得锁,那么此时膨胀为重量级锁,将当前轻量级锁标志位变为(10)重量级,创建objectMonitor对象,让t1持有重量级锁。然后当前线程开始阻塞。
测试代码
测试升级重量级锁
1 public class TestHeavyweightLock {
2
3 public static void main(String[] args) throws InterruptedException {
4 TimeUnit.SECONDS.sleep(5);
5 Object obj = new Object();
6
7 Thread t1 = new Thread() {
8 @Override
9 public void run() {
10 synchronized (obj) {
11 System.out.println("t1 获取偏向锁成功");
12 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
13 try {
14 //让线程晚点儿死亡,造成锁的竞争
15 TimeUnit.SECONDS.sleep(6);
16 } catch (InterruptedException e) {
17 e.printStackTrace();
18 }
19 System.out.println("t1:t2 获取锁失败导致锁升级,此时t1还在执行");
20 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
21 }
22 }
23 };
24
25 Thread t2 = new Thread() {
26 @Override
27 public void run() {
28 synchronized (obj) {
29 System.out.println("t2 获取偏向锁失败,最终升级为重量级锁,等待thread1执行完毕,获取重量锁成功");
30 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
31 try {
32 TimeUnit.SECONDS.sleep(3);
33 } catch (InterruptedException e) {
34 e.printStackTrace();
35 }
36 }
37 }
38 };
39
40 t1.start();
41 //对象头打印需要时间,先让thread1获取偏向锁
42 TimeUnit.SECONDS.sleep(3);
43 t2.start();
44
45 t1.join();
46 t2.join();
47 TimeUnit.SECONDS.sleep(3);
48 System.out.println("t2执行结束,释放锁");
49 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
50
51
52
53
54 // Thread t3 = new Thread(() -> {
55 // synchronized (obj) {
56 // System.out.println("t3 再次获取");
57 // System.out.println(ClassLayout.parseInstance(obj).toPrintable());
58 // }
59 // });
60 // t3.start();
61 /***
62 * 主线程去获取锁,不会让锁降级,t3线程去获取锁,锁会降级
63 */
64 synchronized (obj) {
65 System.out.println("=====主线程中,获取锁=====");
66 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
67 }
68 }
69 }
测试结果
总结:至此锁升级已经介绍完毕,接下来在介绍一下重量级锁的实现机制ObjectMonitor即可。再次梳理整个过程(主要是一个线程t1已经获得锁的情况下,另一个线程t2去尝试获取锁):
1. t2尝试获取偏向锁,发现偏向锁指向t1,获取失败
2. 失败后开始偏向锁撤销,如果t1还存活将轻量级锁指向它,它继续运行;t2尝试获取锁,开始自旋等待t1释放轻量级锁。
3. 如果在自旋过程中t1释放了锁,那么t2获取轻量级锁成功。
4. 如果在自旋结束后,t2未能获取轻量锁,那么锁升级为重量级锁,使t1持有objectmonitor对象,将t2加入EntryList,t2开始阻塞,等待t1释放监视
2.4、jvm的monitor实现(重量级锁)
jvm中Hotspot关于synchronized锁的实现是靠ObjectMonitor(对象监视器)实现的,当多个线程同时请求一个对象监视器(请求同一个锁)时,对象监视器将设置几个状态以用于区分调用线程:
属性 | 意义 |
_header | MarkOop对象头 |
_waiters | 等待线程数 |
_recursions | 重入次数 |
_owner | 指向获得ObjectMonitor的线程 |
_WaitSet | 调用了java中的wait()方法会被放入其中 |
_cxq | _EntryList | 多个线程尝试获取锁时 |
2.4.1、获取锁
线程锁的获取就是改变_owner指针,让他指向自己。
- Contention List:首先将锁定线程的所有请求放入竞争队列
- OnDeck:任何时候只有一个线程是最具竞争力的锁,该线程称为OnDeck(由系统调度策略决定)
锁的获取在jvm中代码实现如下,ObjectMonitor::enter
- 通过CAS尝试把monitor的_owner字段设置为当前线程;
- 如果设置之前的_owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ,记录重入的次数;
- 查看当前线程得得锁记录中得Displaced Mark Word,即是否是该锁的轻量级锁持有者,如果是则是第一次加重量级锁,设置_recursions为1,_owner为当前线程,该线程成功获得锁并返回;
- 如果获取锁失败,则等待锁的释放;
而锁的并发竞争状态维护就是依靠三个队列来实现的,_WaitSet、_cxq | _EntryList|。这三个队列都是由以下的数据结构实现得,所有的线程都会被包装成下面的结构,可以看到其实就是双向链表实现。
monitor竞争失败的线程,通过自旋执行ObjectMonitor::EnterI方法等待锁的释放,EnterI方法的部分逻辑实现如下:
- 当前线程被封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ;
- 自旋CAS将当前节点使用头插法加入cxq队列
- node节点push到_cxq列表如果失败了,再尝试获取一次锁(因为此时同时线程加入,可以减少竞争。),如果还是没有获取到锁,则通过park将当前线程挂起,等待被唤醒,实现如下:
当被系统唤醒时,继续从挂起的地方开始执行下一次循环也就是继续自旋尝试获取锁。如果经过一定时间获取失败继续挂起
2.4.2、释放锁
当某个持有锁的线程执行完同步代码块时,会进行锁的释放。在HotSpot中,通过改变ObjectMonitor的值来实现,并通知被阻塞的线程,具体实现位于ObjectMonitor::exit方法中。
- 初始化ObjectMonitor的属性值,如果是重入锁递归次数减一,等待下次调用此方法,直到为0,该锁被释放完毕。
- 根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog方法唤醒该节点封装的线程,唤醒操作最终由unpark完成。
2.5、wait()/notify()/notifyAll()与hashCode()
这两个方法其实是调用内核的方法实现的,他们的逻辑是将调用wait()的线程加入_WaitSet中,然后等待notify唤醒他们,重新加入到锁的竞争之中,notify和notifyAll不同在于前者只唤醒一个线程后者唤醒所有队列中的线程。值得注意的是notify并不会立即释放锁,而是等到同步代码执行完毕。
hashCode()、wait()方法会使锁直接升级为重量级锁(在看jvm源码注释时看到的),下面测试一下
1、测试wait()
1 public class TestWait {
2 public static void main(String[] args) throws InterruptedException {
3 TimeUnit.SECONDS.sleep(5);
4 Object obj = new Object();
5
6 Thread t1 = new Thread() {
7 @Override
8 public void run() {
9 synchronized (obj) {
10 System.out.println("t1获取锁成功,开始执行,因为t1调用了wait()方法,直接升级为重量级锁");
11 System.out.println("2\n" + ClassLayout.parseInstance(obj).toPrintable());
12 obj.notify();
13 }
14 }
15 };
16
17 Thread t2 = new Thread() {
18 @Override
19 public void run() {
20 synchronized (obj) {
21 System.out.println("t2 获取偏向锁成功开始执行");
22 System.out.println("1\n" + ClassLayout.parseInstance(obj).toPrintable());
23 try {
24 obj.wait();
25 } catch (InterruptedException e) {
26 e.printStackTrace();
27 }
28 }
29 }
30 };
31 t2.start();
32
33 //让t1执行完同步代码块中方法。
34 TimeUnit.SECONDS.sleep(3);
35 t1.start();
36 }
37 }
结果如下:
2、测试hashCode()
1 public class TestHashCode {
2 public static void main(String[] args) throws InterruptedException {
3 TimeUnit.SECONDS.sleep(5);
4 Object obj = new Object();
5
6 synchronized (obj) {
7 System.out.println("t1 获取偏向锁成功,开始执行代码");
8 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
9 obj.hashCode();
10 try {
11 //等待对象头信息改变
12 TimeUnit.SECONDS.sleep(1);
13 } catch (InterruptedException e) {
14 e.printStackTrace();
15 }
16 System.out.println("hashCode() 调用后");
17 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
18 }
19 }
20 }
结果如下:
2.5、锁降级
锁也可以降级,在安全点判断是否有线程尝试获取此锁,如果没有进行锁降级(重量级锁降级为轻量级锁,和之前在书中看到的锁只能升级不同,可能理解的意思不一样)。
测试代码如下,顺便测试了一下重量级锁升级
测试代码:
1 public class TestMonitor {
2
3 public static void main(String[] args) throws InterruptedException {
4 TimeUnit.SECONDS.sleep(5);
5 Object obj = new Object();
6
7 Thread t1 = new Thread() {
8 @Override
9 public void run() {
10 synchronized (obj) {
11 System.out.println("t1 获取偏向锁成功");
12 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
13 try {
14 //让线程晚点儿死亡,造成锁的竞争
15 TimeUnit.SECONDS.sleep(6);
16 } catch (InterruptedException e) {
17 e.printStackTrace();
18 }
19 System.out.println("t1:t2 获取锁失败导致锁升级,此时t1还在执行");
20 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
21 }
22 }
23 };
24
25 Thread t2 = new Thread() {
26 @Override
27 public void run() {
28 synchronized (obj) {
29 System.out.println("t2 获取偏向锁失败,最终升级为重量级锁,等待thread1执行完毕,获取重量锁成功");
30 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
31 try {
32 TimeUnit.SECONDS.sleep(3);
33 } catch (InterruptedException e) {
34 e.printStackTrace();
35 }
36 }
37 }
38 };
39
40 t1.start();
41 //对象头打印需要时间,先让thread1获取偏向锁
42 TimeUnit.SECONDS.sleep(3);
43 t2.start();
44
45 //确保t1和t2执行结束
46 t1.join();
47 t2.join();
48 TimeUnit.SECONDS.sleep(3);
49 System.out.println("t1和t2执行结束");
50 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
51
52 Thread t3 = new Thread(() -> {
53 synchronized (obj) {
54 System.out.println("t3 再次获取");
55 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
56 }
57 });
58 t3.start();
59 }
60 }
结果如下:
t1和t2由于争抢导致锁升级为重量级锁,等待它们执行完毕,启动t3获取同一个锁发现又降级为轻量级锁。
2.6、锁粗化
在使用synchronized
时,JVM
会对同一对象使用的锁进行优化,比如StringBuffer
的append
方法是synchronized
修饰的,JVM
会把相关的append
操作合并成一个synchronized
块。
1 // 粗化前
2 synchronized (obj) {
3 System.out.println("111");
4 }
5 synchronized (obj) {
6 System.out.println("222");
7 }
8 synchronized (obj) {
9 System.out.println("333");
10 }
11
12 // 粗化后
13 synchronized (obj) {
14 System.out.println("111");
15 System.out.println("222");
16 System.out.println("333");
17 }
2.7、锁消除
在使用synchronized
时,当我们同步锁住的对象在方法体内时(一个栈帧)多线程执行不会有任何影响,JVM
会消除当前方法的synchronized
。
如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作可以不需要同步。说明了逃逸分析把锁消除了,并在性能上得到了很大的提升。
这里说明一下Java的逃逸分析是方法级别的,因为JIT的即时编译是方法级别。
1 Object o = new Object();
2 // 消除前
3 synchronized (o) {
4 System.out.println("123");
5 }
6
7 // 消除后
8 System.out.println("123");
逃逸分析参考:【JVM】堆中GC与对象分配内存(九)
简单来说就是把对象分解成一个个基本类型,并且内存分配不再是分配在堆上,而是分配在栈上。这样的好处有,一、减少内存使用,因为不用生成对象头。 二、程序内存回收效率高,并且GC频率也会减少,总的来说和上面优点一的效果差不多