关于多线程:Synchronized同步锁

49次阅读

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

Synchronized 介绍

Synchronized 是由 JVM 实现的一种内置锁,锁的获取和开释都是由 JVM 隐式实现。
Synchronized 是基于底层操作系统的 Mutex Lock 实现的,每次获取和开释锁操作都会带来用户态和内核态的切换,从而减少零碎性能开销。

Synchronized 实现原理

通常 Synchronized 实现同步锁的形式有两种,一种是润饰办法,一种是润饰办法块。以下就是通过 Synchronized 实现的两种同步办法加锁的形式:

// 关键字在实例办法上,锁为以后实例
  public synchronized void method1() {// code}
  
  // 关键字在代码块上,锁为括号外面的对象
  public void method2() {Object o = new Object();
      synchronized (o) {// code}
  }

通过反编译能够看到具体字节码的实现,运行以下反编译命令,就能够输入咱们想要的字节码:

javac -encoding UTF-8 SyncTest.java  // 先运行编译 class 文件命令
javap -v SyncTest.class // 再通过 javap 打印出字节文件

通过输入的字节码,你会发现:Synchronized 在润饰同步代码块时,是由 monitorenter 和 monitorexit 指令来实现同步的。进入 monitorenter 指令后,线程将持有 Monitor 对象,退出 monitorenter 指令后,线程将开释该 Monitor 对象。

  public void method2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: new           #2                  
         3: dup
         4: invokespecial #1                  
         7: astore_1
         8: aload_1
         9: dup
        10: astore_2
        11: monitorenter //monitorenter 指令
        12: aload_2
        13: monitorexit  //monitorexit  指令
        14: goto          22
        17: astore_3
        18: aload_2
        19: monitorexit
        20: aload_3
        21: athrow
        22: return
      Exception table:
         from    to  target type
            12    14    17   any
            17    20    17   any
      LineNumberTable:
        line 18: 0
        line 19: 8
        line 21: 12
        line 22: 22
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [class com/demo/io/SyncTest, class java/lang/Object, class java/lang/Object]
          stack = [class java/lang/Throwable]
        frame_type = 250 /* chop */
          offset_delta = 4

再来看以下同步办法的字节码,你会发现:当 Synchronized 润饰同步办法时,并没有发现 monitorenter 和 monitorexit 指令,而是呈现了一个 ACC_SYNCHRONIZED 标记。这是因为 JVM 应用了 ACC_SYNCHRONIZED 拜访标记来辨别一个办法是否是同步办法。

当办法调用时,调用指令将会查看该办法是否被设置 ACC_SYNCHRONIZED 拜访标记。如果设置了该标记,执行线程将先持有 Monitor 对象,而后再执行办法。在该办法运行期间,其它线程将无奈获取到该 Mointor 对象,当办法执行实现后,再开释该 Monitor 对象。

   public synchronized void method1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED 标记
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 8: 0

通过以上的源码,咱们再来看看 Synchronized 润饰办法是怎么实现锁原理的。JVM 中的同步是基于进入和退出管程(Monitor)对象实现的。每个对象实例都会有一个 Monitor,Monitor 能够和对象一起创立、销毁。Monitor 是由 ObjectMonitor 实现,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp 文件实现,如下:

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

当多个线程同时拜访一段同步代码时,多个线程会先被寄存在 ContentionList 和 _EntryList 汇合中,处于 block 状态的线程,都会被退出到_EntryList 列表。接下来当线程获取到对象的 Monitor 时,Monitor 是依附底层操作系统的 Mutex Lock 来实现互斥的,线程申请 Mutex 胜利,则持有该 Mutex,其它线程将无奈获取到该 Mutex,竞争失败的线程会再次进入 ContentionList 被挂起。

Synchronized 的优化

为了晋升性能,JDK1.6 引入了偏差锁、轻量级锁、重量级锁概念,来缩小锁竞争带来的用户态和内核态的切换开销,而正是新增的 Java 对象头实现了锁降级性能。

Java 对象头

在 JDK1.6 JVM 中,对象实例在堆内存中被分为了三个局部:对象头、实例数据和对齐填充。其中 Java 对象头由 Mark Word、指向类的指针以及数组长度三局部组成。

Mark Word 记录了对象和锁无关的信息。Mark Word 在 64 位 JVM 中的长度是 64bit,咱们能够一起看下 64 位 JVM 的存储构造是怎么样的。如下图所示:

1、偏差锁

偏差锁次要用来优化同一线程屡次申请同一个锁的竞争。在某些状况下,大部分工夫是同一个线程竞争锁资源,例如,在创立一个线程并在线程中执行循环监听的场景下,或单线程操作一个线程平安汇合时,同一线程每次都须要获取和开释锁,每次操作都会产生用户态与内核态的切换。

偏差锁的作用就是,当一个线程再次拜访这个同步代码或办法时,该线程只需去对象头的 Mark Word 中去判断一下是否有偏差锁指向它的 ID,无需再进入 Monitor 去竞争对象了。

一旦呈现其它线程竞争锁资源时,偏差锁就会被撤销。偏差锁的撤销须要期待全局平安点,暂停持有该锁的线程,同时查看该线程是否还在执行该办法,如果是,则降级锁,反之则被其它线程抢占。

因而,在高并发场景下,当大量线程同时竞争同一个锁资源时,偏差锁就会被撤销,产生 stop the word 后,开启偏差锁无疑会带来更大的性能开销,这时咱们能够通过增加 JVM 参数敞开偏差锁来调优零碎性能,示例代码如下:
-XX:-UseBiasedLocking // 敞开偏差锁(默认关上)

2、轻量级锁

当有另外一个线程竞争获取这个锁时,因为该锁曾经是偏差锁,当发现对象头 Mark Word 中的线程 ID 不是本人的线程 ID,就会进行 CAS 操作获取锁,如果获取胜利,间接替换 Mark Word 中的线程 ID 为本人的 ID,该锁会放弃偏差锁状态;如果获取锁失败,代表以后锁有肯定的竞争,偏差锁将降级为轻量级锁。
轻量级锁实用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争。

3、自旋锁与重量级锁

轻量级锁 CAS 抢锁失败,线程将会被挂起进入阻塞状态。如果正在持有锁的线程在很短的工夫内开释资源,那么进入阻塞状态的线程无疑又要申请锁资源,从新申请锁资源会有肯定的开销,如果在进入非阻塞状态之前多试几次抢锁可能就抢到了。

JVM 提供了一种自旋锁,能够通过自旋形式一直尝试获取锁,从而防止线程被挂起阻塞。这是基于大多数状况下,线程持有锁的工夫都不会太长,毕竟线程被挂起阻塞可能会得失相当。

自旋锁重试之后如果抢锁仍然失败,同步锁就会降级至重量级锁,锁标记位改为 10。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet 队列中。

在锁竞争不强烈且锁占用工夫十分短的场景下,自旋锁能够进步零碎性能。一旦锁竞争强烈或锁占用的工夫过长,自旋锁将会导致大量的线程始终处于 CAS 重试状态,占用 CPU 资源,反而会减少零碎性能开销。所以自旋锁和重量级锁的应用都要结合实际场景。

在高负载、高并发的场景下,咱们能够通过设置 JVM 参数来敞开自旋锁,优化零碎性能,示例代码如下:

-XX:-UseSpinning // 参数敞开自旋锁优化(默认关上) 
-XX:PreBlockSpin // 参数批改默认的自旋次数。JDK1.7 后,去掉此参数,由 jvm 管制

动静编译实现锁打消 / 锁粗化

除了锁降级优化,Java 还应用了编译器对锁进行优化。JIT 编译器在动静编译同步块的时候,借助了一种被称为 逃逸剖析 的技术,来判断同步块应用的锁对象是否只可能被一个线程拜访,而没有被公布到其它线程。

确认是的话,那么 JIT 编译器在编译这个同步块的时候不会生成 synchronized 所示意的锁的申请与开释的机器码,即打消了锁的应用。在 Java7 之后的版本就不须要手动配置了,该操作能够主动实现。

锁粗化同理,就是在 JIT 编译器动静编译时,如果发现几个相邻的同步块应用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而 防止一个线程“重复申请、开释同一个锁”所带来的性能开销。

减小锁粒度

除了锁外部优化和编译器优化之外,咱们还能够通过代码层来实现锁优化,减小锁粒度就是一种习用的办法。

当咱们的锁对象是一个数组或队列时,集中竞争一个对象的话会十分强烈,锁也会降级为重量级锁。咱们能够思考将一个数组和队列对象拆成多个小对象,来升高锁竞争,晋升并行度。

最经典的减小锁粒度的案例就是 JDK1.8 之前实现的 ConcurrentHashMap 版本。咱们晓得,HashTable 是基于一个数组 + 链表实现的,所以在并发读写操作汇合时,存在强烈的锁资源竞争,也因而性能会存在瓶颈。而 ConcurrentHashMap 就很很奇妙地应用了分段锁 Segment 来升高锁资源竞争,如下图所示:

总结

JVM 在 JDK1.6 中引入了分级锁机制来优化 Synchronized,当一个线程获取锁时,首先对象锁将成为一个偏差锁,这样做是为了优化同一线程反复获取导致的用户态与内核态的切换问题;其次如果有多个线程竞争锁资源,锁将会降级为轻量级锁,轻量级锁实用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争;轻量级锁还应用了自旋锁来防止线程用户态与内核态的频繁切换,大大地进步了零碎性能;但如果锁竞争太强烈了,那么同步锁将会降级为重量级锁。

缩小锁竞争,是优化 Synchronized 同步锁的要害。咱们应该尽量使 Synchronized 同步锁处于轻量级锁或偏差锁,这样能力进步 Synchronized 同步锁的性能;

通过减小锁粒度来升高锁竞争也是一种最罕用的优化办法;另外咱们还能够通过缩小锁的持有工夫来进步 Synchronized 同步锁在自旋时获取锁资源的成功率,防止 Synchronized 同步锁降级为重量级锁。

正文完
 0