阅读 1605

没内鬼,来点干货!volatile和synchronized

题外话

这篇笔记是我《没内鬼》系列第二篇,其实我计划是把设计模式和多线程并发分为两个系列,统一叫《一起学系列》来系统的介绍 相关的知识,但是想到这篇笔记去年就写成了,一直不发心也痒痒,所以整理一番就发出来,希望大家指正~

另外推荐我上一篇爆文没内鬼,来点干货!SQL优化和诊断

一起学习,一起进步!

volatile关键字

volatile关键字是在一般面试中经常问到的一个点,大家对它的回答莫过于两点:

  • 保证内存可见性
  • 防止指令重排

那为了更有底气,那咱们就来深入看看吧

JMM内存模型

咱们在聊volatile关键字的时候,首先需要了解JMM内存模型,它本身是一种抽象的概念并不真实存在,草图如下:

JMM内存模型

JMM内存模型规定了线程的工作机理:即所有的共享变量都存储在主内存,如果线程需要使用,则拿到主内存的副本,然后操作一番,再放到主内存里面去

这个可以引发一个思考,这是不是就是多线程并发情况下线程不安全的根源?假如所有线程都操作主内存的数据,是不是就不会有线程不安全的问题,随即引发下面的问题

为什么需要JMM内存模型

关于这个问题,我感觉过于硬核,我只能简单的想象假如没有JMM,所有线程可以直接操作主内存的数据会怎么样

  • 上文说过,JMM模型并不是真实存在的,它只是一种规范,这种规范反而可以统一开发者的行为,如果没有规范,可能Java所提倡的一次编译,处处运行就凉凉了
  • 另外我们都知道CPU 时间片轮转机制(就是在极短的时间切换进程,让用户无感知的享受多个进程运行的效果),线程在执行时候其实也是轮着来,假如A线程正在操作一个金钱数据,操作到一半,轮给B线程了,B线程把金额给改了,A线程最后又以错误的数据去入库等等,那问题不就大了去了?

所以我想面对这样的场景,前辈们才模仿CPU解决缓存一致性的思路确定了JMM模型(能力不足,纯属猜测)

在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存

volatile如何保证内存可见性

我们来看一段代码:

public class VolatileTest {
    static volatile String key;
    public static void main(String[] args){
        key = "Happy Birthday To Me!";
    }
}
复制代码

通过对代码进行javap命令,获取其字节码,内容如下(可以忽略啦):

public class com.mine.juc.lock.VolatileTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #5.#21         // java/lang/Object."<init>":()V
   #2 = String             #22            // Happy Birthday To Me!
   #3 = Fieldref           #4.#23         // com/mine/juc/lock/VolatileTest.key:Ljava/lang/String;
   #4 = Class              #24            // com/mine/juc/lock/VolatileTest
   #5 = Class              #25            // java/lang/Object
   #6 = Utf8               key
   #7 = Utf8               Ljava/lang/String;
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               LocalVariableTable
  #13 = Utf8               this
  #14 = Utf8               Lcom/mine/juc/lock/VolatileTest;
  #15 = Utf8               main
  #16 = Utf8               ([Ljava/lang/String;)V
  #17 = Utf8               args
  #18 = Utf8               [Ljava/lang/String;
  #19 = Utf8               SourceFile
  #20 = Utf8               VolatileTest.java
  #21 = NameAndType        #8:#9          // "<init>":()V
  #22 = Utf8               Happy Birthday To Me!
  #23 = NameAndType        #6:#7          // key:Ljava/lang/String;
  #24 = Utf8               com/mine/juc/lock/VolatileTest
  #25 = Utf8               java/lang/Object
{
  static volatile java.lang.String key;
    descriptor: Ljava/lang/String;
    flags: ACC_STATIC, ACC_VOLATILE

  public com.mine.juc.lock.VolatileTest();
    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 11: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/mine/juc/lock/VolatileTest;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: ldc           #2                  // String Happy Birthday To Me!
         2: putstatic     #3                  // Field key:Ljava/lang/String;
         5: return
      LineNumberTable:
        line 16: 0
        line 17: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  args   [Ljava/lang/String;
}
SourceFile: "VolatileTest.java"
复制代码

请大家注意这一段代码:

static volatile java.lang.String key;
    descriptor: Ljava/lang/String;
    flags: ACC_STATIC, ACC_VOLATILE
复制代码

可以看到,volatile关键字在编译的时候会主动为变量增加标识:ACC_VOLATILE,再研究下去就过于硬核了(汇编指令),我可能硬不起来(手动狗头),以后我会再对它进行深入的研究,我们只用了解到,Java关键字volatile,是在编译阶段主动为变量增加了ACC_VOLATILE标识,以此保证了它的内存可见性

即然volatile可以保证内存可见性,那至少有一个场景我们是可以放心使用的,即:一写多读场景

另外,大家在验证volatile内存可见性的时候,不要使用 System.out.println() ,原因如下:

public void println() {
    newLine();
}

/**
 * 是不是赫然看到一个synchronized,具体原因见下文
 */
private void newLine() {
    try {
        synchronized (this) {
            ensureOpen();
            textOut.newLine();
            textOut.flushBuffer();
            charOut.flushBuffer();
            if (autoFlush)
                out.flush();
        }
    }
    catch (InterruptedIOException x) {
        Thread.currentThread().interrupt();
    }
    catch (IOException x) {
        trouble = true;
    }
}
复制代码

为什么会有指令重排

为了优化程序性能,编译器和处理器会对Java编译后的字节码和机器指令进行重排序,在单线程情况下不会影响结果,然而在多线程情况下,可能会出现莫名其妙的问题,案例见下文

指令重排例子

img
img

运行这段代码我们可能会得到一个匪夷所思的结果:我们获得的单例对象是未初始化的。为什么会出现这种情况?因为指令重排

首先要明确一点,同步代码块中的代码也是能够被指令重排的。然后来看问题的关键

 INSTANCE = new Singleton();
复制代码

虽然在代码中只有一行,编译出的字节码指令可以用如下三行表示

  • 1.为对象分配内存空间
  • 2.初始化对象
  • 3.将INSTANCE变量指向刚分配的内存地址

由于步骤2,3交换不会改变单线程环境下的执行结果,故而这种重排序是被允许的。也就是我们在初始化对象之前就把INSTANCE变量指向了该对象。而如果这时另一个线程刚好执行到代码所示的2处

if (INSTANCE == null)
复制代码

那么这时候有意思的事情就发生了:虽然INSTANCE指向了一个未被初始化的对象,但是它确实不为null了,所以这个判断会返回false,之后它将return一个未被初始化的单例对象!

如下:

img

由于重排序是编译器和CPU自动进行的,如何禁止指令重排?

INSTANCE变量加个volatile关键字就行,这样编译器就会根据一定的规则禁止对volatile变量的读写操作重排序了。而编译出的字节码,也会在合适的地方插入内存屏障,比如volatile写操作之前和之后会分别插入一个StoreStore屏障和StoreLoad屏障,禁止CPU对指令的重排序越过这些屏障

即然保证了内存可见,为什么还是线程不安全?

volatile 关键字虽然保证了内存可见,但是问题来了,见代码:

index += 1;  
复制代码

这短短一行代码在字节码级别其实分为了多个步骤进行,如获取变量,赋值,计算等等,如CPU基本执行原理一般,真正执行的是一个个命令,分为很多步骤

volatile 关键字可以保证的是单个读取操作是具有原子性的(每次读取都是从主内存获取最新的值)

但是如 index += 1; 实质是三个步骤,三次行为,因此它无法保证整块代码的原子性

synchronize关键字

驳斥关于类锁的概念

首先驳斥一个关于类锁的概念,synchronize就是对象锁,在普通方法,静态方法,同步块时锁的对象分别是:

类型 代码示例 锁住的对象
普通方法 synchronized void test() { } 当前对象
静态方法 synchronized static void test() { } 锁的是当前类的Class 对象
同步块 void fun () { synchronized (this) {} } 锁的是()中的对象

大家都同意在同步代码块中,锁住的是括号里的对象,那么见以下代码:

public class SynDemo {

    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (SynDemo.class) {
                    System.out.println("真的有所谓的类锁?");
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();

        Thread.sleep(500);
        answer();
    }

    synchronized static void answer () {
        System.out.println("答案清楚了吗");
    }
}

// 输出结果
// 真的有所谓的类锁?
// 间隔几秒左右
// 答案清楚了吗
复制代码

所以实际上所谓的类锁,完全就是当前类的Class对象,所以不要被误导,synchronize就是对象锁

synchronize实现原理

JVM 是通过进入、退出对象监视器(Monitor 来实现对方法、同步块的同步的

具体实现是在编译之后在同步方法调用前加入一个 monitor.enter 指令,在退出方法和异常处插入 monitor.exit 的指令。

其本质就是对一个对象监视器 Monitor 进行获取,而这个获取过程具有排他性从而达到了同一时刻只能一个线程访问的目的

而对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程 monitor.exit 之后才能尝试继续获取锁。

流程图如下:

1566131929317
1566131929317

代码例子:

public static void main(String[] args) {
    synchronized (Synchronize.class){
        System.out.println("Synchronize");
    }
}
复制代码

字节码:

public class com.crossoverjie.synchronize.Synchronize {
  public com.crossoverjie.synchronize.Synchronize();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // class com/crossoverjie/synchronize/Synchronize
       2: dup
       3: astore_1
       **4: monitorenter**
       5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       8: ldc           #4                  // String Synchronize
      10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      13: aload_1
      **14: monitorexit**
      15: goto          23
      18: astore_2
      19: aload_1
      20: monitorexit
      21: aload_2
      22: athrow
      23: return
    Exception table:
       from    to  target type
           5    15    18   any
          18    21    18   any
}
复制代码

为什么会有两次monitorexit

同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁,目的是为了避免异常情况就无法释放锁

synchronized锁的几种形式

之前大家都说千万不要用synchronized,效率太差啦,但是Hotspot团队对synchronized进行许多优化,提供了三种状态的锁:偏向锁、轻量级锁、重量级锁,这样一来synchronized性能就有了极大的提高

偏向锁:就是锁偏向某一个线程。主要是为了处理同一个线程多次获取同一个锁的情况,比如锁重入或者一个线程频繁操作同一个线程安全的容器,但是一旦出现线程之间竞争同一个锁,偏向锁就会撤销,升级为轻量级锁

轻量级锁:是基于CAS操作实现的。线程使用CAS尝试获取锁失败后,进行一段时间的忙等,也就是所谓的自旋操作。尝试一段时间仍无法获取锁才会升级为重量级锁

重量级锁:是基于底层操作系统实现的,每次获取锁失败都会直接让线程挂起,这会带来用户态内核态的切换,性能开销比较大

打一个比方:大家在排队打饭,你有一个专属通道,叫做帅哥美女专属通道,只有你一个人可以自由的同行,这就叫偏向锁

突然有一天,我来了,我也自诩帅哥,所以我盯上了你的通道,但是你还在打饭,然后我就抢过去和你一起打饭,但是这样效率比较低,所以阿姨没问我的时候,我就玩会手机等你,这就叫轻量级锁

突然还有一天,我饿到不行,什么帅哥美女统统滚蛋,就我一个人先打饭,所有阿姨为我服务,给我服务完了再轮到你们,这就叫重量级锁

synchronized除了上锁还有什么作用

  • 获得同步锁
  • 清空工作内存
  • 从主内存中拷贝对象副本到本地内存
  • 执行代码
  • 刷新主内存数据
  • 释放同步锁

这也就是上文提到的System.out.println()为何会影响内存可见性的原因了

Tips

字节码获取方法:

用法: javap <options> <classes>
其中, 可能的选项包括:
  -help  --help  -?        输出此用法消息
  -version                 版本信息
  -v  -verbose             输出附加信息
  -l                       输出行号和本地变量表
  -public                  仅显示公共类和成员
  -protected               显示受保护的/公共类和成员
  -package                 显示程序包/受保护的/公共类
                           和成员 (默认)
  -p  -private             显示所有类和成员
  -c                       对代码进行反汇编
  -s                       输出内部类型签名
  -sysinfo                 显示正在处理的类的
                           系统信息 (路径, 大小, 日期, MD5 散列)
  -constants               显示最终常量
  -classpath <path>        指定查找用户类文件的位置
  -cp <path>               指定查找用户类文件的位置
  -bootclasspath <path>    覆盖引导类文件的位置
复制代码

最后

感谢以下博文及其作者:

面试官没想到一个Volatile,我都能跟他扯半小时

死磕Synchronized底层实现--概论

最后的最后

文章中我留了一个小小的彩蛋,如果你能发现也证明你看的非常仔细啦

夏天到啦,加我微信,我来请你吃一根雪糕~ 仅限5.13日一天哦