volatile面试题

一 什么时候会用到 volatile ?

如果需要保证多线程共享变量的可见性和有序性(禁止指令重排序)

 

二 volatile的实现原理

通过内存屏障技术实现的。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障指令,内存屏障效果有:

  • 禁止volatile 修饰变量指令的重排序

  • 写入数据强制刷新到主存

  • 读取数据强制从主存读取

2.1 内存屏障

  CPU 运行速度实在太快,主存(就是内存)的数据读取速度和CPU 运算速度差了有几个数量级,因此现代计算机系统通过在CPU 和主存之前加了一层读写速度尽可能接近CPU 运行速度的高速缓存来做数据缓冲,这样缓存提前从主存获取数据,CPU 不再从主存取数据,而是从缓存取数据。这样就缓解由于主存速度太慢导致的CPU 饥饿的问题。同时CPU 内还有寄存器,一些计算的中间结果临时放在寄存器内。

现在主流的多核CPU的硬件架构,如下图所示。现代操作系统一般会有多级缓存(Cache Line),一般有L1、L2,甚至有L3

多核心CPU架构

CPU 从缓存读取数据和从内存读取数据除了读取速度的差异?有什么本质的区别吗?不都是读数据写数据,而且加缓存会让整个体系结构变得更加复杂。

缓存和主存不仅仅是读取写入数据速度上的差异,还有另外更大的区别:研究人员发现了程序80%的时间在运行20% 的代码,所以缓存本质上只要把20%的常用数据和指令放进来就可以了(是不是和Redis 存放热点数据很像),另外CPU 访问主存数据时存在二个局部性现象:

1.时间局部性现象

如果一个主存数据正在被访问,那么在近期它被再次访问的概率非常大。想想你程序大部分时间是不是在运行主流程。

2.空间局部性现象

CPU使用到某块内存区域数据,这块内存区域后面临近的数据很大概率立即会被使用到。这个很好解释,我们程序经常用的数组、集合(本质也是数组)经常会顺序访问(内存地址连续或邻近)。

因为这二个局部性现象的存在使得缓存的存在可以很大程度上缓解CPU 饥饿的问题。

 

程序运行时,数据是怎么在主存、缓存、CPU寄存器之间流转的

比如以 i = i + 2; 为例, 当线程执行到这条语句时,会先从主存中读取i 的值,然后复制一份到缓存中,CPU 读取缓存数据(取数指令),进行 i + 2 操作(中间数据放寄存器),然后把结果写入缓存,最后将缓存中i最新的值刷新到主存当中(写回时间不确定)。

数据操作逻辑在单线程环境和多线程环境下有什么区别?

比如i 如果是共享变量(例如对象的成员变量),单线程运行没有任何问题(单核CPU 的多线程也会出现上面的线程不安全的问题,只是产生原因不是多核CPU缓存不一致的问题导致,而是CPU调度线程切换,多线程局部变量不同步引起的),但是多线程中运行就有可能出问题。例如:有A、B二个线程,在不同的CPU 上运行,因为每个线程运行的CPU 都有自己的缓存,A 线程从内存读取i 的值存入缓存,B 线程此时也读取i 的值存入自己的缓存,A 线程对i 进行+1操作,i变成了1,B线程缓存中的变量 i 还是0,B线程也对i 进行+1操作,最后A、B线程先后将缓存数据写入内存,内存预期正确的结果应该是2,但是实际是1。这个就是非常著名的缓存一致性问题。

     早期的一些CPU 设计中,是通过锁总线(总线访问加Lock# 锁)的方式解决的。因为CPU 都是通过总线来读取主存中的数据,因此对总线加Lock# 锁的话,其他CPU 访问主存就被阻塞了,这样防止了对共享变量的竞争。但是锁总线对CPU的性能损耗非常大,把多核CPU 并行的优势直接给干没了!

缓存一致性协议。协议的类型很多(MSI、MESI、MOSI、Synapse、Firefly),最常见的就是Intel 的MESI 协议。缓存一致性协议主要规范了CPU 读写主存、管理缓存数据的一系列规范,如下图所示。

缓存一致性协议

 

MESI 协议的核心思想:

1.定义了缓存中的数据状态只有四种,MESI 是四种状态的首字母。
2.当CPU写数据时,如果写的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态;
2.当CPU读取共享变量时,发现自己缓存的该变量的缓存行是无效的,那么它就会从内存中重新读取。

缓存中数据都是以缓存行(Cache Line)为单位存储;MESI 各个状态描述如下表所示: 

image-20200512091902093

 

MESI 协议和volatile实现的内存可见性时什么关系?

volatile 和MESI 中间差了好几层抽象,中间会经历java编译器,java虚拟机和JIT,操作系统,CPU核心。

volatile 是Java 中标识变量可见性的关键字,说直接点:使用volatile 修饰的变量是有内存可见性的,这是Java 语法定的,Java 不关心你底层操作系统、硬件CPU 是如何实现内存可见的,我的语法规定就是volatile 修饰的变量必须是具有可见性的。
 

线程的工作内存在主存还是缓存中

JMM 中定义的每个线程私有的工作内存是抽象的规范,实际上工作内存和真实的CPU 内存架构如下所示,Java 内存模型和真实硬件内存架构是不同的:

JMM与真实内存架构

JMM 内存模型规范

1.初始变量首先存储在主内存中;
2.线程操作变量需要从主内存拷贝到线程本地内存中;
3.线程的本地工作内存是一个抽象概念,包括了缓存、store buffer(后面会讲到)、寄存器等。
JMM

 

JMM 模型中多线程如何通过共享变量通信呢?

线程间通信必须要经过主内存。

线程A与线程B之间要通信的话,必须要经历下面2个步骤:

1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。

2)线程B到主内存中去读取线程A之前已更新过的共享变量。

线程间通信

 

 

一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作(单一操作都是原子的)来完成: 

1.lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
2.unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量解除锁定,解除锁定后的变量才可以被其他线程锁定。
3.read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
4.load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
5.use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
6.assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
7.store(有的指令是save/存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
8.write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
 

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

1.如果要把一个变量从主内存中复制到工作内存,需要顺序执行read 和load 操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store 和write 操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行,也就是操作不是原子的,一组操作可以中断。
2.不允许read和load、store和write操作之一单独出现,必须成对出现。
3.不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
4.不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
5.一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
6.一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
7.如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
8.如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
9.对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
 

 2.2 Java 通过 Java 内存模型(JMM )实现 volatile 平台无关

volatile 可以让共享变量实现可见性,同时禁止共享变量的指令重排,保障可见性。

从JSR-333 规范 和 实现原理讲:

             ①JSR-333 规范:JDK 5定义的内存模型规范,

                     在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

                             1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

                               2. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。

1.程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
2.锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
3.volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
4.传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
5.线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
7.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
8.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

                      ②实现原理

                  上面说的happens-before原则保障可见性,禁止指令重排保证有序性,如何实现的呢? 

Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,保证共享变量操作的有序性。

内存屏障指令:写操作的会让线程本地的共享内存变量写完强制刷新到主存。读操作让本地线程变量无效,强制从主内存读取,保证了共享内存变量的可见性。

JVM中提供了四类内存屏障指令:

image-20200512091721797

JSR-133 定义的相应的内存屏障,在第一步操作(列)和第二步操作(行)之间需要的内存屏障指令如下:

image-20200511174714486

Java volatile 例子:

image-20200511175002261

以下是区分各个CPU体系支持的内存屏障

(也叫内存栅栏),由JVM 实现平台无关(volatile所有平台表现一致)

image-20200511172853931

 

三 volatile使用总结

volatile 是Java 提供的一种轻量级同步机制,可以保证共享变量的可见性和有序性(禁止指令重排),常用于

状态标志、双重检查的单例等场景。使用原则:

对变量的写操作不依赖于当前值。例如 i++ 这种就不适用。
该变量没有包含在具有其他变量的不变式中
volatile的使用场景不是很多,使用时需要仔细考虑下是否适用volatile,注意满足上面的二个原则。

单个的共享变量的读/写(比如a=1)具有原子性,但是像num++或者a=b+1;这种复合操作,volatile无法保证其原子性;
 

3.1 状态标志的使用

 

比如我们工程中经常用一个变量标识程序是否启动、初始化完成、是否停止等,如下:

volatile修饰状态标志

 

如果不加volatile 修饰,会有什么后果?

比如这是一个带前端交互的系统,有A、 B二个线程,用户点了停止应用按钮,A 线程调用shutdown() 方法,让变量shutdown 从false 变成 true,但是因为没有使用volatile 修饰, B 线程可能感知不到shutdown 的变化,而继续执行 doWork 内的循环,这样违背了程序的意愿:当shutdown 变量为true 时,代表应用该停下了,doWork函数应该跳出循环,不再执行。

 

3.2 懒汉式单例模式

我们常用的 double-check 的单例模式,如下所示:

懒汉式单例模式

为什么使用volatile 修饰了singleton 引用还用synchronized 锁?

volatile 只保证了共享变量 singleton 的可见性,但是 singleton = new Singleton(); 这个操作不是原子的,可以分为三步:

步骤1:在堆内存申请一块内存空间;

步骤2:初始化申请好的内存空间;

步骤3:将内存空间的地址赋值给 singleton;

所以singleton = new Singleton(); 是一个由三步操作组成的复合操作,多线程环境下A 线程执行了第一步、第二步之后发生线程切换,B 线程开始执行第一步、第二步、第三步(因为A 线程singleton 是还没有赋值的),所以为了保障这三个步骤不可中断,可以使用synchronized 在这段代码块上加锁。
 

第一次检查singleton 为空后为什么内部还需要进行第二次检查?

A 线程进行判空检查之后开始执行synchronized代码块时发生线程切换(线程切换可能发生在任何时候),B 线程也进行判空检查,B线程检查 singleton == null 结果为true,也开始执行synchronized代码块,虽然synchronized 会让二个线程串行执行,如果synchronized代码块内部不进行二次判空检查,singleton 可能会初始化二次。
 

 

 

volatile 如何防止指令重排?

指令重排序 是编译器和处理器为了优化程序执行的性能而对指令序列进行重排的一种手段。现象就是CPU 执行指令的顺序可能和程序代码的顺序不一致,例如 a = 1; b = 2; 可能 CPU 先执行b=2; 后执行a=1;

singleton = new Singleton(); 由三步操作组合而成,如果不使用volatile 修饰,可能发生指令重排序。步骤3 在步骤2 之前执行,singleton 引用的是还没有被初始化的内存空间,别的线程调用单例的方法就会引发未被初始化的错误。

指令重排序也遵循一定的规则:

(1).重排序不会对存在依赖关系的操作进行重排

指令重排

(2).重排序目的是优化性能,不管怎样重排,单线程下的程序执行结果不会变

as-if-serial

 

 

参考链接:

 https://angela.blog.csdn.net/article/details/106060526

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章