hospot虚拟机中的synchronized底层实现
概述
synchronized作用
- 原子性:synchronized保证语句块内操作是原子的
- 可见性:synchronized保证可见性(通过“在执行unlock之前,必须先把此变量同步回主内存”实现)
- 有序性:synchronized保证有序性(通过“一个变量在同一时刻只允许一条线程对其进行lock操作”)
synchronized的使用
- 修饰实例方法,对当前实例对象加锁
- 修饰静态方法,多当前类的Class对象加锁
- 修饰代码块,对synchronized括号内的对象加锁
用户态和内核态
内核态:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。
用户态:只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。
为什么要有用户态和内核态?
在最初的时候,没有这样的权限划分,程序可以访问所有的内容,这样很容易就直接把系统卡死,影响其他程序的运行,所以后来添加了访问限制。
由于需要限制不同的程序之间的访问能力, 防止他们获取别的程序的内存数据, 或者获取外围设备的数据, 并发送到网络, CPU划分出两个权限等级 – 用户态和内核态。
现在操作系统中一般都是用户态向内核态(操作系统)获取资源,内核态获取cpu等等这个底层资源
理解Java对象头
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
HotSpot虚拟机的对象头分为两部分信息,第一部分用于存储对象自身运行时数据,如哈希码、GC分代年龄等,这部分数据的长度在32位和64位的虚拟机中分别为32位和64位。官方称为Mark Word。另一部分用于存储指向对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分存储数组长度。
虚拟机位数 对象头结构 描述
32位/64位 Mark Word 存储对象的哈希码、GC分代年龄、锁信息等
32位/64位 Class MetaData Address 指向对象类型数据的指针
32位/64位 数组长度 如果是数组对象的话,有这一部分,否则没有
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。
锁的状态码
虚拟机中判断锁的状态
锁标志位 | 锁 | 偏向锁位 | 描述 |
---|---|---|---|
00 | 轻量级锁(自旋锁、无锁) | 指向线程栈中lock Record的指针 | |
10 | 重量级锁 | 指向互斥锁(重量级锁)的指针 | |
11 | GC标记信息 | CMS过程用到的标记信息 | |
01 | 看偏向锁,偏向锁为0,无锁 | 0 | 当前线程指针javaThread |
01 | 看偏向锁,偏向锁为1,偏向锁 | 1 | 当前线程指针javaThread |
上图内容
hashcode,这里面存放的是原始的hashcode,不是重写的hashcode
分代年龄
- 虚拟机中有新生代和老年代,这个就是这个分代年龄
- 在PS、PO、G1默认15次(这里的分代年龄为4bit,最大为16,所以15次就是最大值)还没有回收,从新生代转移到老年代
- CMS默认是6次
- 虚拟机中有新生代和老年代,这个就是这个分代年龄
Epoch,偏向锁撤销,调优内容
在锁转化的过程中,记录信息越来越少,那原来的信息呢(hashcode、分代年龄)
- 这个信息都记录到当前线程的线程栈(在加锁情况下,只有这一个线程在使用)中,也就是Lock Record,所以轻量级锁和重量级锁会有指针
实现原理
JVM对synchronized的锁优化
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。
0、无锁
顾名思义就是没有对资源进行锁定,所有线程都能访问到同一资源(也就是这个时候仅仅是在对象头上添加了锁的标志位,其他没有进行任何操作)。这就涉及到多种情况
无竞争:线程之间不存在竞争直接获取资源就可以了
存在竞争:使用非锁方式实现同步线程。这就是我们耳熟能详的CAS(Compare And Swap)
CAS通过操作系统的一条指令来实现,所以它可以保证原子性,通过诸如CAS这种方式,我们就可以进行无锁编程。
无锁就是没有线程对这个synchronized进行访问,这个对象还是存在的,但是没有线程去访问,所以有一个无锁状态,只要有一个线程访问那么就会升级为偏向锁(也就是对象头中记录了访问线程的id)。
1、偏向锁
偏向锁是JDK1.6中引用的优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的性能。
偏向锁的目的是:使对象能够认识这个线程,只要是这个线程过来,那么对象直接把锁交出去,我们就可以认为这个锁偏爱这个线程,所以被称为偏向锁,一旦一个对象添加为synchronized,那么这个对象就是偏向锁了,没有无锁到偏向锁的转变。
偏向锁的获取:
判断是否为可偏向状态
如果为可偏向状态,则判断线程ID是否是当前线程,如果是进入同步块;
如果线程ID并未指向当前线程,利用CAS操作竞争锁,如果竞争成功,将Mark Word中线程ID更新为当前线程ID,进入同步块
如果竞争失败,等待全局安全点,准备撤销偏向锁,根据线程是否处于活动状态,决定是转换为无锁状态还是升级为轻量级锁。
当锁对象第一次被线程获取的时候,虚拟机会把对象头中的锁标志位设置为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中,如果CAS操作成功。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
偏向锁的释放:
偏向锁使用了遇到竞争才释放锁的机制。偏向锁的撤销需要等待全局安全点,然后它会首先暂停拥有偏向锁的线程,然后判断线程是否还活着,如果线程还活着,则升级为轻量级锁,否则,将锁设置为无锁状态。
注意:
创建一个对象后,jvm偏向锁的启动是有4秒延时的(默认),这个时候为匿名偏向,因为在开始创建的过程中肯定有大量的线程参与竞争(内存分配的竞争等等),这个时候如果立即打开偏向锁的话,肯定是有性能浪费的(锁竞争过程、锁撤掉),所以打开偏向锁的效率不是一定会提升效率。
1 | jvm中设置立即打开偏向锁 |
2、轻量级锁
轻量级锁也是在JDK1.6中引入的新型锁机制。它不是用来替换重量级锁的,它的本意是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
加锁过程:
在代码进入同步块的时候,如果此对象没有被锁定(锁标志位为“01”状态),虚拟机首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word)。然后虚拟机使用CAS操作尝试将对象的Mark Word更新为指向锁记录(Lock Record)的指针。如果更新成功,那么这个线程就拥有了该对象的锁,并且对象的Mark Word标志位转变为“00”,即表示此对象处于轻量级锁定状态;如果更新失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块中执行,否则说明这个锁对象已经被其他线程占有了。如果有两条以上的线程竞争同一个锁,那轻量级锁不再有效,要膨胀为重量级锁,锁标志变为“10”,Mark Word中存储的就是指向重量级锁的指针,而后面等待的线程也要进入阻塞状态。
锁升级过程:
jdk1.6之前:
CAS某线程自旋次数大于10次(默认)或者竞争的线程数超过cpu总核数的一半(例如:8核cpu超过了4个线程),升级为重量级锁。
可以进行jvm调优
jdk1.6后:
自适应升级
解锁过程:
如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作将对象当前的Mark Word与线程栈帧中的Displaced Mark Word交换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁比传统重量级锁开销更大。
3、重量级锁
Synchronized的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。
但是如果是多线程高兵发的情况下,需要加锁,这个时候无意使用Synchronized是最好的的。
如果是单纯的cas在多线程情况下,大量的线程在自旋,这个自旋操作是需要占据cpu资源的。但是如果用的重量级锁,这个时候是放在一个wait队列中,轮到那个线程,cpu调度哪个线程,后续的那个线程是不占局cpu资源的,这个时候无疑重量级锁的效率更高。
总结:
线程少或者线程运行时间少,适用于自旋锁
线程多或者线程运行时间长,应用与重量级锁
4、自旋锁
互斥同步对性能影响最大的是阻塞的实现,挂起线程和恢复线程的操作都需要转入到内核态中完成,这些操作给系统的并发性能带来很大的压力。
于是在阻塞之前,我们让线程执行一个忙循环(自旋),看看持有锁的线程是否释放锁,如果很快释放锁,则没有必要进行阻塞。
5、锁消除
锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是检测到不可能发生数据竞争的锁进行消除。
6、锁粗化
如果虚拟机检测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
jvm基于进入和退出Monitor对象来实现方法同步和代码块同步。
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。
代码块的同步是利用monitorenter和monitorexit这两个字节码指令。它们分别位于同步代码块的开始和结束位置。当jvm执行到monitorenter指令时,当前线程试图获取monitor对象的所有权,如果未加锁或者已经被当前线程所持有,就把锁的计数器+1;当执行monitorexit指令时,锁计数器-1;当锁计数器为0时,该锁就被释放了。如果获取monitor对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。
这里要注意:
synchronized是可重入的,所以不会自己把自己锁死
synchronized锁一旦被一个线程持有,其他试图获取该锁的线程将被阻塞。
关于ACC_SYNCHRONIZED 、monitorenter、monitorexit指令,可以看一下下面的反编译代码:
1 | public class SynchronizedDemo { |
使用javap -verbose SynchronizedDemo反编译后得到
我们看到对于同步方法,反编译后得到ACC_SYNCHRONIZED 标志,对于同步代码块反编译后得到monitorenter和monitorexit指令。
synchronized是否支持锁重入
支持
最简单的理解:在继承中,父类的A方法是synchronized方法,子类重写了A方法,调用父类方法super.A(),这个时候要是不支持可重入是不是立即发生了死锁。
每次上锁都要进行一次解锁,所以在每次执行synchronized方法后,在本地方法栈中都要生成一个Lock Record,在锁释放后弹出一个LR
————————————————
参考:
https://www.bilibili.com/video/BV1aQ4y1P7Me?from=search&seid=16212058743276143027
https://blog.csdn.net/weixin_38481963/article/details/88384493