avatar

36.Java并发之Synchronized原理简析

上篇文章,我们简单的深入了下J.U.C下面的Condition,在前面也有对Lock进行简要的分析,通过这两篇文章,相信大家对java中的锁有个概念和认识,那么今天我们就来简单分析下JVM中的锁的实现,即Synchronized。今天你会学到如下内容:

  1. Synchronized原理
  2. Synchronized优化分析(偏向锁、轻量级锁、重量级锁)

0x01.Synchronized的使用

  1. 相信大家都用过Synchronized对代码进行同步。我们知道它加在不同的地方,有不同的效果。一般有如下情况:

    • 加在静态方法

      1
      2
      3
      4
      //加在静态方法上
      public synchronized static void a(){

      }
      • 这时候相当于对整个类进行加锁,进入同步代码前要获取当前类对象的锁。
    • 加在普通方法上

      1
      2
      3
      4
      //加在普通方法上
      public synchronized void a(){

      }
      • 这时候相当于对调用该方法的实例对象进行加锁,也就是说进入同步代码前,需要获取到当前实例对象的锁。比如在t1和t2线程中,同时调用该方法,假设都是用同一个对象调用该方法,如果t1获取了锁,那么t2就得等待t1释放锁后才能获得锁。
    • 加在代码块上

      1
      2
      3
      4
      5
      public void a(){
      synchronized(this){
      ....
      }
      }
      • 这时候,就得根据锁对象的不同,从而获取指定的锁。如果是this,则进入代码,需要拿到当前实例对象的锁,如果是类.class,则需要获取到类对象的锁。

0x02.Synchronized的原理

  1. 锁是如何存储的?

    • 回顾下我们上次分析的AQS实现:我们知道会使用CAS对state字段进行赋值,也就是从0->1,只要成功了,就说明获取到了锁,并把获取锁的线程保存起来。我们知道,虽然J.U.C的实现方式和Synchronized的实现方式不一样,但是大体的思想还是相同的。所以肯定在一个什么地方,保存着标识锁的状态以及获取锁的线程。

    • 拿加在代码块上的Synchronized锁为例吧,他会要我们传一个参数,即一个对象,然后每个线程来获取锁的时候,都需要拿到这个对象的锁。既然这样的话,那么锁的状态和一些信息的存储是不是和对象有关系呢?这里就需要涉及到在内存中对象的布局结构了。

      • 对象的布局结构

        以Hotspot虚拟机为例,对象在内存中可以分为三个部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。网上找的图如下,原图地址:Java对象内存布局

        如图所见,对象头中有个区域是Mark Word,我们锁信息就是存储在这个区域。那么mark word这个区域是怎么存储锁信息的呢?它存储的数据如下:

        以上就是我们现在需要知道的在内存中对象的布局知识。这些都在Hostspot虚拟机源码中有体现。

      • 对象在Hotspot中的定义:我们在new一个对象的时候,jvm层面会创建一个instanceOopDesc对象。instanceOopDesc的定义在Hostspot源码中的instanceOopDesc.hpp中。我们稍微瞄一眼。代码如下:

        类中的东西我们不纠结,我也不会~。但是可以见该类是继承于oopDesc,对应文件是opp.hpp,我们点击过去看一眼。

        该类中有个私有的属性_mark,他的类型是markOop,这个属性就是我们对象头中的Mark Word。所以还是有必要再跟一下,对应的文件是markOop.hpp

        枚举里面对应的东西如下:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        age_bits         = 4, //分代年龄,占4个字节
        lock_bits = 2, //锁标识,占2个字节
        biased_lock_bits = 1, //偏向锁标识,占1个字节
        //最大的hash值的位数。在32bit的机器上,该值=32-4-2-1 = 25
        max_hash_bits = BitsPerWord - age_bits - lock_bits -biased_lock_bits,
        //hash值位数,如果在32bit的机器上,值为25,如果是64bit的机器上,就是31
        hash_bits = max_hash_bits > 31 ? 31 : max_hash_bits,
        cms_bits = LP64_ONLY(1) NOT_LP64(0),
        epoch_bits = 2 // 偏向锁时间戳
    • Mark Word在32位虚拟机上的长度是32bit,在64为虚拟机上的长度是64bit。Mark word里面存储的数据会随着锁标志位的变化而变化。可以分为如下几种情况

  2. 为什么任何对象都可以实现锁?

    1. 因为我们任何对象的基类都是Object类,而每个Object类在JVM层面都有一个OopDesc与之对应,而OopDesc中又有Mark word。所以任何对象都可以实现锁。

    2. 线程在获取锁的时候,实际上就是获取一个监视器对象monitor,这点我们把java文件转成字节码的时候可以看到有monitorenter。monitor可以看做一个同步对象,所有的java对象都会带monitor,这个可以在markOop.hpp中找到。如下:

0x03.Synchronized的升级(膨胀)机制

  1. 什么是Synchronized的升级(膨胀)机制?为什么会有这个东西?

    • 在jdk1.6之前,我们Synchronized都是使用重量级锁,即如果有两个线程去竞争锁,那么一个线程拿到锁,另外一个线程就直接阻塞,这样的处理是非常耗费性能的。

    • 为了解决Synchronized耗费性能的问题,在jdk1.6的时候,对Synchronized锁进行了一些优化,引入了偏向锁、轻量级锁。所以在jdk1.6的时候,锁有4中状态,即:

      • 无锁状态
      • 偏向锁状态
      • 轻量级锁状态
      • 重量级锁状态

      这几个状态会随着锁的竞争情况不断升级,即从刚开始的无锁一直升级到最终的重量级锁。注意:锁只能升级,不能降级,目的是为了提高获取锁和释放锁的效率。下面我们一起来看看他们是如何升级的吧。

  2. 无锁-->偏向锁

    1. 偏向锁简介

      • Hotspot虚拟机作者发现,大多数时候,加锁代码不仅仅不会发生多线程竞争,并且锁通常总是都是由一个线程获得。根据这一概率,所以在jdk1.6的时候引入了偏向锁,即这个锁是只偏向某一个线程。
      • 偏向锁是怎么工作的呢?首先我们一个线程获取锁的时候,会在Mark word中记录该线程的id,等下次这条线程再来的时候,就会去Mark word中匹配记录的线程id,如果发现记录的id和本身id相同,就说明该锁是偏向这个线程的,那么这线程就不会再去获取锁。
    2. 升级流程(偏向锁获取流程)

      1. 获取锁对象的Mark word,判断当前是否是偏向状态。那么什么是偏向状态呢?从上面的图来看,它的是否为偏向锁标记(biased_lock_bits)要为1,并且锁标识(lock_bits)为01
      2. 如果上面的条件成立,则说明是可偏向状态,那么就会使用CAS尝试把Mark word里面的线程id改成当前线程的Id,那么这里就会存在两种情况了。
        • 修改成功:说明该线程已经成功获取锁,可以直接执行同步代码块里面的代码了。
        • 修改失败:说明有其他线程早先一步设置成功,说明这个锁存在竞争,这个时候只能寻找全局安全点(没有线程在执行字节码的时候)的时候,对获取了偏向锁的线程进行撤销操作,也就是撤销偏向锁,把这个线程获取的锁升级成轻量级锁。
      3. 如果当前的状态是已偏向状态,那么就会拿Mark word中的线程ID和当前的线程Id进行比较。有下面的两种情况:
        • 相同:直接执行同步代码,相当于已经获取了锁。
        • 不相同:说明该锁已经偏向了其他线程,没办法,只能撤销偏向锁并升级锁为轻量级锁了。
    3. 撤销流程

      1. 什么是锁的撤销?为什么需要撤销锁

        • 偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,
          持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码。

        • 上面有说,如果两个线程竞争锁,这时候偏向锁就不能满足这种情景。既然不满足,那该怎么办呢?只能把锁的状态升级下。总的来说,我理解的锁撤销就是修改锁的状态,即从偏向锁状态改成轻量级锁状态,或者变为无锁状态,这里仅仅只是修改锁的状态,不涉及到轻量级锁的获取。

      2. 撤销锁(只会在全局安全点进行)的流程如下:

        1. 暂停拥有偏向锁的线程。
        2. 判断该线程是否还在临界区内(同步代码块内,即还在执行Synchronized包括的代码中)。
        3. 如果还在临界区则执行偏向锁升级轻量级锁的流程,把锁的状态从偏向锁状态升级为轻量级锁状态,然后恢复暂停的线程。;
        4. 如果线程已经执行完了同步代码块,那么就会把锁的状态变为无锁状态。然后恢复暂停的线程。
      3. 偏向锁的获取流程和撤销流程图(摘自Java并发编程的艺术):

    4. 在我们实际场景中,偏向锁的几率是很小很小,而偏向锁是会有一定的资源消耗,所以建议把偏向锁关闭掉,关闭后,程序会直接进入轻量级锁。配置如下:

      • 使用JVM参数:-XX: -UseBiasedLocking=false关闭偏向锁。
    5. 上面就是Synchronized从无锁状态变为偏向锁,再在有竞争的时候,撤销偏向锁的流程。下面我们就开始捋一捋偏向锁是怎么升级到轻量锁的。

  3. 偏向锁–>轻量级锁

    1. 轻量级锁获取流程

      • 在当前线程的栈帧中(每个线程有个独立的线程栈),开辟一个内存空间(Lock record)。图如下:

      • 把锁对象的对象头中的Mark Word copy一份到上面开辟的内存空间中。官方称为Displaced Mark Word。此时内存的图如下:

      • 使用CAS尝试把锁对象头中的Mark Word,替换为上面创建的内存空间(Lock record)地址,并将Lock record里面的owner指向锁对象。如果成功,则说明当前线程获取锁成功,其他线程自旋获取锁。相反,如果失败则说明该锁已经被其他线程获取,当前线程自旋获取锁。图如下:

    2. 自旋

      • 自旋,说白了就是一个死循环,在一直不断的尝试,直到达到某个条件,结束该死循环。

      • 可见,自旋需要消耗CPU资源。如果不限定一个条件让他结束循环,那么就会一直死循环下去,这样就会GG,玩不下去了。所以JVM中有个默认的自旋次数,默认10次,可以通过JVM参数preBlockSpin修改,一旦自旋的次数达到该值,还没获取锁,就会把该锁升级为重量级锁。

      • 在jdk1.6的时候,引入了自适应自旋锁,可以不需要手动指定自旋次数。JVM通过前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定次数,让自旋锁更加智能。

    3. 轻量级锁的撤销

      ​ 轻量级锁的撤销,其实就是获取锁的逆过程,也就是会通过CAS尝试把线程栈中的Lock record恢复到锁对象头中的Mark word。如果成功,则说明没有竞争发生,如果失败,则会膨胀为重量级锁。

    4. 轻量级锁的升级撤销流程图:

  4. 轻量级锁–>重量级锁

    • 一旦膨胀到了重量级锁,那么线程就得挂起阻塞了,

    • 上面我们说了,在每个对象中,都会持有一个monitor对象,重量锁就是依靠该对象实现的。下面我们来说说重量锁的获取流程。

      • 还是一样,假设有2个线程T1,T2,经历过上面的阶段,来到了重量级锁。

      • T1,T2抢占monitor对象。

      • 假设T2抢先一步获取到了monitor对象。那么T1,就得加入到一个队列(同步队列)中,等待T2释放锁后唤醒。这里就和AQS很像了。所以说,Synchronized和AQS思想是相通的。大致图如下:

      • 同步队列定义在objectMonitor.hpp中。如图:

  5. 最后,让我们来回顾下整个锁的竞争机制吧。

    1. 代码块如下,然后我们有两个线程T1、T2去竞争。那么会有如下几种情况:

      1
      2
      3
      4
      // T1 / T2
      synchronized (lock) {
      // do something
      }
      • 在同一时间,只有T1或者T2进入临界区(Synchronized方法体)。那么这种情况就不会存在竞争。
      • 在同一时间,T1和T2同时进入临界区,那么这时候就会有竞争,但是这种竞争我我们假设不激烈。
      • 在同一时间,T1和T2同时进入临界区,竞争激烈。

      那么,针对这几种情况,我们看看Synchronized的各种锁的竞争机制

    2. 偏向锁

      • 此时T1进入临界区,JVM会锁对象的mark word的锁标记置为01,同时会通过CAS在mark word中记录T1的线程id,此时会进入偏向锁模式。
      • 所谓“偏向”,指的是这个锁会偏向于 Thread#1,若接下来没有其他线程进入临界区,则 Thread#1 再出入临界区无需再执行任何同步操作。也就是说,若只有Thread#1 会进入临界区,实际上只有 Thread#1 初次进入临界区时需要执行 CAS 操作,以后再出入临界区都不会有同步操作带来的开销。
    3. 轻量级锁

      • 我们知道,偏向锁在实际场景中,几乎不可能,更多的是T1、T2一先一后进入临界区。
      • 那么这时候,如果T1先获取锁,还没执行完代码时T2进来了,这时候就会暂停T1的线程,把锁状态改成轻量级锁01,并且T2就只能自旋尝试获取锁。
    4. 重量级锁

      • 如果T1和T2交替执行,那么轻量级锁完全可以满足这个场景。但是,假设T1和T2同时进入临界区,那么这时候就只能把没有获取锁的线程给挂起。

0x04.总结

  1. 最后的最后,来一张流程图来说明锁的升级流程。图摘自Java Synchronised机制

  1. 参考资料和博客


评论