转载于 阿里技术 - 谈谈JVM内部锁升级过程

为什么讲这个?

  • 对象在内存中的内存布局是什么样的?

  • 描述 synchronized 和 ReentrantLock 的底层实现和重入的底层原理。

  • 谈谈 AQS,为什么 AQS 底层是 CAS + volatile?

  • 描述下锁的四种状态和锁升级过程?

  • Object o = new Object() 在内存中占用多少字节?

  • 自旋锁是不是一定比重量级锁效率高?

  • 打开偏向锁是否效率一定会提升?

  • 重量级锁到底重在哪里?

  • 重量级锁什么时候比轻量级锁效率高,同样反之呢?

    加锁发生了什么?

    要弄清加锁之后到底发生了什么,需要看一下对象创建之后在内存中的布局是个什么样的?
    一个对象在 new 出来之后在内存中主要分为 4 个部分:

  • markword 这部分其实是加锁的核心,同时还包括的对象的一些声明信息,例如是否 GC,经过了几次 Young GC 还存活。

  • klass pointer 记录了对象的 class 文件指针。

  • instance data 记录了对象里面的变量数据。

  • padding 作为对齐使用。

借助第三方包 JOL 看看内存布局

1
2
3
4
5
6
7
8
9
10
11
12
13
public class JolDemo {

private static Object o;

public static void main(String[] args) {
o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}

}

拓展:字节排序(摘抄至 《UNIX 网络编程卷一》)
考虑一个 16 位整数,它由 2 个字节组成。内存中存储这两个字节有两种方法:一种是将低序字节存储在起始位置,这称为小端字节序;另一种方法是将高序字节存储在起始位置,这称之为大端字节序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

int main(int argc, char **argv)
{
union{
short s;
char c[sizeof(short)];
} un;
un.s = 0x0102;
// printf("%s: ", CPU_VENDOR_OS);
if (sizeof(short) == 2)
{
if (un.c[0] == 1 && un.c[1] == 2)
printf("bi-endian\n");
else if (un.c[0] == 2 && un.c[1] == 1)
printf("little-endian\n");
else
printf("unknown\n");
} else
printf("sizeof(short) = %d\n", sizeof(short));
return 0;
}

所以分析内存布局的时候应该先确定字节序,然后查看对应的位置。我的机器是小端字节序,所以要倒着看(人习惯的是大端字节序,反过来就是小端字节序)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 90 f0 df 39 (10010000 11110000 11011111 00111001) (970977424)
4 4 (object header) 21 00 00 00 (00100001 00000000 00000000 00000000) (33)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total


从输出结果看:

  • 对象头包含了 2 个字节分为 3 行,其中前两行其实就是 markword,第三行就是 klass 指针。值得注意的是在加锁前后输出从 001 变成 000。markword 用处:8字节(64bit)的头记录一些信息,锁就是修改了 markword 的内容8字节的投记录一些信息。从 001 无锁状态变成了 00 轻量级锁状态。
  • new 出一个 object 对象,占用 16 个字节。对象头占用 12 字节,由于 Object 中没有额外的变量,所以 instance = 0,考虑要对象内存要被 8 字节整除,那么 padding = 4,最后 new Object() 内存大小为 16 字节。

拓展:什么样的对象会进入老年代?很多场景例如对象太大了可以直接进入,但是这里想探讨的是为什么从 Young GC 的对象最多经历 15 次 Young GC 还存活就会进入 Old 区(年龄可以调的,默认是 15)。markword 的图中,用了 4 个 bit 去表示分代年龄,那么能表示的最大范围就是 0 - 15.所以这也就是为什么设置新生代的年龄不超过 15。

锁的升级过程

锁的升级验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class JolDemo {

private static Object o;

public static void main(String[] args) {
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
o = new Object();
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}

}

相比于上个代码,这个代码先沉睡 5 s。

1
2
3
4
5
6
7
8
 java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 50 75 9e (00000101 01010000 01110101 10011110) (-1636478971)
4 4 (object header) 74 02 00 00 (01110100 00000010 00000000 00000000) (628)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

Syn 锁升级之后,jdk1.8 版本的一个底层默认设置 4s 之后开启偏向锁。也就是说在 4s 内是没有开启偏向锁的,加了锁就直接升级为轻量级锁了。

那么这就有几个问题了?

  • 为什么要进行锁升级,以前不是默认 syn 就是重量级锁吗?要么不用要么就用别的不行吗?
  • 既然 4s 内如果加了锁就直接到轻量级锁,那么能不能不要偏向锁,为什么要有偏向锁?
  • 为什么要设置 4s 之后开启偏向锁?

问题1:为什么要进行锁升级?锁了就锁了?不就要加锁吗?

首先明确 jdk1.2 效率非常低。那时候 syn 就是重量级锁,申请锁必须要经过操作系统 kernel 进行系统调用,入队排序操作,操作完之后再返回给用户态。
内核态:用户态如果要做一些比较危险的操作直接访问硬件,很容易把硬件搞死(格式化、访问网卡,访问内存)操作系统为了系统安全分为两层,用户态和内核态。这个过程是非常消耗时间的,导致早期效率特别低。有些 jvm 就可以处理的为什么还要交给操作系统做呢?能不能把 jvm 就可以完成的锁操作拉取出来提升效率,所有也就有了锁优化。

问题2:为什么要有偏向锁?

其实这本质上归根于一个概率问题,统计表示,在我们日常用的 syn 锁过程中 70% - 80% 的情况下,一般都只有一个线程去拿锁,例如我们常使用的 System.out.println,虽然底层加了 syn 锁,但是基本没有多线程竞争的情况。那么这种情况下,没有必要升级到轻量级锁级别了。偏向的意义在于:第一个线程拿到锁,将自己的线程信息标记在锁上,下次进来就不需要再去拿锁验证了。如果超过 1 个线程去抢锁,那么偏向锁就会撤销,升级为轻量级锁,其实我认为严格意义上来讲偏向锁并不算一把真正的锁,因为只有一个线程去访问共享资源的时候才会有偏向锁这个情况。

问题3:为什么 jdk8 要在 4s 后开启偏向锁?

其实这是一个妥协,明确知道在刚开始执行代码时,一定有好多线程来抢锁,如果开了偏向锁效率反而降低,所以上面程序在睡了 5s 之后偏向锁菜开放。为什么加偏向锁效率会降低,因为中途多了几个额外的过程,上了偏向锁之后多个线程争抢共享资源的时候要进行锁升级到轻量级锁,这个过程还得把偏向锁进行撤销再进行升级,所以导致效率会降低。为什么是 4s?这是一个统计的时间值。

锁的升级流程

轻量级锁。线程如果没那么多的话,其实这里就可以理解为 CAS。

1
2
3
4
5
6
7
8
9
10
11
12
13

public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}

public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}

问题4:什么情况下轻量级锁要升级为重量级锁?

如果线程数太多或者 CAS 自旋 10 次依然没有获取到锁,那么也会升级为重量级。

总的来说 2 种情况会从轻量级升级为重量级,10 此自旋或等待 cpu 调度的线程数超过 cpu 核数的一半,自动升级为重量级锁。

问题5:都说 syn 为重量级锁,那么到底重在哪里?

JVM 偷懒把任何跟线程有关的操作全部交给操作系统去做,例如调度锁的同步直接交给操作系统去执行,而在操作系统中要执行先要入队,另外操作系统启动一个线程时需要消耗很多资源,消耗资源比较重。

锁升级过程:

synchronized 的底层实现

这篇内容说的并不是很详细,下次重开一篇文章说一下吧

自我总结

  • Java 内存布局:markword,klass pointer,instance data,padding
  • 锁升级过程:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁