发布于2021-05-29 19:23 阅读(1584) 评论(0) 点赞(22) 收藏(1)
在涉及到多线程同步的问题时,可能第一时间会想到用Synchronized,在jdk1.6以前Synchronized属于重量锁,就是同一时间只允许一个线程获取锁,其他线程都要阻塞等待,但这对于多线程效率问题有很大影响。在jdk1.6之后,Java团队对Synchronized做了优化,进行了锁升级,升级方向:无锁—>偏向锁—>轻量级锁—>重量级锁。
下面我们来详细解析下Synchronized的使用和源码。
1.Synchronized的使用场景?
Synchronized一般有以下三种使用场景:
public class SynchronizedDemo() {
// 修饰实例方法
public synchronized void test() {
...
}
}
// 下面的方法与上面是等价的
public class SynchronizedDemo() {
public void test() {
// this代表该对象的实例
synchronized(this) {
...
}
}
}
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会为了优化我们的代码,会对我们程序进行重排序。
不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的,还有就是有数据依赖的也是不能重排序的。
就比如:
int a = 1;
int b = a;
这两段是怎么都不能重排序的,b的值依赖a的值,a如果不先赋值,那就为空了。
当我们使用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对象。
所有的互斥,其实在这里,就是看你能否获得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,我们就会进入内核运行状态(内核态)。
这个过程是很复杂的,也涉及很多值的传递,我简单概括下流程:
所以大家一直说,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复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作,
把锁对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord。
第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 。
第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。
第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。
8.各种锁的优缺点?
9.Synchronized和Lock比较?
我们先看看他们的区别:
两者一个是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
作者:niceboty
链接:http://www.javaheidong.com/blog/article/207108/888fc5406c0920c63884/
来源:java黑洞网
任何形式的转载都请注明出处,如有侵权 一经发现 必将追究其法律责任
昵称:
评论内容:(最多支持255个字符)
---无人问津也好,技不如人也罢,你都要试着安静下来,去做自己该做的事,而不是让内心的烦躁、焦虑,坏掉你本来就不多的热情和定力
Copyright © 2018-2021 java黑洞网 All Rights Reserved 版权所有,并保留所有权利。京ICP备18063182号-2
投诉与举报,广告合作请联系vgs_info@163.com或QQ3083709327
免责声明:网站文章均由用户上传,仅供读者学习交流使用,禁止用做商业用途。若文章涉及色情,反动,侵权等违法信息,请向我们举报,一经核实我们会立即删除!