面试官问pmc不足之处(估计面试官再也不敢问你了)

推荐学习
  • 死磕「并发编程」100天,全靠阿里大牛的这份最全「高并发套餐」
  • Aliyun四面真可怕,狂问基础 项目 源码 高阶,无爱了
前言

volatile关键字相信大家多多少少都见过,即使没实际用过,也在诸多源码里见过。本文主要围绕volatile是什么、实现原理、使用场景几方面展开介绍。在介绍volatile之前,应该先了解下Java内存模型(JMM,Java Memory Model)

面试官问pmc不足之处(估计面试官再也不敢问你了)(1)

从图中我们可以看到JMM实际上是一种线程通信的规范,具体实现是通过共享内存(工作内存是线程隔离的,主内存是共享的)。线程不能直接修改主内存中的变量,线程1和线程2中的变量都是从主内存中都取,在工作内存中修改后更新到主内存,工作内存中存储的是主内存中变量的副本。

JMM与JVM内存的各大区域是不同层次的划分。主内存中主要存储Java对象的实例,包括成员变量、类信息、常量、静态变量。由于是线程共享的,所以存在线程安全问题;工作内存中主要存储方法的本地变量信息、字节码信号指示器等,由于是线程隔离的,所以不存在线程安全问题。

为什么需要JMM

学过计算机基础的同学应该都知道,CPU寄存器的存取速度和内存(Memory)的速度相差了好几个数量级。如果CPU直接操作内存(Memory),那么CPU绝大多数时间都处于等待状态,会导致CPU利用率极低。所以不得不在CPU和内存(Memory)之间增加高速缓存(一般是三级缓存L1、L2、L3。L1和L2是隔离的,L3是共享的)。具体结构如下

面试官问pmc不足之处(估计面试官再也不敢问你了)(2)

有了缓存层,操作系统可以提前将CPU需要使用到的数据读入缓存,计算完成后写回缓存,再由缓存慢慢同步给内存(Memory)。这样提高了CPU利用率,但是也增加了系统的复杂度,那就是引入了数据一致性问题。存在多个CPU的时候,怎么保证各个CPU缓存中数据是一致的。为了保证数据性一致,需要在访问缓存的同时,遵循一些协议,如MESI。

三级缓存结构虽然解决了CPU和内存的速度差异问题,但是三级缓存的结构过于复杂,并且不同的CPU结构略有差异。Java作为一门跨平台,跨操作系统的语言,就需要一种规范来屏蔽各类厂家CPU以及各个操作系统之间的差异,这种规范正是Java内存模型(JMM)。

很显然,JMM的这种设计也会存在数据一致性问题。导致数据不一致的原因主要有三个:原子性可见性有序性

  • 原子性:一系列操作不可分割,要么都成功执行,要么都失败。
  • 可见性:一个线程对共享变量的修改能及时被其他线程知晓。
  • 有序性:为了提高程序的执行效率,编译器会对编译后的指令进行重排序,即代码的编写顺序不一定就是代码的执行顺序

重排序又可以分为三种:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序。现代CPU采用了指令级并行技术来将多条指令重叠执行。对于不存在数据依赖的指令,CPU可以改变语句对应机器指令的执行顺序
  • 内存系统的重排序。由于CPU使用三级缓存结构,这使得数据加载和存储操作看上去可能是在乱序执行的

以下代码可以验证指令重排序

/** * @author sicimike */ public class OutOfOrder { private static int x = 0, y = 0; private static int a = 0, b = 0; public static void main(String[] args) throws InterruptedException { int i = 0; for (; ; ) { i ; x = 0; y = 0; a = 0; b = 0; Thread one = new Thread(() -> { a = 1; // 1 x = b; // 2 }); Thread two = new Thread(() -> { b = 1; // 3 y = a; // 4 }); one.start(); two.start(); one.join(); two.join(); if (x == 0 && y == 0) { String result = "第" i "次,x = " x ", y = " y; System.out.println(result); break; } } } }

仔细观察这段代码,如果没有指令重排序,x,y可能的结果是:1,1(执行顺序:1->3->2->4)、1,0(执行顺序:3->4->1->2)、0,1(执行顺序:1->2->3->4),但是不会出现:0,0。只有当1和2发生了重排,3和4发生了重排,x,y的结果才会是0,0。也就是当结果输出0,0时,就一定发生了重排序。执行结果(第一次)

面试官问pmc不足之处(估计面试官再也不敢问你了)(3)

执行结果(第二次)

面试官问pmc不足之处(估计面试官再也不敢问你了)(4)

由于发生重排序的时机不确定,所以程序执行可能很快,也有可能很慢,差异可能非常大。

不过重排序也不是随随便便想重排就重排,发生指令重排序的前提是:在单线程下不影响执行结果、对没有数值依赖的代码进行重排序。这就是as-if-serial语义。在多线程情况下有一套更具体的规则,那就是Happens-before。

Happens-before原则

Happens-before由八大原则组成。

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作(线程的执行结果有序
  • 锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作A先行发生于操作B,操作B先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则:Thread对象的start()方法先行发生于该线程的其他任何操作
  • 线程中断规则:对线程中断方法interrupt()的调用先行发生于被中断线程检测到中断事件的发生
  • 线程终结规则:线程中所有操作先行发生于线程的终止检测。通过Thread.join()方法结束、Thread.isAlive()方法的返回值等手段检测到线程已经终止执行。比如在A线程中调用B.join()方法,B线程执行完成后,B对共享变量的修改,对A来说是可见的
  • 对象终结规则:一个对象的初始化方法完成先行发生于该对象的finalize()方法的开始

如果两个操作不满足上述八大原则中的任意一个,那么这两个操作就没有顺序保证,虚拟机可以对这两个操作进行重排序。如果操作A happens-before 操作B,那么A在内存所做的修改对B都是可见的。

volatile

了解了JMM之后再来看看volatile。volatile关键字可以保证可见性有序性,并不能保证原子性

  • 可见性:volatile关键字修饰的变量,如果在某个线程的的工作内存中被修改,会立即写回主内存。并且写回主内存这个操作会让其他线程工作内存中该变量的副本失效(如果该变量也被其他线程修改了,则那个线程工作内存中的缓存不会失效)。
  • 有序性:通过插入内存屏障(Memory Barrier)指令禁止在内存屏障前后的指令执行重排序优化。volatile变量重排序规则表

面试官问pmc不足之处(估计面试官再也不敢问你了)(5)

与volatile相关的内存屏障指令有四条:StoreStore、StoreLoad、LoadLoad、LoadStore。

  • 在每个volatile写操作之前加入StoreStore
  • 在每个volatile写操作之后加入StoreLoad
  • 在每个volatile读操作之后加入LoadLoad
  • 在每个volatile读操作之后加入LoadStore

注意:LoadLoad和LoadStore都是加在volatile操作之后。下面是volatile变量写操作时生成的指令示意图。

面试官问pmc不足之处(估计面试官再也不敢问你了)(6)

StoreStore保证普通写操作对共享变量的修改均已刷新回主内存。下面是volatile变量读操作时生成的指令示意图。

面试官问pmc不足之处(估计面试官再也不敢问你了)(7)

通过这四条内存屏障,便可以保证有序性。

volatile与线程安全

volatile与synchronized相比,使用更加简便,也更加轻量级。但是volatile不能替代synchronized,因为仅仅靠一个volatile关键字不能保证线程安全。举个例子:

public class VolatileDemo { // volatile变量 private volatile static int count = 0; public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> { for (int i = 0; i < 1000; i ) { count ; } }); Thread thread2 = new Thread(() -> { for (int i = 0; i < 1000; i ) { count ; } }); thread1.start(); thread2.start(); // 让thread1、thread2执行完成 thread1.join(); thread2.join(); System.out.println(count); } }

两个线程分别对count变量累加1000次,期望得到的结果是2000,但是运行结果不一定是2000,而是小于等于2000。说明仅仅靠volatile关键字也不能保证线程安全。这个例子线程不安全的原因是count 不是原子操作,可以分为三步:读取count、count值 1、刷新到主内存。所以存在这样的情况:thread1和thread2都把count变量(假设此时count的值为100)读入自己的工作内存,thread1和thread2也都完成了 1操作,但是还没有刷新到主内存。此时thread1和thread2工作内存中的值均为101,,thread1把count最新值刷新到主内存,由于thread2工作内存中的值已经修改过了,所以thread2工作内存中的count缓存并不会失效,并且也会同步回主内存。所以thread1、thread2均同步回主内存后,主内存中的count变量的值是101,而不是102。

即:在不能保证原子性的情况下,volatile关键字并不能保证线程安全。

使用场景

由于volatile并不能保证线程安全,并且我们书写的代码中绝大部分都不是原子操作,所以volatile的使用场景其实非常有限,想要正确的使用volatile关键字,需要遵循以下两点:

  • 对volatile变量的写操作不依赖当前值
  • volatile变量不包含在含有其它变量的不变式中

说得通俗一点就是:volatile只能作为一个独立变量使用,通常就是用于各种flag。比如这篇文章:如何优雅的中断线程。倒数第二个例子便是使用了volatile关键字。volatile变量的 操作正是违反了第一点,所以不算是正确的用法。

补充

我们总是在说,CPU寄存器的存取速度和内存(Memory)的速度相差了好几个数量级,内存与硬盘相差好几个数量级,但是他们到底差多少呢,一张图就可以解决这个问题。

面试官问pmc不足之处(估计面试官再也不敢问你了)(8)

总结

仅仅依靠volatile关键字无法保证线程安全,但是理解volatile关键字的工作原理非常重要。

作者:Sicimike

原文链接:https://blog.csdn.net/Baisitao_/article/details/100942368

,

免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com

    分享
    投诉
    首页