synchronized关键字详解
# synchronized关键字详解
# 1、synchronized关键字简介
synchronized 直译为 同步,它的作用是实现线程同步, synchronized 能够确保同一时刻只有一个线程可以执行某个代码块或方法,我们可以把共享变量的修改放在synchronized 修饰的代码块中或者方法中,从而避免多个线程同时访问共享资源时引发的线程安全问题。
# 2、synchronized作用和使用场景
# 作用
保证线程安全。
由于synchronized的同步机制,能够确保同一时刻只有一个线程可以执行某个代码块, 所以可以保证并发场景下的原子性,有序性,可见性。
# 使用场景
# ①、用在代码块上(类级别同步)
示例:
public class TestA {
private static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (TestA.class) {
for (int i = 0; i < 2000; i++) {
count += 1;
}
}
});
Thread t2 = new Thread(() -> {
synchronized (TestA.class) {
for (int i = 0; i < 2000; i++) {
count += 1;
}
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count); // 4000
}
}
上面例子使用的是类级别的锁(synchronized (TestA.class)
),即TestA的Class对象, 类级别的锁粒度较大。
对于类级别的同步,使用类对象(如 TestA.class
)或静态变量(如 private static final Object lock = new Object();
)作为锁对象,确保所有实例共享同一个锁。
补充知识点(后续整理JVM相关知识点会详细说到):
在同一个类加载器下,对于同一个类,Class对象是唯一的(所以我们上面可以使用TestA.class
作为类级别的锁)。
TestA.class文件中的类只是通过默认的类加载路径被加载,而不涉及自定义类加载器,那么在整个JVM运行期间,无论创建多少个TestA类的对象,TestA.class对应的Class对象都是同一个。
但是,不同的类加载器可以产生不同的Class对象,即使它们加载的类的字节码是相同的。
# ②、用在代码块上(对象级别同步)
示例:
public class TestA {
public int count = 0;
private final Object lock = new Object();
public static void main(String[] args) {
TestA testA1 = new TestA();
for (int i = 0; i < 2000; i++) {
new Thread(() -> {
testA1.counter();
}).start();
}
TestA testA2 = new TestA();
for (int i = 0; i < 2000; i++) {
new Thread(() -> {
testA2.counter();
}).start();
}
System.out.println(testA1.count); // 2000
System.out.println(testA2.count); // 2000
}
public void counter() {
synchronized (lock) {
count++;
}
}
public int getCount() {
return count;
}
}
对于对象级别的同步,使用实例变量private final Object lock = new Object();
作为锁对象,确保每个实例独立同步。
# ③、用在普通方法上(对象级别同步)
public class TestA {
public int count = 0;
public static void main(String[] args) {
TestA testA = new TestA();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 2000; i++) {
testA.counter();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 2000; i++) {
testA.counter();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(testA.count);
}
public synchronized void counter() {
count++;
}
}
synchronized修饰普通方法,它隐式地以调用该方法的对象作为锁对象,即隐含的锁对象是this
关键字所指的对象,在上面的例子中就是 testA
对象。
synchronized修饰普通方法相当于对象锁。对于上面的例子,锁的范围仅限于TestA的具体实例testA。
# ④、用在静态方法上(类级别同步)
public class TestA {
public static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 2000; i++) {
counter();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 2000; i++) {
counter();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count);
}
public synchronized static void counter() {
count++;
}
}
synchronized修饰静态方法,隐含的锁对象是类的 Class 对象实例,在上面的例子中就是 TestA.class
对象。
当synchronized修饰静态方法时,锁作用于整个类的所有实例。
# 总结:
①、synchronized关键字用在代码块上,锁是synchonized括号里配置的对象,可以实现类级别的同步(使用类的Class对象作为锁,或者使用静态实例变量作为锁)。 也可以实现对象级别的同步(使用类的实例变量作为锁)。
②、synchronized关键字用在普通方法上,可以实现对象级别的同步,隐式地以调用该方法的对象作为锁对象,即隐含的锁对象是this
关键字所指的对象。
③、synchronized关键字用在静态方法上,可以实现类级别的同步,隐含的锁对象是类的 Class 对象实例。
补充:
synchronized关键字不能用在构造方法上,构造方法本身由JVM保证线程安全,但是不保证构造方法中使用的共享变量的线程安全,我们可以利用final修饰构造方法中使用的共享变量,达到线程安全的目的。final关键字在线程安全的应用(后面另写一篇文章总结)。
# 3、synchronized底层原理(JVM层面)
上面说了三种synchronized关键字的使用方式,都提到了锁,而且只有synchronized关键字用在代码块上我们显示的给了一个对象作为锁,代码块或者方法执行完毕了,我们也不需要释放锁。
下面我们看看synchronized到底是怎样使用锁,以及怎么释放锁的。
# IDEA配置javap工具查看字节码
配置:
除了-v 还有其他参数可以使用:
-c:显示方法的字节码指令。
-s:显示字段和方法的内部类型签名。
-v:输出详细信息,包括常量池、方法、字段等的修饰符和属性。
-l:显示行号和局部变量表,便于调试和理解字节码与源代码的对应关系。
这些参数也可以一起使用。 (推荐都加上,输入更详细的信息)
使用:
# 查看同步代码块的字节码
查看下下面这段Java代码生成的字节码内容
public class TestA {
public static void main(String[] args) {
synchronized (TestA.class){
// ...
}
}
}
同步代码块字节码:
下面只截取了一部分字节码。
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class TestA
2: dup
3: astore_1
4: monitorenter
5: aload_1
6: monitorexit
7: goto 15
10: astore_2
11: aload_1
12: monitorexit
13: aload_2
14: athrow
15: return
# 分析同步代码块字节码
这里仅分析字节码中 monitorenter
和 monitorexit
这两个JVM指令。
monitorenter
和 monitorexit
是 Java 虚拟机(JVM)指令集中的两个特殊指令,它们用于实现 Java 中synchronized 关键字的同步机制。
在 Java源代码编译阶段(javac 编译),编译器遇到由 synchronized 关键字修饰的方法或代码块时,它会在字节码(.class文件)级别生成相应的 monitorenter 和 monitorexit 指令。
其中:
monitorenter 指令
:会被插入到 synchronized 块的开始处,以及 synchronized 方法的入口。它的作用是尝试获取对象的内部锁(监视器锁)。如果锁未被占用,则当前线程可以获得锁并继续执行;如果锁已被其他线程占用,则当前线程将被阻塞,直到锁被释放。
monitorexit 指令
:会被插入到 synchronized 块的结束处,以及可能抛出异常的路径上(确保即使发生异常也能释放锁)。当线程执行完 synchronized 块的代码或抛出异常时,monitorexit 指令会释放之前获取的锁,允许其他等待的线程有机会获取锁并进入同步块。
画个图演示下 monitorenter
和 monitorexit
流程:
这里的所计数器操作涉及到锁重入的概念。 下面第5点 有详细介绍。
# 查看同步方法的字节码
查看下下面这段Java代码生成的字节码内容
public class TestA {
public synchronized void method() {
System.out.println("synchronized修饰方法");
}
}
同步方法字节码:
下面只截取了一部分字节码。
public synchronized void method();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String synchronized ����
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this LTestA;
# 分析同步方法字节码
synchronized 修饰方法是通过 ACC_SYNCHRONIZED
标识来实现方法级别的同步。ACC_SYNCHRONIZED
是Java类文件中方法的访问标志之一,用来标识方法是否被 synchronized 关键字修饰。指示该方法在执行时会获取对象的监视器,其他线程需要等待锁的释放才能执行该方法的代码。
# 补充知识点:对象头
每个 Java 对象实例都有一个对象头(Object Header),对象头包含几部分,分别是:
Mark Word: Mark Word 是对象头的一部分,用于存储对象的运行时数据,如哈希码(HashCode)、GC 分代年龄(Generational GC age)、锁状态标志、线程持有的锁、偏向线程 ID 等。Mark Word 是一个非常灵活的结构,在不同的对象状态下会有不同的表示方式。下面会说明。
Klass Pointer: 这部分存储的是对象所属类的元数据指针。通过这个指针,可以找到对象的类信息,包括类的名字、父类、方法表等。
Array length: 这个部分只存在于数组对象中,用于存储数组的长度。只有数组对象才有这部分内容。 对于普通对象:对象头包括Mark Word、Klass Pointer。
对于数组对象:对象头包括Mark Word、Klass Pointer、Array length。
Mark Word在不同的对象状态下会有不同的表示方式:
下表是64位JVM下的 Mark Word 各个位储存的数据变化情况(参考了一些资料整理的表格可能不够准确,如果有问题欢迎指正)
解释下上面偏向锁的ThreadID和Epoch(先留个印象,下面锁升级过程会详细介绍)
ThreadID(54bit)
ThreadID 存储持有偏向锁的线程的唯一标识符(ID)。偏向锁的设计目的是为了在无竞争的情况下减少同步的开销。当一个对象第一次被一个线程获取时(必须是一个线程,且对象是匿名偏向状态),会在 Mark Word 中记录该线程的 ID,以表示该对象偏向于该线程。
作用: 当该线程再次进入同步块时,如果对象的 Mark Word 中的 ThreadID 与当前线程的 ID 匹配,线程就可以直接进入,而无需进行任何同步操作,从而提高了性能。
Epoch(2bit)
Epoch 是一个用于表示偏向锁的时间戳或时期的字段。它用来区分不同时间段内的偏向锁,避免出现锁膨胀和长时间持有偏向锁的问题。
作用: Epoch 字段在某些情况下会被更新,例如当偏向锁被撤销时,Epoch 会增加,这样可以防止旧的偏向锁信息干扰新的偏向锁。它确保偏向锁机制能够正常工作,并在需要时重新分配偏向锁。
指向线程栈中锁记录的指针:
当一个线程尝试获取轻量级锁时,JVM 会在该线程的栈中创建一个锁记录(Lock Record)。
锁记录用于存储 Mark Word 的拷贝以及其他锁状态信息。线程尝试将对象头中的 Mark Word 替换为指向线程栈中锁记录的指针。
替换操作通过 CAS 完成,以确保操作的原子性。
如果 CAS 操作成功,Mark Word 中存储的就是指向线程栈中锁记录的指针,此时线程获得轻量级锁。
Mark Word 的内容变为指向锁记录的指针,同时锁记录中保存原始的 MarkWord。
(说人话就是,对象头Mark Word只有几个字节的空间,存不下轻量级锁的信息了,所以把锁的信息和原Mark Word里信息放到线程栈里面去存一个锁记录,Mark Word只存这个锁记录的指针方便快速获取锁记录的详细信息)
指向重量级锁的指针:
当锁膨胀为重量级锁时,JVM在堆中创建一个监视器对象用于管理锁的信息。
对象头中的Mark Word更新为指向监视器对象的指针。
通过这个指针,线程可以访问并操作重量级锁的详细信息。
# Monitor(监视器)
在Java中,每一个对象都有一个关联的监视器,这个监视器用于实现线程同步。监视器是一个结构,它包含了一些用于控制线程访问共享资源的数据和逻辑。
监视器的功能:
同步方法和代码块:监视器用于实现同步方法和synchronized代码块。当一个线程进入一个同步方法或同步代码块时,它必须首先获得该对象的监视器。
线程管理:监视器负责管理等待和通知机制(wait/notify/notifyAll),使得线程可以在条件不满足时等待,条件满足时被唤醒。
# 监视器锁
监视器锁是监视器的一部分,确保同一时间只有一个线程能够执行同步代码块或同步方法。每个对象都有一个隐式的监视器锁,当线程需要进入一个同步代码块或方法时,它必须先获得这个锁。
监视器锁的工作过程:
获取锁: 当一个线程进入synchronized方法或代码块时,它尝试获取监视器锁。如果锁被其他线程持有,则该线程会被阻塞,直到锁被释放。 ( 监视器锁在有些资料上也叫内置锁,属于X锁(即排他锁) )
释放锁: 当线程退出synchronized方法或代码块时,它会释放监视器锁,允许其他被阻塞的线程获取锁并执行同步代码。
重入锁: Java的监视器锁是可重入锁,这意味着如果一个线程已经持有了一个监视器锁,它可以再次进入由同一个监视器保护的同步代码,而不会被阻塞。
# synchronized到底锁的是哪个对象?
synchronized表面上给使用者的感觉是代码块或者函数之间形成了互斥,同一时间只能有一个线程可以执行到synchronized修饰的代码块或者函数。这是synchronized表面给使用者最直观的感觉。
但是实际上synchronized是给对象加了锁。当一个线程进入synchronized方法或代码块时,就会去获取这个对象的锁,上面说了这个锁叫监视器锁。 下面我们来分析下锁到底加在了哪个对象上。
①、对于静态成员函数,锁是加在类的Class对象上面的。相当于代码块的synchronized (TestA.class)
public class TestA {
public static void main(String[] args) {
synchronized (TestA.class){
// 锁的是 TestA.class 这个对象
}
}
public synchronized static void aa(){
// 锁的也是 TestA.class 这个对象
}
}
②、对于非静态成员函数,锁是加在this对象上面的。相当于代码块的synchronized (this)
public synchronized void aa(){
synchronized (this){
// 锁的是 this 这个对象
}
}
public synchronized void bb(){
// 锁的也是 this 这个对象
}
# 让秀逗告诉你锁的本质(通俗点的例子)
通俗点的例子:
一个比较形象的比喻,锁相当于狗狗王国里的五星上将秀逗(也就是狗狗王国的保安),并且这个叫秀逗的狗狗非常称职它是35岁的程序员再就业少走20年弯路应聘上的狗狗王国的保安。
秀逗深谙互斥锁的道理,秀逗把都每个回家的狗狗都当成一个线程,这对于曾经做过程序员的秀逗来说简直小菜一碟,每个狗狗想通过狗狗王国大门前的门禁,都必须得到秀逗的许可,秀逗认为自己就是一把锁,大门的门禁就是秀逗锁住的代码块或者函数,同一时刻,秀逗只允许一只狗狗走过门禁。
这个时候大黄来了,秀逗说,站住,你必须等前面的四眼走完门禁的这段路你才能进去。于是大黄只能乖乖等四眼走过去之后,才能继续进入门禁。
上面的例子,秀逗是锁,大黄和四眼就是线程,门禁就是同步代码块,或者同步的方法。
程序员的角度:
从程序员的角度来看,锁就是一个“对象”,这个对象要完成一些事情:
①、需要记录当前有没有线程获得锁。 (比如上面说的,秀逗不让大黄进入门禁,因为现在门禁里面还有狗狗没走完)
②、需要记录当前获得锁的是哪个线程。(秀逗得知道目前正在通过门禁的是四眼)
③、需要记录还有哪些线程在阻塞等待获取锁。(秀逗得知道后面还有多个狗狗在排队等待进入门禁)
那么我们把这个锁对象单独拎出来实现上面说的三个功能。我们就可以把这个对象放到 synchronized () 的括号里作为锁。
比如,上面的秀逗可以作为锁,把秀逗当成锁对象:
synchronized (秀逗) {
// 需要同步访问的代码
}
这个时候狗狗王国里面的物业经理二哈看秀逗工作轻松,每月轻轻松松月入2千,根本花不完,二哈不乐意了,二哈觉得既然秀逗可以当锁对象,那四眼,大黄也能当,只要四眼,大黄或者其他什么狗狗,都掌握上面说的三点技能:
①、需要记录当前有没有线程获得锁。
②、需要记录当前获得锁的是哪个线程。
③、需要记录还有哪些线程在阻塞等待获取锁。
那么任何一个狗狗都能当锁。
二哈觉得这个主意不错,以后让狗狗们自己都掌握锁对象的技能,然后走门禁的时候,谁正在门禁内谁就是锁对象,后面的狗狗就得排队。
至此,秀逗掌握的技能,其他狗狗都掌握了。狗狗王国也不需要秀逗这个保安了,因为狗狗王国里面的每个狗狗对象现在都掌握了锁的技能,它们每个人都可以当保安(锁对象)。 这样门禁的管理更方便了。
只有秀逗默默流下了泪水~
好,秀逗的故事说完了,我们再来说一下Java中的锁。
上面说了锁也是对象,并且我们的共享资源(比如变量、代码块、方法等)本身也属于对象的一部分。 于是Java语言就把锁和线程要访问的共享资源对象合二为一。 让Java中每个对象都能作为锁,这样synchronized就能够把任意对象当做锁来用。
为了实现每个对象都能作为锁这个目的,于是就有了对象头中的Mark Word以及与对象关联的Monitor,当然Mark Word结合Monitor能干的事情包含但不限于下面这三点:
①、需要记录当前有没有线程获得锁。
②、需要记录当前获得锁的是哪个线程。
③、需要记录还有哪些线程在阻塞等待获取锁。
是不是到这儿就豁然开朗了。
最后总结下这个例子:
锁的比喻:
秀逗: 代表锁。作为狗狗王国的保安,秀逗控制狗狗们(线程)通过门禁(同步代码块或方法)。
大黄和四眼: 代表线程。它们需要等待秀逗(锁)许可才能通过门禁(同步代码块或方法)。
锁的功能:
记录当前有没有线程获得锁:秀逗控制同一时刻只允许一个狗狗通过门禁。
记录当前获得锁的线程:秀逗知道当前正在通过门禁的是哪个狗狗(线程)。
记录等待获取锁的线程:秀逗知道哪些狗狗在排队等候通过门禁。
锁对象的实现:
任何狗狗都可以作为锁对象,只要掌握了记录当前锁状态、当前锁定线程和等待线程的技能。
在Java中,锁对象与共享资源对象合二为一,每个对象都能作为锁,通过synchronized关键字实现同步。
Mark Word和Monitor的作用:
记录锁的状态:当前有没有线程获得锁。
记录获得锁的线程:当前获得锁的是哪个线程。
记录等待获取锁的线程:哪些线程在等待获取锁。
还有一些其他功能,比如记录获取锁后又释放锁进入等待状态的线程等。
# Java中为什么要设计成全员皆锁
下面总结了几个方面的原因:
简化同步机制
在Java中,每个对象都可以作为锁,这使得同步机制变得非常简单和直观。程序员可以使用任何对象来实现同步,而不需要额外的锁对象。这种设计避免了创建和管理独立锁对象的复杂性。统一的锁定模型
统一的锁定模型意味着每个对象都有相同的同步行为和机制。程序员不需要记住额外的规则或模式来使用锁,因为所有对象都可以用于同步。只需使用synchronized关键字即可。提高灵活性
由于每个对象都可以作为锁,程序员可以根据具体的需求选择合适的对象进行同步。这样,能够更灵活地设计同步逻辑。例如,可以使用方法所在的对象(this)作为锁,也可以使用类的静态成员作为锁。提高代码可读性和可维护性
在代码中使用共享资源所在的对象进行同步(例如,使用共享资源对象本身)可以提高代码的可读性和可维护性。这样,读代码的人可以很容易地理解同步的意图,而不需要去寻找额外的锁对象。效率优化
Java虚拟机(JVM)可以对锁的实现进行多种优化,例如偏向锁、轻量级锁和重量级锁的不同优化策略。这些优化可以在不改变程序员使用锁的方式的情况下提高性能。支持细粒度锁定
通过让每个对象都可以作为锁,Java允许程序员使用细粒度的锁定策略,以减少锁争用和提高并发性能。例如,可以为不同的资源使用不同的锁对象,从而实现更高效的并发控制。
上面都是比较书面的表达:说人话就是 这样设计简单方便、灵活好用,方便(JVM)优化。就像为synchronized打广告一样~
# 4、synchronized和wait、notify
# 首先需要先明确一个问题:
# 为什么wait()
和notify()
方法必须和synchronized一起使用?
要回答这个问题我们需要先明确wait()
和notify()
方法的作用:线程通信。而且要保证线程通信的正确性。
wait(): 当一个线程执行wait()时,它会放弃持有的对象锁并进入该对象的等待队列中,直到其他线程调用notify()或notifyAll()唤醒它。线程被唤醒后,需要重新获得对象的锁才能继续执行。
notify(): 当一个线程执行notify()时,它会随机唤醒在该对象等待队列中等待的一个线程(如果有的话)。被唤醒的线程会尝试重新获得对象的锁,成功后继续执行。
notifyAll(): 唤醒所有在该对象等待队列中等待的线程。被唤醒的线程会依次尝试重新获得对象的锁,成功后继续执行。
假如不在synchronized中使用wait()
和notify()
方法会怎么样呢?
看下面的例子:
我使用两个线程分别执行两个方法,这两个方法都使用同一个对象分别调用了wait()
和notify()
,并且没使用synchronized同步。
public class TestA {
public static void main(String[] args) {
TestA testA = new TestA();
new Thread(()->{
try {
testA.aa();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(()->{
testA.bb();
}).start();
}
public void aa() throws InterruptedException {
this.wait();
}
public void bb(){
this.notify();
}
}
执行结果:
抛了异常 IllegalMonitorStateException
Exception in thread "Thread-0" Exception in thread "Thread-1" java.lang.IllegalMonitorStateException
可以想像一下,上面两个线程之间通信,但是对于同一个对象this(指向的是testA对象)来说,一个方法要等待,一个要唤醒。
这个种操作本身就需要同步,如果没有同步就会出现线程安全问题。
比如上面代码就可能出现一种情况: notify() 是在 wait() 之前调用的(没有同步,无法保证线程执行的顺序),则等待的线程可能会永远等待,而导致死锁等线程安全问题。
所以说:
Java 明确要求 wait()
、notify()
和 notifyAll()
必须在持有对象监视器的前提下调用,即这些方法必须在同步块或同步方法中调用,否则会抛出 IllegalMonitorStateException
异常。
现在我们再思考一个之前在Java基础知识中提到过的一个小知识点:
为什么 wait()
、notify()
和 notifyAll()
是和线程通信相关的方法,却放在了Object类中定义?
相信通过上面的讲解,对于这个问题就有非常明确的答案了,之前我在Java基础知识点 (opens new window)这篇文章中说,因为锁可以是任意的对象,所以wait()
、notify()
和 notifyAll()
这些方法只有放在所有类的父类Object中才能满足锁可以是任意的对象这个条件。
现在再结合上面对于 对象头Mark Word、对象监视器,监视器锁的讲解,应该能对这个问题会有更深刻的理解。
# 等待监视器锁的线程放在哪儿了?引出生产者-消费者模式
等待监视器锁的线程通常放在对象的监视器(Monitor)的等待队列中。Java中每个对象都有一个监视器,负责管理锁的状态和线程的同步。
去瞅一眼JVM源码对wait()
、notify()
的注释:(关于JVM源码的下载可以看下之前的文章 Java中volatile关键字详解 (opens new window))
查看 objectMonitor.cpp
和objectMonitor.hpp
这两个C++源码文件。
下面是objectMonitor的构造函数:
ObjectMonitor() {
_WaitSet = NULL;
_cxq = NULL ;
_EntryList = NULL ;
// ... 省略其他
}
_cxq: _cxq 是 ObjectMonitor 类的一个成员变量,表示竞争队列(Contended Queue)。 在多线程环境中,当有多个线程竞争获取同一个对象的锁时,它们会被放置在 _cxq 中等待获取锁。 _cxq 中的线程会在锁释放时被移动到 _EntryList 中。
_EntryList: _EntryList 是 ObjectMonitor 类的另一个成员变量,表示进入列表(Entry List)。 当一个线程成功获取对象的锁时,它会从 _EntryList 中移除。 如果有多个线程等待获取锁,它们会按照一定的策略(如 FIFO)被放置在 _EntryList 中等待。
_WaitSet: _WaitSet 是 ObjectMonitor 类的成员变量,表示等待集合(Wait Set)。 当线程调用对象的 wait() 方法时,它会释放对象的锁并进入 _WaitSet 等待状态。 当其他线程调用对象的 notify() 或 notifyAll() 方法时,会从 _WaitSet 中选择一个或多个线程移动到 _EntryList 或 _cxq 中。
这和我这篇文章中 BlockingQueue详解 (opens new window) 中利用阻塞队列实现线程同步的思想其实是一致的。
实际上利用队列实现通知机制的思想是一种经典的并发编程模式,它通过队列作为中介,实现了多线程之间的解耦和通信。
我们常见的线程池任务分发、消息队列、事件驱动等都是利用的这个思想。
那这个模式书面名称叫啥呢? 没错就是大名鼎鼎的Producer-Consumer Pattern
(生产者-消费者模式).
# 5、synchronized监视器锁可重入原理
可重入锁指的是同一个线程在持有锁的情况下,可以多次进入同步代码块或同步方法,而不会因为自己已经持有锁而阻塞。
synchronized可重入性的实现主要依赖于每个对象的监视器锁(monitor lock)和线程的线程栈帧中的锁计数器(lock count):
需要注意:
在 Java 中,线程的锁计数器(lock count)是针对监视器锁(monitor lock)的,也就是使用 synchronized 关键字获取的锁。这种锁计数器仅适用于监视器锁,而不适用于其他类型的锁,比如 ReentrantLock 或者其他自定义的锁实现。
- 当一个线程进入同步代码块或同步方法时,会尝试获取对象的监视器锁。
- 如果对象的监视器锁未被其他线程持有,当前线程将获取到锁,并将锁计数器设置为1。
- 如果当前线程已经持有了对象的监视器锁,那么在锁计数器上递增。
- 当线程退出同步代码块或同步方法时,锁计数器会递减。
- 当锁计数器递减为0时,表示当前线程已经完全释放了对象的监视器锁,其他等待线程可以尝试获取锁。
例如:
public class TestA {
public static void main(String[] args) {
TestA testA = new TestA();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 3; i++) {
testA.aa();
}
}, "t1");
t1.start();
}
public synchronized void aa() {
String name = Thread.currentThread().getName();
System.out.println(name + "线程执行了aa方法");
}
}
结果:
t1线程执行了aa方法
t1线程执行了aa方法
t1线程执行了aa方法
t1线程执行了三次synchronized 修饰的aa方法,实际上只需要第一次获取testA对象的监视器锁即可,后面执行都是锁重入。
# 动画总结synchronized 监视器锁原理:
通过上面的整理梳理应该能大致理解synchronized的锁原理了。
这里再画个动画,来梳理下多个线程调用synchronized代码块或者方法,获取和释放或者等待的过程, 帮助理解。
这里就不截图了,眨眼补帧吧。哈哈~
调用 wait() 和 notify() 需要用到_WaitSet队列,动画没画,可以自行脑补一下。 因为画这玩意儿太费时间了,我偷懒了~
这里copy一张其他博客上的图来说明这几个队列之间数据的流转:
下面张图片来源https://tech.youzan.com/javasuo-yu-xian-cheng-de-na-xie-shi/ (opens new window)
# 6、synchronized锁升级过程
先回答一个问题。 为什么在JDK6当中优化了监视器锁。
(先来段废话文学~)
因为JDK6之前的锁性能低。通过JDK6当中优化了许多锁的技术细节提高了获取和释放锁整体操作的性能。
好了忘记上面的废话文学吧,还是来点靠谱的分析吧。
# 补充知识点:
上面我们已经分析了synchronized监视器锁在JVM层面的原理。
下面我们继续往底层探索探索。
Java代码的synchronized关键字在编译成.class字节码时,会生成monitorenter
和 monitorexit
指令。
在 JVM 层面: monitorenter 和 monitorexit 指令用于实现监视器锁。每个对象都有一个与之关联的监视器锁,当一个线程进入同步代码块时,它会尝试获取对象的监视器锁,如果成功,则进入同步块;否则,该线程会阻塞,直到获得锁。
在操作系统层面: JVM 的监视器锁最终依赖操作系统提供的同步机制。以 x86-64(AMD64) 架构处理器和 Windows 64 位操作系统为例,JVM 的锁实现会依赖于操作系统提供的 Mutex Lock 来实现线程之间的互斥访问。
在处理器硬件层面: 比如x86-64 架构处理器提供了原子操作指令(如 lock cmpxchg 和 lock xchg),这些指令能够在多核处理器环境中确保某些操作的原子性、可见性、有序性,防止并发访问导致的数据竞争。本质上就是lock前缀指令,可以去看 我的这篇文章volatile关键字详解 (opens new window),里面有介绍lock前缀指令。
# Mutex Lock(互斥锁):
Mutex Lock(互斥锁)是一种同步机制,用于确保在多线程或多进程环境中,同一时刻只有一个线程(或进程)可以访问共享资源。它是通过操作系统提供的原语(如特殊的CPU指令或者操作系统的内核功能)来实现的。
Mutex Lock是操作系统的内核层功能。
# 内核态和用户态:
CPU硬件层面:
内核态(Kernel Mode): CPU可以执行所有指令,包括特权指令。CPU可以访问所有的内存地址和硬件设备。这个模式用于操作系统内核和一些关键的系统级服务。
用户态(User Mode): CPU只能执行非特权指令,受限于访问内存和硬件设备。用户态用于运行应用程序,以保护系统不受用户程序错误或恶意行为的影响。
操作系统软件层面:
内核态: 操作系统内核运行在CPU的内核态下,以便能够执行特权指令和访问所有资源。内核态下运行的代码包括设备驱动程序、文件系统、网络协议栈等。
用户态: 应用程序运行在CPU的用户态下。操作系统通过提供系统调用接口,允许用户态程序请求内核态服务,如文件读写、进程管理、网络通信等。
为什么需要内核态和用户态的区分?
可以从以下几个方面来看:
安全性:CPU内核态和用户态的区分可以防止用户程序直接访问和控制系统核心资源,从而保护系统的稳定性和安全性。
稳定性:CPU内核态运行的代码(如操作系统的内核代码)具有更高的权限,可以执行特权指令,管理系统资源,确保系统的稳定运行。
资源管理:CPU内核态负责资源的分配和管理,通过系统调用提供受控的接口给用户态程序访问。
# 内核态和用户态的切换:
在程序运行过程中,当用户态程序需要执行特权操作时(比如需要使用Mutex Lock时),必须通过系统调用切换到CPU的内核态。操作系统内核在处理完CPU内核态的系统调用后,CPU会返回用户态继续执行用户程序。这种切换过程涉及到保存和恢复 CPU 的上下文信息(包括寄存器、程序计数器、堆栈指针等)。
切换过程 用户态到内核态:当用户态程序发起系统调用时,CPU 切换到内核态,操作系统内核处理请求。 内核态到用户态:操作系统内核完成请求后,CPU恢复用户态的上下文,返回用户态继续执行。
一个线程下的CPU内核态和用户态的切换:
用户态线程执行 --> 系统调用/中断 --> 保存用户态上下文 --> 切换到内核态 --> 执行内核代码 --> 恢复用户态上下文 --> 返回用户态线程执行
线程上下文切换过程示例:(注意和上面区分)
线程 A 执行 --> 保存线程 A 上下文 --> 选择线程 B --> 恢复线程 B 上下文 --> 线程 B 执行
# JDK6之前synchronized为什么性能不好
经过上面的铺垫,再总结下这个JDK6之前synchronized为什么性能不好的问题就比较自然了。
重量级锁(Heavyweight Locking)
实现方式:在JDK6之前,synchronized使用的是基于操作系统的Mutex Lock(互斥锁)进行实现。每次线程进入和退出同步块时,都需要通过调用操作系统的内核函数来获取和释放锁。 内核态和用户态切换:获取和释放锁的过程涉及到CPU从用户态切换到内核态,再从内核态切换回用户态。这种切换需要保存和恢复CPU的上下文信息,开销大。
系统调用开销:每次锁的获取和释放都会进行系统调用,系统调用本身也会带来较大的性能开销。线程阻塞和唤醒
线程阻塞:当一个线程试图获取一个已经被其他线程持有的锁时,该线程会被阻塞。阻塞操作会导致线程的上下文切换,开销大。
线程唤醒:当锁被释放时,需要唤醒一个被阻塞的线程。唤醒操作同样涉及线程的上下文切换,并且需要操作系统内核进行调度,开销也很大。
# 真正的主角登场(JDK1.6对锁的升级)
# 对锁的优化方式介绍
为了解决JDK中锁机制的性能问题。 JDK1.6对锁进行了一系列的升级操作。 主要有下面这些优化方式:
- 锁粗化
- 自旋锁
- 自适应自旋锁
- 锁消除
- 偏向锁
- 轻量级锁
下面分别介绍:
①、锁粗化
通常情况下,建议将同步块的范围尽量缩小,也就是锁粒度尽量缩小,以减少锁的竞争。
然而,如果在一个短时间内,对象被频繁地加锁和解锁,反而会增加性能开销。
锁粗化通过扩展同步块的范围,将多个连续的加锁和解锁操作合并为一个,从而减少锁操作的频率,提升性能。
锁粗化是JVM在即时编译(JIT)期间进行的一种优化,它会自动将多个连续的加锁和解锁操作合并为一个更大的同步块。
示例:
// 优化前
for (int i = 0; i < 100; i++) {
synchronized (lock) {
// 操作
}
}
// 优化后
synchronized (lock) {
for (int i = 0; i < 100; i++) {
// 操作
}
}
②、自旋锁
自旋锁是指线程在短时间内尝试获取锁时,不会立即进行阻塞,而是在循环中不断尝试获取锁。
这样可以避免线程频繁地进入和退出阻塞状态(涉及线程上下文切换),从而减少线程调度带来的开销。
但如果自旋时间过长,会让CPU空转,会占用CPU资源,反而降低性能。
示例:
// 简单自旋锁示例
while (!compareAndSet(lock, null, Thread.currentThread())) {
// 自旋
}
③、自适应自旋锁
自适应自旋锁是自旋锁的改进版本。自适应自旋锁的自旋次数不是固定的,而是根据前一次自旋锁的获取情况以及锁的拥有者的状态来动态调整。自适应自旋锁能够更加智能地选择自旋时间,进一步提升性能。
示例:
// 自适应自旋锁示例(伪代码)
int spins = calculateAdaptiveSpins();
while (!compareAndSet(lock, null, Thread.currentThread()) && spins > 0) {
spins--;
}
④、锁消除
锁消除是指JVM在即时编译(JIT)期间,通过对代码的逃逸分析,判断某些同步块所使用的锁对象不会逃逸出线程,从而消除这些不必要的锁操作。这样可以减少不必要的同步开销,提高程序执行效率。
示例:
public void example() {
Object lock = new Object();
synchronized (lock) {
// 操作
}
}
// 优化后
public void example() {
// JVM会消除不必要的同步操作
// 操作
}
⑤、偏向锁
偏向锁是指当一个线程获得锁后,锁进入偏向模式,此时锁会偏向于该线程,如果该线程再次请求同一个锁,便不再进行加锁操作,直接进入同步块,从而减少锁操作的开销。当有其他线程请求该锁时,偏向锁才会被撤销。
⑥、轻量级锁
轻量级锁是指当锁处于无竞争或低竞争状态时,线程会通过CAS操作(Compare-And-Swap)将对象头的Mark Word替换为指向线程栈中锁记录的指针。轻量级锁可以避免重量级锁的开销,提高性能。如果出现一定条件的竞争,轻量级锁会膨胀为重量级锁。
⑦、重量级锁
重量级锁是通过操作系统的互斥量(Mutex)来实现的,当线程竞争激烈或轻量级锁膨胀时,会使用重量级锁。重量级锁会让线程进入阻塞状态,这样可以避免CPU空转,但会带来较高的线程上下文切换开销。
简单总结下:
优化方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
锁粗化 | 减少锁操作的频率,提升性能 | 不适合所有场景,可能会引入不必要的锁竞争 | 短时间内频繁加锁和解锁的场景 |
自旋锁 | 避免线程阻塞,减少线程调度开销 | 长时间自旋会占用CPU资源,降低系统性能 | 预期锁竞争时间很短的场景 |
自适应自旋锁 | 动态调整自旋次数,更智能地选择自旋时间 | 需要额外的逻辑判断,自旋时间较长仍会占用CPU资源 | 锁竞争情况变化较大的场景 |
锁消除 | 消除不必要的同步块,减少同步开销 | 依赖于JVM的逃逸分析,不适用于所有情况 | 锁对象不会逃逸出线程的场景 |
偏向锁 | 减少同一线程多次请求锁的开销 | 多线程竞争时撤销偏向锁需要额外开销 | 锁大部分时间被同一线程占用的场景 |
轻量级锁 | 避免重量级锁的开销,提升性能 | 有锁竞争时会膨胀为重量级锁,带来额外开销 | 低竞争环境中的锁操作 |
重量级锁 | 确保线程安全,通过操作系统的Mutex Lock互斥锁实现 | 线程阻塞和上下文切换开销大 | 高竞争环境中需要严格线程同步的场景 |
# 锁的升级过程
锁的升级过程涉及了Java中 synchronized 关键字在不同竞争条件下的优化和状态变化。这些状态包括无锁、偏向锁、轻量级锁和重量级锁,根据线程竞争的程度和情况,JVM会自动将锁从一种状态升级到另一种状态,以降低获取锁的成本,提高并发性能。
下表是64位JVM下的 Mark Word 各个位储存的数据变化情况(上面已经提到过了)
注意: 偏向锁还有一个匿名偏向的状态下面会说到。
匿名偏向状态:即锁对象为偏向锁,但是没有线程偏向于这个锁对象。对应的 Mark Word 标志位后三位是001,前61位都是0。
# 查看对象的内存结构
一开始查锁升级相关资料的时候感觉有点绕,还是觉得自己去看下对象头比较靠谱。于是就想JDK有没有提供类似反射的机制获取对象头信息的功能。查了查资料好像没有。 只能通过第三方的jar(JOL)提供的功能。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
jol-core 是一个 Java 库,用于分析对象布局(Layout)和对象内存结构。它的全称是 Java Object Layout,通常简称为 JOL。
主要用处:
对象布局分析,锁状态分析。通过分析对象的结构来进行性能优化。
# ①、无锁
需要先补充知识点:
偏向锁是JDK1.6引入的,与此同时,JVM也引入了和偏向锁相关的JVM启动参数。
查看默认的JVM启动参数命令:java -XX:+PrintFlagsFinal -version
有非常多默认的JVM启动参数命令,下面列出来两个和偏向锁相关的配置参数。
# 这个参数用于启用或禁用偏向锁
# 默认值是 true,表示偏向锁是启用的。
UseBiasedLocking = true
# 这个参数指定在JVM启动后多长时间(以毫秒为单位)启用偏向锁。
# 默认值是 4000,表示偏向锁在JVM启动后4秒钟才会启用。
BiasedLockingStartupDelay = 4000
示例代码:
import org.openjdk.jol.info.ClassLayout;
public class TestA {
public static void main(String[] args) {
TestA testA = new TestA(); // 无锁
System.out.println("======== 无锁 =========");
System.out.println(ClassLayout.parseInstance(testA).toPrintable());
}
}
运行结果(只取Mark Word 部分):
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
non-biasable表示无锁,0x0000000000000001 转成64位二进制后 ,后三位 001 也对应无锁
分析:
JVM默认启用偏向锁,但是JVM启动后4秒钟才会启用,TestA testA = new TestA();
是在JVM启用偏向锁之前就创建了,此时testA
对象处于无锁状态。
# ②、偏向锁的匿名偏向状态
JVM的参数UseBiasedLocking
是个启动开关没啥好说的。
JVM的参数BiasedLockingStartupDelay
为什么要延迟启用偏向锁呢?
延迟启用偏向锁的主要目的是希望在应用程序启动阶段,避免由于类加载和初始同步造成的短期内大量偏向锁撤销开销。
JVM启动时会延时初始化偏向锁,默认是4秒(可以通过 JVM参数BiasedLockingStartupDelay
修改)。初始化后会将所有加载的Klass的prototype header修改为匿名偏向样式。Klass 是 JVM 用来表示 Java 类的一种结构。每个 Klass 对象都包含一个 prototype header,这是一个模板,用于初始化新创建的对象的对象头(Object Header)。当创建一个新对象时,它的对象头会从这个模板中复制初始值。
所以当创建一个对象时,会通过Klass的prototype_header来初始化该对象的对象头。
在JVM偏向锁初始化结束后,后续创建的所有对象都为匿名偏向状态(因为JVM偏向锁初始化后会将所有加载的Klass的prototype header修改为匿名偏向样式),在此之前创建的对象则为无锁状态。而对于无锁状态的锁对象,如果有竞争,会直接进入到轻量级锁可以避免偏向锁撤销的开销,从而提高JVM的启动速度。
比如可以看下图JVM启动时会加载很多类到内存,类加载的时候就用了synchronized 代码块,此时如果是默认的JVM启动参数,那么4秒内创建的对象刚创建出来是无锁状态,一但遇到竞争就会跳过偏向锁直接进入轻量级锁状态。这也是JVM优化锁性能的一种方式。
示例代码:
import org.openjdk.jol.info.ClassLayout;
import java.util.concurrent.TimeUnit;
public class TestA {
public static void main(String[] args) {
try {
// BiasedLockingStartupDelay = 4000
// 这个参数指定在JVM启动后多长时间(以毫秒为单位)启用偏向锁。
// 默认值是 4000,表示偏向锁在JVM启动后4秒钟才会启用。
// 这里等待5秒
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 5秒后创建对象
TestA testA = new TestA();
System.out.println("======== 偏向锁(匿名偏向状态) =========");
System.out.println(ClassLayout.parseInstance(testA).toPrintable());
}
}
运行结果(只取Mark Word 部分):
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
biasable表示可以偏向,0x0000000000000005 转成64位二进制后 ,后三位 101 也对应偏向锁,前61位都是0,表示匿名偏向状态。
# ③、 匿名偏向状态→偏向锁
匿名偏向状态的对象在满足以下条件时可以转变为偏向锁状态: 单线程首次访问:
当一个线程第一次访问该对象的同步代码块时,JVM将该对象的锁标志设置为偏向锁,并将Mark Word中的线程ID字段更新为当前线程的ID。
无竞争:
如果在一个线程持有偏向锁期间,没有其他线程尝试获取该对象的锁,偏向锁将保持不变。此时,线程可以在不进行同步操作的情况下,快速进入和退出同步块。
代码示例:
import org.openjdk.jol.info.ClassLayout;
import java.util.concurrent.TimeUnit;
public class TestA {
public static void main(String[] args) {
try {
// BiasedLockingStartupDelay = 4000
// 这个参数指定在JVM启动后多长时间(以毫秒为单位)启用偏向锁。
// 默认值是 4000,表示偏向锁在JVM启动后4秒钟才会启用。
// 这里等待5秒
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 5秒后创建对象
TestA testA = new TestA();
System.out.println("======== 偏向锁(匿名偏向状态) =========");
System.out.println(ClassLayout.parseInstance(testA).toPrintable());
// 遇到同步代码块
synchronized (testA){
System.out.println("======== 匿名偏向状态 → 偏向锁 =========");
System.out.println(ClassLayout.parseInstance(testA).toPrintable());
}
}
}
运行结果:
======== 偏向锁(匿名偏向状态) =========
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
======== 匿名偏向状态 → 偏向锁 =========
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000002e92805 (biased: 0x000000000000ba4a; epoch: 0; age: 0)
biased表示偏向锁状态,0x0000000002e92805 转成64位二进制后 ,后三位 101 也对应偏向锁,前61位有非0数据,表示锁偏向的线程id等信息。
# ③、无锁或偏向锁→轻量级锁
升级为轻量级锁: 升级条件:
偏向锁撤销: 如果一个对象已经被偏向某个线程,但是另一个线程尝试获取该对象的锁,偏向锁会被撤销,从而升级为轻量级锁。
轻量级锁的优势: 轻量级锁通过CAS操作尝试获取锁,避免了线程阻塞。虽然CAS操作比偏向锁稍有开销,但在高竞争环境下,其效率要高于频繁撤销和重新设置偏向锁的开销。
轻量级锁转换和获取过程概述:
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
代码示例:
import org.openjdk.jol.info.ClassLayout;
import java.util.concurrent.TimeUnit;
public class TestA {
public static void main(String[] args) {
// 无锁对象
TestA testA = new TestA();
System.out.println("======== 无锁状态 =========");
System.out.println(ClassLayout.parseInstance(testA).toPrintable());
synchronized (testA) {
System.out.println("======== 无锁状态 → 轻量级锁 =========");
System.out.println(ClassLayout.parseInstance(testA).toPrintable());
}
try {
// BiasedLockingStartupDelay = 4000
// 这个参数指定在JVM启动后多长时间(以毫秒为单位)启用偏向锁。
// 默认值是 4000,表示偏向锁在JVM启动后4秒钟才会启用。
// 这里等待5秒
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 匿名偏向锁对象
TestA testB = new TestA();
System.out.println("======== 匿名偏向锁状态 =========");
System.out.println(ClassLayout.parseInstance(testB).toPrintable());
synchronized (testB) {
System.out.println("======== 匿名偏向锁状态 → 偏向锁 且偏向主线程=========");
System.out.println(ClassLayout.parseInstance(testB).toPrintable());
}
// 此时另一个线程t1尝试获取 testB 对象的锁 会导致testB的偏向锁撤销 从而升级为轻量级锁
new Thread(()->{
synchronized (testB) {
System.out.println("======== 偏向锁 → 轻量级锁 =========");
System.out.println(ClassLayout.parseInstance(testB).toPrintable());
}
},"t1").start();
}
}
运行结果:
======== 无锁状态 =========
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
======== 无锁状态 → 轻量级锁 =========
TestA object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00000000033ff2d0 (thin lock: 0x00000000033ff2d0)
======== 匿名偏向锁状态 =========
TestA object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
======== 匿名偏向锁状态 → 偏向锁 且偏向主线程=========
TestA object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00000000035a2805 (biased: 0x000000000000d68a; epoch: 0; age: 0)
======== 偏向锁 → 轻量级锁 =========
TestA object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00000000206af270 (thin lock: 0x00000000206af270)
分析:
无锁状态 → 轻量级锁 :这个是因为JVM偏向锁功能还没启动完成。所以线程遇到同步代码块会直接由 无锁状态 → 轻量级锁。
偏向锁 → 轻量级锁: 因为testB
对象已经被偏向主线程,但是另一个线程t1
尝试获取该对象的锁,偏向锁会被撤销,从而升级为轻量级锁。
# ④、轻量级锁→重量级锁
轻量级锁升级为重量级锁的条件: 自旋失败: 当一个线程尝试获取一个已经被其他线程持有的轻量级锁时,JVM会让这个线程自旋一段时间,尝试获取锁。如果自旋次数超过一定的阈值(默认是10次,对应JVM参数PreInflateSpin
),自旋会失败,导致 轻量级锁→重量级锁。
竞争激烈: 如果多个线程频繁地争抢同一个锁,并且这些线程自旋失败的情况持续发生,那么 JVM 会将该锁升级为重量级锁,以避免自旋浪费大量 CPU 资源。
线程数目多: 当等待获取锁的线程数目达到一定程度(通常是 2 个以上),轻量级锁会被升级为重量级锁。重量级锁依赖操作系统的互斥机制,会导致线程阻塞和上下文切换。
JVM参数:
## 轻量级锁升级为重量级锁之前自旋等待的次数
## 这个参数决定了线程在放弃轻量级锁并升级为重量级锁之前,自旋尝试获取锁的最大次数
PreInflateSpin = 10
## 线程在进入阻塞状态之前自旋等待的次数
## 这个参数决定了线程在放弃轻量级锁并进入阻塞状态之前,自旋尝试获取锁的最大次数
PreBlockSpin = 10
在windows版本的java version "1.8.0_202"
这个版本中 我并没有找到PreBlockSpin
这个参数,也许这个版本已经移除了PreBlockSpin
这个参数。(猜测)
当 JVM 决定将轻量级锁膨胀为重量级锁时,会创建一个重量级锁(monitor 对象)。轻量级锁的持有线程将对象头的 Mark Word 中的指针更新为指向 monitor 对象。Monitor 对象包含了锁的状态、持有锁的线程、等待队列等信息。
竞争锁失败的线程将进入 monitor 对象的等待队列,操作系统会阻塞这些线程。当持有锁的线程释放锁时,它会通知操作系统唤醒等待队列中的一个或多个线程,尝试重新获取锁。重量级锁的获取和释放涉及操作系统的系统调用,会导致CPU进行状态切换(用户态→内核态→用户态)、还有可能导致线程阻塞和唤醒(线程上下文切换),因此性能较低(这个在上面的知识铺垫里有详细说到)。
代码示例:
import org.openjdk.jol.info.ClassLayout;
public class TestA {
public static void main(String[] args) {
// 无锁对象
TestA testA = new TestA();
System.out.println("======== 无锁状态 =========");
System.out.println(ClassLayout.parseInstance(testA).toPrintable());
synchronized (testA) {
System.out.println("======== 无锁状态 → 轻量级锁 =========");
System.out.println(ClassLayout.parseInstance(testA).toPrintable());
}
new Thread(() -> {
synchronized (testA) {
System.out.println("======== 轻量级锁 → 重量级锁 t1竞争=========");
System.out.println(ClassLayout.parseInstance(testA).toPrintable());
}
}, "t1").start();
new Thread(() -> {
synchronized (testA) {
System.out.println("======== 轻量级锁 → 重量级锁 t2竞争=========");
System.out.println(ClassLayout.parseInstance(testA).toPrintable());
}
}, "t2").start();
}
}
运行结果:
======== 无锁状态 =========
TestA object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
======== 无锁状态 → 轻量级锁 =========
TestA object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00000000032bf1a8 (thin lock: 0x00000000032bf1a8)
======== 轻量级锁 → 重量级锁 t1竞争=========
TestA object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00000000205ef070 (thin lock: 0x00000000205ef070)
======== 轻量级锁 → 重量级锁 t2竞争=========
TestA object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x000000001ceef5da (fat lock: 0x000000001ceef5da)
可以看到t2竞争的时候锁升级到了重量级。
有时候执行上面代码,t1和t2打印的都有可能是重量级。
# 锁升级图示:
这个就不自己整理了(又偷懒了~)
图片来源https://tech.youzan.com/javasuo-yu-xian-cheng-de-na-xie-shi/ (opens new window)
图片来源的这篇博客里对于锁的升级过程,偏向JVM实现层面。如果想深入了解的话,建议去仔细看看这篇博客。
注意:
偏向锁在JDK 15中被标记为弃用(deprecated),并计划在未来版本中删除。
在JDK 17中,偏向锁已经被移除。
JDK为什么要移除偏向锁?
大致有下面几个原因:
- 复杂性: 偏向锁增加了HotSpot JVM代码的复杂性。
- 收益减少: 在现代硬件和应用程序中,偏向锁带来的性能提升变得不明显。
- 维护成本: 随着JVM的演进,维护偏向锁功能变得越来越困难。
就是吃力不讨好了,干脆就不要了。
# 7、总结synchronized保证原子、可见、有序性原理
# ①、Java代码层面
在 Java 中,使用 synchronized 关键字可以确保同一时刻只有一个线程能执行被 synchronized
修饰的代码块或方法,从而保证临界区代码的原子性和线程安全。同时JMM的监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。也保证了可见性。
# ②、JVM 层面
锁的实现 synchronized 关键字在编译后的字节码中,会转换成 monitorenter 和 monitorexit 指令。monitorenter 在同步代码块的开始位置,monitorexit 在同步代码块的结束位置和异常处。
Monitor 和对象头 每个对象在 JVM 中都有一个对象头,其中包含Mark Word区域,Mark Word包含锁标志位和其他信息。monitorenter 和 monitorexit 指令会检查并操作对象头中Mark Word的锁信息。如果锁竞争激烈,JVM 会进行锁升级,并在对象头中设置指向 monitor 对象的指针。Monitor 对象包含锁的状态、持有锁的线程以及等待队列等信息。
注意点:
Monitor 监视器并不是在对象创建时就存在的,而是在锁竞争激烈的情况下(如轻量级锁失败并升级为重量级锁时)才会创建。初始状态下,对象头的 Mark Word 仅包含基本的锁信息,而不包含具体的 Monitor 对象。只有在需要重量级锁时,才会分配并使用 Monitor 对象来管理锁的状态和线程的阻塞与唤醒。
锁的类型
偏向锁:无竞争时,线程偏向于自己持有的锁。
轻量级锁:有少量竞争时,采用 CAS 操作避免线程阻塞。
重量级锁:高竞争时,使用操作系统的互斥锁机制,涉及线程阻塞和唤醒,性能较低。
# ③、操作系统层面
互斥锁(Mutex Lock) 重量级锁依赖于操作系统的互斥锁机制。当轻量级锁竞争失败时,JVM 会将其升级为重量级锁,涉及系统调用(可能会有线程的上下文切换和CPU内核态状态转换)。
# ④、处理器层面
缓存一致性协议和lock
前缀指令
多处理器系统中,为了保证可见性和有序性,处理器采用缓存一致性协议(如 MESI 协议)来维护各个处理器缓存的同步。
JIT编译器使用lock
前缀指令完成处理器级别的原子操作,禁止重排序操作,以此保证共享变量的原子性和有序性,并通过缓存一致性协议保证共享变量的可见性。
# 补充:为什么有synchronized了JDK还要设计Lock
除了 synchronized 关键字之外,JDK 还引入了 java.util.concurrent.locks 包中的显式锁(如 ReentrantLock)。两者虽然都用于线程同步,但各有优缺点和适用场景。
原因其实很简单,有些业务场景synchronized不支持。
比如:
尝试获取锁,2秒获取不到就放弃。
让等待的线程响应中断。
设置锁的公平性。
获取不到锁立马返回false(非阻塞式)。
有多个等待或者唤醒的条件。
无法自定义synchronized的行为。
上面说的synchronized不支持的 Lock 都支持,但是synchronized并非一无是处,synchronized不用显式释放锁,api使用简单,不需要创建特殊的锁对象。
所以实际业务中,进行简单的同步使用synchronized是比较推荐的。
Lock 的详解就放到后面吧。
# 写在最后
这篇文章写着写着,又是一周过去了。。。发现并发这块内容真的,不整理还好,一整理就真的~ 一周能整理出一篇就不错了,自闭中~
希望这篇文章对你理解synchronized有所帮助。
我写的这篇内容也只是东拼西凑罢了,稍微加了点自己的理解,补充了一些知识点,让知识点更自然的衔接起来。
因为写这些文章的目的就是构建自己Java知识体系,所以排版就顺着自己的思维走会显得很啰嗦。
最后感谢下面这些资料:
https://tech.youzan.com/javasuo-yu-xian-cheng-de-na-xie-shi
https://www.cnblogs.com/star95/p/17542850.html
https://javaguide.cn
https://pdai.tech
https://ifeve.com
《Java并发编程的艺术》
《Java并发实现原理:JDK源码剖析》
《Java并发编程之美》
《图解Java多线程设计模式》