一、前言
在涉及到多线程同步的问题时,可能第一时间会想到用Synchronized,在jdk1.6以前Synchronized属于重量锁,就是同一时间只允许一个线程获取锁,其他线程都要阻塞等待,但这对于多线程效率问题有很大影响。在jdk1.6之后,Java团队对Synchronized做了优化,进行了锁升级,升级方向:无锁—>偏向锁—>轻量级锁—>重量级锁。
下面我们来详细解析下Synchronized的使用和源码。
二、正文
1.Synchronized的使用场景?
Synchronized一般有以下三种使用场景:
- 修饰实例方法,对当前实例对象this加锁(this关键字所代表的意思是该对象的实例)
public class SynchronizedDemo() {
// 修饰实例方法
public synchronized void test() {
...
}
}
// 下面的方法与上面是等价的
public class SynchronizedDemo() {
public void test() {
// this代表该对象的实例
synchronized(this) {
...
}
}
}
- 修饰静态方法,对当前类的Class对象加锁,下面三种方法是相等的
public class SynchronizedDemo() {
public void test() {
synchronized(SynchronizedDemo.class) {
...
}
}
}
public class SynchronizedDemo() {
public synchronized static void test() {
...
}
}
public class SynchronizedDemo() {
public void test() {
synchronized(this.getClass()) {
...
}
}
}
- 修饰代码块,指定一个加锁对象,给对象加锁
public class SynchronizedDemo() {
public void test() {
synchronized(new AddDemo()) {
...
}
}
}
上面的synchronized(new AddDemo())是给AddDemo这个对象实例加锁,若是我们没有一个明确的对象作为锁,只想让一段代码同步,可以创建一个特殊的对象来充当锁,如下:
public class SynchronizedDemo() {
private final Object lock = new Object();
public void test() {
synchronized(lock) {
...
}
}
}
这个lock不具体指锁住具体的对象,只是为了执行一段同步代码。
Tip:这里有一个优化的小细节,new byte[0]作为锁对象比new Object()更好,因为会减少字节码的操作次数。
我们通过反编译得出的字节码可以看出,new byte [0] 确实比 new Object () 少 4 条字节码操作。再计算一下内存占用,在 64 位 jvm 默认开启 UseCompressedOops 的情况下(Java 1.6.0_23 版本开始就默认开启了),一个空对象,不包含任何成员变量,大小 16 字节,一个 byte [0] 数组,大小也是 16 字节,是相等的。
于是验证了上面的结论:用 new byte [0] 作为锁对象是优于 new Object () 的。在日常的 java 开发中,可以注意到这个细节的点来优化代码。
那么synchronized是怎么实现加锁的呢?
在介绍这个之前,我们首先去了解下Java对象构成,因为前面提到过,Java的锁都是基于对象的。
2.在JVM中,Java对象构成?
在 JVM 中,对象在内存中分为三块区域:
-
对象头
Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
-
实例数据
这部分主要是存放类的数据信息,父类的信息。
-
对其填充
由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。
Tip:不知道大家有没有被问过一个空对象占多少个字节?就是8个字节,是因为对齐填充的关系哈,不到8个字节对其填充会帮我们自动补齐。
问题延申:
(1)在32为操作系统或者在 64 位 jvm 默认开启 UseCompressedOops 的情况下,Object o = new Object()一共占用多少个字节?
java空对象占8个字节,对象的引用占4个字节。
所以上面那条语句所占的空间是4byte+8byte=12byte.
java中的内存是以8字节的倍数来分配的,所以分配的内存是16byte.(2)UseCompressedOops是做什么用的?
拿新建一个对象来说:Object o = new Object()
如果不开启普通对象指针压缩,-UseCompressedOops,会在内存中消耗24个字节,o 指针(klass pointer)占8个字节,Object对象占16个字节。
如果开启普通对象指针压缩,+UseCompressedOops,会在内存中消耗20个字节,o指针(klass pointer)占4个字节,Object对象占16个字节。
这样一看,好像UseCompressedOops 对Object的内存并没有影响,其实不然,Object对象在内存中的布局,包括markword 、
klass pointer、实例数据和填充对其,开启UseCompressedOops,默认会开启UseCompressedClassPointers,会压缩klass pointer 这部分的大小,由8字节压缩至4字节,间接的提高内存的利用率。
3.我们常说的有序性、可见性、原子性,可重入性、不可中断性,Synchronized是如何保证的?
- 有序性
我在Volatile章节已经说过了CPU会为了优化我们的代码,会对我们程序进行重排序。
as-if-serial
不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的,还有就是有数据依赖的也是不能重排序的。
就比如:
int a = 1;
int b = a;
这两段是怎么都不能重排序的,b的值依赖a的值,a如果不先赋值,那就为空了。
Synchronized不能禁止指令重新排序,但是可以保证了有序性。
这个有序性是相对语义来看的,线程与线程间,每个synchronized块可以看成是一个原子操
作,它保证每个时刻只有一个线程执行同步代码,并且可以解决线程的工作内存和主内存不一致问题(会清空工作内存,从主内存中读取数据)。
- 可见性
同样在Volatile章节我介绍到了现代计算机的内存结构,以及JMM(Java内存模型),这里我需要说明一下就是JMM并不是实际存在的,而是一套规范,这个规范描述了很多java程序中线程共享变量的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,Java内存模型是对共享数据的可见性、有序性、和原子性的规则和保障。
当某一线程进入synchronized代码块前后,线程会获取锁,清空工作内存,从主内存拷贝共享变量最新值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存,线程释放锁。
而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。这样synchronized就保证可见性。
- 原子性
其实他保证原子性很简单,确保同一时间只有一个线程能拿到锁,能够进入代码块这就够了。
这几个是我们使用锁经常用到的特性,那synchronized他自己本身又具有哪些特性呢?
- 可重入性
synchronized锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。
那可重入有什么好处呢?
可以避免一些死锁的情况,也可以让我们更好封装我们的代码。
synchronized可重复锁实现原理
synchronized底层的实现原理是利用计算机系统的mutex Lock实现。每一个可重入锁都会关联一个线程ID和一个锁状态status。
当一个线程请求方法时,会去检查锁状态,如果锁状态是0,代表该锁没有被占用,直接进行CAS操作获取锁,将线程ID替换成自己的线程ID。如果锁状态不是0,代表有线程在访问该方法。此时,如果线程ID是自己的线程ID,并且是可重入锁,会将status自增1,然后获取到该锁,进而执行相应的方法。如果是非重入锁,就会进入阻塞队列等待。
释放锁时,可重入锁,每一次退出方法,就会将status减1,直至status的值为0,最后释放该锁。
释放锁时,非可重入锁,线程退出方法,直接就会释放该锁。
所以,从一定程度上来说,可重入锁可以避免死锁的发生。
- 不可中断性
不可中断就是指,一个线程获取锁之后,另外一个线程处于阻塞或者等待状态,前一个不释放,后一个也一直会阻塞或者等待,不可以被中断。
值得一提的是,Lock的tryLock方法是可以被中断的。
4.Synchronized底层实现原理?
这里看实现很简单,我写了一个简单的类,分别有锁方法和锁代码块,我们反编译一下字节码文件,就可以了。
先看看我写的测试类:
public class SynchronizedDemo {
public static void main(String[] args) {
}
Object lock = new Object();
public synchronized void methodA(){
synchronized(lock){
}
}
}
编译完成,我们去对应目录执行 javap -p -v -c xxx.class 命令查看反编译的文件:
$ javap -p -v -c SynchronizedDemo.class
Classfile /D:/javaProjectTest/leetcode/target/classes/com/example/demo/testSolution/SynchronizedDemo.class
Last modified 2021-5-14; size 694 bytes
MD5 checksum b34d7d0723f3a99882e83da7c5a90247
Compiled from "SynchronizedDemo.java"
public class com.example.demo.testSolution.SynchronizedDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #2.#25 // java/lang/Object."<init>":()V
#2 = Class #26 // java/lang/Object
#3 = Fieldref #4.#27 // com/example/demo/testSolution/SynchronizedDemo.lock:Ljava/lang/Object;
#4 = Class #28 // com/example/demo/testSolution/SynchronizedDemo
#5 = Utf8 lock
#6 = Utf8 Ljava/lang/Object;
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/example/demo/testSolution/SynchronizedDemo;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 methodA
#19 = Utf8 StackMapTable
#20 = Class #28 // com/example/demo/testSolution/SynchronizedDemo
#21 = Class #26 // java/lang/Object
#22 = Class #29 // java/lang/Throwable
#23 = Utf8 SourceFile
#24 = Utf8 SynchronizedDemo.java
#25 = NameAndType #7:#8 // "<init>":()V
#26 = Utf8 java/lang/Object
#27 = NameAndType #5:#6 // lock:Ljava/lang/Object;
#28 = Utf8 com/example/demo/testSolution/SynchronizedDemo
#29 = Utf8 java/lang/Throwable
{
java.lang.Object lock;
descriptor: Ljava/lang/Object;
flags:
public com.example.demo.testSolution.SynchronizedDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: new #2 // class java/lang/Object
8: dup
9: invokespecial #1 // Method java/lang/Object."<init>":()V
12: putfield #3 // Field lock:Ljava/lang/Object;
15: return
LineNumberTable:
line 9: 0
line 13: 4
LocalVariableTable:
Start Length Slot Name Signature
0 16 0 this Lcom/example/demo/testSolution/SynchronizedDemo;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 12: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 args [Ljava/lang/String;
public synchronized void methodA();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // 这里
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: getfield #3 // Field lock:Ljava/lang/Object;
4: dup
5: astore_1
6: monitorenter // 这里
7: aload_1
8: monitorexit // 这里
9: goto 17
12: astore_2
13: aload_1
14: monitorexit // 这里
15: aload_2
16: athrow
17: return
Exception table:
from to target type
7 9 12 any
12 15 12 any
LineNumberTable:
line 15: 0
line 17: 7
line 18: 17
LocalVariableTable:
Start Length Slot Name Signature
0 18 0 this Lcom/example/demo/testSolution/SynchronizedDemo;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 12
locals = [ class com/example/demo/testSolution/SynchronizedDemo, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
}
SourceFile: "SynchronizedDemo.java"
同步代码
大家可以看到几处我标记的,我在最开始提到过对象头,他会关联到一个monitor对象。
- 当我们进入一个方法的时候,执行monitorenter,就会获取当前对象的一个所有权,这个时候monitor进入数为1,当前的这个线程就是这个monitor的owner。
- 如果你已经是这个monitor的owner了,你再次进入,就会把进入数+1.
- 同理,当他执行完monitorexit,对应的进入数就-1,直到为0,才可以被其他线程持有。
所有的互斥,其实在这里,就是看你能否获得monitor的所有权,一旦你成为owner就是获得者。
同步方法
不知道大家注意到方法那的一个特殊标志位没,ACC_SYNCHRONIZED。
同步方法的时候,一旦执行到这个方法,就会先判断是否有标志位,然后,ACC_SYNCHRONIZED会去隐式调用刚才的两个指令:monitorenter和monitorexit。
所以归根究底,还是monitor对象的争夺。
monitor
我说了这么多次这个对象,大家是不是以为就是个虚无的东西,其实不是,monitor监视器源码是C++写的,在虚拟机的ObjectMonitor.hpp文件中。
我看了下源码,他的数据结构长这样:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; // 线程重入次数
_object = NULL; // 存储Monitor对象
_owner = NULL; // 持有当前线程的owner
_WaitSet = NULL; // wait状态的线程列表
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 多线程竞争锁进入时的单向链表
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁状态block状态的线程列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
synchronized底层的源码就是引入了ObjectMonitor。
大家说熟悉的锁升级过程,其实就是在源码里面,调用了不同的实现去获取获取锁,失败就调用更高级的实现,最后升级完成。
5.重量级锁
大家在看ObjectMonitor源码的时候,会发现Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数,对应的线程就是park()和upark()。
这个操作涉及用户态和内核态的转换了,这种切换是很耗资源的,所以知道为啥有自旋锁这样的操作了吧,按道理类似死循环的操作更费资源才是对吧?其实不是,大家了解一下就知道了。
先解释下用户态和内核态:
Linux系统的体系结构大家大学应该都接触过了,分为用户空间(应用程序的活动空间)和内核。
我们所有的程序都在用户空间运行,进入用户运行状态也就是(用户态),但是很多操作可能涉及内核运行,比如I/O,我们就会进入内核运行状态(内核态)。
这个过程是很复杂的,也涉及很多值的传递,我简单概括下流程:
- 用户态把一些数据放到寄存器,或者创建对应的堆栈,表明需要操作系统提供服务。
- 用户态执行系统调用(系统调用是操作系统的最小功能单位)。
- CPU切换到内核态,跳到对应的内存指定的位置执行指令。
- 系统调用处理器去读取我们先前放到内存的数据参数,执行程序的请求。
- 调用完成,操作系统重置CPU为用户态返回结果,并执行下个指令。
所以大家一直说,1.6之前是重量级锁,没错,但是他重量的本质,是ObjectMonitor调用的过程,以及Linux内核的复杂运行机制决定的,大量的系统资源消耗,所以效率才低。
还有两种情况也会发生内核态和用户态的切换:异常事件和外围设备的中断 大家也可以了解下。
6.锁优化升级
由于1.6之前Synchronized一直是重量级锁,在多线程中效率堪忧,所以1.6之后对其进行了锁优化升级,升级方向如下:
无锁——>偏向锁——>轻量级锁——>重量级锁
大致的过程如下图:
几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件会比较苛刻,锁降级发生在Stop The World期间,当JVM进入安全点的时候,会检查是否有闲置的锁,然后进行降级。
关于锁降级有两点说明:
1.不同于大部分文章说锁不能降级,实际上HotSpot JVM 是支持锁降级的,文末有链接。
2.上面提到的Stop The World期间,以及安全点,这些知识是属于JVM的知识范畴,本文不做细讲。
6.1偏向锁
之前我提到过了,对象头是由Mark Word和Klass pointer 组成,锁争夺也就是对象头指向的Monitor对象的争夺,一旦有线程持有了这个对象,标志位修改为1,就进入偏向模式,同时会把这个线程的ID记录在对象的Mark Word中。
这个过程是采用了CAS乐观锁操作的,每次同一线程进入,虚拟机就不进行任何同步的操作了,对标志位+1就好了,不同线程过来,CAS会失败,也就意味着获取锁失败。
偏向锁在1.6之后是默认开启的,1.5中是关闭的,需要手动开启参数是xx:-UseBiasedLocking=false。
偏向锁关闭,或者多个线程竞争偏向锁怎么办呢?
6.2 轻量级锁
还是跟Mark Work 相关,如果这个对象是无锁的,jvm就会在当前线程的栈帧中建立一个叫锁记录(Lock Record)的空间,用来存储锁对象的Mark Word 拷贝。
然后线程尝试用CAS将锁的Mark Word替换为指向Lock Record的指针,成功就说明加锁成功,改变锁标志位,执行相关同步操作。
如果失败了,就会判断当前对象的Mark Word是否指向了当前线程的栈帧,是则表示当前的线程已经持有了这个对象的锁,否则说明被其他线程持有了,继续锁升级,修改锁的状态,之后等待的线程也阻塞。
6.3自旋锁
我不是在上面提到了Linux系统的用户态和内核态的切换很耗资源,其实就是线程的等待唤起过程,那怎么才能减少这种消耗呢?
进行不断自旋,防止线程被挂起,一旦可以获取资源,就直接尝试成功,直到超出阈值,自旋锁的默认大小是10次,-XX:PreBlockSpin可以修改。
但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁。
7.锁升级流程?
每一个线程在准备获取共享资源时:
第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。
第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。
第三步,两个线程都把锁对象的HashCode复制到自己新建的用于存储锁的记录空间(Lock Record),接着开始通过CAS操作,
把锁对象的MarKword的内容修改为自己新建的记录空间(Lock Record)的地址的方式竞争MarkWord。
第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 。
第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。
第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。
8.各种锁的优缺点?
9.Synchronized和Lock比较?
我们先看看他们的区别:
- synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是一个接口,是JDK层面的有丰富的API。
- synchronized会自动释放锁,而Lock必须手动释放锁。
- synchronized是不可中断的,Lock可以中断也可以不中断。
- 通过Lock可以知道线程有没有拿到锁,而synchronized不能。
- synchronized能锁住方法和代码块,而Lock只能锁住代码块。
- Lock可以使用读锁提高多线程读效率。
- synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。
两者一个是JDK层面的一个是JVM层面的,我觉得最大的区别其实在,我们是否需要丰富的api。
三、总结
开始我们首先介绍了Synchronized的使用场景,之后介绍了Java对象在JVM中的构成、Synchronized如保证有序性,可见性,原子性,可重入性,不可中断性、Synchronized的底层原理和锁升级原理,最后介绍各个锁的优缺点。
可见1.6之后Oracle公司对Synchronized做了极大的优化,更好的提升了锁的性能。
我们不能光只是会调用API,而是应该知道其底层原理,应知其然知其所以然,学习其源码的设计思想,这会潜移默化的改变我们以后设计代码的思路,让我设计出更好更高效的代码,并且在遇到问题的时候快速定位问题所在。不要只做CRUD boy or girl啊,这样又如何提升我们自己呢?
最后引用我很佩服的一个人经常说的话:你知道的越多,你不知道的越多!
文章参考:
https://mp.weixin.qq.com/s/2ka1cDTRyjsAGk_-ii4ngw
https://www.bookstack.cn/read/RedSpider1-concurrent/article-02-9.md