【笔记】sync和volatile

154 阅读7分钟

特性

Sync直接保证了多线程中的:

  • 原子性

直接将数据上锁,同一时间只会有一个线程可以拿到锁,因此是将临界区的数据独占,将数据上的并行处理,在加锁的节点上变成了单线程的处理逻辑,因此也保证了多线程中的:

  • 顺序性(后一个必须等到前一个释放锁,但内部可能发生重排序,这里的顺序性是代码块内外的顺序性)
  • 可见性(其他的线程都不能读写加锁对象,因此也确保了线程持有副本的数据不会与主存中的不一致)

同时,Sync的锁特性为:

  • 可重入性(见可重入锁)
  • 不可中断性(线程不释放锁时不会被中断)

synchronized 在退出的时候,能保证 synchronized 块中对于共享变量的写入一定会刷入到主内存中

synchronized 保证了释放监视器锁之前的代码一定会在释放锁之前被执行(如 temp 的初始化一定会在释放锁之前执行完 ),但是没有任何规则规定了,释放锁之后的代码不可以在释放锁之前先执行。

此处需要回顾一下JMM中对于sync的规定:

监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

使用方法

Synchronized可以用于修饰:

  • 变量
  • 方法
  • 代码块

使用目的

先看一个基础的:

 public class SynTest{
 private static int i = 0;
 ​
 public void start() {
     for(int m=0 ;m < 1000 ;m++,i++);
 }
     
 @Override
 public void run() {
     start();
     }
 ​
 //...........
 ​
 public static void main(String[] args){
 for (int i = 0; i < 10; i++) {
             Thread t = new Thread(new SynTest());
             t.start();
         }
 Thread.sleep(3000);
 System.out.println(i);
   }
 }

很经典的example,结果大概率小于10000。使用Synchronized将资源上锁。

正确使用方法

使用以下几种方式能达到结果正确:

  1.  private static final Object mutex = new Object();
      public void start() {
             for(int m=0 ;m < 1000 ;m++){
                 synchronized (mutex) {i++;}
             }
         }
    

    加一个静态final对象并加锁。

  2. 对start方法进行方法声明的修改:

     public static synchronized void start() {
         for(int m=0 ;m < 1000 ;m++){
              i++;
         }
     }
    
错误使用方法
  1. 对非final的static对象加锁,此时锁的只是该对象实例:

  2. 对非静态方法使用synchronized关键字、

  3. 死锁

    1. 死锁四个条件:

      1. 互斥条件:该资源任意一个时刻只由一个线程占用。
      2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
      3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
      4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
总结

synchronized可以在三个地方声明:

  1. 方法:public synchronized void cr()
  2. 变量:synchronized (mutex){}
  3. 代码块(同上)

原理

写了个示例类

 public class SynTest3 {
     public static synchronized void test(){
         synchronized (new Object()){}
     }
 }

调用命令 javac -p 编译后,通过javap -v 查看编译后的文件内容如下:

 public class threadReview.SynTest3
   minor version: 0
   major version: 52
   flags: ACC_PUBLIC, ACC_SUPER
 Constant pool:
    #1 = Methodref          #2.#17         // java/lang/Object."<init>":()V
    #2 = Class              #18            // java/lang/Object
    #3 = Class              #19            // threadReview/SynTest3
    #4 = Utf8               <init>
    #5 = Utf8               ()V
    #6 = Utf8               Code
    #7 = Utf8               LineNumberTable
    #8 = Utf8               LocalVariableTable
    #9 = Utf8               this
   #10 = Utf8               LthreadReview/SynTest3;
   #11 = Utf8               test
   #12 = Utf8               StackMapTable
   #13 = Class              #18            // java/lang/Object
   #14 = Class              #20            // java/lang/Throwable
   #15 = Utf8               SourceFile
   #16 = Utf8               SynTest3.java
   #17 = NameAndType        #4:#5          // "<init>":()V
   #18 = Utf8               java/lang/Object
   #19 = Utf8               threadReview/SynTest3
   #20 = Utf8               java/lang/Throwable
 {
   public threadReview.SynTest3();
     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   LthreadReview/SynTest3;
 
   public static synchronized void test();
     descriptor: ()V
     flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
     Code:
       stack=2, locals=2, args_size=0
          0: new           #2                  // class java/lang/Object
          3: dup
          4: invokespecial #1                  // Method java/lang/Object."<init>":()V
          7: dup
          8: astore_0
          9: monitorenter
         10: aload_0
         11: monitorexit
         12: goto          20
         15: astore_1
         16: aload_0
         17: monitorexit
         18: aload_1
         19: athrow
         20: return
       Exception table:
          from    to  target type
             10    12    15   any
             15    18    15   any
       LineNumberTable:
         line 5: 0
         line 6: 20
       StackMapTable: number_of_entries = 2
         frame_type = 255 /* full_frame */
           offset_delta = 15
           locals = [ class java/lang/Object ]
           stack = [ class java/lang/Throwable ]
         frame_type = 250 /* chop */
           offset_delta = 4
 }
 SourceFile: "SynTest3.java"

代码块:

  • 进入一个方法时,一旦执行到了monitorenter,那么直到方法的最后一个monitorexit之前,其他线程都无法获取到这个对象的使用权。
  • 事实上如果在源代码里多加几个内部的synchronized,会出现多个monitorenter连续出现后才出现第一个moniotrexit,这里就是可重入性的由来。

方法:

不知道大家注意到方法那的一个特殊标志位没,ACC_SYNCHRONIZED

同步方法的时候,一旦执行到这个方法,就会先判断是否有标志位,然后,ACC_SYNCHRONIZED会去隐式调用刚才的两个指令:monitorenter和monitorexit。

所以归根究底,还是monitor对象的争夺。

Monitor是个什么东西?

我说了这么多次这个对象,大家是不是以为就是个虚无的东西,其实不是,monitor监视器源码是C++写的,在虚拟机的ObjectMonitor.hpp文件中。

我看了下源码,他的数据结构长这样:

 ObjectMonitor() {
  _header       = NULL;
  _count        = 0;
  _waiters      = 0,
  _recursions   = 0;  // 线程重入次数
  _object       = NULL;  // 存储Monitor对象
  _owner        = NULL;  // 持有当前线程的owner
  _WaitSet      = NULL;  // wait状态的线程列表
  _WaitSetLock  = 0 ;
  _Responsible  = NULL ;
  _succ         = NULL ;
  _cxq          = NULL ;  // 单向列表
  FreeNext      = NULL ;
  _EntryList    = NULL ;  // 处于等待锁状态block状态的线程列表
  _SpinFreq     = 0 ;
  _SpinClock    = 0 ;
  OwnerIsThread = 0 ;
  _previous_owner_tid = 0;
 }

这块c++代码,我也放到了我的开源项目了,大家自行查看。

synchronized底层的源码就是引入了ObjectMonitor,我上面说的,还有大家经常听到的概念,在这里都能找到源码。

img

大家说熟悉的锁升级过程,其实就是在源码里面,调用了不同的实现去获取获取锁,失败就调用更高级的实现,最后升级完成。

锁实现原理

重量级锁就都在上面了,sync的锁升级有几个步骤(不可逆):

偏向锁->轻量级锁-> 重量级锁

偏向锁

像是更广义上的可重入性。

根据上面的Monitor,以及字节码中的monitorenter/exit,可知Synchronized事实上争夺的锁就是monitor对象。

锁争夺也就是对象头指向的Monitor对象的争夺,一旦有线程持有了这个对象,标志位修改为1,就进入偏向模式,同时会把这个线程的ID记录在对象的Mark Word中。

这个过程是采用了CAS乐观锁操作的,每次同一线程进入,虚拟机就不进行任何同步的操作了,对标志位+1就好了,不同线程过来,CAS会失败,也就意味着获取锁失败。

轻量级锁

偏向锁关闭或多个线程同时竞争偏向锁时,会升级为轻量级锁。

如果这个对象是无锁的,jvm就会在当前线程的栈帧中建立一个叫锁记录(Lock Record)的空间,用来存储锁对象的Mark Word 拷贝,然后把Lock Record中的owner指向当前对象。

JVM接下来会利用CAS尝试把对象原本的Mark Word 更新到Lock Record的指针,成功就说明加锁成功,改变锁标志位,执行相关同步操作。

如果失败了,就会判断当前对象的Mark Word是否指向了当前线程的栈帧,是则表示当前的线程已经持有了这个对象的锁,否则说明被其他线程持有了,继续锁升级,修改锁的状态,之后等待的线程也阻塞。

参考

juejin.cn/post/684490…

juejin.cn/post/684490…

juejin.cn/post/684490…

volatile

简单地说,volatile是JVM对于MESI规则的一个补充,并且是遵循JMM中happens-before规范的一个实现。

相比于sync的重量级并有三特性的保证(可见性,原子性,顺序性),volatile可以说是一个部分的sync:

  • volatile的性能要比sync好一些
  • volatile只能保证可见性和顺序性以及特殊情况下的原子性。

必要性

结合MESI协议,我们知道:

  • 由于多级缓存、多CPU的存在,实际上数据的变更并不是立即写入到内存中的,甚至在缓存中都不一定能够保证是最新的。

在没有关键字介入的情况下,JMM仅保证了数据的声明在使用之前这一顺序性,那么当需要数据的M状态是实时通知的条件下,可能会出现预期之外的结果。

那么这样子就需要其他东西来保证这一条件了。

结合volatile在编码中的实际表现以及上期MESI的相关内容,其实volatile使用了内存屏障来实现相关功能。

  • 写屏障 Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。
  • 读屏障Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。

操作实例:单例DCL

DCL:double check lock

单例分为:饿汉与懒汉方式。

饿汉方式是预先创建的方式,因此不存在线程相关的问题。

懒汉方式,是调用时再判断,不存在则新建,这里就会出现问题:

  • 如果多个同时进入调用,可能会导致多次创建。

那么就需要使用同步块。每一个特殊机制都在代码块里加上了注释。

 public class DCLDemo {
 ​
     //使用volatile,见【2】
     private static volatile DCLDemo instance;
 ​
     private DCLDemo() {
     }
 ​
     public static DCLDemo getInstance(){
         if(instance == null){
             //【1】sync:加锁防止其他地方的修改。注意:这里锁的是这个类
             //【2】这里就是为什么要加上volatile:
             //对象的构建分为3步:
             //1.分配内存2.内存地址上初始化数据3.内存地址赋予变量
             //如果没有volatile,那么这里可能发生重排序,先赋予变量再初始化数据
             //此时内存地址有了,对象不为null,但数据为null;如果这里其他方法进入了,
             //那么上面instance!=null了,就返回了这个半初始化的数据,就出错了
             //因此这里需要加上volatile,防止数据assign先于init,保证不会返回这种数据
             synchronized (DCLDemo.class){
                 if(instance == null){
                     
                     instance = new DCLDemo();
                 }
             }
         }
         return instance;
     }
 }