阅读 4006

从偏向锁是如何升级到重量级锁的

简介

在 jdk1.6 之前我们会说 synchronized 是个重量级锁,在此之后 JVM 对其做了很多的优化,之后使用 synchronized 线程在获取锁的时候根据竞争的状态可以是偏向锁、轻量级锁和重量级锁。

而在关于锁的技术中,又出现了一些比如锁粗化、锁消除、自旋锁、自适应自旋锁他们又是什么,本文后续会一一说明。

注意的是我们讨论的都是 synchronized 同步,即隐式加锁。使用 Lock 加锁的话它是另外的实现方式。

什么是重量级锁

要想知道 JVM 为什么对其进行优化,我们就要先来了解下重量级锁到底是什么,为什么要对其进行优化,我们来看一段代码

public synchronized void f() {
    System.out.println("hello world");
}
复制代码

javap 反编译后

public synchronized void f();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 3: 0
        line 4: 8

复制代码

当某个线程访问这个方法的时候,首先会去检查是否有 ACC_SYNCHRONIZED 有的话就需要先获得对应的监视器锁才能执行。

当方法结束或者中间抛出未被处理的异常的时候,监视器锁就会被释放。

在 Hotspot 中这些操作是通过 ObjectMonitor 来实现的,通过它提供的功能就可能做到获取锁,释放锁,阻塞中等待锁释放再去竞争锁,锁等待被唤醒等功能,我们来探讨下它是如何做到的。

每个对象都持有一个 Monitor, Monitor 是一种同步机制,通过它我们就可以实现线程之间的互斥访问,首先来列举下 ObjectMonitor 的几个我们需要讨论的关键字段

  • _owner,ObjectMonitor 目前被哪个线程持有
  • _entryList,阻塞队列(阻塞竞争获取锁的一些线程)
  • _WaitSet,等待队列中的线程需要等待被唤醒(可以通过中断,singal,超时返回等)
  • _cxq,线程获取锁失败放入 _cxq 队列中
  • _recursions,线程重入次数,synchronized 是个可重入锁

从一个线程开始竞争锁到方法结束释放锁后阻塞队列线程竞争锁的执行的流程如上图,然后来分别分析一下,在获取锁和释放锁着两种情况。

获取锁的时候

释放锁的时候

在 jdk1.6 之前,synchronized 就直接会去调用 ObjectMonitor 的 enter 方法获取锁(第一张图)了,然后释放锁的时候回去调用 ObjectMonitor 的 exit 方法(第二张图)这被称之为重量级锁,可以看出它涉及到的操作复杂性。

那么思考一下

如果说同一时间本身就只有一个线程去访问它,那么就算它存在共享变量,由于不会被多线程同时访问也不存在线程安全问题,这个时候其实就不需要执行重量级加锁的过程。只需要在出现竞争的时候在使用线程安全的操作就行了

从而就引出了偏向锁轻量级锁

自旋锁

自旋锁自 jdk1.6 开始就默认开启。由于重量级锁的唤醒以及挂起对都需要从用户态转入内核态调用来完成,大量并发的时候会给系统带来比较大的压力,所以就出现了自旋锁,来避免频繁的挂起以及恢复操作。

自旋锁的意思是线程 A 已经获得了锁在执行,那么线程 B 在获取锁的时候,不阻塞,不放弃 CPU 执行时间直接进行死循环(有限定次数)不断的去争抢锁,如果线程 A 执行速度非常快的完成了,那么线程 B 能够较快的就获得锁对象执行,从而避免了挂起和恢复线程的开销,也能进一步的提升响应时间。

自旋锁默认的次数为 10 次可以通过 -XX:PreBlockSpin 来更改

自适应性自旋

跟自旋锁类似,不同的是它的自旋时间和次数不再固定了。比如在同一个锁对象上,上次自旋成功的获得了锁,那么 JVM 就会认为下一次也能成功获得锁,进而允许自旋更长的时间去获取锁。如果在同一个锁对象上,很少有自旋成功获得过锁,那额 JVM 可能就会直接省略掉自旋的过程。

自旋锁和自适应锁类似,虽然自旋等待避免了线程切换的开销,但是他们都不放弃 CPU 的执行时间,如果锁被占用的时间很长,那么可能就会存在大量的自旋从而浪费 CPU 的资源,所以自旋锁是不能用来替代阻塞的,它有它适用的场景

偏向锁

锁会偏向于第一个执行它的线程,如果该锁后续没有其他线程访问过,那我们就不需要加锁直接执行即可。

如果后续发现了有其它线程正在获取该锁,那么会根据之前获得锁的线程的状态来决定要么将锁重新偏向新的线程,要么撤销偏向锁升级为轻量级锁。

Mark Word 锁标识如下

thread ID - 是否是偏向锁 锁标志位
thread ID epoch 1 01(未被锁定)

线程 A - thread ID 为 100,去获取锁的时候,发现锁标志位为 01 ,偏向锁标志位为 1 (可以偏向),然后 CAS 将线程 ID 记录在对象头的 Mark Word,成功后

thread ID - 是否是偏向锁 锁标志位
100 epoch 1 01(未被锁定)

以后先 A 再次执行该方法的时候,只需要简单的判断一下对象头的 Mark Word 中 thread ID 是否是当前线程即可,如果是的话就直接运行

假如此时有另外一个线程线程 B 尝试获取该锁,线程 B - thread ID 为 101,同样的去检查锁标志位和是否可以偏向的状态发现可以后,然后 CAS 将 Mark Word 的 thread ID 指向自己,发现失败了,因为 thread ID 已经指向了线程 A ,那么此时就会去执行撤销偏向锁的操作了,会在一个全局安全点(没有字节码在执行)去暂停拥有偏向锁的线程(线程 A),然后检查线程 A 的状态,那么此时线程 A 就有 2 种情况了。

第一种情况,线程 A 已经终止状态,那么将 Mark Word 的线程 ID 置位空后,CAS 将线程 ID 偏向线程 B 然后就又回到上述又是偏向锁线程的运行状态了

thread ID - 是否是偏向锁 锁标志位
101 epoch 1 01(未被锁定)

第二种情况,线程 A 处于活动状态,那么就会将偏向锁升级为轻量级锁,然后唤醒线程 A 执行完后续操作,线程 B 自旋获取轻量级锁。

thread ID 是否是偏向锁 锁标志位
0 00(轻量级锁定)

可以发现偏向锁适用于从始至终都只有一个线程在运行的情况,省略掉了自旋获取锁,以及重量级锁互斥的开销,这种锁的开销最低,性能最好接近于无锁状态,但是如果线程之间存在竞争的话,就需要频繁的去暂停拥有偏向锁的线程然后检查状态,决定是否重新偏向还是升级为轻量级别锁,性能就会大打折扣了,如果事先能够知道可能会存在竞争那么可以选择关闭掉偏向锁

有的小伙伴会说存在竞争不就应该立马升级为重量级别锁了吗,不一定,下面讲了轻量级锁就会明白了。

轻量级锁

如果说线程之间不存在竞争或者偶尔出现竞争的情况并且执行锁里面的代码的速度非常快那么就很适合轻量级锁的场景了,如果说偏向锁是完全取消了同步并且也取消了 CAS 和自旋获取锁的流程,它是只需要判断 Mark Word 里面的 thread ID 是否指向自己即可(其它时间点有少许的判断可以忽略),那么轻量级锁就是使用 CAS 和自旋锁来获取锁从而降低使用操作系统互斥量来完成重量级锁的性能消耗

轻量级锁的实现如下

JVM 会在当前线程的栈帧中创建用于存储锁记录的空间,然后将对象头的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word 然后线程尝试使用 CAS 将对象头的 Mark Word 替换为指向锁记录的指针

假设线程 B 替换成功,表明成功获得该锁,然后继续执行代码,此时 Mark Word 如下

线程栈的指针 锁状态
stack pointer 1 -> 指向线程 B 00(轻量级锁)

此时线程 C 来获取该锁,CAS 修改对象头的时候失败发现已经被线程 B 占用,然后它就自旋获取锁,结果线程 B 这时正好执行完成,线程 C 自旋获取成功

线程栈的指针 锁状态
stack pointer 2 -> 线程 C 00(轻量级锁)

此时线程 D 又获取该锁,发现被线程 C 占用,然后它自旋获取锁,自旋默认 10 次后发现还是无法获得对应的锁(线程 C 还没有释放),那么线程 D 就将 Mark Word 修改为重量级锁

线程栈的指针 锁状态
stack pointer 2 -> 线程 C 10(重量级锁)

然后这时线程 C 执行完成了,将栈帧中的 Mark Word 替换回对象头的 Mark Word 的时候,发现有其它线程竞争该锁(被线程 D 修改了锁状态)然后它释放锁并且唤醒在等待的线程,后续的线程操作就全部都是重量级锁了

线程栈的指针 锁状态
10(重量量级锁)

需要注意的是锁一旦升级就不会降级了

锁消除

锁消除主要是 JIT 编译器的优化操作,首先对于热点代码 JIT 编译器会将其编译为机器码,后续执行的时候就不需要在对每一条 class 字节码解释为机器码然后再执行了从而提升效率,它会根据逃逸分析来对代码做一定程度的优化比如锁消除,栈上分配等等

public void f() {
    Object obj = new Object();
    synchronized(obj) {
         System.out.println(obj);
    }
}
复制代码

JIT 编译器发现 f() 中的对象只会被一个线程访问,那么就会取消同步

public void f() {
    Object obj = new Object();
    System.out.println(obj);
}
复制代码

锁粗化

如果在一段代码中连续的对同一个对象反复加锁解锁,其实是相对耗费资源的,这种情况下可以适当放宽加锁的范围,减少性能消耗。

当 JIT 发现一系列连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中的时候,会将加锁同步的范围扩散到整个操作序列的外部。

for (int i = 0; i < 10000; i++) {
    synchronized(this) {
        do();
    }
}
复制代码

粗化后的代码

synchronized(this) {
    for (int i = 0; i < 10000; i++) {
        do();
    }
}
复制代码

参考:

文章分类
后端
文章标签