【深入多线程并发编程一】synchronized实现原理

synchronized实现原理.png

synchronized底层语义原理

    Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。下面先来了解一个概念Java对象头,这对深入理解synchronized实现原理非常关键。

java 对象的组成

  • java对象组成
    • 对象头: Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位,在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。他是实现synchronized的关键。synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽。
      (Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头

    • 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

    • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可

java对象头
长度 内容 说明
32/64bit Mark Word 存储对象的hashcode或锁信息等
32/64bit Class Metadata Address 存储到对象类型的数据指针
32/32bit Array length 数组长度(如果当前对象是数组)
对象头中Mark Word介绍
  • Mark Word存储对象的hashcode、分代年龄、锁标记位等,以32位虚拟机为例Mark Word的存储结构如下,

    锁状态 25bit 4bit 1bit是否为偏向锁 2bit所标识位
    无状态锁 对象的hashCode 对象的分代年龄 0 01

    Mark Word在运行期间会随着锁的标志位改变而改变,可能变化成如下的四种结构。

    锁状态 30bit 2bit锁标志位
    轻量级锁 指向栈中锁记录的指针 00
    锁状态 30bit 2bit锁标志位
    重量级锁 指向互斥量(重量级锁)的指针 10
    锁状态 30bit 2bit锁标志位
    GC标志 11
    锁状态 23bit 2bit 4bit分代年龄 1bit是否偏向锁 2bit锁标志位
    偏向锁 线程ID Epoch 对象分代年龄 1 01

锁的介绍

    锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级

  • 偏向锁

        HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程

    • 拓展介绍CAS简介:

          CAS(Compare and swap)比较和替换是设计并发算法时用到的一种技术。简单来说,比较和替换是使用一个期望值和一个变量的当前值进行比较,如果当前变量的值与我们期望的值相等,就使用一个新值替换当前变量的值。
      (概念的了解)

    • 偏向锁撤销:

          偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程

    • 偏向锁总结:

          偏向锁是一种针对加锁操作的优化手段,多数情况下锁不存在多线程竞争。而且多次由同一个锁获得,这样减少了同一线程获取锁的代价(CAS操作),如果一个线程获得了锁,那么对象进入偏向锁状态,此时Mark Word 的结构也变为偏向锁结构。当同一线程在此请求锁时,无需其他操作即可获取,所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,但是也不会直接就升级到重量级锁,而是先升级到轻量级锁。

【深入多线程并发编程一】synchronized实现原理_第1张图片
11.png
  • 轻量级锁

    • 轻量级锁加锁

          线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS操作将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

      • 拓展:自旋锁——轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了
    • 轻量级锁的解锁

          轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

【深入多线程并发编程一】synchronized实现原理_第2张图片
12.png
  • 重量级锁

        当轻量级锁膨胀为重量级锁的时候,就不会回到轻量级锁,当锁处于这个状态下的时候,其它线程尝试获得锁都会被阻塞。这也是为了避免不必要的自旋操作,因为自旋操作消耗cpu,当持有的线程释放锁之后唤醒其它线程,被唤醒的线程开始新一轮的争夺。

synchronized深入分析

    轻量级锁和偏向锁是Java 6 对 synchronized 锁进行优化后新增加的。而我们常说的synchronized的对象锁即重量级锁。锁标志位10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

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 ;
}

    ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示

【深入多线程并发编程一】synchronized实现原理_第3张图片
monitor

    由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因

  • 深入理解synchronized代码块
    public int add2(int a, int b) {
        synchronized (this){
            return a + b;
        }
    }

执行javap -v 类名.class能看到反编译后的结果

public int add2(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=5, args_size=3
         0: aload_0
         1: dup
         2: astore_3
         3: monitorenter
         4: iload_1
         5: iload_2
         6: iadd
         7: aload_3
         8: monitorexit
         9: ireturn
        10: astore        4
        12: aload_3
        13: monitorexit
        14: aload         4
        16: athrow

    从字节码中可知同步语句块的实现使用的是monitorenter和monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor,重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

  • 深入理解synchronized方法
    public synchronized int add(int a, int b) {
        return a + b;
    }

反编译结果:

 public synchronized int add(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=3, args_size=3
         0: iload_1
         1: iload_2
         2: iadd
         3: ireturn
      LineNumberTable:
        line 9: 0

    方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

    从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。

  • 锁的对比以及如何优化了synchronized?

    偏向锁使用的场景是只有一个线程访问同步块的场景,当同一线程获取同一个锁的时候无需额外的消耗,但是出现多线程竞争是会产生撤销锁的消耗。根据经验大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

    轻量锁适用的场景同步时间非常快,竞争过程不会阻塞。但是长时间获取不到会自旋获取锁消耗cpu。根据经验“对绝大部分的锁,在整个同步周期内都不存在竞争”,这也是轻量级锁的优化依据,也是决定其适用场景的依据。“如果存在同一时间访问同一锁的场合(下文有解释)”,就会导致轻量级锁膨胀为重量级锁。通俗点来说,就是刚获取一会就执行完了,也就是锁根本无法感知有其他线程参与竞争,即同步块执行速度快,一旦感知到存在其他线程竞争则就会膨胀成重量级锁。也就说如果在获取锁时自旋操作(很短时间100次左右)任然获取锁失败就表示存在竞争。自旋就是其感知的过程。在感知到之前同步块执行完就不会升级,他就认为自己成功获取锁,不存在竞争。
    重量级锁适用于同步块执行时间长,吞吐高,重量级锁不使用自旋不会消耗cpu,缺点就是线程阻塞,相应时间慢。

参考资料:

《java并发编程艺术》

《java多线程编程核心技术》

《深入理解Java虚拟机》

https://blog.csdn.net/javazejian/article/details/72828483

你可能感兴趣的:(【深入多线程并发编程一】synchronized实现原理)