文章目录
- 【JUC并发编程系列】深入理解Java并发机制:Synchronized机制深度剖析、HotSpot下的并发奥秘(四、synchronized 原理分析)
- 1. 虚拟机环境
- 2. 基本数据类型占多少字节
- 3. JVM对象头
- 3.1 Klass Pointer
- 3.2 实例属性
- 3.3 对齐填充
- 3.4 查看Java对象布局
- 3.5 论证压缩效果
- 3.6 New 一个对象占用多少字节
- 3.7 HotSpot源码分析
- 3.8 对象头详解
- 3.8.1 获取HashCode
- 3.8.2 对象状态
- 3.8.2.1 偏向锁
- 3.8.2.2 轻量锁
- 3.8.2.3 重量锁
- 4. 字节码文件分析
- 4.1 monitorenter(获取锁)
- 4.2 monitorexit
- 4.3 ACC_SYNCHRONIZED
- 5. ObjectMonitor源码解读
- 5.1 锁池
- 5.2 等待池
- 5.3 wait与notify原理分析
- 6. Hotspot源码解读
- 6.1 synchronized底层实现原理总结
- 6.2 偏向锁原理分析
- 6.2.1 偏向锁撤销
- 6.2.2 批量重偏向
- 6.2.3 批量撤销
- 6.3 轻量锁原理分析
- 6.4 重量锁原理分析
- 6.5 锁粗化
- 6.6 锁消除
- 6.7 JDK15 默认关闭偏向锁优化原因
【JUC并发编程系列】深入理解Java并发机制:Synchronized机制深度剖析、HotSpot下的并发奥秘(四、synchronized 原理分析)
1. 虚拟机环境
注意JDK的环境统一安装:jdk-8u291-windows-x64 避免出现结果不一样。
错误
正确
Java HotSpot™ 64-Bit Server VM (build 25.291-b10, mixed mode)
2. 基本数据类型占多少字节
32位还是64位
64位虚拟机 对象头占用16个字节— 没有压缩指针
32位虚拟机 对象头占用8个字节
New 一个java对象 在开启压缩指针的情况下
对象头===12字节
64位换算位===8个字节 ----Mark Word
-
bit --位:位是计算机中存储数据的最小单位,指二进制数中的一个位数,其值为“0”或“1”。
-
byte --字节:字节是计算机存储容量的基本单位,一个字节由8位二进制数组成。在计算机内部,一个字节可以表示一个数据,也可以表示一个英文字母,两个字节可以表示一个汉字。
64位 ==8个字节
64位===多少字节 ==8个字节
1Byte=8bit (1B=8bit) 8
1KB=1024Byte(字节)=8*1024bit
1MB=1024KB
1GB=1024MB
基本数据类型 | 位(bit) | 字节(byte) |
---|---|---|
int | 32bit | 4byte |
short | 16bit | 2byte |
long | 64bit | 8byte |
byte | 8bit | 1byte |
char | 16bit | 2byte |
float | 32bit | 4byte |
double | 64bit | 8byte |
boolean | 1bit | — |
3. JVM对象头
New 一个对象占用多少字节呢?
一个对象如何组成的?
-
对象头:
-
Mark Word
-
Klass Pointer
-
-
实例数据:成员属性
-
对齐填充:要保证一个对象的内存大小为8的倍数
偏向锁/轻量锁/重量锁 锁状态存放在我们对象什么地方中?
在Java中,对象的锁状态是通过对象头(Object Header)来存储和管理的。对象头是每个Java对象实例的一部分,它不包含用户定义的字段,而是用于支持虚拟机实现类的内存布局管理、垃圾收集等特性。对于锁的升级机制来说,对象头中包含了Mark Word,这是存储锁状态的关键部分。
下面是不同锁状态下Mark Word的内容概述:
-
无锁状态:
- 在这个阶段,Mark Word 中存储的是对象的HashCode、分代年龄等信息。
-
偏向锁状态:
- 当一个线程访问同步块并获取到锁之后,Mark Word 中会记录这个线程的身份标识(Thread ID),以及一些额外的标志位表示这是一个偏向锁。
-
轻量级锁状态:
- 当其他线程尝试获取同一个锁时,如果锁还处于偏向状态,那么锁将被升级为轻量级锁。此时,Mark Word 中不再存储Thread ID,而是存储指向当前线程栈中的锁记录指针(Lock Record Pointer)。
-
重量级锁状态:
- 如果轻量级锁竞争失败(即有多个线程同时竞争同一个锁),那么锁将会升级为重量级锁。此时,Mark Word 中会存储一个指向 Monitor 对象的指针。Monitor 对象是一个位于堆外内存的数据结构,用来管理更复杂的锁定行为,比如等待队列和条件变量等。
需要注意的是,Mark Word 的具体布局和内容可能会根据不同的JVM实现而有所不同。例如,在64位系统上,Mark Word 可能只占用一个指针大小的空间,而在32位系统上可能占用更少的空间。此外,JVM可以通过指针压缩等技术来节省空间。
总的来说,锁的状态和相关信息都存储在对象头中的Mark Word里,并随着锁的升级过程而改变其内容。
在JVM中,对象在内存中的布局分为三个部分:对象头、实例数据和对齐填充
**HotSpot
虚拟机的对象头(Object Header
)包括两部分信息:**
-
第一部分
"Mark Word"
:用于存储对象自身的运行时数据, 如哈希码(HashCode
)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等. -
第二部分
"Klass Pointer"
:对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。(数组,对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。 )
注意:
-
markword
: 32位 占4字节 ,64位 占 8字节 -
klasspoint
: 开启压缩占4字节,未开启压缩 占 8字节。 -
64位 压缩前:对象头=8+8=16个字节
-
64位 压缩后:对象头=8+4=12个字节—额外补充4个字节
3.1 Klass Pointer
这一部分用于存储对象的类型指针,该指针指向它的类元数据,jvm通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。
如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64的JVM将会比32位的JVM多耗费50的内存。为了节约内存可以使用选项 -XX:+UseCompressedOops
开启指针压缩。其中 oop即ordinary object pointer
普通对象指针。
-XX:+UseCompressedOops
开启指针压缩
-XX:-UseCompressedOops
不开启指针压缩
对象头:Mark Word+Klass Pointer
类型指针 未开启压缩的情况下
32位 Mark Word =4bytes
,类型指针 4bytes
,对象头=8bytes =64bits
64位 Mark Word =8bytes
,类型指针 8bytes
,对象头=16bytes=128bits;
注意:默认情况下,开启了指针压缩 可能只有12字节,必须是8字节的整数倍
所以会额外补充4个字节。
3.2 实例属性
就是定义类中的成员属性
3.3 对齐填充
对齐填充并不是必然存在的,也没有特定的含义,仅仅起着占位符的作用。
由于HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。
3.4 查看Java对象布局
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
public class Test02 extends Thread {
static class TestLock {
}
private TestLock testLock = new TestLock();
@Override
public void run() {
}
public void create() {
synchronized (testLock) {
}
}
public static void main(String[] args) {
TestLock testLock = new TestLock();
System.out.println(Integer.toHexString(testLock.hashCode()));
System.out.println(ClassLayout.parseInstance(testLock).toPrintable());
}
}
3.5 论证压缩效果
-
启用指针压缩
-XX:+UseCompressedOops
(默认开启) -
禁止指针压缩:
-XX:-UseCompressedOops
- 默认开启指针压缩
Object objectLock = new Object();
System.out.println(ClassLayout.parseInstance(objectLock).toPrintable());
- 关闭指针压缩
3.6 New 一个对象占用多少字节
public class Test03 {
static class A {
int i = 1;
double j = 1.0;
boolean k = true;
}
public static void main(String[] args) {
A a = new A();
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
在开启了指针压缩的情况下:
-
A 对象头 12个字节
-
实例数据 int j=4 4个字节 long i=1 8个字节 boolean m=false 1个字节
-
对齐补充 7个字节。
-
总共32个字节。
3.7 HotSpot源码分析
HotSpot----阿里巴巴
3.8 对象头详解
注意:该描述是为64位虚拟机
-
哈希值:31位的对象标识hashCode,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。它是一个地址,用于栈对堆空间中对象的引用指向,不然栈是无法找到堆中对象的
-
GC分代年龄(占4位):记录幸存者区对象被GC之后的年龄age,一般age为15(阈值为15的原因是因为age只有4位最大就可以将阈值设置15)之后下一次GC就会直接进入老年代,要是还没有等到年龄为15,幸存者区就满了怎么办,那就下一次GC就将大对象或者年龄大者直接进入老年代。
-
锁状态标志:记录一些加锁的信息(我们都是使用加锁的话,在底层是锁的对象,而不是锁的代码,锁对象的话,那会改变什么信息来表示这个对象被改变了呢?也就是怎么才算加锁了呢?
4位字符编码的最大值
如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
3.8.1 获取HashCode
hashCode
是懒加载的底层是C++实现的
public class Test03 {
private static Object objectLock = new Object();
public static void main(String[] args) throws InterruptedException {
System.out.println(Integer.toHexString(objectLock.hashCode()));
System.out.println("此时对象头中是正常的 hashCode");
System.out.println(ClassLayout.parseInstance(objectLock).toPrintable());
synchronized (objectLock) {
System.out.println("此时对象头中是没有 hashCode 对象头中存的是Lock Record(锁记录对象)的地址");
System.out.println(Integer.toHexString(objectLock.hashCode()));
System.out.println(ClassLayout.parseInstance(objectLock).toPrintable());
}
Thread.sleep(5000);
System.out.println("此时对象头中是正常的 hashCode");
System.out.println(Integer.toHexString(objectLock.hashCode()));
System.out.println(ClassLayout.parseInstance(objectLock).toPrintable());
}
}
- 刚开始是:无锁状态,
hashCode
是在对象(Object
)的Mark Word
中
-
当线程中出现锁 (
synchronized
)的情况下: 此时是轻量级锁状态,对象(Object
)的Mark Word
中的hashCode
值替换为LockRecod
(锁记录对象)地址,而对象(Object
)的hashCode
值放到了LockRecod
中(因为对象(Object
)不够存)
详细解释:
- 创建锁记录(
Lock Record
)对象,每个线程的栈帧(方法)都会包含一个锁记录的结构,内部可以储存锁定关联对象的Mark Word
。 - 锁记录中
Object reference
(对象引用)指向锁对象,采用CAS
算法替换Object
锁对象的Mark Word
,将Mark Word
的值存入锁记录。 - 如果
CAS
执行成功,则对象头中存储了锁记录地址和状态00
,表示该线程获取到锁。
3.8.2 对象状态
-
未锁定
-
无锁
-
轻量锁
-
重量锁
-
GC标记
偏向锁标识位 | 锁标识位 | 锁状态 | 存储内容 |
---|---|---|---|
0 | 01 | 未锁定 | hash code(31),年龄(4) |
1 | 01 | 偏向锁 | 线程ID(54),时间戳(2),年龄(4) |
无 | 00 | 轻量级锁 | 栈中锁记录的指针(64) |
无 | 10 | 重量级锁 | monitor的指针(64) |
无 | 11 | GC标记 | 空,不需要记录信息 |
单独1位标记偏向锁
锁的状态在对象头中用3位来表示
-
无锁状态:001
-
偏向锁101
-
轻量锁 000
-
重量锁 010
-
GC标记011
3.8.2.1 偏向锁
public class Test01 {
static class A {
}
public static void main(String[] args) {
A a = new A();
System.out.println("开启了偏向锁,但是偏向锁没有关联偏向锁线程");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
public class Test01 {
static class A {
}
public static void main(String[] args) {
A a = new A();
System.out.println("开启了偏向锁,但是偏向锁没有关联偏向锁线程");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
synchronized (a){
System.out.println("开启了偏向锁,偏向锁是给我们主线程");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
synchronized (a){
System.out.println("开启了偏向锁,偏向锁是给我们主线程");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
}
3.8.2.2 轻量锁
public class Test01 {
static class A {
}
public static void main(String[] args) {
A a = new A();
System.out.println("开启了偏向锁,但是偏向锁没有关联偏向锁线程");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
synchronized (a) {
System.out.println("开启了偏向锁,偏向锁是给我们主线程");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
synchronized (a) {
System.out.println("开启了偏向锁,偏向锁是给我们主线程");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
//有另一个线程与主线程竞争锁,所以撤销偏向锁,升级为轻量级锁
new Thread(() -> {
synchronized (a) {
System.out.println("子线程:升级为轻量级锁");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
}).start();
}
}
3.8.2.3 重量锁
public class Test02 {
private static Object objectLock = new Object();
public static void main(String[] args) throws InterruptedException {
System.out.println(">>----------------无锁状态-------------------<<");
System.out.println(ClassLayout.parseInstance(objectLock).toPrintable());
new Thread(() -> {
synchronized (objectLock) {
System.out.println("...子线程开始执行...");
try {
Thread.sleep(5000);
System.out.println(">>----------------子线程获取锁,轻量级锁状态-------------------<<");
System.out.println(ClassLayout.parseInstance(objectLock).toPrintable());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
System.out.println(">>----------------子线程释放锁-------------------<<");
System.out.println(ClassLayout.parseInstance(objectLock).toPrintable());
System.out.println("...子线程结束执行...");
}, "子线程1").start();
Thread.sleep(1000);
sync();
}
public static void sync() throws InterruptedException {
System.out.println("...主线程开始调用sync方法...");
synchronized (objectLock) {
System.out.println(">>----------------主线程获取锁,重量级锁状态-------------------<<");
System.out.println(ClassLayout.parseInstance(objectLock).toPrintable());
}
}
}
4. 字节码文件分析
Java语言底层如何实现的呢? 虚拟机 C++
synchronized
底层是在虚拟机实现好的,源码属于C++编写,不需要自己获取锁和释放锁,属于java关键字
public static void a(){
// 获取锁
synchronized(lockobject){
count++;
}
}
通过javap -p -v Test01.class
查看它的内部结构和字节码指令
当我们在使用synchronized
锁,通过javap
反汇编指令可以得出:
-
synchronized
锁底层通过monitorenter
和monitorexit
指令实现。 -
monitorenter
:获取锁,相当于lock.lock();
-
monitorexit
:释放锁,相当于lock.unlock();
-
也就是底层实际上基于JVM级别的C++对象,当多个线程在获取锁的时,会创建一个monitor(监视器)对象,该对象成员变量有
owner
拥有锁的线程、recursions
重入次数等。同步块的实现使用了
monitorenter
和monitorexit
指令:-
他们隐式的执行了
lock()
和unLock()
操作,用于提供原子性保证。 -
monitorenter
指令插入到同步代码块开始的位置、monitorexit
指令插入到同步代码块结束位置,jvm
需要保证每个monitorenter
都有一个monitorexit
对应。这两个指令,本质上都是对一个对象的监视器(monitor
)进行获取,这个过程是排他的,也就是说同一时刻只能有一个线程获取到由synchronized
所保护对象的监视器线程执行到monitorenter
指令时,会尝试获取对象所对应的monitor
所有权,也就是尝试获取对象的锁;而执行monitorexit
,就是释放monitor
的所有权。 -
有执行
monitorenter
和monitorexit
,其中monitorexit
指令有两个,分别代表正常退出和异常退出。下面我们看看这两个指令的官方文档的介绍。
4.1 monitorenter(获取锁)
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorenter
简单翻译:
每一个对象都会和一个监视器C++ monitor关联。监视器被占用时会被锁住,其他线程无法来获取该monitor。当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应的monitor的所有权。
其过程如下:
-
若monior的进入数为0,线程可以进入monitor,并将monitor的进入数置为1。当前线程成为monitor的owner(所有者)
-
若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入数加1
-
若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直到monitor的进入数变为0,才能重新尝试获取monitor的所有权。
4.2 monitorexit
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorexit
简单翻译:
-
能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。
-
执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
monitorexit释放锁:monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。为什么会有两个monitorexit,因为 synchronized锁的同步代码块如果抛出异常的情况下,则自动释放锁。
4.3 ACC_SYNCHRONIZED
synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理
-
5. ObjectMonitor源码解读
Java底层使用 C++ hotspot虚拟机,Objectmonitor 底层基于C++实现。
下载hotspot虚拟机
Hotspot 源码位置:\hotspot\src\share\vm\runtime\objectMonitor.hpp
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0; // 递归次数/重入次数
_object = NULL; // 存储Monitor关联对象
_owner = NULL; // 记录当前持有锁的线程ID
_WaitSet = NULL; // 等待池:处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 多线程竞争锁时的单向链表
FreeNext = NULL ;
_EntryList = NULL ; // 锁池:处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
5.1 锁池
EntryList
锁池:假设线程A已经拥有了某个对象的锁,而其它的线程想要调用这个对象的某个synchronized
方法(或者synchronized
块),由于这些线程在进入对象的synchronized
方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。
EntryList
(锁池): 当前的线程获取锁失败,会阻塞等待, 在链表数据结构存放。
5.2 等待池
WaitSet
(等待池):当前一个线程主动释放锁,同时阻塞等待,相当于调用了wait
方法,在等待池中存放。
WaitSet
等待池:假设一个线程A调用了某个对象的wait()
方法,线程A就会释放该对象的锁(因为wait()
方法必须出现在synchronized
中,这样自然在执行wait()
方法之前线程A就已经拥有了该对象的锁),同时线程A就进入到了该对象的等待池中。如果另外的一个线程调用了相同对象的notifyAll()
方法,那么处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。如果另外的一个线程调用了相同对象的notify()
方法,那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池.
-
如果线程调用了对象的
wait()
方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。 -
当有线程调用了对象的
notifyAll()
方法(唤醒所有wait
等待线程)或notify()
方法(只随机唤醒一个wait
等待线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。 -
优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用
wait()
方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了synchronized
代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。
5.3 wait与notify原理分析
-
调用
wait
方法,即可进入WaitSet
(等待池)变为WAITING
状态 -
BLOCKED
和WAITING
的线程都处于阻塞状态,不占用CPU时间片 -
BLOCKED
线程会在owner
(持有锁)线程释放锁的时候被唤醒 -
WAITING
线程会在owner
线程调用notify
或notifyAll
时唤醒,但唤醒后并不意味着立刻获得锁,仍需进入EntryList
(锁池)重新竞争
解释BLOCKED
和 WAITING
:BLOCKED
和 WAITING
都是线程处于阻塞状态的情况,但原因不同:BLOCKED
是因为试图获取已由其他线程持有的锁;WAITING
是因为线程主动调用 wait()
方法并等待被唤醒。持有锁的线程(OWNER
)负责管理锁的释放和同步控制。
-
锁池: 没有获取到锁的线程
-
等待池:调用
wait
方法 -
相同点: 都会阻塞
-
Notify()
:只会唤醒等待中一个线程 -
NotifyAll()
:唤醒所有的线程
等待池的线程被唤醒之后,等待池中的线程转移到锁池中,从新竞争锁的资源。
6. Hotspot源码解读
6.1 synchronized底层实现原理总结
6.2 偏向锁原理分析
偏向锁在没有竞争时,(就自己这一个线程),每次重入仍然执行CAS操作,Java6中引入了偏向锁来做进一步优化;只有第一次使用CAS将线程ID设置到对象的Mark Word
中,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS,以后只要不发生竞争,这个对象就归该线程所有。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中记录获取到偏向锁的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word
里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁;如果测试失败,则需要再测试一下Mark Word
中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
6.2.1 偏向锁撤销
由于偏向锁使用了一种直到竞争发生时才会释放的机制,所以当其他线程竞争偏向锁时,持有偏向锁的线程才会去释放锁。
6.2.2 批量重偏向
批量重偏向:当一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,会导偏向锁重偏向的操作。
批量撤销:在多线程竞争剧烈的情况下,使用偏向锁将会降低效率,于是乎产生了批量撤销机制。
-
启动设置参数:
-
通过JVM的默认参数值,批量重偏向和批量撤销的阈值。
-
设置JVM参数
-XX:+PrintFlagsFinal
,在项目启动时即可输出JVM的默认参数值 -
intx BiasedLockingBulkRebiasThreshold = 20
默认偏向锁批量重偏向阈值 -
intx BiasedLockingBulkRevokeThreshold = 40
默认偏向锁批量撤销阈值 -
当然我们可以通过
-XX:BiasedLockingBulkRebiasThreshold
-
-XX:BiasedLockingBulkRevokeThreshold
来手动设置阈值
-
-
以class为单位,为每个class维护一个偏向锁撤销计数器。每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。每个class对象也会有一个对应的epoch字段,每个处于偏向锁状态对象的mark word中也有该字段,其初始值为创建该对象时,class中的epoch值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获取锁时,发现当前对象的epoch值和class不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其mark word的Thread Id改为当前线程ID
-
注意:此时没有其他线程与当前线程竞争锁资源的情况下才可以匹量重偏向。
public class Test01 {
static class A {}
public static void main(String[] args) throws InterruptedException {
//延时产生可偏向对象 演示 批量偏向锁
Thread.sleep(5000);
//创造100个偏向线程t1的偏向锁
List<A> listA = new ArrayList<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
A a = new A();
synchronized (a) {
listA.add(a);
}
}
try {
//为了防止JVM线程复用,在创建完对象后,保持线程t1状态为存活
Thread.sleep(100000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
//睡眠5s钟保证线程t1创建对象完成
Thread.sleep(5000);
System.out.println("打印t1线程,list中第19个对象的对象头:");
System.out.println((ClassLayout.parseInstance(listA.get(18)).toPrintable()));
System.out.println("打印t1线程,list中第20个对象的对象头:");
System.out.println((ClassLayout.parseInstance(listA.get(19)).toPrintable()));
//创建线程t2竞争线程t1中已经退出同步块的锁
Thread t2 = new Thread(() -> {
//这里面只循环了30次
for (int i = 0; i < 30; i++) {
A a = listA.get(i);
synchronized (a) {
if (i == 18 || i == 19) {
System.out.println("第" + (i + 1) + "次偏向结果");
System.out.println((ClassLayout.parseInstance(a).toPrintable()));
}
}
}
try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t2.start();
Thread.sleep(3000);
System.out.println("打印list中第19个对象的对象头:");
System.out.println((ClassLayout.parseInstance(listA.get(18)).toPrintable()));
System.out.println("打印list中第20个对象的对象头:");
System.out.println((ClassLayout.parseInstance(listA.get(19)).toPrintable()));
}
}
6.2.3 批量撤销
当一个偏向锁如果撤销次数到达40的时候就认为这个对象设计的有问题;那么JVM会把这个对象所对应的类所有的对象都撤销偏向锁;并且新实例化的对象也是不可偏向的。
public class Test02 {
static class A {}
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
List<A> listA = new ArrayList<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
A a = new A();
synchronized (a) {
listA.add(a);
}
}
try {
Thread.sleep(100000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
Thread.sleep(5000);
Thread t2 = new Thread(() -> {
//这里循环了40次,达到了批量撤销的阈值
for (int i = 0; i < 40; i++) {
A a = listA.get(i);
synchronized (a) {
}
}
System.out.println("打印t1线程,list中第20个对象的对象头:");
System.out.println((ClassLayout.parseInstance(listA.get(19)).toPrintable()));
System.out.println("打印t1线程,list中第21个对象的对象头:");
System.out.println((ClassLayout.parseInstance(listA.get(20)).toPrintable()));
try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t2.start();
Thread.sleep(5000);
Thread t3 = new Thread(() -> {
for (int i = 19; i < 40; i++) {
A a = listA.get(i);
synchronized (a) {
if (i == 19 || i == 20) {
System.out.println("thread3 第" + (i+1) + "次");
System.out.println((ClassLayout.parseInstance(a).toPrintable()));
}
}
}
});
t3.start();
Thread.sleep(10000);
System.out.println("重新输出新实例A");
System.out.println((ClassLayout.parseInstance(new A()).toPrintable()));
}
}
6.3 轻量锁原理分析
引入轻量级锁的目的:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级为重量级锁,所以轻量级锁的出现并非是要替代重量级锁。
注意:轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
轻量级锁机制
-
创建锁记录:
- 当一个线程试图锁定一个对象时,会在该线程的本地栈帧中创建一个锁记录(Lock Record)对象。
- 锁记录对象用于存储与锁定相关的元数据,包括指向被锁定对象的引用以及该对象的
Mark Word
。
-
使用CAS更新Mark Word:
- 锁记录中的
Object reference
字段会指向想要锁定的对象。 - 使用比较并交换(Compare-and-Swap, CAS)操作来尝试用锁记录的地址替换对象头中的
Mark Word
。 - 这个过程是原子性的,确保了多线程环境下的安全性。
- 锁记录中的
-
成功获取锁的状态标记:
- 如果CAS操作成功,则说明没有其他线程持有该对象的锁,当前线程可以获取锁。
- 对象头中的
Mark Word
会被更新为锁记录的地址,并且状态位会被设置为特定值(通常是00
),以表示当前线程已经成功获取了锁。
轻量级锁升级以及重入锁的处理机制
-
锁升级:
- 当一个线程尝试获取一个已经被其他线程持有的轻量级锁时,表明存在多个线程竞争同一个锁。
- 此时轻量级锁会升级为重量级锁(膨胀或膨化过程),以保证锁的独占性。
-
重入锁处理:
- 如果当前线程已经获取到了锁,并再次尝试获取同一把锁(即重入),则会在当前线程的栈帧中为该锁创建一个新的锁记录(Lock Record)。
- 每次重入都会创建一个新的锁记录,并将其作为重入次数的计数器。
-
解锁与重入计数:
- 当线程退出
synchronized
代码块时,会检查当前线程的栈帧中的锁记录是否仍然指向该锁。 - 如果锁记录地址为
null
,这通常意味着锁记录已被重置,表示重入计数减一。 - 如果栈帧中还存在该锁的锁记录,则表示还有重入次数未解除,需要继续保留锁。
- 当线程退出
-
解锁过程:
- 在完全退出
synchronized
代码块时,会使用CAS操作将保存在锁记录中的Mark Word
的值恢复到对象头中。 - 如果CAS操作成功,则表示解锁成功;如果失败,则可能需要进一步处理,例如处理与其他线程的竞争情况。
- 在完全退出
6.4 重量锁原理分析
如果其他的线程尝试轻量级的过程中,CAS多次还是失败,则轻量级会升级为重量级锁。
重量级锁通过对象内部的监视器(monitor
)实现,其中monitor
的本质是依赖于底层操作系统的Mutex Lock
实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
源码相关:ObjectMonitor::enter
6.5 锁粗化
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是在某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的请求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。
public static void a() {
synchronized (lock) {
b();
}
synchronized (lock) {
c();
}
}
//改为
public static void a1() {
synchronized (lock) {
b();
c();
}
}
6.6 锁消除
锁消除是发生在编译器级别的一种锁优化方式。
有时候我们写的代码完全不需要加锁,却执行了加锁操作。
比如,StringBuffer类的append操作:
public static void main(String[] args) {
long start = System.currentTimeMillis();
int size = 10000;
for (int i = 0; i < size; i++) {
createStringBuffer("mayikt", "meite");
}
long timeCost = System.currentTimeMillis() - start;
System.out.println("createStringBuffer:" + timeCost + " ms");
}
public static String createStringBuffer(String str1, String str2) {
StringBuffer sBuf = new StringBuffer();
sBuf.append(str1);// append方法是同步操作
sBuf.append(str2);
return sBuf.toString();
}
代码中createStringBuffer
方法中的局部对象sBuf
,就只在该方法内的作用域有效,不同线程同时调用createStringBuffer()
方法时,都会创建不同的sBuf
对象,因此此时的append
操作若是使用同步操作,就是白白浪费的系统资源,这个时候程序就是帮我们撤销锁。
6.7 JDK15 默认关闭偏向锁优化原因
JDK15默认关闭偏向锁优化,如果要开启可以使用XX:+UseBiasedLocking
,但使用偏向锁相关的参数都会触发deprecate
警告
原因:
-
偏向锁导致
synchronization
子系统的代码复杂度过高,并且影响到了其他子系统,导致难以维护、升级。 -
在现在的jdk中,偏向锁带来的加锁时性能提升从整体上看并没有带来过多收益(撤销锁的成本过高,需要等待全局安全点,再暂停线程做锁撤销)
-
官方说明中有这么一段话:
since the introduction of biased locking into HotSpot also change the amount of uncontended operations needed for that relation to remain true.
,原子指令成本变化(我理解是降低),导致自旋锁需要的原子指令次数变少(或者cas操作变少 个人理解),所以自旋锁成本下降,故偏向锁的带来的优势就更小了,维持偏向锁的机会成本(opportunity cost)过高,所以不如废除。