Synchronized的讲解

536 阅读11分钟

Synchronized关键字的介绍

Synchronized关键字用于解决多个线程之间访问资源的同步性,Synchronized关键字可以保证修饰的方法或代码块在任意时刻只能有一个线程执行,Synchronized是JVM实现的一种内置锁,锁的获取和释放是由JVM隐式实现的。在早期版本Synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖底层操作系统的Mutext Lock实现的,每次获取和释放锁操作都会带来用户态和内核态的切换。从而增加系统性能开销。因此,在锁竞争激烈的情况下,Synchronized同步锁在性能上就表现得非常糟糕,也常被大家成为重量级锁。但是在Java1.6之后Java官方从JVM层面对Synchronized有较大的优化,所以现在Synchronized所效率也优化得很不错,JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

Synchronized的使用

Synchronized关键字主要有三种使用方式

  • 修饰实例方法:作用于当前对象实例加锁,进入同步代码块前要获得当前对象实例的锁。
  • 修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员(static表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用了一个实例对象的非静态synchronized方法,而线程B需要调用这个实例对象所属类的静态synchronized方法,是允许的,不会发生互斥现象,因为访问静态synchronized方法占用的锁是当前类的锁,而访问静态synchronized方法占用的锁是当前实例对象锁。
  • 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁
// 关键字在实例方法上,锁为当前实例
  public synchronized void method1() {
      // code
  }
  
  // 关键字在代码块上,锁为括号里面的对象
  public void method2() {
      Object o = new Object();
      synchronized (o) {
          // code
      }
  }

总结:synchronized关键字加到static静态方法和synchronized(class)代码块上都是给Class类上锁。synchronized关键字加到实例方法上是给对象实例上锁。尽量不要使用synchronized(String a)因为JVM中,字符串常量池具有缓冲功能!

下面用双重校验锁实现单例例子讲解synchronized关键字的具体作用。

public class Singleton {

    //volatile防止指令重排序(volatile的详解:)
    private volatile static Singleton instance;

    private Singleton() {
    }

    public static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (instance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Synchronized底层原理

下面我们以SyncTest.java反编译后的字节码来讲解Synchronized的底层原理

public class SyncTest {
    //关键字在实例方法上,锁当前实例
    public synchronized void method1(){
        
    }
    
    //关键字在代码块上,锁为括号里的对象
    public void method2(){
        Object o = new Object();
        synchronized (o){
            
        }
    }
}

运行以下反编译命令,就可以输出我们想要的字节码:

javac -encoding UTF-8 SyncTest.java //先运行编译class文件命令

javap -v SyncTest.class //再通过javap打印出字节文件

以下是输出的完整字节码文件:

Classfile /Users/storage/code/ideaworkplaces/fhero-grow/fhero-core/src/main/java/com/github/fhero/core/processEngine/SyncTest.class
  Last modified 2019-6-2; size 451 bytes
  MD5 checksum 36d3f114dd97a27b4a7d46a03d4da3ce
  Compiled from "SyncTest.java"
public class com.github.fhero.core.processEngine.SyncTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #2.#16         // java/lang/Object."<init>":()V
   #2 = Class              #17            // java/lang/Object
   #3 = Class              #18            // com/github/fhero/core/processEngine/SyncTest
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               method1
   #9 = Utf8               method2
  #10 = Utf8               StackMapTable
  #11 = Class              #18            // com/github/fhero/core/processEngine/SyncTest
  #12 = Class              #17            // java/lang/Object
  #13 = Class              #19            // java/lang/Throwable
  #14 = Utf8               SourceFile
  #15 = Utf8               SyncTest.java
  #16 = NameAndType        #4:#5          // "<init>":()V
  #17 = Utf8               java/lang/Object
  #18 = Utf8               com/github/fhero/core/processEngine/SyncTest
  #19 = Utf8               java/lang/Throwable
{
  public com.github.fhero.core.processEngine.SyncTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 10: 0

  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 14: 0

  public void method2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: new           #2                  // class java/lang/Object
         3: dup
         4: invokespecial #1                  // Method java/lang/Object."<init>":()V
         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/github/fhero/core/processEngine/SyncTest, class java/lang/Object, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
}
SourceFile: "SyncTest.java"

通过输出的字节码文件,我们可以发现,Synchronized在修饰同步代码块的时候,是由monitorenter和monitorexit指令来实现同步的。进入monitorenter指令后,线程持有Monitor对象,退出monitorenter指令后,线程将释放Monitor对象。同步方法的字节码没有使用monitorenter和monitorexit指令,而是出现了一个ACC_SYNCHRONIZED标志。这是因为JVM使用了ACC_SYNCHRONIZED访问标志来区分一个方法是否是同步方法。当方法调用时,调用指令将会检查该方法是否被设置ACC_SYNCHRONIZED访问标志。如果设置了该标志,执行线程将先持有Monitor对象,然后在执行方法。在该方法运行期间,其他线程将无法获取到Monitor对象,当方法执行完成后,再释放该Monitor对象。

通过以上的源码,我们再来看看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状态的线程,都会被加入到该列表。接下来当线程获取到对象的Monitor时,Monitor是依靠地城操作系统的Mutex Lock来实现互斥的,线程申请Mutex成功,则持有该Mutex,其他线程将无法获取到该Mutex,竞争失败的线程会再次进入ContentionList被挂起。 如果线程调用wait()方法,就会释放当前持有的Mutex,并且该线程会进入WaitSet集合中,等待下一次被唤醒。如果当前线程顺利执行完方法,也将释放Mutex。

同步锁在这种实现方式中,因Monitor是依赖于底层的操作系统实现,存在用户态与内核态之间的切换,所以增加了性能开销。

Synchronized底层的优化

为了提升性能,JDK1.6引入了偏向锁、轻量级锁、重量级锁概念,来减少锁竞争带来的上下文切换,而且正是新增的Java对象头实现了锁升级功能。当Java对象被Synchronized关键字修饰成为同步锁后,围绕这个锁的一系列升级操作都和Java对象头有关。

Java对象头

在jdk 1.6 JVM中,对象实例在堆内存中被分为三个部分:对象头、实例数据和对齐填充。其中Java对象头由Mark Word、指向类的指针以及数组长度三部分组成。 Mark Word在64位JVM中的长度是64bit,如图所示是64位JVM的存储结构:

锁升级功能主要依赖于Mark Word中的锁标志位和释放偏向锁标志位,Synchronized同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。

偏向锁

偏向锁主要用来优化同一个线程多次申请同一个锁的竞争。在某些情况下,大部分时间是同一个线程竞争锁资源,例如,在创建一个线程并在线程中执行循环监听的场景下,或单线程操作一个线程安全集合时,同一个线程每次都需要获取和释放锁,每次操作都会发生用户态和内核态的切换。

偏向锁的作用就是,当一个线程再次访问这个同步代码或者方法时,该线程只需去对象头的Mark Word中去判断一下是否有偏向锁指向它的ID,无需再进入Monitor去竞争对象了。当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是01,“是否偏向锁”标志位设置为1,并且记录抢到锁的线程ID,表示进入偏向锁状态。

一旦出现其他线程竞争锁资源时,偏向锁就会被撤销。偏向锁的撤销需要等待全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行该方法,如果是,则升级锁,反之被其他线程抢占。

下图中红线流程部分为偏向锁获取和撤销流程:

因此,在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生stop the word后,开启偏向锁无疑会带来更大的性能开销,这时我们可以通过添加JVM参数关闭偏向锁来调优系统性能,如下所示:

-XX:-UseBiasedLocking //关闭偏向锁(默认打开)

-XX:+UseHeavyMonitors //设置重量级锁

轻量级锁

当有另外一个线程竞争获取这个锁时,由于该所已经是偏向锁,当发现对象头Mark Word中的线程ID不是自己的线程ID,就会进行CAS操作获取锁,如果获取锁成功,直接替换Mark Word中的线程ID为自己的ID,该锁会保持偏向锁装填;如果获取锁失败,代表当前锁有一定的竞争,偏向锁将会升级为轻量级锁。

轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争。

下图中红线流程部分为升级轻量级锁及操作流程:

自旋锁与重量级锁

轻量级锁CAS抢锁失败,线程将会被挂起进入阻塞状态。如果正在持有锁的线程在很短的时间内释放资源,那么进入阻塞状态的线程无疑又要申请锁资源。

JVM提供了一中自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞会得不偿失。

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

下图中红线流程为自选后升级为重量级锁的流程:

在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。一旦锁竞争激烈或锁占用的时间过长,自旋锁将会导致大量的线程一直处于CAS重试状态,占用CPU资源,反而会增加系统性能开销。所以自旋锁和重量级锁的使用都要结合实际场景。

在高负载、高并发的场景下,可以设置JVM参数来关闭自旋锁,优化系统性能,示例代码如下所示:


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

Synchronized和Lock的区别