synchronized

首先讲一下原子性以及互斥。

举个例子,在32位CPU上执行long(64位)变量的写操作时,会存在多线程下读写不一致的问题。

因为32位CPU下对其写会拆分成两次操作,一次写高32位和一次写底32位,而这个操作无法保证其原子性所以产生并发问题了。

 

原子性

指即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行,简单的说就是一个或者多个操作在 CPU 执行的过程中不被中断的特性。

 

互斥

如果同一时刻只有一个线程执行则被称之为互斥,把一段需要互斥执行的代码称为临界区。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。

 

 互斥锁

锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是一种互斥锁。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,通过其修饰的临界区是互斥的,使用方法如下:

public class demo{
    
    private final Object monitor = new Object();

    // 修饰非静态方法
    synchronized void method1() {
        // 临界区
    }

    // 修饰静态方法
    synchronized static void method2() {
        // 临界区
    }

    // 修饰代码块
    void method3() {
        synchronized(monitor) {
            // 临界区
        }
    }

}

当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 demo.class;

当修饰非静态方法的时候,锁定的是当前实例对象 this

而修饰代码块的锁对象是自定义的对象,注意这个对象需要保证不可变,否则会出现不同步的情况。

其中要注意受保护资源和锁之间的关联关系是 N:1 的关系,意思是用一把锁保护多个资源,比如下例代码就有问题了:

public class demo2 {
    private static long value = 0L;
    synchronized long get() {
        return value;
    }
    synchronized static void add() {
        value ++;
    }
}

此处get方法和add方法的锁对象其实是两个,自然两个临界区就没有互斥关系了,所以存在并发问题。

 

实现原理

对第一个互斥锁的代码例子使用javap工具进行分析。

public class demo
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #2.#20         // java/lang/Object."<init>":()V
   #2 = Class              #21            // java/lang/Object
   #3 = Fieldref           #4.#22         // demo.monitor:Ljava/lang/Object;
   #4 = Class              #23            // demo
   #5 = Utf8               monitor
   #6 = Utf8               Ljava/lang/Object;
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               method1
  #12 = Utf8               method2
  #13 = Utf8               method3
  #14 = Utf8               StackMapTable
  #15 = Class              #23            // demo
  #16 = Class              #21            // java/lang/Object
  #17 = Class              #24            // java/lang/Throwable
  #18 = Utf8               SourceFile
  #19 = Utf8               demo.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Utf8               java/lang/Object
  #22 = NameAndType        #5:#6          // monitor:Ljava/lang/Object;
  #23 = Utf8               demo
  #24 = Utf8               java/lang/Throwable
{
  public demo();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: new           #2                  // class java/lang/Object
         8: dup
         9: invokespecial #1                  // Method java/lang/Object."<init>":()V
        12: putfield      #3                  // Field monitor:Ljava/lang/Object;
        15: return
      LineNumberTable:
        line 1: 0
        line 3: 4

  synchronized void method1();
    descriptor: ()V
    flags: ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 5: 0

  static synchronized void method2();
    descriptor: ()V
    flags: ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=0, args_size=0
         0: return
      LineNumberTable:
        line 7: 0

  void method3();
    descriptor: ()V
    flags:
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: getfield      #3                  // Field monitor:Ljava/lang/Object;
         4: dup
         5: astore_1
         6: monitorenter
         7: aload_1
         8: monitorexit
         9: goto          17
        12: astore_2
        13: aload_1
        14: monitorexit
        15: aload_2
        16: athrow
        17: return
      Exception table:
         from    to  target type
             7     9    12   any
            12    15    12   any
      LineNumberTable:
        line 10: 0
        line 11: 17
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 12
          locals = [ class demo, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
}

  

javap是jdk自带的反解析工具。它的作用就是根据class字节码文件,反解析出当前类对应的code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。

所以先要使用javac demo.java编译,生成字节码文件后用javap -verbose demo进行反编译生成汇编代码。

可以看到method3()同步块的方法中,有两个指令monitorenter和monitorexit:

monitorenter:线程执行monitorenter指令时尝试获取monitor的所有权(当monitor被占用时就会处于锁定状态),而monitor可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被 描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象就带了一把看不见的锁,它叫做内部锁或者Monitor锁

过程如下:

  • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
  • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
  • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;

所以synchronized也是一种可重入锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞,这就是通过上述的计数器实现的,当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁。

monitorexit:执行monitorexit的线程必须是对象引用所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

为什么会有两个monitorexit指令?查了下stackoverflow,如果同步块正常退出释放锁使用第一个monitorexit,如果同步块中出现Exception或者Error,将使用第二个monitorexit。

通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
但是method1和2是synchronized修饰的同步方法,并非通过monitor实现,可以看到常量池中多了ACC_SYNCHRONIZED的标识符,JVM就是根据该标示符来实现方法的同步的:
如果该方法设置了ACC_SYNCHRONIZED标识符如果设置了则先获取monitor,获取成功后然后执行方法,执行完释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象,这就实现了互斥。

 

JAVA对象头

synchronized的锁实际上是存在java对象头中的,而因为Java面向对象的思想,在JVM中需要大量存储对象,存储时为了实现一些额外的功能,需要在对象中添加一些标记字段用于增强对象功能,这些标记字段组成了对象头。那么在Hotspot虚拟机中,对象头实际包含如下部分:

Class Pointer(类型指针):存储对象类型数据的指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。它的字段位长度也与JVM有关;

Array Length(数组长度):如果是数组对象,那么还需要有额外的空间用于存储数组的长度。

Mark Word(标记字段):默认存储对象自身的运行时数据,例如HashCode、分代年龄和锁信息等。该字段位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位,如下图所示:

其中1位区分是否偏向锁,最后两位存储了锁的标志位。

在Java 6之前,Monitor的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。

为了减少获得锁和释放锁带来的性能消耗,1.6后引入了锁的概念,提供了三种不同的Monitor实现,也就是常说的三种不同的锁:偏向锁(Biased Locking)、轻量级锁和重量级锁,很大程度提升了性能。下面介绍下这三种锁:

偏向锁

由于多数情况下,锁不仅不存在多线程竞争而且总是由同一个线程多次获得,为了减少资源损耗所以引入偏向锁。在HotSpot的虚拟机中,当一个线程访问同步块并尝试获取锁时,会在对象头和栈帧中的记录里存储锁的偏向的线程ID,以后该线程进入和退出同步块的代码时候,不需要通过CAS操作进行加锁解锁 ,只需要检测下MarkWord中的是否存储着指向当前线程的偏向锁。如果检测成功,则说明已经获得了锁,如果检测不成功,则需要测试MarkWord中偏向锁的标识是非设置成1。

 

轻量级锁

当关闭偏向锁功能(通过JVM参数关闭:-XX:-UseBiasedLocking=false)或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,

加锁步骤如下:线程在执行同步块之前,JVM会现在当前的线程的栈帧中创建用于存储锁记录的空间,并肩对象头的MarkWord复制到锁的记录中,官方称为Displaced Mark Word。因为在申请对象锁时 需要以该值作为CAS的比较条件,同时在升级到重量级锁的时候,能通过这个比较判定是否在持有锁的过程中此锁被其他线程申请过,如果被其他线程申请了,则在释放锁的时候要唤醒被挂起的线程。。然后线程尝试使用CAS操作将对象头MarkWord替换为指向锁记录的指针,如果不成功,说明锁存在竞争,当前线程通过自旋等来获取锁。

解锁:轻量级锁解锁时会通过CAS将Displaced Mark Word替换回对象头,如果成功就表示当前锁没有竞争,失败了就表示存在竞争,会升级成重量级锁。

 

重量级锁

依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”。synchronized是通过Monitor锁来实现的,其本质就是依赖于底层的操作系统的Mutex Lock来实现的,所以synchronized就是重量级锁。而因为Mutex Lock会使用户态切换至核心态,开销大消耗时间较长,这也是其性能不高的原因。

 

除了上述三种锁之外,还有其他的优化:

自旋锁

竞争锁的失败的线程,并不会真实的在操作系统层面挂起等待,而是JVM会让线程做几个空循环(基于预测在不久的将来就能获得),在经过若干次循环后,如果可以获得锁,那么进入临界区,如果还不能获得锁,才会真实的将线程在操作系统层面进行挂起。
适用场景:自旋锁可以减少线程的阻塞,这对于锁竞争不激烈,且占用锁时间非常短的代码块来说,有较大的性能提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。 如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,线程自旋的消耗大于线程阻塞挂起操作的消耗,造成cpu的浪费。
 
 
总的来说,所谓锁的升级、降级,就是JVM优化synchronized运行的机制,当JVM检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。当没有竞争出现时,默认会使用偏斜锁。JVM会利用CAS操作,在对象头上的Mark Word部分设置线程ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。 如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM就需要撤销(revoke)偏斜锁,并切换到轻量级锁实现。轻量级锁依赖CAS操作Mark Word来试图获取锁,如果重试成 功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。

 

参考资料

《JAVA并发编程的艺术》

JVM源码分析之synchronized实现

深入理解Java并发之synchronized实现原理

https://juejin.im/post/5b4eec7df265da0fa00a118f

posted @ 2019-07-02 22:41  morphの  阅读(322)  评论(0编辑  收藏  举报