转谈谈 JVM 内部锁升级过程
Posted JMCui
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了转谈谈 JVM 内部锁升级过程相关的知识,希望对你有一定的参考价值。
一、加锁发生了什么
//System.out.println都加了锁
public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}
简单加锁发生了什么?
要弄清楚加锁之后到底发生了什么需要看一下对象创建之后再内存中的布局是个什么样的?
一个对象在 new 出来之后在内存中主要分为 4 个部分:
- Markword 这部分其实就是加锁的核心,同时还包含的对象的一些生命信息,例如是否 GC、进过了几次 Young GC 还存活等。
- klass pointer 记录了指向对象的 class 文件指针。
- instance data 记录了对象里面的变量数据。
- padding 作为对齐使用,对象在 64 位服务器版本中,规定对象内存必须要能被 8 字节整除,如果不能整除,那么就靠对齐来补。举个例子:new 出了一个对象,内存只占用 18 字节,但是规定要能被 8 整除,所以 padding=6。
知道了这 4 个部分之后,我们来验证一下底层。借助于第三方包 JOL = Java Object Layout java 内存布局去看看。很简单的几行代码就可以看到内存布局的样式:
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
public class JOLDemo {
private static Object o;
public static void main(String[] args) {
o = new Object();
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
将结果打印出来:
从输出结果看:
-
对象头包含了 12 个字节分为 3 行,其中前 2 行其实就是 Markword,第三行就是 klass 指针。值得注意的是在加锁前后输出从 001 变成了 000。Markword 用处:8 字节(64bit)的头记录一些信息,锁就是修改了 Markword 的内容 8 字节(64bit)的头记录一些信息,锁就是修改了markword的内容字节(64bit)的头记录一些信息。从 001 无锁状态,变成了 00 轻量级锁状态。
-
new 出一个 object 对象,占用 16 个字节。对象头占用 12 字节,由于 Object 中没有额外的变量,所以 instance = 0,考虑要对象内存大小要被 8 字节整除,那么 padding=4,最 后 new Object() 内存大小为 16 字节。
二、锁的升级过程
2.1 锁的升级验证
探讨锁的升级之前,先做个实验。两份代码,不同之处在于一个中途让它睡了5秒,一个没睡。看看是否有区别。
public class JOLDemo {
private static Object o;
public static void main(String[] args) {
o = new Object();
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
----------------------------------------------------------------------------------------------
public class JOLDemo {
private static Object o;
public static void main(String[] args) {
try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); }
o = new Object();
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
这两份代码会不会有什么区别?运行之后看看结果:
有点意思的是,让主线程睡了 5s 之后输出的内存布局跟没睡的输出结果居然不一样。Syn 锁升级之后,jdk1.8 版本的一个底层默认设置 4s 之后偏向锁开启。也就是说在 4s 内是没有开启偏向锁的,加了锁就直接升级为轻量级锁了。
那么这里就有几个问题了?
- 为什么要进行锁升级,以前不是默认 syn 就是重量级锁么?要么不用要么就用别的不行么?
- 既然 4s 内如果加了锁就直接到轻量级,那么能不能不要偏向锁,为什么要有偏向锁?
- 为什么要设置 4s 之后开始偏向锁?
问题 1:为什么要进行锁升级?锁了就锁了,不就要加锁么?
首先明确 syn 锁 在 jdk1.2 之前效率非常低。那时候 syn 就是重量级锁,申请锁必须要经过操作系统老大 kernel 进行系统调用,入队进行排序操作,操作完之后再返回给用户态。
内核态:用户态如果要做一些比较危险的操作直接访问硬件,很容易把硬件搞死(格式化,访问网卡,访问内存干掉等),操作系统为了系统安全分成两层:用户态和内核态。申请锁资源的时候用户态要向操作系统老大内核态申请。Jdk1.2 的时候用户需要跟内核态申请锁,然后内核态还会给用户态。这个过程是非常消耗时间的,导致早期效率特别低。有些 jvm 就可以处理的为什么还交给操作系统做去呢?能不能把 jvm 就可以完成的锁操作拉取出来提升效率,所以也就有了锁优化。
问题 2:为什么要有偏向锁?
其实这本质上归根于一个概率问题,统计表示,在我们日常用的 syn 锁过程中 70%-80% 的情况下,一般都只有一个线程去拿锁,例如我们常使用的 System.out.println、StringBuffer,虽然底层加了 syn 锁,但是基本没有多线程竞争的情况。那么这种情况下,没有必要升级到轻量级锁级别了。
偏向的意义在于:第一个线程拿到锁,将自己的线程信息标记在锁上,下次进来就不需要在拿去拿锁验证了。如果超过 1 个线程去抢锁,那么偏向锁就会撤销,升级为轻量级锁,其实我认为严格意义上来讲偏向锁并不算一把真正的锁,因为只有一个线程去访问共享资源的时候才会有偏向锁这个情况。
问题 3:为什么 jdk8 要在 4s 后开启偏向锁?
其实这是一个妥协,明确知道在刚开始执行代码时,一定有好多线程来抢锁,如果开了偏向锁效率反而降低,所以上面程序在睡了 5s 之后偏向锁才开放。为什么加偏向锁效率会降低,因为中途多了几个额外的过程,上了偏向锁之后多个线程争抢共享资源的时候要进行锁升级到轻量级锁,这个过程还的把偏向锁进行撤销在进行升级,所以导致效率会降低。为什么是 4s?这是一个统计的时间值。
当然我们是可以禁止偏向锁的,通过配置参数 -XX:-UseBiasedLocking = false 来禁用偏向锁。jdk15 之后默认已经禁用了偏向锁。本文是在 jdk8 的环境下做的锁升级验证。
2.2 锁的升级流程
上面已经验证了对象从创建出来之后进内存从无锁状态->偏向锁(如果开启了)->轻量级锁的过程。对于锁升级的流程继续往下,轻量级锁之后就会变成重量级锁。首先我们先理解什么叫做轻量级锁,从一个线程抢占资源(偏向锁)到多线程抢占资源升级为轻量级锁,线程如果没那么多的话,其实这里就可以理解为 CAS(Compare and Swap:比较并交换值)。
问题 4:什么情况下轻量级锁要升级为重量级锁呢?
首先我们可以思考的是多个线程的时候先开启轻量级锁,如果它 carry 不了的情况下才会升级为重量级。那么什么情况下轻量级锁会 carry 不住?
- 如果线程数太多,比如上来就是 10000 个,那么这里 CAS 要转多久才可能交换值,同时 CPU 光在这 10000 个活着的线程中来回切换中就耗费了巨大的资源,这种情况下自然就升级为重量级锁,直接叫给操作系统入队管理,那么就算 10000 个线程那也是处理休眠的情况等待排队唤醒。
- CAS 如果自旋 10 次依然没有获取到锁,那么也会升级为重量级。
总的来说,两种情况都会从轻量级升级为重量级,10 次自旋或等待 cpu 调度的线程数超过 cpu 核数的一半,自动升级为重量级锁。整个锁升级过程如图所示:
问题 5:都说 syn 为重量级锁,那么到底重在哪里?
JVM 偷懒把任何跟线程有关的操作全部交给操作系统去做,例如调度锁的同步直接交给操作系统去执行,而在操作系统中要执行先要入队,另外操作系统启动一个线程时需要消耗很多资源,消耗资源比较重,重就重在这里。
原文链接:
以上是关于转谈谈 JVM 内部锁升级过程的主要内容,如果未能解决你的问题,请参考以下文章