JVM的锁升级

JVM的锁升级

  1. Java ☕
  2. 1 year ago
  3. 13 min read
为何说内置锁存在多种状态

在JDK.6之前,所有的Java内置锁都是重量级锁.重量级锁会造成CPU在用户态与核心态之间产生频繁的切换,所以代价比较高,效率不太好

JDK1.6版本为了减少获取锁和释放锁带来的性能消耗,引入了’偏向锁’和’轻量级锁’实现.所以在JDK1.6的版本里,一共有四种锁的状态: 无锁状态 偏向锁状态 轻量级锁状态 重量级锁状态 这些状态随着竞争的激烈程度会逐渐升级

内置锁可以升级但是不可以降级,也就是说从偏向锁升级为轻量级锁之后不能再降级为偏向锁,以此类推,这种只能升级却不能降级的机制主要目的是为了提高锁的获取和释放效率

为什么会存在锁升级的现象?

synchronized最初的实现方式是 阻塞或者唤醒一个Java线程需要操作系统来切换CPU状态来完成,这种由操作系统执行的转换势必造成大量上下文切换,效率也不高

重量级锁synchronized有2个比较显而易见的问题:

  • 如果同步代码块的逻辑很简单,但是由于需要切换上下文,就有可能造成切换的时间比代码执行的时间还长,得不偿失
  • 如果锁的竞争烈度比较高,那么将会产生大量的上下文切换,性能下降

这里最关键的就是Java的多线程是映射到操作系统的原生线程之上,如果需要阻塞或者唤醒一个线程,就需要调用系统函数,由操作系统来帮我们完成,这会消耗大量的系统资源

这也就是在JDK1.6之前,synchronized效率底下的原因,因此在1.6中为了减小获取锁和释放锁带来的性能消耗,于是便引入了 ‘偏向锁’和’轻量级锁’

锁的四种状态

锁目前有4中状态,级别从低到高依次是: 无锁 偏向锁 轻量级锁 重量级锁,并且锁的状态只能升级不能降级!

不同锁的特点:

锁状态存储内容锁标志位
无锁对象hashCode,对象GC分代年龄,是否偏向锁(0)01
轻量级锁偏向线程id,偏向时间戳,对象GC分代年龄,是否偏向锁(1)01
轻量级锁指向栈中所记录指针00
重量级锁指向互斥量的指针10

以下是在64位操作系统中Java对象头的内存布局情况

image-20241001220441164

public class Test {
    public  void addOrder(Object lock){
        synchronized (lock){
            // do something may cost 10 second
        }
    }
}
无锁

对于共享资源,没有线程来访问共享资源时,此时锁对象处于无锁状态,例子中,当程序启动后,在addOrder方法没有被调用之前,lock对象就一直处于无锁状态

偏向锁

当有线程A第一次调用了addOrder方法,JVM会对这个lock锁对象做一些设置,比如将对象头中的是否偏向锁位设置为1,对象头中的线程id设置为第一次调用该方法的线程id,后续如果这个线程A再次访问addOrder方法,会根据lock对象中的偏向锁标识和偏向线程id进行对比,对比成功则直接获取到锁,进入临界区执行代码,这里也有synchronized可重入的功能,一个线程可以多次重复执行被加锁的代码

轻量级锁

当线程A正在临界区代码内执行逻辑的时候(锁还没有释放),此时另一个线程B也执行到了临界区,此时JVM并不会将线程B执行挂起,而是会尝试使用轻量级锁(此时锁从偏向升级为轻量级锁),让线程B以CAS的方式来尝试获取锁(自旋会消耗CPU资源,但不会造成线程的上下文切换),如果通过CAS的方式,线程B能够获取到锁,那么最好,即使之后B正在执行临界区代码时又来了线程C,仍然采用相同的CAS方式获取锁

CAS虽然可以避免上下文切换,但是如果一直获取不到锁,长时间进行自旋也是得不偿失,因此,JVM会控制线程B的自旋次数,到达指定阈值的自旋次数还获取不到锁,那么就会直接从偏向锁升级为重量级锁

重量级锁

升级为重量级锁有2种情况:

  • 所已经被持有,此时锁为偏向锁,在未释放锁前,其他线程来竞争,按理说应该升级为轻量级锁,但是如果不满足轻量级锁的条件(自旋次数太多),就会升级为重量级锁
  • 偏向锁时,如果自旋时很快获取到了锁,那么锁就升级为轻量级锁,当锁状态为轻量级锁时,又出现了多线程竞争,那就说明此时锁的竞争烈度已经超过刚才升级为轻量级锁的烈度 JVM就会将锁继续升级为重量级锁

重量级锁由操作系统实现,性能消耗相对较高

锁升级

锁升级是针对synchronized在不同的竞争条件下的一种优化手段,根据当前线程对临界区代码竞争程度,在这4种状态之间流转(只能升级),从而降低获取锁的成本,提高获取锁的性能

①:当JVM启动之后到一个共享资源第一次被访问,这段时间内,这个锁处于无锁状态,对象头的偏向锁标记位=0锁标志位都是01

image-20241001223450546

②:当共享资源第一次被某个线程访问时,就会触发升级,从无锁升级为偏向锁,此时会在锁对象的对象头中的偏向线程id里存储这个首次访问的线程的线程id,偏向锁标记位=1,锁标记位=01,如果后续这个线程再次来访问这个共享资源,字需要直接比较这个偏向线程id即可快速获取到锁,执行临界区代码,几乎没有性能损耗

JDK1.6之后,JVM有2个关于偏向锁的参数是默认开启的:

  • -XX:+UseBaisedLocking : 是都开启偏向锁,默认为开启
  • -XX:BaisedLockingStartupDelay=4000 偏向锁延迟时间,标识JVM在启动后4秒之后才会打开偏向锁,也可以设置为0,那么JVM启动成功就会打开偏向锁

由于硬件资源的不断升级,获取锁的成本主键下降,在JDK15中默认已经关闭了偏向锁,如果没有开启偏向锁,或者在JVM的偏向锁延迟时间之前,当线程访问共享资源时,这个锁会直接从无锁升级为轻量级锁

image-20241001225853059

③: 接第②点,如果没有开启偏向锁,或者在偏向锁延迟时间之前出现了多线程竞争,此时锁状态会直接从从无锁升级为轻量级锁,在开启偏向锁并且当前锁状态已经是偏向锁的前提下,如果还有其他线程在访问临界区代码,那么锁有可能升级为轻量级锁(A在执行期间B又来获取锁,也就是出现竞争),也有可能继续保持偏向锁(A执行完之后B再来获取锁,没有出现多线程竞争),轻量级锁的效率会高于重量级锁(即便是几十次的自旋,效率也远高于上下文的切换)

轻量级锁是在当前执行临界区代码的线程的栈帧中创建一个LockRecord的空间,尝试将锁对象头中的MarkWord拷贝到栈帧中的LockRecord,如果拷贝成功,则使用CAS将对象头的MarkWord更新为LockRecord的指针,并将LockRecord里面的owner指针指向锁锁对象头中的MarkWord,如果CAS失败并且当前只有一个线程在等待,则继续自旋尝试拷贝,当超过一定的自旋次数或者又有第三个线程在此时来获取锁,那么此时就会触发锁升级,从轻量级锁升级为重量级锁

image-20241001225920317

④:若果在轻量级锁状态下获取锁失败,说明还存在竞争,就会升级为重量级锁,此时JVM会将线程阻塞,知道获取到锁之后才能进入临界区代码(这里和轻量级锁的区别,一个是CAS,一个是阻塞),底层采用的是操作系统的mutex lock来实现的,每个对象指向一个monitor对象,这个monitor对象在堆中和锁是关联的,通过将monitorenter指令插入到同步代码块编译后的开始位置,将monitorexit插入到同步代码块编译后的结束和异常处,来实现加锁和解锁

image-20241001230507529

锁升级实战

使用JOL工具来查看对象头中的信息

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>
</dependency>
无锁->轻量级锁

无锁升级为轻量级锁有2种情况

第一种: 关闭偏向锁

public class Test {
    public static void main(String[] args) {
        Object lock = new Object();
        System.out.println("未开启偏向锁,对象信息");
        System.out.println(ClassLayout.parseInstance(lock).toPrintable());
        synchronized (lock) {
            System.out.println("已获取到锁信息");
            System.out.println(ClassLayout.parseInstance(lock).toPrintable());
        }
        System.out.println("已释放锁信息");
        System.out.println(ClassLayout.parseInstance(lock).toPrintable());
    }
}

执行结果分析:

由于关闭了偏向锁,所以在第一次访问时,直接从无锁升级为了轻量级锁,在执行玩同步代码块之后,MarkWord中的值后三位又变回了001,即无锁状态,也就是说轻量级锁在同步代码块执行完成之后进行了释放

image-20241001232945060

第二种:在偏向锁的延迟时间之前获取锁

使用相同的代码,但是开启偏向锁

image-20241001233707879

无锁->偏向锁

相同的代码,只不过在执行同步代码块之前sleep一下(超过偏向锁延迟时间)

image-20241001234512406

Java Core