面试官问synchronized为什么性能会比Lock慢一点

2024年 3月 8日 25.5k 0

前置思考

实现锁应该考虑的问题

  • 如何获取资源(锁)?
  • 获取不到资源的线程如何处理?
  • 如何释放资源?
  • 资源释放后如何让其他线程获取资源?
  • 由此可以得出实现一把锁,应该具备哪些逻辑

    • 锁的标识需要有个标识或者状态来表示锁是否已经被占用。
    • 线程抢锁的逻辑多个线程如何抢锁,如何才算抢到锁,已经抢到锁的线程再次抢锁如何处理等等。
    • 线程挂起的逻辑线程如果抢到锁自然顺利往下运行了,而那些没有抢到锁的线程怎么处理呢?如果一直处于活跃状态,cpu肯定是吃不消,那就需要挂起。具体又如何挂起呢?
    • 线程存储机制没有抢到锁的线程就挂起了,而且被挂起的线程可能有很多个,这些线程总要放在某个地方保存起来等待唤醒,然而这么多被挂起的线程,要唤醒哪一个呢?这就需要一套保存机制来支撑唤醒逻辑。
    • 线程释放锁的逻辑线程在执行完后就要释放锁,跟抢锁逻辑是对应的,其实也是操作锁标识。
    • 线程唤醒的逻辑锁释放后,就要去唤醒被阻塞的线程,这就要考虑唤醒谁,如何唤醒,唤醒后的线程做什么事情。

    这是我们在探讨AQS时候的思考,这里也把它贴过来,是因为同样是锁,基本的实现思路都是一样的,也方便对比两个锁的操作,同样带着上面的思考,我们来看看synchronized是怎么处理的

    从synchronized的使用入手

  • synchronized可以修饰实例方法。
  • synchronized可以修饰静态方法。
  • synchronized可以修饰实例方法的代码块。
  • synchronized可以修饰静态方法的代码块。
  • 怎么使用不必多说,本篇就以修饰实例方法中的代码块为例来看下它的底层逻辑,如下为代码块代码。

    synchronized(this){
                
    }

    synchronized是jvm级别实现的,在java端只是一个关键字,它在编译后会变为如下图的样子,整个代码块前后被monitorenter 和 monitorexit包裹,也就是说synchronized关键字的在编译后就变为上面两个关键字。

    图片图片

    jvm运行代码的时候,当遇到这两个关键字时候就会做出相应的处理。

    其实很简单,这两个关键字无非是在逻辑前后分别进行抢锁和释放锁,与ReentrantLock的lock和unlock是一个道理。

    monitorenter 和 monitorexit这两个是jvm级别字节码指令,不难想到jvm在运行代码的时候,遇到monitorenter关键字,一定会启动抢锁的逻辑,包括抢锁,入队,阻塞;而遇到monitorexit的时候一定会走释放锁逻辑,包括释放锁,唤醒阻塞线程。

    前置了解

    创建对象

    通过synchronized代码块的用法我们就能知道java的synchronized使用是依赖一个对象的,所以我们下面先看下java中的对象是怎么创建的。

    java中如何创建一个对象?java代码会被编译成字节码然后被jvm运行,jvm在遇到new关键字的时候就会启动对象的创建流程,对象的大致流程如下:

    图片图片

    默认情况下jvm加载类是懒加载的,所以创建对象的第一步是判断类是否已经加载,如果没有加载,需要先走类的加载流程。

    接下来是分配内存,一个对象在类加载的时候就可以知道所需要的内存大小,此时就是在堆中划分一块区域出来作为对象的私密空间,具体如何分配和具体使用的垃圾回收器有关,jvm篇再细讲,在偌大的堆中怎么为一个对象划分区间呢?这里的分配主要是两种方法:指针碰撞和空闲列表,但是不管哪种划分方法都会存在并发问题,此时jvm的解决方案是TLAB和cas配合失败重试。

    初始化零值这一步是给对象中的属性赋零值,比如int类型默认为0,这一步是避免属性不赋值的情况下出现空指针异常。

    每个对象都会有一个对象头区域,这个区域包括Mark Word,元数据指针,数组长度三个部分,Mark Word用于保存对象的运行时数据,比如hashcode,分代年龄,锁标识等,元数据指针是当前对象所属类对象的地址,只有数组对象才会有数组长度。

    最后初始化对象,这个时候一个完整的对象生成了。

    一个完整对象的结构如下:

    图片图片

    可以看到结构中有一个对齐填充,对齐填充是为了满足对象的大小为8字节的整数倍,只有8字节的整数倍才是最高效的存取方式。所以一个对象的大小总是8字节的整数倍。

    对象头

    对象头是对象中用于保存实例数据外的运行时数据的区域。

    我们知道java是面向对象的,在java的世界一切皆对象,所以整个jvm的设计都是围绕对象,包括对象所属类的加载,对象的创建,对象的保存,对象的销毁,对象的回收,锁的实现,以及jvm的内存结构等等都要围绕对象设计,这就导致对象自身会有很多的运行时数据,比如垃圾回收依赖的分代年龄,代码运行过程中用于标识对象唯一的hashcode,当用作锁对象的时候锁的相关信息存储,记录当前对象所属的类对象指针等等。

    所以jvm设计了对象头,对象头包括Mark Word,MetaDate,数组长度三部分。

    Monitor

    jvm对于锁的设计是监视器锁,当对象作为锁的时候,jvm会为该对象关联一个Monitor对象(一一对应),这个Monitor对象就是该对象携带的一个监视器。

    这个Monitor对象是jvm级别实现的,是一个jvm级别的对象,所以我们在java端开发的时候是看不到摸不到的,但却是真实存在的。

    Monitor对象结构如下:

    ObjectMonitor() {
        _header       = NULL;
        _count        = 0; // 记录个数
        _waiters      = 0,
        _recursions   = 0;
        _object       = NULL;
        _owner        = NULL; // 占用资源的线程
        _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
        _WaitSetLock  = 0 ;
        _Responsible  = NULL ;
        _succ         = NULL ;
        _cxq          = NULL ;
        FreeNext      = NULL ;
        _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
        _SpinFreq     = 0 ;
        _SpinClock    = 0 ;
        OwnerIsThread = 0 ;
      }

    synchronized锁在优化前就是一个地地道道的重量级锁,重量级锁的实现其实就是依赖于多线程争夺Monitor锁的拥有权,而Monitor锁的实现则依赖于操作系统底层的互斥原语mutex,因此每一次获取Monitor锁的时候都会经过用户态到内核态的切换,性能很低。这便是重量锁的由来。

    实现原理

    上面做了一些相关知识的介绍,可能还比较碎片化,接下来我们就通过加锁流程将所有信息都串联起来。

    1.5的锁

    在jdk1.6之前的锁就是单纯的Monitor锁,所以性能是很差的,1.6开始对锁做了优化,性能才得到提升。

    1.6的锁

    JVM内置锁在1.5之后版本做了如下重大的优化,在做了优化后,其性能显著提高,基本与ReentrantLock保持同等性能。

    • 锁粗化
    • 锁消除
    • 锁升级:轻量级锁 偏向锁 适应性自旋

    接下来由浅入深讲解优化内容

    1. 锁消除

    锁的消除,顾名思义就是将锁去除,因为有些场景下锁是可以去除的

    public void sync()  {
            Sync sync=new Sync();
            synchronized(sync){
    
            }
    }

    如上这种情况,我们知道进出一个方法就是当前线程栈的入栈出栈,所以方法内部只要不涉及共享资源操作就是线程安全的,如上这段代码,sync对象声明在方法内部,其引用是局部变量,是线程独享资源不是共享资源,是线程独有资源,随着出栈发生,对象也就销毁了,因此此处是可以不用加锁的,锁消除优化就是对这种情况进行去除锁的处理。

    jvm如何进行优化的呢?jvm在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,通过逃逸分析判断方法中的是否存在共享资源,如果无共享资源则去除不存在共享资源竞争的锁,从而节省请求锁时间。

    典型的案例是StringBuffer的使用,后续会讲解。

    通过上面我们知道锁消除依赖逃逸分析,逃逸分析是可以通过jvm参数配置的,如下:

    -XX:+DoEscapeAnalysis 开启逃逸分析

    -XX:+EliminateLocks 开启锁消除

    逃逸分析可以简单理解为分析资源是否能逃逸到其他方法或者其他线程中。

    2. 锁粗化

    顾名思义,把小范围的多个锁变成大范围少个锁。

    public Sync sync=new Sync();
    
    public void sync()  {
            
            synchronized(sync){
    
            }
            synchronized(sync){
    
            }
            synchronized(sync){
    
            }
    }

    上面的代码可以看出,一个逻辑被多次加同一把锁,每一次上锁都是会耗时的,所以完全可以把多个锁合并为一个锁,这样只需要上一次锁就可以了,大大节省了时间。

    同样jvm在即时编译的时候会扫描判断是否存在可以粗化的锁行为。

    3. 锁膨胀

    锁膨胀又叫锁升级。

    锁升级是锁优化后的锁机制,这个机制中包含这样几个概念:偏向锁,轻量级锁,适应性自旋,重量级锁。

    锁升级是依靠对象头的Mark Word来保存标志信息的,接下来以32位操作系统来看下锁升级过程中的对象头中运行时数据的变化。

    图片图片

    • 无锁状态 当没有任何线程进入的时候,此时处于无锁状态,Mark Word中会有25bit的空间大小留给hashcode,4bit的空间大小留给对象的分代年龄信息,1bit的空间大小是标识是否偏向(0否,1是),2bit的空间大小是锁标识位。

    此时锁标志位为01,是否偏向为0,代表无锁状态。但是此时并不一定有hashcode,因为hashcode是代码运行过程中调用生成方法才生成的,如果运行过程不调用就不会生成。

    请注意,hashcode的生成是会影响锁的升级过程的。

    • 偏向锁状态

    当第一个线程T1进入代码块后的步骤(前提条件是全程无hashcode生成)

  • 判断是否处于偏向中(通过Mark Word中的是否偏向判断)
  • 此时未处于偏向中,当前线程会将自己的线程id保存到Mark Word中,设置是否偏向为1,此时锁标志位依然是01
  • 此时Mark Word为:23bit的线程id,4bit的分代年龄,是否偏向为1,锁标识位依然为01。

    此时是偏向锁状态,它其实是一种特殊的无锁状态。

    上面的过程是建立在全程无hashcode生成的基础上,我们知道了hashcode会占用25bit,线程id会占用23bit,如果过程有hashcode生成怎么办,这里涉及到两个问题。

    第一个问题,T1进入前就已经生成了hashcode怎么处理?

    jvm的做法是如果偏向前已经生成hashcode,那么就放弃偏向,直接进入轻量级锁。

    第二个问题,T1进入后锁状态变为了偏向锁,此时生成hashcode怎么处理?

    jvm的做法是撤销偏向,直接进入重量级锁。

    所以我们在使用锁的时候要特别注意hashcode生成给锁升级带来的影响。

    • 轻量级锁状态

    当第二个线程T2进入代码块后

  • 判断是否处于偏向中(通过Mark Word中的是否偏向判断)
  • 如果处于偏向中,T2会以cas的方式试图将Mark Word中的线程id替换为自己的线程id
  • 如果T1已经执行完代码块,T2一定是可以替换成功的,此时锁依然是偏向锁状态
  • 如果T1没有执行完代码块,T2一定是替换不成功的,此时将进入偏向锁撤销升级为轻量级锁的过程
  • 首先T1会进入到安全点,T1和T2会在自己的栈空间开辟一块区域用于保存锁记录,同时复制一份Mark Word到这个锁记录中,同时cas的方式将自己栈空间这个锁记录的指针设置到Mark Word中去,因为T1持有偏向锁,所以T1会优先设置成功,此时Mark Word中有30bit的锁记录指针和2bit的锁标志位,此时的锁标志位为00代表轻量级锁,锁记录指针指向当前持有轻量级锁的线程中栈空间的地址。T2没有替换成功,将会进入不断轮询失败重试过程。
  • 轻量级锁是在资源竞争压力不是很大的情况下,避免每个线程都去获取锁而造成用户态到内核态的切换,这个切换是比较耗时的,这样就能提高性能,但是如果竞争压力大的情况下轻量级锁就不行了,因为压力大意味着有很多线程在轮序失败重试获取轻量级锁,短时间内会造成cpu压力飙升,甚至拖垮cpu,这个时候就必须升级为重量级锁。

    那么如何才算竞争压力大,什么时候会升级为重量级锁呢?

    jvm默认轮询次数限制值为十次,超过十次获取不到资源就代表竞争压力比较大了,用户也可以使用如下参数配置来自行更改这个次数

    -XX:PreBlockSpin

    但是有个问题,如果通过这个默认值或者这个jvm参数配置限制数量,那意味着jvm全系统的锁都要遵循,这个数字可能不适用于所有的锁,因此jvm引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了。

  • 重量级锁
  • 重量级锁就是Monitor锁,也叫监视器锁,其实现是依靠操作底层的互斥原语Mutes Lock,因为每一次获取Monitor锁都需要用户态到内核态的切换,所以比较耗时,也就是重量级锁的由来。当自旋的条件破坏后,比如自旋次数达到限制或者竞争的压力越来越大,将不再自旋,轻量级锁升级为重量级锁,当前对象头中的Mark Word被复制一份到Monitor对象中,Mark Word中原来的轻量级锁的锁记录指针被换成Monitor对象的指针,然后所有的线程会抢夺Monitor锁的拥有权,以cas方式将自己的线程id填充到Monitor对象的_owner字段,同时_count字段加1,当然此时能够cas成功的只会是原来持有轻量级锁的线程,而那些没有获取到Monitor锁的线程将会被阻塞并放入Monitor对象的_EntryList字段等待唤醒。

    此时锁的标志位为10,表示重量级锁。

    当线程退出Monitor锁,便会将Monitor锁中的_count减1,清空_owner,jvm会随机唤醒_EntryList集合中一个线程重新获取Monitor锁,这个随机便突出了synchronized的不公平性。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论