前言很久以前面试实习生的时候,有人问我关于synchronized锁升级的流程。但实际上,我并不太明白它优化了哪些地方,针对什么场景进行了优化。其实我想知道锁升级过程中的引入场景。尤其是看到JDK15放弃和禁用了偏向锁之后,我其实很纳闷为什么要把这个技术去掉,是JDK有更好的优化,还是这个技术现在已经不适用了。这里就直接说答案吧,答案在JEP374中。本来想直接贴出答案的,但是考虑到部分同学对synchronized升级过程不是很清楚,这里简单说一下锁升级过程。其实这也是面试中比较常见的问题,但是往往是面试官问我锁升级过程,而不是问锁升级有哪些场景会受益。这是我经常想知道的地方,而不是问为什么,我问什么。总有一些口碑优化是大家都愿意相信的,但是我们都不得不相信那句话:没有调查,就没有话语权。synchronized锁简介这里我们简单回顾一下synchronized,synchronized是我们遇到的第一个同步工具,它有很多别名:内部锁、排他锁、悲观锁。它保证原子性、可见性和顺序。synchronized关键字修饰的方法称为同步方法(SynchronizedMethod),synchronized修饰的静态实例方法称为同步实例方法。同步方法的整个方法称为临界区。//修饰方法publicsynchronizedvoidsynchronizedDemo(){}//修饰静态方法publicstaticsynchronizedvoidsynchronizedStaticDemo(){}publicvoidsynchronizedDemoPlus(){//修饰代码块synchronized(this){}}Java平台任意对象只有一把锁与之关联。线程进入临界区需要申请锁,那么锁放在哪里呢?答案是对象头。一个普通Java对象的内部结构如下图所示:一般来说,我们对这把锁的理解是,当多个线程进入临界区时,都会去申请这把锁。如果被其他线程获取,则该线程会被阻塞。更准确的描述是JVM会为每一个内部锁分配一个entryset,用于记录等待获取对应内部锁的线程。当这些线程申请的锁被持有线程释放时,其中一个锁入口集合任何线程都会被JVM唤醒。看到这里,可能有同学会问,JVM是如何处理上面提到的锁的获取和释放的。这其实需要借助反编译指令。这里我们通过同步代码块来观察synchronized的内部实现:/***这段代码需要编译形成字节码。*其实问题的答案就在于字节码。*/publicclassThreadDemo{publicvoidsynchronizedDemo(){synchronized(this){}}}然后找到这个类对应的字节码所在的文件夹,打开命令行执行如下命令javap-c./ThreadDemo。classmonitorenter为进入临界区和申请锁指令,monitorexit代表进入临界区,释放锁指令。那为什么会有两条releaselock指令呢?这是一个很好的问题。底部释放锁指令是为临界区代码中的异常准备的。JVM在实现monitorenter和monitorexit时需要使用原子操作(CAS),代价比较大。锁升级概述但是我们知道Java中的线程是在操作系统层面映射到线程的,所以唤醒需要向操作系统提出请求。如果一个线程长时间不持有锁,让线程陷入深度睡眠,然后让操作系统去唤醒,成本有点高。这导致锁升级:一开始,对象头的锁状态是解锁。当线程进入临界区执行代码时,如果成功获取到锁,JVM会为每个对象维护一个偏好(Bias),即一个线程获取到该对象对应的内部锁时第一次,那么这个线程会被记录为对象的首选线程(BiasedThread)。原子操作减少了锁申请和释放的开销。这种优化是基于这样的观察,即锁大多是无竞争的,并且这些锁在其整个生命周期中最多由一个线程持有。其实网上大部分的博客也是根据这种情况来介绍为什么要引入偏向锁的。其实看到这句话,我是真的不明白。我用同步来解决多线程竞争资源带来的问题。那么上述观察是基于什么样的场景呢?要回答这个问题,我先问一个问题:Java线程安全的集合有哪些?普通同学可能会回答:ConcurrentHashMapCopyOnWriteArrayListCopyOnWriteArraySet这些集合是比较复杂的并发集合,是在JDK1.5引入的。其实还有一些不为人知的并发安全集合:HashtableVector这两个集合是Java的原始集合。自从引入JDK1.0后,一般不会有人选择使用这两个集合。原因是这两个线程安全的实现简单粗暴,每个方法都加了synchronized。看了下ArrayList和HashMap的介绍时间。当时是JDK1.2,所以早期的Java程序员别无选择。只能使用Hashtable和Vector。即使在单线程的使用场景下,ArrayList和HashMap也无法使用。Java是一种向前兼容的语言。尽管JDK19即将发布,但部分项目的JDK还停留在JDK5、6。所以在JDK6中引入偏向锁是为了优化早期JDK代码的性能。这也是JDK15移除偏向锁的原因之一。更致命的原因是启用偏向锁会导致性能下降。为什么要移去偏向锁让我们仔细的看下面JEP374这个案例为什么要移去偏向锁:偏向锁是HotSpot虚拟机中使用的一种优化技术,用于减少无竞争锁的开销。它旨在通过假设监视器保持由给定线程拥有直到另一个线程尝试获取它来避免在获取监视器时执行比较和交换原子操作。监视器的初始锁定使监视器偏向该线程,从而避免在同一对象上的后续同步操作中需要原子指令。当许多线程对以单线程方式使用的对象执行许多同步操作时,从历史上看,偏向锁会导致比常规锁定技术显着的性能改进。过去看到的性能提升在今天已经不那么明显了。许多受益于偏向锁定的应用程序都是使用早期Java集合API的较旧的遗留应用程序,这些应用程序在每个访问(例如,Hashtable和Vector)。较新的应用程序通常使用Java1.2中针对单线程场景引入的非同步集合(例如HashMap和ArrayList),或者使用Java5中针对多线程场景引入的性能更高的并发数据结构。这意味着如果更新代码以使用这些较新的类,由于不必要的同步而受益于偏向锁定的应用程序可能会看到性能改进。此外,围绕线程池队列和工作线程构建的应用程序通常在禁用偏向锁定的情况下表现更好。(SPECjbb2015就是这样设计的,例如,而SPECjvm98和SPECjbb2005则不是)。偏向锁定伴随着在争用的情况下需要昂贵的撤销操作的成本。因此,从中受益的应用程序只是那些表现出大量无竞争同步操作的应用程序,就像那些提到的d以上,因此执行廉价的锁所有者检查的成本加上偶尔昂贵的撤销仍然低于执行逃避的比较和交换原子指令的成本。自从将偏向锁定引入HotSpot以来,原子指令成本的变化也改变了保持该关系所需的非竞争操作的数量。另一个值得注意的方面是,即使之前的成本关系为真,当花在同步操作上的时间仍然只占应用程序总工作负载的一小部分时,应用程序不会从偏向锁定中获得明显的性能改进。偏向锁定引入了很多将复杂的代码添加到同步子系统中,并且也会侵入其他HotSpot组件。这种复杂性是理解代码各个部分的障碍,也是在同步子系统中进行重大设计更改的障碍。至最后,我们希望禁用、弃用并最终删除对偏向锁定的支持。这里直接谷歌翻译一下:Biasedlocking是HotSpot虚拟机中使用的一种优化技术,用来减少非竞争锁的开销。它旨在通过假设监视器保持由给定线程拥有直到另一个线程尝试获取它来避免在获取监视器时执行比较和交换原子操作。监视器的初始锁定使监视器偏向该线程,从而避免在同一对象上的后续同步操作中需要原子指令。当许多线程对以单线程方式使用的对象执行许多同步操作时,偏向锁定在历史上导致了比传统锁定技术显着的性能改进。过去看到的性能提升在今天已经远不那么明显了。许多受益于偏向锁定的应用程序都是较旧的遗留应用程序,它们使用在每次访问时同步的早期Java集合API(例如Hashtable和Vector)。较新的应用程序通常使用Java1.2中为单线程场景引入的非同步集合(例如HashMap和ArrayList),或者Java5中为多线程场景引入的性能更高的并发数据结构。这意味着如果应用程序的代码更新为使用这些较新的类,则由于不必要的同步而受益于偏向锁定的应用程序可能会看到性能改进。此外,围绕线程池队列和工作线程构建的应用程序通常在禁用偏向锁定的情况下表现更好。(比如SPECjbb2015就是这样设计的,而SPECjvm98和SPECjbb2005则不是)。偏向锁定伴随着在争用情况下需要昂贵的撤消操作的成本。因此,唯一从中受益的应用程序是那些表现出大量无竞争同步操作的应用程序,如上面提到的那些,因此进行廉价锁所有者检查的成本加上偶尔昂贵的撤消仍然低于进行闪避比较的成本-and-swap原子指令。自从向HotSpot引入偏向锁定以来,原子指令成本的变化也改变了保持这种关系正确所需的非竞争操作的数量。另一个值得注意的方面是,即使之前的成本关系是正确的,应用程序也不会从偏向锁定中获得显着的性能提升,花在同步操作上的时间仍然只占应用程序总工作负载的一小部分。部分。自从向HotSpot引入偏向锁定以来,原子指令成本的变化也改变了保持这种关系正确所需的非竞争操作的数量。另一个值得注意的方面是,即使之前的成本关系是正确的,应用程序也不会从偏向锁定中获得显着的性能提升,花在同步操作上的时间仍然只占应用程序总工作负载的一小部分。自从向HotSpot引入偏向锁定以来,原子指令成本的变化也改变了保持这种关系正确所需的非竞争操作的数量。另一个值得注意的方面是,即使之前的成本关系是正确的,应用程序也不会从偏向锁定中获得显着的性能提升,花在同步操作上的时间仍然只占应用程序总工作负载的一小部分。偏向锁在同步子系统中引入了很多复杂的代码,同时也侵入了其他HotSpot组件。这种复杂性是理解部分代码和在同步子系统中进行重大设计更改的障碍。为此,我们希望禁用、弃用并最终删除对偏向锁定的支持。综上所述,引入偏向锁的目的主要是为了优化JDK1.2之前的HashTable和Vector。这两个合集就是我们上面说的。对应的锁在整个集合生命周期中有时只会被一个线程获取。现在去掉偏向锁的原因是这两组基本没人用,而且撤销偏向锁也需要很高的成本,所以JDK15决定去掉这个特性。轻量级锁到重量级锁?偏向锁这里就不说了,说说锁升级的过程:从无锁升级到偏向锁,假设其他线程访问偏向锁申请锁,那么偏向锁升级为轻量级锁这时,这种轻量级锁的具体表现就是,获取锁失败的线程不会陷入阻塞状态,而是会自旋,即不停地循环获取锁,而是长期自旋selection会消耗CPU资源,所以达到一定次数后,就会达到重量级锁。如果锁处于重量级锁状态,则获取锁失败的线程将进入阻塞状态。《Java 多线程编程 实战指南》看到的一段描述:在锁竞争的情况下,当一个线程申请锁的时候,如果恰好锁被其他线程持有,那么该线程需要等待持有线程释放锁,实现这种等待的一种保守的方式是挂起线程,但是挂起线程会引起上下文切换,所以对于特定的锁实例,这种实现策略更适合系统中大部分线程持有锁的情况选择一个长时间的场景,这样可以抵消上下文切换的开销。另一种实现方法是使用忙等待。所谓忙等待相当于一个空循环体的循环语句,如下代码所示:while(lockIsHeldByOtherThread){}。可以看出,忙等待就是反复进行空操作,直到需要的条件成立为止。这种策略的优点是不会引起上下文切换,缺点是会消耗处理器资源。实际上,JVM并不一定要在以上两种实现策略中选择一种,它可以将以上两种策略结合起来使用。对于一个特定的锁实例,JVM会根据其运行过程中收集到的信息来判断线程持有锁的时间是“长”还是“短”。对于线程持有“时间较长”的锁,JVM会选择暂停等待策略,对于线程持有“时间较短”的锁,JVM会选择忙等待策略。JVM也可以先采用busy-waiting策略,当busy-waiting失败后,再采用pause-waiting策略。JVM虚拟机的这种优化称为自适应锁。其实我看这段的时候就在想,JVM会先采用pause等待策略,然后再调整成busy等待策略吗?我找遍了全网,还是找不到这个讨论。相关讨论仅针对自适应锁。自适应锁是在JDK1.6中引入的。自适应锁可以理解为自适应自选锁。自适应是指自选时间times不再是固定的,而是由之前在同一把锁上的可选时间和锁主的状态决定的。如果在同一个锁对象上,自选等待刚刚成功获取到锁,持有锁的线程正在运行,那么虚拟机会认为这次自旋很可能再次成功,然后继续自旋等待时间相对较长。如果对于一个锁,自旋很少能成功获取,那么以后再尝试获取这个锁,很可能会省略自选过程,直接阻塞线程,避免浪费处理器资源。其实问题基本到这里就结束了。锁的升级流程我们已经基本回答完了。JDK8之后的锁升级过程应该是从无锁到偏向锁,然后自己选择。自旋成功率高,则继续自旋,如果自旋获取锁成功率比较低,则消耗资源较多,进入重量级锁。锁升级过程标准答案如果面试官问锁升级过程,我觉得标准答案是这样的:在JDK8到14,是从lock-free到lock-adaptivelock。所谓自适应锁,就是JVM会根据运行的进程进行加锁。线程中收集的信息用于决定是自旋还是阻塞线程。如果自旋获取锁的成功率比较高,则由偏向锁升级为轻量级锁。如果自旋获取锁的失败率比较高,也就是说单个线程持有锁的时间比较长,那么JVM就会从轻量级锁变成重量级锁。JDK15中去掉了偏向锁,因为引入了偏向锁,主要是为了优化JDK1.0的两个集合相关的代码,但是现在好像很少用到这两个集合了,JVM取消了偏向锁状态它消耗更多的资源,所以JDK15取消了偏向锁。所以JDK15的锁升级过程是从无锁到轻量级锁再到重量级锁。本来打算把锁降级写到最后,但是锁降级涉及到一个安全点,而安全点的介绍是和GC混在一起的。本来打算引入的,后来想了想,引入安全点不太现实。参考文档JEP374:DeprecateandDisableBiasedLockinghttps://openjdk.org/jeps/374深度解析:锁升级过程和锁状态,看完本文你就明白了!https://segmentfault.com/a/11...不得不说的Java“锁”的事情https://tech.meituan.com/2018...
