JAVA锁的集大成应用者--synchronized的锁优化

前言

之前博客转载过美团的锁介绍文章 【基本功】不可不说的Java“锁”事–转自美团技术博客,写的非常好,但是在锁的落地中,有哪个可以囊括大部分锁的落地应用,我觉得synchronized可以是一个。下面就讲讲synchronized的锁优化。

对象头

我们用过synchronized的都知道,它的使用语法是用一个对象来当“钥匙”的。哪个线程有这把钥匙,哪个线程就可以自由出入synchronized的作用范围。反之,则会拒之门外。有没有想过,我们为什么要用对象来当锁,这里面就有对象头的概念。

synchronized用的锁是存在Java对象头里的,那么什么是Java对象头呢?Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,下图就是不同锁状态下Mark Word记录数据的区别,其中比较重要的列就是 锁状态,锁标志位,线程ID,下面synchronized的原理就跟这些息息相关。

JAVA锁的集大成应用者--synchronized的锁优化_第1张图片

synchronized的原理(锁膨胀)

JAVA锁的集大成应用者--synchronized的锁优化_第2张图片

前面介绍了对象头可能不明所以,不知道怎么用的,没有关系,前面是介绍基本概念,这一章正式讲解synchronized的内部。synchronized以前是一个纯重量级的锁,使用操作系统互斥量来实现,给人的印象是效率低下。但是后来优化后,我个人认为,synchronized的原理就是锁膨胀的过程,因为synchronized的锁会经历 无锁 → 偏向锁 → 轻量级锁 → 重量级锁的一个过程.先从无锁讲起。

无锁

无锁非常好理解,当没有一个线程访问或者只有一个线程访问时,就是无锁状态,无锁状态下 对象头的 锁标志位 就是 '01' . 你可以对照上面的Mark Word图查 01 对应的行,但是你会发现有一个叫 "偏向锁"的也是 01,两者有啥区别?下面就会解释

偏向锁

当只有一个线程访问的时候,除了 锁标志位 设为 '01',还会做一件重要的事情

  • 在Mark Word中记录 当前访问线程的线程id

你也可以在上图中看到 偏向锁无锁 状态下 偏向锁就是多了一个 线程id ,图中epoch指的是偏向锁的时间戳,这里不讲解这个。

何谓偏向锁 – 当只有一个线程访问时,虚拟机认为这段代码是无需加锁的,也没有必要加锁,这就提高了代码的运行性能。但是需要记录下 当前的线程ID,一旦有其它线程进入 发现对象头的中线程ID不是 自己的id,说明这个锁开始存在竞争。 那就进入了synchronized锁的下一阶段。

匿名偏向

  • -XX:+UseBiasedLocking 启动偏向锁
  • -XX:BiasedLockingStartupDelay 偏向锁启动的延时时间 ,默认4s

这里介绍两个关于偏向锁的jvm参数,一个是是否启动偏向锁,一个是 jvm启动多久之后开启偏向锁, 这两个参数就决定了 synchronized 锁升级的走向

  • 如果偏向锁压根没有开启,那么synchronized锁升级就会跳过偏向锁,直接升级为轻量级锁。
  • 如果偏向锁开启,但是延迟了0秒(没有延迟),这时候创建的对象就是一个匿名偏向对象,这个对象 锁标志位 设为 '01',但是这个对象还没有偏向任何线程。
  • 如果偏向锁开启,但是延迟了30秒,那么new 出来的对象就是一个普通对象,如果这个对象30秒之后被当作synchronized的锁,那么就会升级为偏向锁。

上面就是在解释上图中 匿名偏向的含义, 有人问为什么要有 XX:BiasedLockingStartupDelay 这个配置参数,因为jvm一开始启动的时候肯定有大量的多线程操作,在高并发的环境下,偏向锁不是一个好的选择,还不如一开始就使用重量级锁排队等待。等一段时间过去,线程竞争没有那么激烈的时候,再开启偏向锁。

轻量级锁

从这里开始就要分别介绍两个角色的宿命, 一个是本身通过偏向获得锁的线程A,另一个是 其它想要,但还未获得访问通行权的线程B。

对于线程A来说: 没有竞争的时候,它的日子过得很潇洒,每次只需要简单的检查一下线程ID 就可以畅通无阻。 然而幸福总是短暂的,线程B的到来宣告 线程A开始执行 偏向撤销

  • 线程A已执行完毕的话。

偏向撤销会恢复到 ‘未锁定’ 的状态(线程 ID 为空,标志位为01),让线程B重新偏向

  • 线程A仍在执行的话。

虚拟机就会在线程A的栈帧中建立一个名为锁记录的空间(Lock Record),用于存储对象头的Mark Word拷贝,官方称呼这个拷贝为 Displaced Mark Word . 注意这个Displaced Mark Word 是未升级 轻量锁 前,存储对象头Mark Word的拷贝。

然后线程A 尝试获取轻量级锁-- 通过CAS尝试把Mark Word的stack pointer更新为指向本地栈帧Lock Record的指针, 更新成功,就获得了 轻量级锁,并且锁标志为变成 00,这时的状态可以参考下图:
JAVA锁的集大成应用者--synchronized的锁优化_第3张图片
对于线程B来说:它对于线程A来说是一个外来者,是它导致了 线程A的 偏向撤销( 偏向撤销不会主动发生),它也会建立自己的Lock Record,并且尝试通过CAS把Mark Word指向自己的Lock Record,如果失败的话,它会开始自旋等待(循环重试)。什么是轻量锁,轻量就在于没有重量级的系统切换,通过CAS+自旋来避免大的性能开销。当然,自旋的次数也是有限度的,持有轻量锁的线程A可能不会那么快释放锁,这时候 重量级锁就登场了。

重量级锁

上面说到 对于线程B来说,轻量锁 都没办法解决的问题 就说明竞争情况不容乐观,虽然轻量锁不涉及系统层面的切换开销,但是 自旋循环等操作都是消耗cpu的操作,什么东西都有利有弊, 线程B接下来就会选择将 轻量级锁继续膨胀,升级为 重量级锁,锁的标志位设为 ‘10’,并且自我挂起等待唤醒(重量级锁的重要特征)。

对于线程A来说,它获得了轻量级锁,当它执行完逻辑内容,它需要释放锁,这里释放锁是通过 CAS 将 Displaced Mark Word 替换当前对象头来实现的。这里细讲下CAS的预期值,新值,内存值 是啥,新值都知道,就是Displaced Mark Word,预期值是啥?肯定希望对象头现在存的是轻量级的锁标志为 00 ,然而内存值是啥,这就要打个问号了,前文已经说了,线程B在自旋后是可以把锁升级为 重量级锁,这时候线程A释放锁的CAS操作就会失败,线程A依旧会退出,但是会唤醒被挂起的线程B。

至此,我们大致分析了synchronized整个锁膨胀的流程。这一套涉及了 偏向锁,自旋,轻量级锁,重量级锁,然而,synchronized除了前文主体流程之外,还涉及到其它的锁优化技术,这也是为什么我认为synchronized是JAVA锁的集大成应用者。

JAVA的其它锁优化

自适应锁

我们知道了自旋锁,还有一种自适应的自旋锁。 自适应意味着自旋的时间不再是固定的, 而是由前一次在同一个锁上的自旋时间以及锁拥有者的状态来决定。如果在同一个锁对象上, 自旋等待刚好成功获得锁, 并且在持有锁的线程在运行中, 那么虚拟机就会认为这次自旋也是很有可能获得锁, 进而它将允许自旋等待相对更长的时间。

锁消除

给一段代码,这是一个字符串拼接的方法,StringBuffer 是线程安全的,因为它的每个方法都用synchronized修饰。但是这一个方法里我们看不到共享的变量,换句话说,sb变量的作用域只在这个方法体内,变量并没有逃逸,也就没有多线程问题。

没有多线程问题,用synchronized锁是不是有点浪费?虚拟机会帮我们做逃逸分析和锁消除,程序在运行的时候并没有锁的存在,尽可能帮我们提升程序性能。可惜的是我查了资料,java的逃逸分析没在编译期运行,我们不能通过字节码看到直观的优化效果。

    public void test(){
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 10; i++) {
            sb.append(i);
        }
    }

锁粗化

还是上面的例子,append方法是个synchronized修饰的方法,在循环重一遍遍调用的话 就会一直经历加锁解锁的过程,这也很消耗性能,锁粗化就是把这种代码的锁扩大到更大的范围,比如放到for循环以外,只做一次加锁解锁操作,这也是jvm帮我们做的优化。

资料

  • 【死磕Java并发】—–深入分析synchronized的实现原理
  • 周志明:《深入理解Java虚拟机》

文章修订

  • 2020.04.12 拓展匿名偏向的知识

你可能感兴趣的:(多线程)