本文主要介绍了java中同步锁的升级过程,具有很好的参考价值。希望对大家有帮助。如有错误或不足之处,请不吝赐教。
目录
同步锁升级(偏置锁、轻量级锁和重量级锁)java同步锁高级知识点同步同步锁java对象头偏置锁轻量级锁重量级锁on自旋锁打印偏置锁参数同步原理分析1:同步原理分析1:对象头JVM中的同步实现原理3:锁优化1、锁升级2、锁
synchronized锁的升级(偏向锁、轻量级锁及重量级锁)
java同步锁前置知识点
1.如果在编码中使用锁定,可以使用synchronized关键字同步锁定方法和代码块。
2.Synchronized同步锁是jvm内置的隐式锁(相对于Lock,隐式锁定和释放)。
3.3的实施。同步锁依赖于操作系统,系统调用获取和释放锁会导致用户状态和内核状态切换。
4.在4.jdk1.5之前,只能使用同步锁定。1.6中引入了锁同步锁(请求锁基于java实现,锁和释放显式,性能更好)。
5.jdk1.6针对synchronized锁提出了偏置锁、轻量级锁和重量级锁的概念(实际上是对synchronized的性能进行优化,使锁竞争引起的上下文切换最小化)。
6.无论使用同步还是锁,线程上下文切换都是不可避免的。
7.LOCK相对于synchronized的一个性能优化是,当线程被阻塞时,lock对Lock的获取不会导致用户模式和内核模式之间的切换,但synchronized会(见第3点)。但是,线程阻塞会导致上下文切换(参见第6点)。
8.java线程的阻塞和唤醒依赖于操作系统的调用,这就导致了用户态和内核态的切换。
9.上面提到的用户态和内核态的切换是进程上下文切换而不是线程上下文切换。
本文重点介绍同步锁的升级。
synchronized同步锁
java对象头
每个java对象都有一个对象头,它由一个类型指针和一个标记字段组成。
在64位虚拟机中,压缩指针没有打开,标签字段占用64位,类型指针占用64位,共16个字节。
锁类型信息是tag字段的后2位:00表示轻量级锁,01表示无锁或有偏锁,10表示重量级锁;如果倒数第二位是1,则此类偏置锁定使能,如果是0,则此类偏置锁定禁用。
如下图,图片来源wiki
左栏表示偏置锁定被启用(框1),右栏表示偏置锁定被禁用(框3)。1和3都表示无锁的初始状态。如果启用了偏置锁,锁升级的步骤应该是1-2-4-5。如果禁用了偏置锁,锁升级的步骤应该是3-4-5。
我用的是jdk8。打印参数后,默认设置是启用偏置锁定。如果它被禁用:-XX:-UseBiasedLocking。
关于偏置锁定还有其他几个参数:
注意参数BiasedLockingStartupDelay,默认值是4000ms,表示虚拟机在使用偏置锁之前会延迟4s(先使用轻量级锁)。
偏向锁
偏向锁的场景是大多数时候只有同一个线程请求锁,没有多线程竞争锁。查看对象标题中的红框2。有一个线程ID字段:当第一个线程被锁定时,jvm通过cas将当前线程地址设置为线程ID标志位,后三位为101。下一次同一个线程获取锁时,只需要检查后三位是否为101,是否是当前线程,epoch是否等于锁对象的类的epoch(wiki说不要再设置cas来优化当前多处理器上的cas操作)。
锁偏向优化带来的性能提升,意味着避免了获取锁和进行系统调用导致的用户状态和内核状态的切换。因为同一个线程获取锁,所以不必每次获取锁时都进行系统调用。
如果当前线程的线程ID在获取锁时(处于解锁状态)与当前线程不匹配,则被偏置的锁将被撤销,当前线程将再次被偏置。如果次数达到biasedlockingBulkrebiasthreshold的值,默认为20次,当前类的有偏锁将失效,影响将是epoch值的变化。锁定类的纪元值将增加1,并且随后的锁定对象将把该类的纪元值复制到图中的纪元标记位。如果撤销总数达到BiasedLockingBulkrevokethreshold的值(默认为40),则禁用当前类的偏置锁,即对象头右侧的列,直接从轻锁开始锁定(锁被升级)。
斜锁撤销是一个非常麻烦的过程,需要所有线程到达一个安全点(STW发生),遍历所有线程的线程栈,检查是否持有锁对象以避免丢失锁,并处理epoch。
如果有多线程竞争,偏向锁会升级为轻量锁。
轻量级锁
轻量级锁处理的场景是不同的线程在同一时间段请求锁(线程交替执行)。即使在同一时间段内有多个线程争用锁,获得锁的线程持有锁的时间很短,并且很快释放它。
当线程被锁时,如果判断不是重量级锁,在当前线程栈中会打开一个空间,作为锁记录,复制锁对象头的tag字段(复制是为了做记录,因为锁对象头的tag字段的值会被替换为刚刚复制的tag字段的空间地址,就像对象头的图像中指向锁记录部分的指针一样。至于后两位,因为内存对齐,所以是00)。然后,基于CAS操作,复制该标签字段的地址被设置为锁对象头的标签位的值。如果成功,就获得锁。如果锁定时判断不是重量级锁,后两位不是01(从偏锁或解锁状态),说明已经有线程持有。如果是当前线程(需要重新进入),则设置为0。这里是一个堆栈结构,直接按一个0就可以了。当锁最终被释放时,它被从堆栈中释放。最后一个元素记录了锁对象的原始标记字段的值,然后通过CAS将其设置为锁对象头。
注意,获取锁时,cas失败,当前线程会自旋一段时间,达到一定次数。如果升级为重量级锁,当前线程也会被阻塞。
重量级锁
重量级就是我们通常所说的添加同步锁,也就是基于java的锁实现。当获取和释放锁时,需要系统调用,这会导致上下文切换。
关于自旋锁
关于自旋锁,我查阅了相关资料,主要有两种解释:
1.在竞争中失败的是轻量级锁。它不是立即扩展到重量级,而是首先旋转一定次数以尝试获取锁;
2.即使重量级锁竞争失败,也不会立即封锁,还会旋转一定次数(这里涉及一个自调整算法)。
关于这个描述,我们还是要看看jvm的源代码实现,才能确定哪个是真实的:
打印偏向锁的参数
如下所示:
-XX: UnlockDiagnosticVMOptions
-XX:PrintBiasedLockingStatistics
我在main方法中循环获得相同的锁,打印结果如下:
公共静态void main(String[] args) {
int num=0;
for(int I=0;i 1 _ 000 _ 000000i ) {
同步(锁定){
num
}
}
}
synchronized原理解析
一:synchronized原理解析
1:对象头
首先,我们需要知道对象在内存中的布局:
众所周知,对象存储在堆内存中。对象大致可以分为三部分,即对象头、实例变量和填充字节。
对象头zhuyao由MarkWord和Klass Point(类型指针)组成,其中Klass Point是对象指向其类元数据的指针。虚拟机通过这个指针来确定这个对象是哪个类实例,用标记字来存储对象本身的运行时数据。如果对象是数组对象,则对象头占用3个字,如果对象是非数组对象,则对象头占用2个字。(1字=2字节=16位).
变量存储对象的属性信息,包括父类的属性信息,根据4个字节对齐。
填充字符,因为虚拟机要求对象字节必须是8字节的整数倍,而填充字符用来组成这个整数倍。
从第一部分我们可以知道,Synchronized方法,无论是装饰方法还是代码块,都是通过持有装饰对象的锁来实现同步的,那么Synchronized锁对象存在于哪里呢?答案在锁对象的对象头的标记字里。那么MarkWord在对象头里是什么样子的,也就是它存储了什么?
在32位虚拟机中:
在64位虚拟机中:
上图中的偏置锁和轻量级锁是在java6之后优化锁机制时引入的。下面锁升级一节会详细解释。Synchronized关键字对应于重量级锁。接下来将说明Hotspot JVM中重量级锁的实现锁。
2:Synchronized在JVM中的实现原理
对应于重量级锁的锁标志位是10,它存储指向重量级监视器锁的指针。在Hotspot中,对象的监视器锁对象是由ObjectMonitor对象(C)实现的,其与同步相关的数据结构如下:
对象监视器(){
_ count=0;//用于记录该对象被线程锁定的次数
_ waiters=0;
_ recursion=0;//锁的重新进入次数
_ owner=NULL//指向保存ObjectMonitor对象的线程
_ WaitSet=NULL//处于等待状态的线程将被添加到_WaitSet
_ WaitSetLock=0;
_ EntryList=NULL//处于等待锁块状态的线程将被添加到列表中。
}
光看这些数据结构还是搞不清楚监控锁的工作机制,所以我们先来看看线程在获取锁的几种状态时的转变:
线程的生命周期有五种状态:开始、运行、等待、阻塞和死亡。
对于同步修饰方法(代码块):
当多个线程同时访问这个方法时,这些线程会先被放入_EntryList队列,然后线程会处于阻塞状态。
当线程获得实例对象的监视器锁时,它可以进入运行状态并执行方法。此时,ObjectMonitor对象的_owner指向当前线程,而_count加1表示当前对象锁被一个线程获取。
当处于运行状态的线程调用wait()方法时,当前线程释放monitor对象,进入等待状态。ObjectMonitor对象的_owner变为null,并且_count减少1。同时线程进入_WaitSet队列,直到有线程调用notify()方法唤醒线程,线程重新获取monitor对象,进入_Owner区域。
如果当前线程执行完毕,那么monitor对象也被释放,进入等待状态,ObjectMonitor对象的_owner变为null,_count减1。
那么同步的修饰代码块/方法是如何获取monitor对象的呢?
在JVM规范中可以看到,方法同步和代码块同步都是基于进入和退出monitor对象来实现的,但是两者在具体实现上有很大的区别。用javap反编译类字节码文件可以得到反编译后的代码。
(1)Synchronized修饰代码块:
同步代码块同步在要同步的代码块的开头插入monitorentry指令,在同步结束或异常位置插入monitorexit指令;为了确保JVM的monitorentry和monitorexit成对出现,任何对象都有一个与之对应的监视器。当这个对象的监视器被按住时,它将被锁定。
例如,同步代码块如下所示:
公共类SyncCodeBlock {
public int I;
公共void syncTask(){
同步(这){
我;
}
}
}
反编译同步代码块编译后的类字节码文件,结果如下(只保留方法部分反编译后的内容):
public void sync task();
描述符:()V
标志:ACC_PUBLIC
代码:
堆栈=3,局部变量=3,参数大小=1
0: aload_0
1: dup
2:故事1
3: monitorenter //注意此处输入同步方法。
4: aload_0
5: dup
6:获取字段#2 //字段i:I
9: iconst_1
10: iadd
11: putfield #2 //Field i:I
14: aload_1
15: monitorexit //注意这里,退出同步方法。
16:转到24
19:阿斯托雷_2
20: aload_1
21: monitorexit //注意这里,退出同步方法。
22: aload_2
23:一排
24:返回
例外表:
//省略其他字节码.
可以看到,同步方法块在进入代码块时插入monitorentry语句,在退出代码块时插入monitorexit语句。为了保证monitorexit语句无论是正常执行(第15行)还是异常跳出代码块(第21行)都能执行,会出现两条monitorexit语句。
(2)Synchronized修饰方法:
Synchronized方法同步不再通过插入monitorentry和monitorexit指令实现,而是通过方法调用指令隐式读取运行时常量池中的ACC_SYNCHRONIZED标志。如果设置了方法表结构(method_info Structure)中的ACC_SYNCHRONIZED标志,那么线程会在执行方法之前获取对象的monitor对象。如果成功,它将执行方法代码,并在执行后释放监视器对象。如果监视器对象已被其他线程获取,当前线程将被阻塞。
同步方法代码如下:
公共类同步方法{
public int I;
公共同步void syncTask(){
我;
}
}
反编译同步方法编译后的类字节码,结果如下(只保留方法部分反编译后的内容):
公共同步void sync task();
描述符:()V
//方法标识符ACC_PUBLIC代表公共修改,ACC_SYNCHRONIZED表示这个方法是同步方法。
标志:ACC_PUBLIC、ACC_SYNCHRONIZED
代码:
堆栈=3,局部变量=1,参数大小=1
0: aload_0
1: dup
2:获取字段#2 //字段i:I
5: iconst_1
6: iadd
7: putfield #2 //Field i:I
10:返回
行号表:
第十二行:0
第13行:10
}
可以看出,monitorentry和monitorexit指令没有出现在方法的开头和结尾,但是出现了ACC_SYNCHRONIZED标志位。
三、锁的优化
1、锁升级
四种锁定状态:无锁定状态、偏置锁定状态、轻量级锁定状态和重量级锁定状态(从最低级别到最高级别)。
(1)偏向锁:
为什么要引入偏置锁?
热点作者经过大量研究发现,大部分时间不存在锁竞争,往往是一个线程多次获取同一个锁。所以如果它每次都要争锁的话,会增加很多不必要的成本。为了降低获取锁的成本,引入了偏置锁。
锁定偏置的升级:
当线程1访问代码块并获得锁对象时,它将在java对象头和堆栈帧中记录有偏锁的threadID。因为偏置锁不会主动释放锁,所以未来线程1再次获取锁时,需要比较当前线程的threadID和Java对象头中的threadID是否一致。如果一致(或者线程1获取了锁对象),就没有必要使用CAS来加锁和解锁。如果不一致(其他线程,比如线程2,竞争锁对象,但是有偏锁不会主动释放线程1的threadID,这个线程的threadID还是存储的),就要检查Java对象头中记录的线程1是否是活的。如果不是活的,锁对象重置为解锁状态,其他线程(线程2)可以竞争将其设置为偏置锁;如果它是活动的,那么立即查找这个线程(线程1)的堆栈帧信息。如果还需要持有这个锁对象,那么挂起当前线程1,取消偏置锁,升级为轻量级锁。如果线程1不再使用这个锁对象,则将锁对象状态设置为解锁状态,并再次将其偏置到新线程。
取消偏置锁定:
默认开启偏置锁,启动时间通常比应用启动慢几秒。如果不希望这种延迟,可以使用-xx:biasedlockingstartupdelay=0;
如果不想偏锁,可以用-XX设置:-UseBiasedLocking=false;
(2)轻量级锁
为什么要引入轻质锁?
轻量级锁考虑了没有很多线程竞争锁对象,线程持有锁的时间很短的情况。因为阻塞一个线程需要CPU从用户态切换到内核态,代价很大。如果阻塞后很快释放锁,代价会比收益多一点,所以这个时候不要阻塞线程,让它旋转,等待锁被释放就行了。
轻量级锁什么时候升级为重量级锁?
当线程1获取一个轻量级锁时,它会先将锁对象的对象头MarkWord复制到线程1的堆栈帧中创建的空间(名为DisplaceMarkword)来存储锁记录,然后用CAS将对象头中的内容替换为线程1存储的锁记录(DisplaceMarkword)的地址;
如果线程1同时复制对象头(在线程1CAS之前),线程2准备获取锁,将对象头复制到线程2的锁记录空间,但是当线程2CAS时,发现线程1已经更改了对象头,线程2 CAS失败,那么线程2尝试使用自旋锁等待线程1释放锁。
但是旋转时间太长就不行了,因为消耗CPU,所以旋转次数是有限的,比如10次或者100次。如果旋转次数达到了线程1还没有释放锁,或者线程1还在执行,线程2还在旋转等待,另一个线程3来争夺这个锁对象,那么这个时候轻量级锁就会扩展成重量级锁。重量级锁阻塞除拥有锁的线程之外的所有线程,以防止CPU空闲。
注意:为了避免无用旋转,轻量级锁一旦扩展为重量级锁,就不会降级为轻量级锁;升级到轻量级锁的偏置锁不能降级为偏置锁。总之,锁可以升级但不能降级,但偏锁状态可以重置为解锁状态。
(3)这几种锁的优缺点(偏向锁、轻量级锁、重量级锁)
2、锁粗化
按理说同步块的范围应该越小越好,同步应该只在共享数据的实际范围内进行。这样做的目的是最大限度地减少需要同步的操作数量,并缩短阻塞时间。如果有锁竞争,等待锁的线程也可以尽快得到锁。
但是,锁定和解锁也会消耗资源。如果有一系列连续的锁定和解锁操作,可能会导致不必要的性能损失。
锁粗化是将多个连续的加锁和解锁操作连接在一起,扩展成更大范围的锁,从而避免频繁的加锁和解锁操作。
3、锁消除
Java虚拟机在JIT编译时(可以简单理解为在一段代码即将第一次执行时进行编译,也称为即时编译),通过扫描运行上下文和分析转义,去除无法竞争共享资源的锁。通过以这种方式消除不必要的锁,可以节省无意义的请求锁的时间。
以上个人经历,希望能给大家一个参考,也希望大家多多支持我们。
郑重声明:本文由网友发布,不代表盛行IT的观点,版权归原作者所有,仅为传播更多信息之目的,如有侵权请联系,我们将第一时间修改或删除,多谢。