关于java:难搞的偏向锁终于被-Java-移除了

44次阅读

共计 9594 个字符,预计需要花费 24 分钟才能阅读完成。

背景

在 JDK1.5 之前,面对 Java 并发问题,synchronized 是一招鲜的解决方案:

  1. 一般同步办法,锁上以后实例对象
  2. 动态同步办法,锁上以后类 Class 对象
  3. 同步块,锁上括号外面配置的对象

拿同步块来举例:

public void test(){synchronized (object) {i++;}
}

通过 javap -v 编译后的指令如下:

monitorenter 指令是在编译后插入到同步代码块的开始地位;monitorexit是插入到办法完结和异样的地位(理论暗藏了 try-finally),每个对象都有一个 monitor 与之关联,当一个线程执行到 monitorenter 指令时,就会取得对象所对应的 monitor 的所有权,也就取得到了对象的锁

当另外一个线程执行到同步块的时候,因为它没有对应 monitor 的所有权,就会被阻塞,此时控制权只能交给操作系统,也就会从 user mode 切换到 kernel mode, 由操作系统来负责线程间的调度和线程的状态变更, 须要频繁的在这两个模式下切换(上下文转换 )。这种有点竞争就找内核的行为很不好,会引起很大的开销,所以大家都叫它 重量级锁,天然效率也很低,这也就给很多童鞋留下了一个积重难返的印象 —— synchronized 关键字相比于其余同步机制性能不好

收费的 Java 并发编程小册在此

锁的演变

来到 JDK1.6,要怎么优化能力让锁变的轻量级一些?答案就是:

轻量级锁:CPU CAS

如果 CPU 通过简略的 CAS 能解决加锁 / 开释锁,这样就不会有上下文的切换,较重量级锁而言天然就轻了很多。然而当竞争很强烈,CAS 尝试再多也是节约 CPU,衡量一下,不如升级成重量级锁,阻塞线程排队竞争,也就有了轻量级锁升级成重量级锁的过程

程序员在谋求极致的路线上是永无止境的,HotSpot 的作者通过钻研发现,大多数状况下,锁不仅不存在多线程竞争,而且总是由 同一个线程 屡次取得,同一个线程重复获取锁,如果还依照轻量级锁的形式获取锁(CAS),也是有肯定代价的,如何让这个代价更小一些呢?

偏差锁

偏差锁理论就是锁对象潜意识「偏心」同一个线程来拜访,让锁对象记住线程 ID,当线程再次获取锁时,亮出身份,如果同一个 ID 间接就获取锁就好了,是一种 load-and-test 的过程,相较 CAS 天然又轻量级了一些

可是多线程环境,也不可能只是同一个线程始终获取这个锁,其余线程也是要干活的,如果呈现多个线程竞争的状况,也就有了偏差锁降级的过程

这里能够先思考一下:偏差锁能够绕过轻量级锁,间接降级到重量级锁吗?

都是同一个锁对象,却有多种锁状态,其目标不言而喻:

占用的资源越少,程序执行的速度越快

偏差锁,轻量锁,它俩都不会调用零碎互斥量(Mutex Lock),只是为了晋升性能,多出的两种锁的状态,这样能够在不同场景下采取最合适的策略,所以能够总结性的说:

  • 偏差锁:无竞争的状况下,只有一个线程进入临界区,采纳偏差锁
  • 轻量级锁:多个线程能够交替进入临界区,采纳轻量级锁
  • 重量级锁:多线程同时进入临界区,交给操作系统互斥量来解决

到这里,大家应该了解了全局大框,但依然会有很多疑难:

  1. 锁对象是在哪存储线程 ID 才能够辨认同一个线程的?
  2. 整个降级过程是如何过渡的?

想了解这些问题,须要先晓得 Java 对象头的构造

意识 Java 对象头

依照惯例了解,辨认线程 ID 须要一组 mapping 映射关系来搞定,如果独自保护这个 mapping 关系又要思考线程平安的问题。奥卡姆剃刀原理,Java 万物皆是对象,对象皆可用作锁,与其独自保护一个 mapping 关系,不如中心化将锁的信息保护在 Java 对象自身上

Java 对象头最多由三局部形成:

  1. MarkWord
  2. ClassMetadata Address
  3. Array Length(如果对象是数组才会有这部分

其中 Markword 是保留锁状态的要害,对象锁状态能够从偏差锁降级到轻量级锁,再降级到重量级锁,加上初始的无锁状态,能够了解为有 4 种状态。想在一个对象中示意这么多信息天然就要用 存储,在 64 位操作系统中,是这样存储的(留神色彩标记),想看具体正文的能够看 hotspot(1.8) 源码文件 path/hotspot/src/share/vm/oops/markOop.hpp 第 30 行

有了这些根本信息,接下来咱们就只须要弄清楚,MarkWord 中的锁信息是怎么变动的

意识偏差锁

单纯的看上图,还是显得非常形象,作为程序员的咱们最喜爱用代码谈话,贴心的 openjdk 官网提供了能够查看对象内存布局的工具 JOL (java object layout)

Maven Package

<dependency>
  <groupId>org.openjdk.jol</groupId>
  <artifactId>jol-core</artifactId>
  <version>0.14</version>
</dependency>

Gradle Package

implementation 'org.openjdk.jol:jol-core:0.14'

接下来咱们就通过代码来深刻理解一下偏差锁吧

留神:

上图 (从左到右) 代表 高位 -> 低位

JOL 输入后果(从左到右)代表 低位 -> 高位

来看测试代码

场景 1

    public static void main(String[] args) {Object o = new Object();
        log.info("未进入同步块,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

来看输入后果:

下面咱们用到的 JOL 版本为 0.14, 率领大家疾速理解一下位具体值,接下来咱们就要用 0.16 版本查看输入后果,因为这个版本给了咱们更敌对的阐明,同样的代码,来看输入后果:

看到这个后果,你应该是有疑难的,JDK 1.6 之后默认是开启偏差锁的,为什么初始化的代码是无锁状态,进入同步块产生竞争就绕过偏差锁间接变成轻量级锁了呢?

尽管默认开启了偏差锁,然而开启 有提早,大略 4s。起因是 JVM 外部的代码有很多中央用到了 synchronized,如果间接开启偏差,产生竞争就要有锁降级,会带来额定的性能损耗,所以就有了提早策略

咱们能够通过参数 -XX:BiasedLockingStartupDelay=0 将提早改为 0,然而 不倡议 这么做。咱们能够通过一张图来了解一下目前的状况:

场景 2

那咱们就代码提早 5 秒来创建对象,来看看偏差是否失效

    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);
        Object o = new Object();
        log.info("未进入同步块,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

从新查看运行后果:

这样的后果是合乎咱们预期的,然而后果中的 biasable 状态,在 MarkWord 表格中并不存在,其实这是一种 匿名偏差状态,是对象初始化中,JVM 帮咱们做的

这样当有线程进入同步块:

  1. 可偏差状态:间接就 CAS 替换 ThreadID,如果胜利,就能够获取偏差锁了
  2. 不可偏差状态:就会变成轻量级锁

那问题又来了,当初锁对象有具体偏差的线程,如果新的线程过去执行同步块会偏差新的线程吗?

场景 3

    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);
        Object o = new Object();
        log.info("未进入同步块,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }

        Thread t2 = new Thread(() -> {synchronized (o) {log.info("新线程获取锁,MarkWord 为:");
                log.info(ClassLayout.parseInstance(o).toPrintable());
            }
        });

        t2.start();
        t2.join();
        log.info("主线程再次查看锁对象,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o){log.info(("主线程再次进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

来看运行后果,奇怪的事件产生了:

  • 标记 1 : 初始可偏差状态
  • 标记 2 :偏差主线程后,主线程退出同步代码块
  • 标记 3 : 新线程 进入同步代码块,升级成了轻量级锁
  • 标记 4 : 新线程轻量级锁退出同步代码块,主线程查看,变为不可偏差状态
  • 标记 5 : 因为对象不可偏差,同 场景 1 主线程再次进入同步块,天然就会用轻量级锁

至此,场景一二三能够总结为一张图:

从这样的运行后果上来看,偏差锁像是“一锤子买卖 ”,只有偏差了某个线程,后续其余线程尝试获取锁,都会变为轻量级锁,这样的偏差十分有局限性。 事实上并不是这样,如果你认真看标记 2(已偏差状态),还有个 epoch 咱们没有提及,这个值就是突破这种局限性的要害,在理解 epoch 之前,咱们还要理解一个概念——偏差撤销

收费的 Java 并发编程小册在此

偏差撤销

在真正解说偏差撤销之前,须要和大家明确一个概念——偏差锁撤销和偏差锁开释是两码事

  1. 撤销:抽象的说就是多个线程竞争导致不能再应用偏差模式的时候,次要是告知这个锁对象不能再用偏差模式
  2. 开释:和你的惯例了解一样,对应的就是 synchronized 办法的退出或 synchronized 块的完结

何为偏差撤销?

从偏差状态撤回原有的状态,也就是将 MarkWord 的第 3 位(是否偏差撤销)的值,从 1 变回 0

如果只是一个线程获取锁,再加上「偏心」的机制,是没有理由撤销偏差的,所以偏差的撤销只能产生在有竞争的状况下

想要撤销偏差锁,还不能对持有偏差锁的线程有影响,所以就要期待持有偏差锁的线程达到一个 safepoint 平安点 (这里的平安点是 JVM 为了保障在垃圾回收的过程中援用关系不会发生变化设置的一种平安状态,在这个状态上会暂停所有线程工作),在这个平安点会挂起取得偏差锁的线程

在这个平安点,线程可能还是处在不同状态的,先说论断(因为源码就是这么写的,可能有纳闷的中央会在前面解释)

  1. 线程不存活或者活着的线程但退出了同步块,很简略,间接撤销偏差就好了
  2. 活着的线程但仍在同步块之内,那就要升级成轻量级锁

这个和 epoch 貌似还是没啥关系,因为这还不是全副场景。偏差锁是特定场景下晋升程序效率的计划,可并不代表程序员写的程序都满足这些特定场景,比方这些场景(在开启偏差锁的前提下):

  1. 一个线程创立了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种 case 下,会导致大量的偏差锁撤销操作
  2. 明知有多线程竞争(生产者 / 消费者队列),还要应用偏差锁,也会导致各种撤销

很显然,这两种场景必定会导致偏差撤销的,一个偏差撤销的老本无所谓,大量偏差撤销的老本是不能漠视的。那怎么办?既不想禁用偏差锁,还不想忍耐大量撤销偏差减少的老本,这种计划就是设计一个 有阶梯的底线

批量重偏差(bulk rebias)

这是第一种场景的疾速解决方案,以 class 为单位,为每个 class 保护一个偏差锁撤销计数器,每一次该 class 的对象产生偏差撤销操作时,该计数器 +1,当这个值达到重偏差阈值(默认 20)时:

BiasedLockingBulkRebiasThreshold = 20

JVM 就认为该 class 的偏差锁有问题,因而会进行批量重偏差, 它的实现形式就用到了咱们下面说的 epoch

Epoch,如其含意「纪元」一样,就是一个工夫戳。每个 class 对象会有一个对应的 epoch 字段,每个 处于偏差锁状态对象 mark word 中也有该字段,其初始值为创立该对象时 class 中的 epoch 的值(此时二者是相等的)。每次产生批量重偏差时,就将该值加 1,同时遍历 JVM 中所有线程的栈

  1. 找到该 class 所有 正处于加锁状态 的偏差锁对象,将其 epoch 字段改为新值
  2. class 中 不处于加锁状态 的偏差锁对象(没被任何线程持有,但之前是被线程持有过的,这种锁对象的 markword 必定也是有偏差的),放弃 epoch 字段值不变

这样下次取得锁时,发现以后对象的 epoch 值和 class 的 epoch,本着 今朝不问前朝事 的准则(上一个纪元),那就算以后曾经偏差了其余线程,也不会执行撤销操作,而是间接通过 CAS 操作将其mark word 的线程 ID 改成以后线程 ID,这也算是肯定水平的优化,毕竟没降级锁;

如果 epoch 都一样,阐明没有产生过批量重偏差, 如果 markword 有线程 ID,还有其余锁来竞争,那锁天然是要降级的(如同后面举的例子 epoch=0)

批量重偏差是第一阶梯底线,还有第二阶梯底线

批量撤销(bulk revoke)

当达到重偏差阈值后,假如该 class 计数器持续增长,当其达到批量撤销的阈值后(默认 40)时,

BiasedLockingBulkRevokeThreshold = 40

JVM 就认为该 class 的应用场景存在多线程竞争,会标记该 class 为不可偏差。之后对于该 class 的锁,间接走轻量级锁的逻辑

这就是第二阶梯底线,然而在第一阶梯到第二阶梯的过渡过程中,也就是在彻底禁用偏差锁之前,还给一次痛改前非的机会,那就是另外一个计时器:

BiasedLockingDecayTime = 25000
  1. 如果在间隔上次批量重偏差产生的 25 秒之内,并且累计撤销计数达到 40,就会产生批量撤销(偏差锁彻底 game over)
  2. 如果在间隔上次批量重偏差产生超过 25 秒之外,那么就会重置在 [20, 40) 内的计数, 再给次机会

大家有趣味能够写代码测试一下临界点,察看锁对象 markword 的变动

至此,整个偏差锁的工作流程能够用一张图示意:

到此,你应该对偏差锁有个根本的意识了,然而我心中的好多疑难还没有解除,咱们持续看:

HashCode 哪去了

下面场景一,无锁状态,对象头中没有 hashcode;偏差锁状态,对象头还是没有 hashcode,那咱们的 hashcode 哪去了?

首先要晓得,hashcode 不是创建对象就帮咱们写到对象头中的,而是要通过 第一次 调用 Object::hashCode() 或者 System::identityHashCode(Object) 才会存储在对象头中的。第一次 生成的 hashcode 后,该值应该是始终放弃不变的,但偏差锁又是来回更改锁对象的 markword,必定会对 hashcode 的生成有影响,那怎么办呢?,咱们来用代码验证:

场景一

    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);

        Object o = new Object();
        log.info("未生成 hashcode,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());

        o.hashCode();
        log.info("已生成 hashcode,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o){log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

来看运行后果

论断就是:即使初始化为可偏差状态的对象,一旦调用 Object::hashCode() 或者System::identityHashCode(Object),进入同步块就会间接应用轻量级锁

场景二

如果已偏差某一个线程,而后生成 hashcode,而后同一个线程又进入同步块,会产生什么呢?来看代码:

    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);

        Object o = new Object();
        log.info("未生成 hashcode,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o){log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }

        o.hashCode();
        log.info("生成 hashcode");
        synchronized (o){log.info(("同一线程再次进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

查看运行后果:

论断就是:同场景一,会间接应用轻量级锁

场景三

那如果对象处于已偏差状态,在同步块中调用了那两个办法会产生什么呢?持续代码验证:

    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);

        Object o = new Object();
        log.info("未生成 hashcode,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o){log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
            o.hashCode();
            log.info("已偏差状态下,生成 hashcode,MarkWord 为:");
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

来看运行后果:

论断就是:如果对象处在已偏差状态,生成 hashcode 后,就会间接升级成重量级锁

最初用书中的一段话来形容 锁和 hashcode 之前的关系

调用 Object.wait() 办法会产生什么?

Object 除了提供了上述 hashcode 办法,还有 wait() 办法,这也是咱们在同步块中罕用的,那这会对锁产生哪些影响呢?来看代码:

    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);

        Object o = new Object();
        log.info("未生成 hashcode,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o) {log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());

            log.info("wait 2s");
            o.wait(2000);

            log.info(("调用 wait 后,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

查看运行后果:

论断就是,wait 办法是互斥量(重量级锁)独有的,一旦调用该办法,就会升级成重量级锁(这个是面试能够说出的亮点内容哦)

最初再持续丰盛一下锁对象变动图:


收费的 Java 并发编程小册在此

辞别偏差锁

看到这个题目你应该是有些慌,为啥要辞别偏差锁,因为保护老本有些高了,来看 Open JDK 官网申明,JEP 374: Deprecate and Disable Biased Locking,置信你看下面的文字说明也深有体会

这个阐明的更新工夫间隔当初很近,在 JDK15 版本就曾经开始了

一句话解释就是保护老本太高

最终就是,JDK 15 之前,偏差锁默认是 enabled,从 15 开始,默认就是 disabled,除非显示的通过 UseBiasedLocking 开启

其中在 quarkus 上的一篇文章阐明的更加间接

偏差锁给 JVM 减少了微小的复杂性,只有多数十分有教训的程序员能力了解整个过程,保护老本很高,大大妨碍了开发新个性的过程(换个角度了解,你把握了,是不是就是那多数有教训的程序员了呢?哈哈)

总结

偏差锁可能就这样的走完了它的毕生,有些同学可能间接提问,都被 deprecated 了,JDK 都 17 了,还讲这么多干什么?

  1. java 任它发,我用 Java8,这是很多支流的状态,至多你用的版本没有被 deprecated
  2. 面试还是会被常常问到
  3. 万一哪天有更好的设计方案,“偏差锁”又以新的模式回来了呢,理解变动能力更好了解背地设计
  4. 奥卡姆剃刀原理,咱们事实中的优化也一样,如果没有必要不要减少实体,如果减少的内容带来很大的老本,不如大胆的破除掉,承受一点落差

之前对于偏差锁我也只是单纯的实践认知,然而为了写这篇文章,我翻阅了很多材料,包含也从新查看 Hotspot 源码,说的这些内容也并不能齐全阐明偏差锁的整个流程细节,还须要大家具体实际追踪查看,这里给出源码的几个要害入口,不便大家追踪:

  1. 偏差锁入口:http://hg.openjdk.java.net/jd…
  2. 偏差撤销入口:http://hg.openjdk.java.net/jd…
  3. 偏差锁开释入口:http://hg.openjdk.java.net/jd…

文中有疑难的中央欢送留言探讨,有谬误的中央还请大家帮忙斧正

灵魂诘问

  1. 轻量级和重量级锁,hashcode 存在了什么地位?

参考资料

感激各路前辈的精髓总结,能够让我参考了解:

  1. https://www.oracle.com/techne…
  2. https://www.oracle.com/techne…
  3. https://wiki.openjdk.java.net…
  4. https://github.com/farmerjohn…
  5. https://zhuanlan.zhihu.com/p/…
  6. https://mp.weixin.qq.com/s/G4…
  7. https://www.jianshu.com/p/884…

日拱一兵 | 原创

正文完
 0