并发编程-从字节码到HotSpot彻底搞定Synchronized

·  阅读 455

一、Synchronized的简单介绍

相信用过java的同学对Synchronized都不陌生,它是一个代码同步器,可以修饰普通方法、静态方法和代码块,而锁的粒度不太一样。

  • 修饰普通方法:锁的对象是当前调用这个方法的对象。不同的对象之间没有竞争关系

  • 修饰静态方法:锁的对象是这个class类, 不同的对象之间也存在竞争关系

  • 修饰代码块:锁的是synchronized (object)里面的这个对象。

在多线程的环境下,当多个线程并发去操作同一个共享资源时,可能会出现线程安全问题,看一段代码:

public class MyTest {

    private static int a = 0;

    public static void main(String[] args) {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        for (int i=0;i<100;i++){
            new Thread(){
                @Override
                public void run() {
                    try {
                        countDownLatch.await();
                        for(int m=0;m<10000;m++){
                            a++;
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }.start();
        }

        countDownLatch.countDown();

        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.err.println(a);
    }
}
复制代码

多次尝试之后的运行结果:

983386
复制代码

这里我们开了100个线程(实际上开不了那么多,这个先略),每个线程对a加10000次,想当然的结果应该是1000000。为什么会出现少算的情况呢?即为什么会发生线程安全的问题?

1. 什么是线程安全问题?

上篇文章中讲到了JMM内存模型,每个线程都有自己的工作内存,对某一个共享变量进行操作的话,必须要从主内存read、load到工作内存,而在并发环境下,有可能出现多个线程同时从主内存中把 a=?读到工作内存,那既然这些线程读到的值都是一样的,执行a+1运算之后再store、write回主内存,是不是就出现了少算的情况了,这就是我们经常说的线程安全问题。

试想一下,如果我们工作中的核心代码出现了线程安全问题,那可完蛋了。正好我最近在做一个下单相关的需求(假设是单结点的),如果库存少扣了,那又会引起一大波客诉

2. 怎么避免线程安全问题?

  1. 本文说的 Synchronized,它可以锁方法,锁代码块。
  2. ReentrantLock, 它是AQS中的一种悲观锁,拥有排他、可重入、公平与非公平的特性。
  3. CAS操作,全称是Compred and Swap(比较交换),它是一种无锁技术,下篇文章讲AQS的时候会详细介绍。

作为一个好奇宝宝,当然是想知道Synchronized的底层是如何实现的呀

那么,小可爱!!!请带着你的好奇,往下看吧。

二、Synchronized的底层原理

既然 Synchronized是一个修饰符,那么我们肯定要看看编译之后的字节码

以简单代码为例:

1. 锁方法

public class MyTest {

    private static int a = 0;

    public synchronized static void main(String[] args) {
        a++;
    }
}
复制代码

我们通过javap -v MyTest.class得到一个比较易读的字节码文件,发现MyTest这个类的flag多了一个修饰符ACC_SYNCHRONIZED(静态方法会多一个ACC_STATIC),如下:

public class com.example.spring.jvmTest.MyTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#21         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#22         // com/example/spring/jvmTest/MyTest.a:I
   #3 = Class              #23            // com/example/spring/jvmTest/MyTest
   #4 = Class              #24            // java/lang/Object
   #5 = Utf8               a
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/example/spring/jvmTest/MyTest;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               <clinit>
  #19 = Utf8               SourceFile
  #20 = Utf8               MyTest.java
  #21 = NameAndType        #7:#8          // "<init>":()V
  #22 = NameAndType        #5:#6          // a:I
  #23 = Utf8               com/example/spring/jvmTest/MyTest
  #24 = Utf8               java/lang/Object
{
  public com.example.spring.jvmTest.MyTest();
    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 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/example/spring/jvmTest/MyTest;

  public static synchronized void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field a:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field a:I
         8: return
      LineNumberTable:
        line 8: 0
        line 9: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;

  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: iconst_0
         1: putstatic     #2                  // Field a:I
         4: return
      LineNumberTable:
        line 5: 0
}
复制代码

2. 锁代码块

public class MyTest {

    private static int a = 0;

    private static Object object = new Object();

    public static void main(String[] args) {
        synchronized (object){
            System.err.println(++a);
        }
    }
}
复制代码

我们通过javap -v MyTest.class发现锁的那个代码块的入口会多一个monitorenter的指令,出口多了一个monitorexit的指令,如下:

public class com.example.spring.jvmTest.MyTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#30         // java/lang/Object."<init>":()V
   #2 = Fieldref           #7.#31         // com/example/spring/jvmTest/MyTest.object:Ljava/lang/Object;
   #3 = Fieldref           #32.#33        // java/lang/System.err:Ljava/io/PrintStream;
   #4 = Fieldref           #7.#34         // com/example/spring/jvmTest/MyTest.a:I
   #5 = Methodref          #35.#36        // java/io/PrintStream.println:(I)V
   #6 = Class              #37            // java/lang/Object
   #7 = Class              #38            // com/example/spring/jvmTest/MyTest
   #8 = Utf8               a
   #9 = Utf8               I
  #10 = Utf8               object
  #11 = Utf8               Ljava/lang/Object;
  #12 = Utf8               <init>
  #13 = Utf8               ()V
  #14 = Utf8               Code
  #15 = Utf8               LineNumberTable
  #16 = Utf8               LocalVariableTable
  #17 = Utf8               this
  #18 = Utf8               Lcom/example/spring/jvmTest/MyTest;
  #19 = Utf8               main
  #20 = Utf8               ([Ljava/lang/String;)V
  #21 = Utf8               args
  #22 = Utf8               [Ljava/lang/String;
  #23 = Utf8               StackMapTable
  #24 = Class              #22            // "[Ljava/lang/String;"
  #25 = Class              #37            // java/lang/Object
  #26 = Class              #39            // java/lang/Throwable
  #27 = Utf8               <clinit>
  #28 = Utf8               SourceFile
  #29 = Utf8               MyTest.java
  #30 = NameAndType        #12:#13        // "<init>":()V
  #31 = NameAndType        #10:#11        // object:Ljava/lang/Object;
  #32 = Class              #40            // java/lang/System
  #33 = NameAndType        #41:#42        // err:Ljava/io/PrintStream;
  #34 = NameAndType        #8:#9          // a:I
  #35 = Class              #43            // java/io/PrintStream
  #36 = NameAndType        #44:#45        // println:(I)V
  #37 = Utf8               java/lang/Object
  #38 = Utf8               com/example/spring/jvmTest/MyTest
  #39 = Utf8               java/lang/Throwable
  #40 = Utf8               java/lang/System
  #41 = Utf8               err
  #42 = Utf8               Ljava/io/PrintStream;
  #43 = Utf8               java/io/PrintStream
  #44 = Utf8               println
  #45 = Utf8               (I)V
{
  public com.example.spring.jvmTest.MyTest();
    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 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/example/spring/jvmTest/MyTest;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=3, args_size=1
         0: getstatic     #2                  // Field object:Ljava/lang/Object;
         3: dup
         4: astore_1
         5: monitorenter
         6: getstatic     #3                  // Field java/lang/System.err:Ljava/io/PrintStream;
         9: getstatic     #4                  // Field a:I
        12: iconst_1
        13: iadd
        14: dup
        15: putstatic     #4                  // Field a:I
        18: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
        21: aload_1
        22: monitorexit
        23: goto          31
        26: astore_2
        27: aload_1
        28: monitorexit
        29: aload_2
        30: athrow
        31: return
      Exception table:
         from    to  target type
             6    23    26   any
            26    29    26   any
      LineNumberTable:
        line 10: 0
        line 11: 6
        line 12: 21
        line 13: 31
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      32     0  args   [Ljava/lang/String;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 26
          locals = [ class "[Ljava/lang/String;, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: iconst_0
         1: putstatic     #4                  // Field a:I
         4: new           #6                  // class java/lang/Object
         7: dup
         8: invokespecial #1                  // Method java/lang/Object."<init>":()V
        11: putstatic     #2                  // Field object:Ljava/lang/Object;
        14: return
      LineNumberTable:
        line 5: 0
        line 7: 4
}
SourceFile: "MyTest.java"
复制代码

3. 总结

从编译的结果来看

  1. 同步代码块是通过monitorentermonitorexit来实现的,每个同步对象都有一个自己的Monitor,加锁过程如下: image.png

  2. 同步方法则是通过加了ACC_SYNCHRONIZED修饰,JVM在调用该类的方法时,会先检查该类是否被ACC_SYNCHRONIZED修饰,如果是,执行线程会先获取Monitor,获取成功才能执行对应方法,执行完之后再释放Monitor,在方法的执行期间,其他线程都无法获取同一个Monitor对象,被阻塞的线程会被挂起,等待CPU重新调度,而这个过程会导致其他线程在用户态和内核态直接来回切换,对性能影响较大。

Synchronized是基于JVM内置锁Monitor实现,通过进入与退出Monitor实现方法与代码块的同步。而Monitor的底层又依赖于操作系统的Mutex Lock(互斥锁),Mutex Lock是一种重量级的锁,当然性能也较低。所以JVM在1.5版本之后做了重大优化,如:锁粗化,锁消除,锁的膨胀升级等技术来减少锁的开销。

4. Monitor到底是什么?

Java是一种面向对象的语言,那么Monitor也不例外,在HotSpot虚拟机中,Monitor被定义为ObjectMonitor, 其源码如下(C++实现的):

image.png

ObjectMonitor中维护了两个集合,WaitSet(处于wait状态的线程会被加入到其中)和EntryList(处于等待锁而阻塞的线程被加入到其中)用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter),owner指向指针指向当前持有ObjectMonitor对象的线程,多线程访问同步代码时,具体步骤如下:

  1. 首先进入EntryList集合,当线程获取到对象的monitor后,owner指针指向获取到monitor的当前线程,同时把Monitor中的计数器count加1。
  2. 若线程调用wait()方法,会释放掉当前持有的Monitor,owner指针指向null,count减1,同时该线程进入到WaitSet等待被唤醒。
  3. 若当前线程执行完毕,也会释放掉当前持有的Monitor,owner指针指向null,count减1,以便其他线程进入获取Monitor

注意:notify/notifyAll/wait等方法也会使用到Monitor,所以必须在同步代码块中使用。

三、JVM对Synchronized的优化

1.Synchronized与AQS的前世今生

大家有没有想过,为什么JDK内置了两个同步锁?既然有了Synchronized为什么还要AQS?

经过查阅资料发现,JDK5之前Synchronized是一把重量级的锁,性能较低,针对这个问题,dog li(道格丶李)就手写了一套GUI的并发包,其中AQS性能远超Synchronized,后来JDK被oracle收购之后,oracle公司强大可想而知,不甘平凡,后面对Synchronized做了极大的优化(就是我们下文要讲的),其性能和AQS已经相差不多了。后面GUI包也被oracle收购了,然后就造就了今天JDK的两个内置的并发工具

2.自旋锁和自适应性自旋锁

自旋:当线程A去请求获取锁的时候,这个锁正在被其它线程占用,但是线程A并不会马上进入阻塞状态,而是循环请求锁(自旋)。这样做的目的是因为很多时候持有锁的线程会很快释放锁的,线程A可以尝试一直请求锁,没必要被挂起放弃CPU时间片,因为线程被挂起然后到唤醒这个过程开销很大(需要经历多次用户态、内核态之间切换),如果线程A自旋指定的时间还没有获取到锁,仍然会被挂起。

自适应性自旋:自适应性自旋是自旋的升级、优化,自旋的时间不再固定,而是由上一次在同一个锁上的自旋时间及锁的拥有者的状态决定。例如线程如果自旋成功了,那么下次自旋的次数会增多,因为JVM认为既然上次成功了,那么这次自旋也很有可能成功,那么它会允许自旋的次数更多。反之,如果对于某个锁,自旋很少成功,那么在以后获取这个锁的时候,自旋的次数会变少甚至忽略,避免浪费CPU资源。有了自适应性自旋,随着程序运行和性能监控信息的不断完善,JVM对程序锁的状况预测就会变得越来越准确。

3.锁的粗化与消除

锁消除:锁消除是指虚拟机在JIT即时编译期间,通过逃逸分析,会对同步代码块内没有对共享变量进行读写操作的锁进行消除。

看下面的代码:虽然StringBuffer的append方法是Synchronized修饰的,但是通过逃逸分析得知StringBuffer属于局部变量,并且不会逃离该方法,即不会被外部所引用,故test方法肯定是线程安全的,从而发生了锁消除。

public class MyTest {

    public static void main(String[] args) {
        new MyTest().test();
    }

    public  void  test(){
        StringBuffer sb = new StringBuffer();
        sb.append("hello boom");
    }
}
复制代码

锁粗化:在使用锁的时候,需要让同步块的锁的范围尽可能小,锁的次数尽可能少。如果JVM检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作的外部。

看下面的代码:StringBuffer的append方法是Synchronized修饰的,调用两次append方法,那么就要获取两次锁,性能大大折扣,从而JVM对该代码发生了锁粗化,只用获取一次锁 ,锁住两个append方法。

public class MyTest {

    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        sb.append("hello");
        sb.append("boom");
    }
}
复制代码

4.锁的膨胀升级

Synchronized锁的状态有四种(单向的、依次升级):无锁偏向锁轻量级锁重量级锁

对象的组成中Mark Word会记录锁的状态,通过这些状态进行锁的记录与锁的升级,这篇有详细介绍对象的组成:juejin.cn/post/694723…

偏向锁

程序在运行过程中,如果不存在多线程竞争锁,并且锁总是被同一个线程获取,为了减少同一线程获取锁的代价(会涉及到CAS操作,相对耗时)而引入了偏向锁。

偏向锁的思想是,如果一个线程获取到了锁,那么锁就进入偏向锁模式,此时Mark Word的结构也会变为偏向锁的状态,当这个线程再次获取同样的锁时,无需经历任何获取锁的过程,直接拿到锁,这样就省去了大量获取锁的操作,从而提升了程序的性能。

但是在锁竞争比较激烈的情况下,可能每次获取到锁的线程都不一样,从而导致偏向锁失效,会升级到轻量级锁。

轻量级锁: 偏向锁失效,会升级到轻量级锁。Mark Word的结构也会变为轻量级锁的状态,轻量级锁是用于多线程交替执行同步代码块的场景,如果在同一时刻多个线程竞争同一把锁,那么轻量级锁就会升级为重量级锁。

重量级锁:我们说的重量级锁,即调用操作系统底层的Mutex Lock

下图是网上找来的锁升级的过程,俺觉得挺详细的,借鉴一下。

image.png

分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改