【Java并发004】原理层面:synchronized关键字全解析
一、前言
synchronized关键字在需要原子性、可见性和有序性这三种特性的时候都可以作为其中一种解决方案,看起来是“万能”的。的确,大部分并发控制操作都能使用synchronized来完成。在多线程并发编程中Synchronized一直是元老级角色,很多人都会称呼它为重量级锁,但是随着Java SE1.6对Synchronized进行了各种优化之后,有些情况下它并不那么重了,本文详细介绍了Java SE1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。
面试官问题:为什么说jdk6之后synchronized不再那么重量了?
JDK1.6之前的内建锁,即synchronized,jdk1.6之后,内建锁=无锁+偏向锁+轻量级锁+重量级锁,并加入锁升级机制,所以说,jdk6以后,synchronized不再那么重量了,其实,不是synchronized不再那么重量,而是一定要不得已才使用synchronized。
小结:对于为什么说jdk6之后synchronized不再那么重量了,解释是,jdk6以前,只有无锁+重量级锁两种状态,加锁就是重量级锁,阻塞调用,jdk6以后,程序员再使用synchronized的时候,底层有无锁+偏向锁+轻量级锁+重量级锁,四种状态,一定要不得已才使用synchronized,不那么重量的了,当然对于程序员的使用synchonized来说,是透明的,所以说jdk6之后synchronized不再那么重量了。
二、synchronized的使用
2.1 synchronized五种情况
修饰目标 | 锁 | |
---|---|---|
方法 | 实例方法 | 当前实例对象(即方法调用者) |
静态方法 | 类对象 | |
代码块 | this | 当前实例对象(即方法调用者) |
class对象 | 类对象 | |
任意Object对象 | 任意示例对象 |
五种情况主要是方法级别锁和代码级别锁,还有就是锁对象的不同,方法级别锁和代码级别锁很好理解,看代码就懂,锁对象不同是什么意思?
第一种情况和第三种情况,形成锁竞争:普通方法和代码块中使用this是同一个监视器(锁),即某个具体调用该代码的对象
第二种情况和第四种情况,形成竞争:静态方法和代码块中使用该类的class对象是同一个监视器,任何该类的对象调用该段代码时都是在争夺同一个监视器的锁定
小结:同步两因素:第一多线程,第二锁对象:
- 多线程:就是因为存在不止一个线程才能形成竞争,只有一个线程你和谁竞争,所以一旦设置同步,多线程必不可少;
- 锁对象:锁对象表示的是竞争的对象,即多个线程之间竞争什么,竞争的对象相同才能形成竞争,竞争的对象不同是无法形成竞争的,举一反三,比如非同步方式(没有synchronized修饰),就啥都不竞争,和同步原子没半毛钱关系,代码中通过对竞争的资源加锁解锁形成原子操作,所以一旦涉及同步,锁对象必不可少。
2.2 synchronized五种情况的使用
2.2.1 synchronized五种情况的使用
public class Synchronized {
//synchronized关键字可放于方法返回值前任意位置,本示例应当注意到sleep()不会释放对监视器的锁定
//实例方法
public synchronized void instanceMethod() {
for (int i = 0; i < 5; i++) {
System.out.println("instanceMethod");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
//静态方法
public synchronized static void staticMethod() {
for (int i = 0; i < 5; i++) {
System.out.println("staticMethod");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void thisMethod() {
//this对象
synchronized (this) {
for (int i = 0; i < 5; i++) {
System.out.println("thisMethod");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public void classMethod() {
//class对象
synchronized (Synchronized.class) {
for (int i = 0; i < 5; i++) {
System.out.println("classMethod");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public void anyObject() {
//任意对象
synchronized ("anything") {
for (int i = 0; i < 5; i++) {
System.out.println("anyObject");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
2.2.2 第一种情况和第三种情况
普通方法和代码块中使用this是同一个监视器(锁),即某个具体调用该代码的对象
public static void main(String[] args) {
Synchronized syn = new Synchronized();
for (int i = 0; i < 10; i++) {
new Thread() {
@Override
public void run() {
syn.thisMethod();
}
}.start();
new Thread() {
@Override
public void run() {
syn.instanceMethod();
}
}.start();
}
}
我们会发现输出结果总是以5个为最小单位交替出现,证明sychronized(this)和在实例方法上使用synchronized使用的是同一监视器。如果去掉任一方法上的synchronized或者全部去掉,则会出现instanceMethod和thisMethod无规律的交替输出。
2.2.3 第二种情况和第四种情况
静态方法和代码块中使用该类的class对象是同一个监视器,任何该类的对象调用该段代码时都是在争夺同一个监视器的锁定
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Synchronized syn = new Synchronized();
new Thread() {
@Override
public void run() {
syn.staticMethod();
}
}.start();
new Thread() {
@Override
public void run() {
syn.classMethod();
}
}.start();
}
}
输出以5个为最小单位交替出现,证明两段代码是同一把锁,如果去掉任一synchronnized则会无规律交替出现。
三、synchronized源码解析
3.1 对象头 + Mark Word
3.1.1 同步锁对象的对象头(对象头=Mark Word + Class Metadata Address + Array length,三个每一个占一个字宽)
线程同步两要素=多线程+锁对象,对于锁对象,这里介绍其对象头
对象头=Mark Word + Class Metadata Address + Array length,三个每一个占一个字宽。
Markdown存放对象的hashCode或锁信息;
Class Metadata Address存放存储到对象类型数据的指针;
ArrayLength,数组类型特有,存放数组类型长度。
如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头;
如果对象是非数组类型,则用2字宽存储对象头。
在32位虚拟机中,1字宽等于4字节,即32bit,64位虚拟机中,1字宽=8字节=64bit,如表所示:
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/64bit | Array length | 数组的长度(如果当前对象是数组) |
3.1.2 对象头的Mark Word(无锁状态 + 四种数据)
Mark Word无锁状态,如下:
锁状态 | 25bit | 4bit | 1bit是否偏向锁 | 2bit锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
Mark Word可能变化为存储以下4种数据,如表所示:
锁状态 | 25bit | 4bit | 1bit | 2bit | |
---|---|---|---|---|---|
23bit | 2bit | 是否偏向锁 | 锁标志位 | ||
轻量级锁 | 指向栈中所记录的指针 | 00 | |||
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | |||
GC标志 | 空 | 11 | |||
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 |
3.1.3 小结:对象头 + Mark Word
对象头三个部分:
对象头=Mark Word + Class Metadata Address + Array length,三个每一个占一个字宽。
Markdown存放对象的hashCode或锁信息;(无锁状态:29bit hashcode+ 3bit lock)
Class Metadata Address存放存储到对象类型数据的指针;
ArrayLength,数组类型特有,存放数组类型长度。
小结:五种状态:
无锁状态:
hashcode 哈希码 29bit
biased_lock: 偏向锁标识位,1bit
lock: 锁状态标识位,2bit 01偏向锁(线程id 23 + 偏向时间戳 2 + 分代年龄 4 = 29 + lock 3):
JavaThread: 保存持有偏向锁的线程ID,23bit
epoch: 保存偏向时间戳,2bit(到40,升级为轻量级锁)
age: 保存对象的分代年龄,4bit
biased_lock: 偏向锁标识位,1bit
lock: 锁状态标识位,2bit 01
其中,线程id 23 + 偏向时间戳 2 + 分代年龄 4 都是偏向锁特有,后两个都是涉及时间轻量锁和重量锁(30+2):
ptr: monitor的指针(就是锁指针,同步代码块的锁由monitorenter和monitorexit完成,同步方法的锁由ACC_SYNCHRONIZED修饰,底层是一致的),30bit
lock: 锁状态标识位,2bit 00 10GC标志(30+2)
空,30bit
lock: 锁状态标识位,2bit 11
记忆方法:偏向锁和无锁一起记忆,其他三个,轻量级锁和重量级锁一起记忆。
3.2 javap命令:从.java文件到.class文件,synchronized代码级别和方法级别的区别(代码级别:monitorenter+monitorexit,方法级别:ACC_SYNCHRONIZED)
金手指:JVM第二篇就用过这个javap命令
3.2.1 synchronized代码级别锁底层:monitorenter+monitorexit
我们写个demo看下,使用javap命令,查看JVM底层是怎么实现synchronized
public class TestSynMethod1 {
synchronized void hello() {
}
public static void main(String[] args) {
String anything = "anything";
synchronized (anything) { // 任意字符串作为锁对象
System.out.println("hello word");
}
}
}
线程同步两要素:多线程和对象锁,这里只有main线程,对象锁是可以随便找一个字符串变量,因为这里只要测试一个synchronized关键字底层是如何保证原子性的,synchronized关键字底层实现,不用模拟多线程对竞争资源争夺。
同步块的jvm实现,可以看到它通过monitorenter和monitorexit实现锁的获取和释放。通过图片中的注解可以很好的解释synchronized的特性2,当代码段执行结束或出现异常后会自动释放对监视器的锁定。
3.2.2 synchronized方法级别锁底层:ACC_SYNCHRONIZED
如果synchronized在方法上,那就没有上面两个指令,取而代之的是有一个ACC_SYNCHRONIZED修饰,表示方法加锁了。然后可以在常量池中获取到锁对象,实际实现原理和同步块一致,后面也会验证这一点
辨析方法级别锁和代码级别锁
synchronized代码级别锁底层:monitorenter+monitorexit
synchronized方法级别锁底层:ACC_SYNCHRONIZED
联系:如果synchronized在方法上,底层使用ACC_SYNCHRONIZED修饰该方法,然后在常量池中获取到锁对象,实际实现原理和同步块一致
四、原理:锁升级整个流程(偏向锁获取 + 偏向锁撤销 + 轻量锁加锁 + 轻量锁解锁)
1、只能升级不能降级:目的是为了提高 获得锁和释放锁的效率
2、升级顺序:无锁状态 0 01、偏向锁状态101、轻量级锁状 态000和重量级锁状态010
4.1 锁升级
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状 态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高 获得锁和释放锁的效率。
4.2 偏向锁(偏向锁获取 + 偏向锁撤销)
4.2.1 偏向锁获取过程(两个IF判断步骤)
偏向锁的引入是适应Java并发的实际需求:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,所以,Java并发中为了让线程获得锁的代价更低而引入了偏向锁。
当一个线程访问同步块并获取到同步锁的时候,会在同步锁对象的对象头和栈帧中的锁记录Lock Record里存储锁偏向的线程ID(金手指:偏向锁对象头的mark word:线程id 23 + 偏向时间戳 2 + 分代年龄 4 = 29 + lock 3,和无锁一起记忆),以后该线程再次进入和退出的时候,同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下当前同步锁对象的对象头的Mark Word里是否存储着指向当前线程的偏向锁。
tip: 检测的对象为thread id
当一个线程获取偏向锁的时候,会将这个线程的thread id写到同步锁对象的对象头的mark word里面去,所以判断的时候,根据threadid判断。
偏向锁检测的对象为线程id,这也就是为什么偏向锁需要23位线程id,其他四种情况的mark down都不需要线程id,无锁是29位字节码,轻量级锁和重量级锁是30位的monitor锁指针,gc标志是30位的空。
- 如果测试成功,表示线程已经获得了锁;如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成0?
如果偏向标志为0,则将它设置为1,更新自己的线程ID到对象头的mark word字段中;
如果偏向标志为1,表示此时偏向锁已经被别的线程获取,则不断尝试使用CAS获取偏向锁或者将偏向锁撤销,在不断CAS自旋过程中,大概率会升级为轻量级锁。
面试官:偏向锁获取的整个过程可以用下图来小结
当一个线程第一次访问同步代码块并获取锁时(使用cas操作获取到锁),会在同步锁对象的对象头的栈帧中的锁记录lock record中记录存储偏向锁的线程ID。以后该线程再次进入同步块时不再需要CAS来加锁和解锁,只需简单测试一下对象头的mark word中偏向锁线程ID是否是当前线程ID(所以说,对于一个线程来说,偏向锁的获取只需要第一次使用CAS操作,该线程后面的直接判断即可,最乐观,最高效,加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距);
如果成功,表示线程已获取到锁直接进入代码块运行;如果测试失败(要么无锁,要么就是有其他的线程,偏向锁不适合多线程,多次cas操作自旋(如上图),大概率会变成轻量级锁),检查当前偏向锁字段是否为0?
如果为0,表示是001,无锁,将偏向锁字段设置为1,并且更新自己的线程ID到同步锁对象的对象头的mark word字段当中(下面我们可以在程序中打印这个对象的对象头 good,用来查看锁升级的过程);如果为1,表示此时偏向锁已经被别的线程获取,则此线程不断尝试使用CAS获取偏向锁或者将偏向锁撤销,升级为轻量级锁(升级概率较大,偏向时间戳epoche默认达到40升级为轻量级锁)。
后面的测试类2中,main线程两次偏向锁,第一次需要cas,第二次直接判断就好了,很方便快捷。
问题:上图中,测试失败,不断自旋,然后两条路,升级为轻量级锁和撤销偏向锁是不同的两条路?
回答:嗯嗯,这个图告诉我们,多个线程下,偏向锁只有两个归宿,锁竞争失败方撤销偏向锁或者将锁更新为轻量级锁
金手指:关于获取偏向锁,就是两个对于同步锁对象的判断,同步锁对象中是否存储当前线程id?是的话直接执行同步代码块,不是的话判断同步锁对象中偏向标志是否为0,为0表示当前无锁,直接设置偏向标志为1和线程id即可,不为0表示已经有其他线程持有偏向锁,然后自旋,自旋两个结果,锁竞争失败方撤销偏向锁或者将锁更新为轻量级锁。
4.2.2 偏向锁撤销过程(四个步骤)
偏向锁释放的时机:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁撤销的两个阶段(考虑撤销 + 执行撤销):
1、考虑撤销:等待有线程尝试竞争偏向锁,才会考虑撤销。
2、执行撤销:考虑撤销完毕后,如果是确定要撤销,一定要等到JVM的safepoint点,才会执行撤销,因为这里没有正在执行的字节码。
tip:这里是第一点和第二点使用了考虑撤销和执行撤销,有先后顺序,考虑撤销并决定撤销后才会在safepoint执行撤销偏向锁,因为这里没有正在执行的字节码。
偏向锁撤销流程:
- 首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着;
- 如果线程活动状态,则将同步锁对象头设置成无锁状态;
- 如果线程非活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录lock record,栈中的锁记录lock record和同步锁对象的对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁;
- 最后唤醒暂停的线程。
下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。一个图概括了偏向锁的获取和撤销:
对于上图中概念的解释:
- 第一个判断,同步锁对象的thread id是否为当前线程id?返回为false,第一次一定是没有;
- 第二个判断,当前偏向字段是否为0?返回true,表示无锁,将threadid1设置到同步锁对象的对象头中的mark word(存放哈希码和锁对象)中,threadid1设置到锁对象成功,进入偏向锁状态;
此时,同步锁对象的对象头中的mark word存放者thread id1,末尾三位为101,表示进入偏向锁状态; - 执行同步代码块:就是synchronized代码块;
- 当线程1正在执行同步代码块的时候,如果线程2要访问同步代码块,同样经过两个条件判断;
- 第一个判断:同步锁对象的thread id是否为当前线程id?返回为false,同步锁对象的thread id一定不是thread id2;
- 第二个判断:当前偏向字段是否为0?返回false,当前锁偏向字段为1,表示已有线程占用偏向锁;
- 线程2自旋,使用cas想要将同步锁对象的mark word中的thread id设置为自己的,但是没有成功(因为线程1还没执行完成,其实,多个线程下,偏向锁只有两个归宿,锁竞争失败方撤销偏向锁或者将锁更新为轻量级锁),这里要验证撤销偏向锁,所以这里线程2发起撤销偏向锁
- 撤销偏向锁第一步:首先暂停拥有偏向锁的线程,这里是暂停线程1,如上图,线程1被暂停,然后检查持有偏向锁的线程是否活着;
- 撤销偏向锁第二步:如果持有偏向锁的线程1 活动状态,则将同步锁对象头设置成无锁状态;
- 撤销偏向锁第三步:如果持有偏向锁的线程1 非活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录lock record和同步锁对象的对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁(三种);
- 撤销偏向锁第四步:最后唤醒暂停的线程。
- 这里,线程1还活着,恢复到无锁 001,最后唤醒被暂停的线程1,就是上图的恢复线程。
小结:这里,撤销偏向锁四步骤,先第一步,线程1被暂停,检查线程1是否活动状态;然后第二步,线程1 活动状态,将同步锁对象头设置为无锁状态;最后第四步,唤醒被暂停的线程1,恢复线程。
注意1:考虑撤销:撤销偏向锁触发条件是等到有竞争才撤销/释放偏向锁,这是考虑撤销。
注意2:执行撤销:考虑撤销并确定撤销后,才会执行撤销,而且要等待JVM safepoint点执行撤销,因为这里没有正在执行的字节码。
注意3:撤销/释放偏向锁的持有偏向锁的线程1,不是线程2,线程2啥都没有,撤销啥。
最后再上一张图补一下(获取偏向锁 + 撤销/释放偏向锁)
4.2.3 附:关闭偏向锁
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
小结:关闭偏向锁注意两个:
JVM参数设置: XX:BiasedLockingStartupDelay=0 关闭偏向锁延迟
JVM参数设置:XX:UseBiasedLocking=false 关闭偏向锁,直接进入轻量级锁
4.3 轻量级锁(轻量级锁加锁 + 轻量级锁解锁)
4.3.1 引入两个新概念:Lock Record + Displaced Mark Word
上面关于偏向锁,我们在介绍了对象头和Mark Word之后,都是拿同步锁对象来研究的,比如获取偏向锁的两个判断,同步锁对象中的thread id是否是当前线程?同步锁对象中的偏向字段是否为0?无锁情况下直接更新同步锁对象的 thread id 和 设置偏向字段为1。
这里介绍轻量级锁,引入一个新的名词 Lock Record 锁记录 和一个新的动词 Displaced Mark Word。
Displaced Mark Word定义:线程在执行同步块之前,JVM会先在当前线程的栈桢(方法调用栈)中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,这个动作称之为 Displaced Mark Word。
tip1:Displaced Mark Word 是一个动作,发生在线程执行同步块之前。
tip2:Lock Record 是一个名词,是当前线程的帧栈中的一个空间名,用来存放对象的对象头的mark word。
4.3.2 轻量级锁加锁(三步骤)
接上面的,thread2获得偏向锁失败,只能自旋,只有两个归宿,要么让 thread1 撤销/释放偏向锁,要么thread1 升级轻量级锁。
流程(偏向锁升级为轻量级锁,即轻量级锁加锁):
- 线程尝试使用CAS操作,将同步锁对象头中的Mark Word替换为指向锁记录的指针。
- 如果成功,当前线程获得锁,轻量级锁加锁成功。
- 如果失败,表示其他线程已经竞争到轻量级锁(金手指:这里thread1成功,获得到锁,thread2失败,表示thread1已经成功得到锁,只能自旋),当前线程便尝试使用自旋来获取锁。
偏向锁加锁和轻量级锁加锁:
偏向锁和轻量级锁,锁竞争失败方都是cas自旋获取锁,修改mark word,如果加锁失败,也都是自旋。
4.3.3 轻量级锁解锁(三步骤)
流程(轻量级锁解锁):
- 同步代码块执行完成,轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头;
- 如果成功,则表示没有竞争发生,轻量级锁解锁成功;
- 如果失败,表示当前锁存在竞争(金手指:下图中,线程1执行完代码块,轻量级锁释放失败,因为thread2在竞争锁),锁就会膨胀成重量级锁(下图中markword第四个010)。
轻量级锁加锁 + 轻量级锁解锁:
上图中同步锁对象头中的mark word,一共涉及三个,从上到下,无锁、轻量级锁、重量级锁
无论是偏向锁还是轻量锁,锁竞争失败方都会自旋,这里讲轻量级锁自旋,因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了,继续自旋尝试就是浪费,不会成功的),所以规定,一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时, 都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮 的夺锁之争。
小结:
轻量级锁:多个线程在不同时间段请求同一把锁,也就是基本不存在锁竞争。针对此种情况,JVM采用轻量级锁来避免线程的阻塞以及唤醒。
只要在同一时间内有线程去竞争锁,那么线程执行一次CAS操作,然后发现已经被别的线程抢占,直接升级为重量级锁,不在进行CAS操作,避免无用自旋(将锁升级为重量锁、阻塞自旋线程这两招就是为了避免无用自旋获取轻量锁)
轻量锁加锁 + 轻量锁解锁
轻量锁加锁:
线程在执行同步代码块之前,JVM先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的mark word字段直接复制到此空间中。然后线程尝试使用CAS将对象头的mark word替换为指向锁记录的指针(指当前线程),如果成功表示获取到轻量级锁。如果失败,表示其他线程竞争轻量级锁,当前线程便使用自旋来不断尝试。
轻量锁释放0:
解锁时,会使用CAS将复制的mark word替换回对象头,如果成功,表示没有竞争发生,正常解锁。如果失败,表示当前锁存在竞争,进一步膨胀为重量级锁(下图中markword第四个010)。
4.3.4 对比:偏向锁、轻量级、重量级锁
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 最乐观的锁,适用于一个线程访问同步代码块,偏向锁不适用与多个线程,因为多个线程竞争同步资源,一定会有失败,失败了就是偏向锁撤销,然后不但自旋,到达阈值epoche=40,就升级轻量级锁 |
轻量级锁 | 竞争的线程不会阻塞,不断自旋,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 响应时间很短,同步块执行速度非常快,适用于多个线程在不同时间段申请同一把锁 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。同步块执行速度较长。 |
重量级锁会阻塞,唤醒请求加锁的线程(这就是为了将锁升级重量级锁的原因,避免轻量锁无效自旋浪费CPU)。针对的是多个线程同一个时刻竞争同一把锁的情况,JVM采用自适应自旋,来避免线程在面对非常小的同步块时,仍会被阻塞以及唤醒。
轻量级锁采用CAS操作,将锁对象的标记字段替换为指向线程的指针,存储着锁对象原本的标记字段。针对的是多个线程在不同时间段申请同一把锁的情况。
偏向锁只会在第一次请求时采用CAS操作,在锁对象的mark word字段中记录下当前线程ID,此后运行中持有偏向锁的线程不再有加锁过程。针对的是锁仅会被同一线程持有
偏向锁是一个线程,多个线程大概率升级为轻量级锁,轻量级锁和重量级锁都是多个线程。
既然是一个线程,偏向锁有什么用?因为对象一开始就是偏向锁,遇到synchronized大概率变为轻量级锁,你也可以一开始就是轻量级锁
4.3.5 同步锁对象的对象头中的mark word变化(获取偏向锁+撤销偏向锁+升级为轻量锁之前+升级为轻量锁/轻量锁加锁+轻量锁解锁)
同步锁对象的对象头中的mark word
- 第一次获得偏向锁的时候,会将thread id放到栈帧中的锁记录Lock Record和同步锁对象的对象头中,其余次只要对比就好
- 撤销偏向锁的时候,如果持有偏向锁的线程不存活,栈中的锁记录lock record和同步锁对象的对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁(三种)
- 升级为轻量级锁之前,线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到刚刚新建的锁记录空间中;
- 轻量级锁加锁的时候,线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,表示轻量级锁加锁成功;
- 轻量级锁解锁的时候,使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,解锁成功,如果失败,升级为重量级锁。
4.4 重量级锁(重量级锁加锁 + 重量级锁解锁)(了解即可)
4.4.1 重量级锁的底层支持
4.4.1.1 ObjectMonitor类
基于HotSpot实现的JVM中,关于synchronized锁的实现是靠ObjectMonitor(对象监视器)实现的,当多个线程同时请求一个对象监视器(请求同一个锁)时,对象监视器将设置几个状态以用于区分调用线程。
底层ObjectMonitor类的常用属性(获取重量锁 + 释放重量锁 用到):
属性 | 意义 |
---|---|
_header | MarkOop对象头,对象头 |
_waiters | 等待线程数,int |
_recursions | synchronized可重入锁,记录重入次数,int |
_owner | 指向获得ObjectMonitor的线程,指针,重要 |
_WaitSet | 用于线程通信的集合,调用了java中的wait()方法会被放入其中,Set 等待集合 |
_cxq_EntryList | 用于线程同步的集合,多个线程尝试获取锁时,List |
4.4.1.2 节点ObjectWaiter类(双链表,链表节点中存放线程)
重量锁的并发竞争状态维护就是依靠三个队列来实现的,分别是 _WaitSet、_cxq | _EntryList 。这三个队列都是由ObjectWaiter类 实现的,其实就是双向链表实现(金手指:有_prev和_next指针),链表中每一个节点存放线程。
对于 _WaitSet、_cxq | _EntryList ,都是双链表,但是实现的功能不同。
_cxq | _EntryList 是用来实现线程同步的,
_WaitSet 是用来实现线程通信的,底层是等待队列,对应的Object类的 wait()/notify()/notifyAll() 方法,进入 _WaitSet 是失去参与同步锁竞争,弹出 _WaitSet 是有机会参与同步锁竞争。
4.4.1.3 线程同步:宏观synchronized 与 底层链表 _cxq|_EntryList
获取重量锁本质是修改_owner指针:当synchronized是重量锁的时候,线程获取锁底层实现就是改变_owner指针,让他指向自己。
对于上图中概念的解释:
Contention List:首先将锁定线程的所有请求放入竞争队列
OnDeck:任何时候只有一个线程是最具竞争力的,该线程称为OnDeck(由系统调度策略决定)
对于上图中流程的解释:
- Contention List 包括 _cxq和_EntryList,用户实现线程同步,将锁定线程的所有请求放入竞争队列,接收多个线程;
- 选择出最有竞争力的线程(由系统调度策略决定,所以说synchronized是非公平的独占锁),该线程称为 OnDeck ,修改 _owner 指针,指向这个 OnDeck 线程(即获得ObjectMonitor的线程)
- 线程执行过程中,如果有线程通信的要求,调用notify()释放一个 _WaitSet 集合中的线程(即ObjectWaiter对象),放到_EntryList中,允许其参与下一次同步锁竞争。
4.4.1.4 线程通信:宏观的wait()/notify()/notifyAll()与底层链表_WaitSet
宏观Object类的wait()/notify()/notifyAll()方法,其实是调用内核的方法实现的,他们的逻辑是:
- 调用wait()的线程加入_WaitSet中,即失去竞争同步锁的机会;
- 调用notify()唤醒_WaitSet链表节点中的线程,就可以重新得到竞争同步锁的机会。
- notify和notifyAll不同在于前者只唤醒一个线程,后者唤醒所有队列中的线程。
- 值得注意的是notify并不会立即释放锁,而是等到同步代码执行完毕。
4.4.2 重量锁加锁(ObjectMonitor类的enter()方法)
4.4.2.1 重量锁的获取过程,ObjectMonitor::enter
- 设置_owner字段,CAS操作成功表示获取锁 :通过CAS尝试把monitor的_owner字段设置为当前线程;
- 设置_recursions字段,记录重入次数 :如果设置之前的_owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ,记录重入的次数;
- 查看当前线程的锁记录空间中的Displaced Mark Word,即是否是该锁的轻量级锁持有者,如果是则是第一次加重量级锁,设置_recursions为1,_owner为当前线程,该线程成功获得锁并返回;
- 如果获取锁失败,则等待锁的释放。
4.4.2.2 ObjectMonitor::EnterI方法,自旋等待锁释放(核心:for循环)
monitor竞争失败的线程,通过自旋执行ObjectMonitor::EnterI方法等待锁的释放,EnterI方法的部分逻辑实现如下:
- 当前线程被封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ;
- 自旋CAS将当前节点使用头插法加入cxq队列
- node节点push到_cxq列表如果失败了,再尝试获取一次锁(因为此时同时线程加入,可以减少竞争),如果还是没有获取到锁,则通过park将当前线程挂起,等待被唤醒。
当被系统唤醒时,继续从挂起的地方开始执行下一次循环也就是继续自旋尝试获取锁。如果经过一定时间获取失败继续挂起。
4.4.3 重量锁解锁(ObjectMonitor类的exit()方法)
当某个持有锁的线程执行完同步代码块时,会进行锁的释放。在HotSpot中,通过改变ObjectMonitor的值来实现,并通知被阻塞的线程,具体实现位于ObjectMonitor::exit方法中。
- 初始化ObjectMonitor的属性值,如果是重入锁递归次数减一,等待下次调用此方法,直到为0,该锁被释放完毕。
- 根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog方法唤醒该节点封装的线程,唤醒操作最终由unpark完成。
4.4.4 Object对象(即任意对象)调用hashCode()、wait()方法会使锁直接升级为重量级锁
调用hashCode()、wait()方法会使锁直接升级为重量级锁(在看jvm源码注释时看到的),下面测试一下。
4.4.4.1 调用wait()直接升级为重量级锁
构造demo的想法:要使用wait(),就要配合notify()/notifyAll()唤醒,使用一个线程是做不到的,一定要使用两个线程。
调用wait方法
public class TestWait {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
O object = new O();
Thread thread1 = new Thread() {
@Override
public void run() {
synchronized (object) { // 因为thread2释放锁,所以这里thread1可以成功
System.out.println("thread1获取锁成功,开始执行,因为thread1调用了wait()方法,直接升级为重量级锁");
System.out.println("2\n" + ClassLayout.parseInstance(object).toPrintable());
object.notify();
}
}
};
Thread thread2 = new Thread() {
@Override
public void run() {
synchronized (object) {
System.out.println("thread2 获取偏向锁成功开始执行");
System.out.println("1\n" + ClassLayout.parseInstance(object).toPrintable()); //打印对象头
try {
object.wait(); // 阻塞thread2,并将object锁对象变为重量级锁,同时thread2释放锁,给机会thread1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
thread2.start(); // 先启动thread2
//让thread1执行完同步代码块中方法。
TimeUnit.SECONDS.sleep(3);
thread1.start();
}
}
测试结果
面试语言组织:调用wait()直接将偏向锁升级为重量级锁,构造demo很简单,新建两个线程,休眠5秒,启动第一个线程,打印对象头为偏向锁,调用wait()进入阻塞状态,释放锁,将锁直接设置为重量级锁,启动第二个线程打印对象头就可以知道
4.4.4.2 调用hashCode()直接升级为重量级锁
构造demo想法:调用hashcode只需要一个线程就好了,直接使用main线程,看代码
调用hashCode()
public class TestLightweightLock {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
O object = new O();
synchronized (object) { // objetc作为同步锁对象了,这句必须有,否则打印看不到锁升级
System.out.println("thread1 获取偏向锁成功,开始执行代码");
System.out.println(ClassLayout.parseInstance(object).toPrintable());
object.hashCode();
try {
//等待对象头信息改变
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("hashCode() 调用后");
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}
}
面试语言组织:调用hashcode()直接将偏向锁升级为重量级锁,构造demo很简单,新建一个线程,休眠5秒,启动线程,打印对象头为偏向锁,调用hashcode(),打印对象头为重量级锁。
测试结果
4.4.5 锁的降级(重量级锁降级为轻量级锁)
锁也可以降级,在安全点判断是否有线程尝试获取此锁,如果没有进行锁降级(重量级锁降级为轻量级锁,和之前在书中看到的锁只能升级不同,可能理解的意思不一样)。
测试代码如下,顺便测试了一下重量级锁升级
public class TestMonitor {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
O object = new O();
Thread thread1 = new Thread() {
@Override
public void run() {
synchronized (object) { // object作为同步锁对象
System.out.println("thread1 获得偏向锁");
System.out.println(ClassLayout.parseInstance(object).toPrintable());
try {
//让线程晚点儿死亡,造成锁的竞争
TimeUnit.SECONDS.sleep(6);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("thread2 获取锁失败导致锁升级,此时thread1还在执行");
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}
};
Thread thread2 = new Thread() {
@Override
public void run() {
synchronized (object) {
System.out.println("thread2 获取偏向锁失败,最终升级为重量级锁,等待thread1执行完毕,获取重量锁成功");
System.out.println(ClassLayout.parseInstance(object).toPrintable());
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
thread1.start(); // 启动thread1
//对象头打印需要时间,先让thread1获取偏向锁
TimeUnit.SECONDS.sleep(5);
//thread2去获取锁,因为t1一直在占用,导致最终升级为重量级锁
thread2.start();
//确保t1和t2执行结束
thread1.join();
thread2.join();
TimeUnit.SECONDS.sleep(1);
Thread t3 = new Thread(() -> {
synchronized (object) {
System.out.println("再次获取");
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
});
t3.start();
}
}
测试结果
t1和t2由于争抢导致锁升级为重量级锁,等待它们执行完毕,启动t3获取同一个锁发现又降级为轻量级锁。
五、实践:锁升级整个流程(偏向锁获取 + 偏向锁撤销 + 轻量锁加锁 + 轻量锁解锁)
5.1 实践:验证偏向锁默认开启,但是有4s启动延迟
理论:java6以后默认开启偏向锁,但是偏向锁要在应用程序启动几秒钟之后才激活。
使用JOL工具类,打印对象头
添加maven依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.8</version>
</dependency>
新建O类和TestInitial类测试,设置启动参数-XX:+PrintFlagsFinal
class O {
int a = 1;
}
public class TestInitial {
public static void main(String[] args) {
O object = new O();
//打印对象头 object header = mark word 无锁存放hashcode,有锁存放锁信息 + Class Metadata Address 对象类型数据指针 + array length 数组长度
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}
运行结果(很长,不完成粘贴,只要分析部分):
结果如下,重点关注红框内的内容,偏向标志位true,表示默认是开启偏向锁的,第一个验证完成
0 1 2 3 表示48=32bit Markdown 16进制表示未 01 00 00 00 二进制表示为后面,红框中最后三位 0 01 表示无锁
4 5 6 7 表示48=32bit
8 9 12 11 表示4*8=32bit Array length 数组长度
12 13 14 15 表示int类型 4 *8 =32bit 4个字节 int O.a value为1
com.O object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) c2 c0 00 20 (11000010 11000000 00000000 00100000) (536920258)
12 4 int O.a 1
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
为什么打印出来的对象头的第一个字节末尾三位是001,未加锁?
我们的偏向锁明明是开启的,这是因为由4s中的延时开启,这一设计的目的是因为程序在启动初期需要初始化大量类,此时会发生大量锁竞争,如果开启偏向锁,在冲突时锁撤销要耗费大量时间。
修改TestInitial程序,第一行添加延时5s
class O {
int a = 1;
}
public class TestInitial {
public static void main(String[] args) throws Exception {
TimeUnit.SECONDS.sleep(5);
O object = new O();
//打印对象头 object header = mark word 无锁存放hashcode,有锁存放锁信息 + Class Metadata Address 对象类型数据指针 + array length 数组长度
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}
测试结果如下
如上测试结果中:
0 1 2 3 表示48=32bit Markdown 16进制表示未 05 00 00 00 二进制表示为后面,红框中最后三位 101 表示偏向锁
4 5 6 7 表示48=32bit
8 9 12 11 表示4*8=32bit Array length 数组长度
12 13 14 15 表示int类型 4 *8 =32bit 4个字节 int O.a value为1
这里打印的是对象头,因为没有synchronized,所以根本就不访问同步代码块,object对象根本就没有成为同步锁对象,只是一个普通的Object对象(后面的程序,访问synchronized同步块并将object作为同步锁对象并打印其对象头),所以object对象头里面的就是 00000000 00000000 00000000 00000101 线程id为全0,因为object没有作为同步锁对象,main线程不需要访问同步代码块
可以发现过了偏向锁延时启动时间后,我们再创建对象,对象头锁状态变成了偏向锁
解释:
刚才是001,对象头锁状态变成了无锁,
现在延迟5s,对象头锁状态变成了偏向锁
问题1:默认开启偏向锁的原因?
解释:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,所以,Java并发中为了让线程获得锁的代价更低而引入了偏向锁。jdk6引入偏向锁,就是支持使用无锁-偏向锁-轻量级锁-重量级锁这个结构,所以默认偏向锁开启。
问题2:偏向锁延迟4秒的原因?
解释:因为程序在启动初期需要初始化大量类,此时会发生大量锁竞争,如果开启偏向锁,在冲突时锁撤销要耗费大量时间。
5.2 实践:同一个线程中,main线程偏向锁的释放和再次获取,第二次只需要判断就好,不需要cas(只涉及偏向锁,验证获取偏向锁的两个if判断)
解释器执行monitorenter时会进入到InterpreterRuntime.cpp的InterpreterRuntime::monitorenter函数,具体实现如下:
synchronizer.cpp文件的ObjectSynchronizer::fast_enter函数:
BiasedLocking::revoke_and_rebias函数过长,下面就简单分析下(着重分析一个线程先获得锁,下面会通过实验来验证结论)
当线程访问同步块时首先检查同步锁对象的对象头中是否存储了当前线程(和java中的ThreadId不一样),如果有则直接执行同步代码块。
即此时JavaThread*指向当前线程id如果没有,查看对象头是否是偏向锁且指向线程id为空,如下:
测试代码
public class TestBiasedLock {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
O object = new O();
synchronized (object) {
System.out.println("1\n" + ClassLayout.parseInstance(object).toPrintable());
}
TimeUnit.SECONDS.sleep(1);
System.out.println("2\n" + ClassLayout.parseInstance(object).toPrintable());
}
}
测试结果
注意:这个二进制是一个个字节从后往前读的:
十进制的50321413,就是二进制的 00000010 11111111 11011000 00000101
00000010 11111111 11011000 00000101 作为32bit就是
线程id 23 + 偏向时间戳 2 + 分代年龄 4 = 29 + lock 3
线程id 00000010 11111111 1101100 (这里打印的是对象头,如果没有synchronized,根本就不访问同步代码块,那么object对象头里面的就是 00000000 00000000 00000000 00000101 线程id为全0,因为object没有作为同步锁对象,main线程不需要访问同步代码块)
epoch偏向时间戳 0 0
age分代年龄 0000
锁信息 101
第一部分和第二部分,对象头都是 101
第一个因为延迟5s,所以已经执行了4s的偏向启动延迟,所以是101 上面解释过了
结合初始化的测试,我们可以得知偏向锁的获取方式。
第一次,CAS设置当前对象头指向自己,如果成功,则获得偏向锁(t1获得了偏向锁)开始执行代码。
第二次,知道了拥有偏向锁的线程在执行完成后,偏向锁JavaTherad*依然指向第一次的偏向。
5.3 实践:t2线程升级为轻量级锁
问题1:偏向锁什么时候升级为轻量级锁?
回答1:偏向锁中epoche偏向时间戳:Epoch 默认最大值为40,到超过40后会变成轻量级锁。
问题2:为什么锁可以升级不能降级?
这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率
t2撤销偏向锁升级为轻量级锁:t2尝试获取偏向锁,此时对象头指向的不是自己(指向t1,而不是t2),开始撤销偏向锁, 升级为轻量级锁。偏向锁的撤销,需要等待全局安全点,然后检查持有偏向锁的线程(t1)是否活着。
偏向锁撤销两条件:获取偏向锁失败+全局安全点safepoint
(1). 如果t1存活:让该线程(t1)获取轻量级锁,将对象头中的Mark Word替换为指向锁记录的指针,然后唤醒被暂停的线程。 也就是说将当前锁(即t1持有的偏向锁)升级为轻量级锁,并且让之前持有偏向锁的线程(t1)继续持有轻量级锁。
(2). 如果t1已经死亡:将t1对象头设置成无锁状态
偏向锁升级为轻量级锁之后,t1继续持有轻量级锁;之前尝试获取偏向锁失败引发锁升级的线程(t2)继续尝试获取轻量级锁,具体做法是:线程2 尝试使用 CAS将对象头中的Mark Word替换为指向锁记录的指针,如果失败,开始自旋(即重复获取一定次数),在自旋过程中过CAS设置成功,则成功获取到轻量锁对象。
tip1:tips:在当前线程的栈桢中然后创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。
tip2:JVM中采用的是自适应自旋锁,即如果第一次自旋获取锁成功了,那么在下次自旋时,自旋次数会适当增加,采用自旋的原因是尽量减少内核用户态的切换,也就是说t2尝试获取偏向锁失败,导致偏向锁的撤销,撤销后,线程(t2)继续尝试获取轻量级锁(这就是自适应自旋,减少内核用户态的切换)。
public class TestLightweightLock3 {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
O object = new O();
Thread thread1 = new Thread() {
@Override
public void run() {
synchronized (object) { // 访问同步代码块
System.out.println("thread1 获取偏向锁成功");
System.out.println(ClassLayout.parseInstance(object).toPrintable()); // object同步锁的持有对象是thread1,状态为偏向锁
}
}
};
Thread thread2 = new Thread() {
@Override
public void run() {
synchronized (object) { // 线程2访问同步代码块
System.out.println("thread2 获取偏向锁失败,升级为轻量级锁,获取轻量级锁成功");
System.out.println(ClassLayout.parseInstance(object).toPrintable()); // object同步锁的持有对象是thread2,状态为轻量级锁
}
}
};
thread1.start();
thread1.join(); //让thread1死亡
// 中间没有Thread.sleep() thread1没有足够时间打印对象头,就让thread2启动,造成竞争,从而将偏向锁撤销,升级为轻量级锁
thread2.start();
thread2.join(); //thread2死亡
System.out.println("thread2执行结束,释放轻量级锁");
System.out.println(ClassLayout.parseInstance(object).toPrintable()); // 最后打印同步锁
}
}
上述测试的是,thread1获取了偏向锁,JavaThread*指向thread1。thread2在thread1执行完毕后尝试获取偏向锁,发现该偏向锁指向thread1,因此开始撤销偏向锁,然后尝试获取轻量级锁。
测试结果
t1先执行获取偏向锁成功,开始执行。
t2获取偏向锁失败,升级为轻量级锁
问题:第一个打印,为什么持有锁的是thread1?
回答:thread1是第一个访问synchronized同步代码块,将object对象设置为同步锁对象的(main线程没有访问synchronized同步代码块将object对象设置为同步锁对象,thread2在后面才这样做),所以持有object同步锁的是thread1。
问题:第一个打印,为什么object同步锁对象的对象头为偏向锁?
回答:启动的时候延迟5秒,所以是偏向锁。
问题:第二个打印,为什么持有object同步锁的是thread2?
回答:main线程中,先执行 thread1.join(); 再执行thread2.start(); ,此时thread1合并到主线程,thread2一定可以竞争到object对象。
问题:第二个打印,为什么object同步锁对象的对象头为轻量级锁?
回答:main线程中,先执行 thread1.join(); 再执行thread2.start(); ,但是 中间没有Thread.sleep() thread1没有足够时间打印对象头,就让thread2启动,造成竞争,从而将偏向锁撤销,升级为轻量级锁,所以thread2获取到的是轻量级锁。
t2获取轻量级锁成功,执行同步代码块
问题:第三个打印,为什么线程id全为0?
回答:同步代码块执行结束,object不再是同步锁对象,而是一个普通的Object对象,所以线程id全为0;
问题:第三个打印,为什么为001,无锁状态?
回答:thread2释放轻量级锁的时候,使用原子的CAS操作将Displaced Mark Word替换回到对象头,这里成功,没有竞争发生,变成了无锁。
t2在自旋过程中成功获取了轻量级锁,那么t2开始执行。此时对象头格式为: 00 轻量级锁;
在t2执行结束后,释放轻量级锁,锁状态为 001 无锁。
5.4 实践:t2线程升级为轻量级锁,然后自旋未获取轻量级,升级为重量级锁
如果t2在自旋过程中未能获得轻量锁,达到阈值,那么此时膨胀为重量级锁,将当前轻量级锁标志位变为(10)重量级,创建objectMonitor对象,让t1持有重量级锁。然后当前线程开始阻塞。
public class TestMonitor {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5); //延迟5s
O object = new O();
Thread thread1 = new Thread() {
@Override
public void run() {
synchronized (object) { // object作为同步锁的锁对象
System.out.println("thread1 获得偏向锁");
System.out.println(ClassLayout.parseInstance(object).toPrintable()); // 打印对象头
try {
//让线程晚点儿死亡,造成锁的竞争
TimeUnit.SECONDS.sleep(6); // 同步代码块长久一点,就是thread1持有锁的时间长久一点
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("thread2 获取锁失败导致锁升级,此时thread1还在执行");
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}
};
Thread thread2 = new Thread() {
@Override
public void run() {
synchronized (object) {
System.out.println("thread2 获取偏向锁失败,最终升级为重量级锁,等待thread1执行完毕,获取重量锁成功");
System.out.println(ClassLayout.parseInstance(object).toPrintable());
try {
TimeUnit.SECONDS.sleep(3); // thread2打印对象头,需要点时间
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
thread1.start();
//对象头打印需要时间,先让thread1获取偏向锁
TimeUnit.SECONDS.sleep(5);
thread2.start();
}
}
测试结果
总结:至此锁升级已经介绍完毕,接下来在介绍一下重量级锁的实现机制ObjectMonitor即可。再次梳理整个过程(主要是一个线程t1已经获得锁的情况下,另一个线程t2去尝试获取锁):
- t2尝试获取偏向锁,发现偏向锁指向t1,获取失败
- 失败后开始偏向锁撤销,如果t1还存活将轻量级锁指向它,它继续运行;t2尝试获取锁,开始自旋等待t1释放轻量级锁。
- 如果在自旋过程中t1释放了锁,那么t2获取轻量级锁成功。
- 如果在自旋结束后,t2未能获取轻量锁,那么锁升级为重量级锁,使t1持有objectmonitor对象,将t2加入EntryList,t2开始阻塞,等待t1释放监视器
六、面试金手指(synchronizd底层实现:为面试而准备的内容,语言文字为主)
6.1 synchronizd底层实现:synchronized五种用法和同步锁对象头的五种状态
6.1.1 synchronized五种用法(多线程+锁对象)
修饰目标 | 锁 | |
---|---|---|
方法 | 实例方法 | 当前实例对象(即方法调用者) |
静态方法 | 类对象 | |
代码块 | this | 当前实例对象(即方法调用者) |
class对象 | 类对象 | |
任意Object对象 | 任意示例对象 |
五种情况主要是方法级别锁和代码级别锁,还有就是锁对象的不同,方法级别锁和代码级别锁很好理解,看代码就懂,锁对象不同是什么意思?
第一种情况和第三种情况,形成锁竞争:普通方法和代码块中使用this是同一个监视器(锁),即某个具体调用该代码的对象
第二种情况和第四种情况,形成竞争:静态方法和代码块中使用该类的class对象是同一个监视器,任何该类的对象调用该段代码时都是在争夺同一个监视器的锁定
小结:同步两因素:第一多线程,第二锁对象:
- 多线程:就是因为存在不止一个线程才能形成竞争,只有一个线程你和谁竞争,所以一旦设置同步,多线程必不可少;
- 锁对象:锁对象表示的是竞争的对象,即多个线程之间竞争什么,竞争的对象相同才能形成竞争,竞争的对象不同是无法形成竞争的,举一反三,比如非同步方式(没有synchronized修饰),就啥都不竞争,和同步原子没半毛钱关系,代码中通过对竞争的资源加锁解锁形成原子操作,所以一旦涉及同步,锁对象必不可少。
6.1.2 synchronized代码级别和方法级别区别
synchronized代码级别锁底层:monitorenter+monitorexit
synchronized方法级别锁底层:ACC_SYNCHRONIZED
如果synchronized在方法上,底层使用ACC_SYNCHRONIZED修饰该方法,然后在常量池中获取到锁对象,实际实现原理和同步块一致
6.1.3 对象头 + Mark Word
对象头三个部分:
对象头=Mark Word + Class Metadata Address + Array length,三个每一个占一个字宽。
Markdown存放对象的hashCode或锁信息;(无锁状态:29bit hashcode+ 3bit lock)
Class Metadata Address存放存储到对象类型数据的指针;
ArrayLength,数组类型特有,存放数组类型长度。
小结:五种状态:
无锁状态:
hashcode 哈希码 29bit
biased_lock: 偏向锁标识位,1bit
lock: 锁状态标识位,2bit 01偏向锁(线程id 23 + 偏向时间戳 2 + 分代年龄 4 = 29 + lock 3):
JavaThread: 保存持有偏向锁的线程ID,23bit
epoch: 保存偏向时间戳,2bit(到40,升级为轻量级锁)
age: 保存对象的分代年龄,4bit
biased_lock: 偏向锁标识位,1bit
lock: 锁状态标识位,2bit 01
其中,线程id 23 + 偏向时间戳 2 + 分代年龄 4 都是偏向锁特有,后两个都是涉及时间轻量锁和重量锁(30+2):
ptr: monitor的指针(就是锁指针,同步代码块的锁由monitorenter和monitorexit完成,同步方法的锁由ACC_SYNCHRONIZED修饰,底层是一致的),30bit
lock: 锁状态标识位,2bit 00 10GC标志(30+2)
空,30bit
lock: 锁状态标识位,2bit 11
记忆方法:偏向锁和无锁一起记忆,其他三个,轻量级锁和重量级锁一起记忆。
6.2 synchronizd底层实现:锁升级
6.2.1 锁升级的由来
1、只能升级不能降级,目的是为了提高 获得锁和释放锁的效率
2、升级顺序:无锁状态 0 01、偏向锁状态101、轻量级锁状 态000和重量级锁状态010
6.2.2 偏向锁获取 + 偏向锁撤销/解锁 + 偏向锁关闭
获取偏向锁:
关于获取偏向锁,就是两个对于同步锁对象的判断,同步锁对象中是否存储当前线程id?是的话直接执行同步代码块,不是的话判断同步锁对象中偏向标志是否为0,为0表示当前无锁,直接设置偏向标志为1和线程id即可,不为0表示已经有其他线程持有偏向锁,然后自旋,自旋两个结果,锁竞争失败方撤销偏向锁或者将锁更新为轻量级锁。
偏向锁撤销的两个阶段(考虑撤销 + 执行撤销):
- 考虑撤销:等待有线程尝试竞争偏向锁,才会考虑撤销。
- 执行撤销:考虑撤销完毕后,如果是确定要撤销,一定要等到JVM的safepoint点,才会执行撤销,因为这里没有正在执行的字节码。
tip:这里是第一点和第二点使用了考虑撤销和执行撤销,有先后顺序,考虑撤销并决定撤销后才会在safepoint执行撤销偏向锁,因为这里没有正在执行的字节码。
偏向锁撤销流程:
- 首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着;
- 如果线程活动状态,则将同步锁对象头设置成无锁状态;
- 如果线程非活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录lock record,栈中的锁记录lock record和同步锁对象的对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁;
- 最后唤醒暂停的线程。
6.2.3 轻量锁加锁 + 轻量锁解锁
轻量锁加锁流程(偏向锁升级为轻量级锁,即轻量级锁加锁):
- 线程尝试使用CAS操作,将同步锁对象头中的Mark Word替换为指向锁记录的指针。
- 如果成功,当前线程获得锁,轻量级锁加锁成功。
- 如果失败,表示其他线程已经竞争到轻量级锁(金手指:这里thread1成功,获得到锁,thread2失败,表示thread1已经成功得到锁,只能自旋),当前线程便尝试使用自旋来获取锁。
偏向锁加锁和轻量级锁加锁:
偏向锁和轻量级锁,锁竞争失败方都是cas自旋获取锁,修改mark word,如果加锁失败,也都是自旋。
轻量锁解锁流程:
- 同步代码块执行完成,轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头;
- 如果成功,则表示没有竞争发生,轻量级锁解锁成功;
- 如果失败,表示当前锁存在竞争(如线程1执行完代码块,轻量级锁释放失败,因为thread2在竞争锁),锁就会膨胀成重量级锁。
6.2.4 重量锁加锁 + 重量锁解锁 + 调用wait()和hashcode()直接变为重量锁
重量级锁加锁:
- _owner字段 :通过CAS尝试把monitor的_owner字段设置为当前线程;
- synchronized和lock都是可重入锁 _owner、_recursions:如果设置之前的_owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ,记录重入的次数;
- 查看当前线程的锁记录空间中的Displaced Mark Word,即是否是该锁的轻量级锁持有者,如果是则是第一次加重量级锁,设置_recursions为1,_owner为当前线程,该线程成功获得锁并返回;
- 如果获取锁失败,则等待锁的释放;
重量级锁释放:
- 初始化ObjectMonitor的属性值,如果是重入锁递归次数减一,等待下次调用此方法,直到为0,该锁被释放完毕。
- 根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog方法唤醒该节点封装的线程,唤醒操作最终由unpark完成。
调用wait()的线程加入_WaitSet中,然后等待notify唤醒他们,重新加入到锁的竞争之中,值得注意的是notify并不会立即释放锁,而是等到同步代码执行完毕。
调用wait()和hashcode()直接变为重量锁:
- 调用wait()变为重量锁:调用wait()直接将偏向锁升级为重量级锁,构造demo很简单,新建两个线程,休眠5秒,启动第一个线程,打印对象头为偏向锁,调用wait()进入阻塞状态,释放锁,将锁直接设置为重量级锁,启动第二个线程打印对象头就可以知道
- 调用hashcode()变为重量锁:调用hashcode()直接将偏向锁升级为重量级锁,构造demo很简单,新建一个线程,休眠5秒,启动线程,打印对象头为偏向锁,调用hashcode(),打印对象头为重量级锁。
锁也可以降级,在安全点判断是否有线程尝试获取此锁,如果没有进行锁降级
七、小结
synchronized关键字全解析,完成。
天天打码,天天进步!!!
【Java并发004】原理层面:synchronized关键字全解析的更多相关文章
- Java并发编程之三:volatile关键字解析 转载
目录: <Java并发编程之三:volatile关键字解析 转载> <Synchronized之一:基本使用> volatile这个关键字可能很多朋友都听说过,或许也都用过 ...
- Java并发之synchronized关键字深度解析(二)
前言 本文继续[Java并发之synchronized关键字深度解析(一)]一文而来,着重介绍synchronized几种锁的特性. 一.对象头结构及锁状态标识 synchronized关键字是如何实 ...
- Java并发编程原理与实战五:创建线程的多种方式
一.继承Thread类 public class Demo1 extends Thread { public Demo1(String name) { super(name); } @Override ...
- [Java并发] AQS抽象队列同步器源码解析--锁获取过程
要深入了解java并发知识,AbstractQueuedSynchronizer(AQS)是必须要拿出来深入学习的,AQS可以说是贯穿了整个JUC并发包,例如ReentrantLock,CountDo ...
- [Java并发] AQS抽象队列同步器源码解析--独占锁释放过程
[Java并发] AQS抽象队列同步器源码解析--独占锁获取过程 上一篇已经讲解了AQS独占锁的获取过程,接下来就是对AQS独占锁的释放过程进行详细的分析说明,废话不多说,直接进入正文... 锁释放入 ...
- Java并发编程原理与实战九:synchronized的原理与使用
一.理论层面 内置锁与互斥锁 修饰普通方法.修饰静态方法.修饰代码块 package com.roocon.thread.t3; public class Sequence { private sta ...
- Java并发编程-深入探讨synchronized实现原理
synchronized这个关键字对应Java程序猿来说是非常的熟悉,只要遇到要解决线程安全问题的地方都会使用这个关键字.接下来一起来探讨一下synchronized到底时怎么实现线程同步,使用syn ...
- 并发系列2:Java并发的基石,volatile关键字、synchronized关键字、乐观锁CAS操作
由并发大师Doug Lea操刀的并发包Concurrent是并发编程的重要包,而并发包的基石又是volatile关键字.synchronized关键字.乐观锁CAS操作这些基础.因此了解他们的原理对我 ...
- Java并发编程原理与实战十九:AQS 剖析
一.引言在JDK1.5之前,一般是靠synchronized关键字来实现线程对共享变量的互斥访问.synchronized是在字节码上加指令,依赖于底层操作系统的Mutex Lock实现.而从JDK1 ...
- Java并发编程学习:volatile关键字解析
转载:https://www.cnblogs.com/dolphin0520/p/3920373.html 写的非常棒,好东西要分享一下 Java并发编程:volatile关键字解析 volatile ...
随机推荐
- PostgreSQL 修改执行计划 GroupAggregate 为 HashAggregate
1.前言 PostgreSQL 聚合算法有两种,HashAggregate and GroupAggregate .我们知道GroupAggregate 需要对记录进行排序,而 HashAggrega ...
- Kubernetes Operator: Operator
Operator 就可以看成是 CRD 和 Controller 的一种组合特例,Operator 是一种思想,它结合了特定领域知识并通过 CRD 机制扩展了 Kubernetes API 资源,使用 ...
- Elasticsearch 堆内存
转载自:https://www.lbbniu.com/6148.html 1.什么是堆内存? Java 中的堆是 JVM 所管理的最大的一块内存空间,主要用于存放各种类的实例对象. 在 Java 中, ...
- Docker 部署 JIRA(破解版)
一. 说明 1.1 素材 本文采用素材如下: Docker镜像 Github链接(https://github.com/cptactionhank) 破解工具 Gitee链接(https://gite ...
- 报错 Invalid options in vue.config.js: "baseUrl" is not allowed 问题解决
报错 Invalid options in vue.config.js: "baseUrl" is not allowed vue3.0版本中 执行 npm run build会出 ...
- jmeter录制登录脚本
1.添加代理服务器 在非测试元件添加http代理服务器,端口写8888,域写127.0.0.1 在排除模式里填入.*.(js|css|PNG|jpg|ico|png|gif|woff|ttf).* 2 ...
- 使Squashfs可写的办法
yiyi@yiyi-HP-Pavilion-Notebook:~/Applications/Office2016$ pwd /home/yiyi/Applications/Office2016 yiy ...
- InetAddress.getLocalHost() 执行很慢?
背景介绍 某次在 SpringBoot 2.2.0 项目的一个配置类中引入了这么一行代码: InetAddress.getLocalHost().getHostAddress() 导致项目启动明显变慢 ...
- 微信小程序开发优化
一.开发优化一 1.使用Vant Weapp 1.1 什么是Vant Weapp Vant Weapp官网链接 Vant Weapp是有赞前端团队开源的一套小程序UI组件库,助力开发者快速搭建小程序应 ...
- <一>关于进程虚拟地址空间区域内存划分和布局
C++代码在编译完成后会生产.exe程序(windows平台), .EXE以文件的形式存储在磁盘上,当运行.exe程序的时候 操作系统会将磁盘上的.exe文件加载到内存中,那么在加载到内存中的时候,操 ...