面试官:通过volatile标记的变量的作用是什么?
背过八股文的都知道,可以解决编译层面的可见性和指令重排序问题。 但是这样回答还不够,我需要让面试官觉得你有点东西😄。
回答思路:
- 首先介绍java的内存模型,JMM主要特点,8大原子类型操作,可见性,有序性。
- 介绍为啥出现不可见,以及指令重排,会导致什么问题。
- volatile修饰的变量为啥可以实现可见性和禁止指令重排。
一、java的内存模型
Java的内存模型即JMM( Java Memory Model),它定义了 Java 虚拟机在运行 Java 程序时如何处理内存的读写问题,以及线程之间的内存可见性和同步机制。它描述的是和多线程相关的一组规范,需要每个不同版本的JVM 的实现来遵守这个规范。JMM 是理解并发编程中线程间交互的基础,和CPU内存模型类似,JMM是基于CPU的内存模型来建立的,JMM的标准屏蔽了底层计算机的不同。
图cpu内存模型
JMM内存模型图
JMM 主要包括以下几个关键特性:
- 主内存与工作内存:
- 主内存(Main Memory):所有线程共享的内存区域,存储了Java程序中的实例字段、静态字段和构成数组的元素。
- 工作内存(Working Memory):每个线程都有自己的工作内存,它是线程私有的内存缓冲区,用于存储线程使用的变量的副本。
- 内存操作:
- 写操作:线程对工作内存中的变量进行修改后,需要将这个修改操作写回到主内存中。
- 读操作:线程需要从工作内存中读取变量值,如果工作内存中没有该变量的副本,线程需要从主内存中读取。
- 内存可见性: 当一个线程修改了共享变量的值,这个新值对于其他线程来说应当是可见的。JMM 通过在变量写入时同步到主内存,以及在变量读取时从主内存中同步来保证内存可见性。 整个 Java 内存模型实际上是围绕着三个特征建立起来的。分别是:原子性,可见性,有序性。这三个特征可谓是整个 Java 并发的基础。
二、原子性:
JMM的八大原子操作如下
名称 | 作用 |
---|---|
lock(锁定) | 作用于主内存中的变量,把变量标识为线程独占的状态。 |
unlock(解锁) | 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。 |
read(读取) | 作用于主内存的变量,把变量的值从主内存传输到线程的工作内存中,以便下一步的 load 操作使用。 |
load(载入) | 作用于工作内存的变量,把 read 操作主存的变量放入到工作内存的变量副本中。 |
use(使用) | 作用于工作内存的变量,把工作内存中的变量传输到执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。 |
assign(赋值) | 作用于工作内存的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作。 |
store(存储) | 作用于工作内存的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的 write 使用。 |
write(写入) | 作用于主内存中的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。 |
串联起来,如图所示八大原子操作流程图:
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要按顺序执行 store 和 write 操作。原子性指的是一个操作是不可分割,不可中断的,一个线程在执行时不会被其他线程干扰。
我们来看下面的代码:
java
boolean b = false;
boolean b2 = b;
int i = 0;
i++;
i = i + 1;
上面这几句代码那个是原子性操作?
- 第一句和第三句是基本类型赋值操作,必定是原子性操作;
- 第二句先读取 b 的值,再赋值到 b2,两步操作,不能保证原子性;
- 第四和第五句其实是等效的,先读取 i 的值,再 +1,最后赋值到 i,三步操作了,不能保证原子性;
JMM 只能保证基本的原子性,如果要保证一个代码块的原子性,提供了 monitorenter
和 moniterexit
两个字节码指令,也就是 synchronized
关键字,或者java.util.concurrent包下的LOCK接口实现代码块的原子性。
而java中对volatile关键字的变量在非原子操作上是非线程安全的,什么意思?我们先理解一下可见性
三、可见性
可见性指当一个线程修改共享变量的值,其他线程能够立即知道被修改了。Java 是利用 volatile 关键字来提供可见性的。 当变量被 volatile 修饰时,这个变量被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。而普通变量则不能保证这一点。 如下代码:
java
public class WorkManager {
static class SharedObject {
private volatile static Boolean keepRunning = true;
volatile static int i = 0;
void start() {
new Thread(() -> {
for(int a = 0;a < 10000;a++){
i++;
}
while (keepRunning) {
System.out.println(Thread.currentThread().getName() + " keepRunning :" + i);
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
}
void stop() {
keepRunning = false;
System.out.println(Thread.currentThread().getName() + " stop :");
}
}
public static void main(String[] args) throws InterruptedException {
SharedObject sharedObject = new SharedObject();
sharedObject.start();
IntStream.range(0, 10).forEach((i) -> {
SharedObject sharedObject1 = new SharedObject();
sharedObject1.start();
});
Thread.sleep(1000);
sharedObject.stop();
Thread.sleep(500);
System.out.println("exit:" + sharedObject.i);
}
}
当keepRunning为false的时候,所有线程任务都停止工作。但是i的值并不是预期的100000.是因为volatile只保证可见性,但是不提供原子性。当对volatile修复的变量原子类型操作,其他线程可以立即看见并知道,此时volatile可以非常高效和轻量的达到某一个变量的线程安全。
四、volatile实现可见性的底层原理
先说结论,Java中volatile变量是通过底层汇编指令lock前缀指令来实现的,他会锁住这块内存缓存并回写到主内存中。并且是其他线程里的工作缓存的副本变量失效。
我们对代码转换成汇编代码查看,在对a1进行++操作的时候做了什么。
java
public class MyDemo {
public volatile static int a1;
public static int fun1(){
a1++;
return a1;
}
}
通过命名转换成汇编代码以后,发现了lock指令,lock addl 表示将一个内存地址加锁。小伙伴们可以根据另一篇文章Java文件转换dex字节码指令集和查看汇编代码,查看没有volatile变量和有volatile变量的指令区别。
五、指令不会重排的原理
我们写一个方法,jvm虚拟机最终会将方法的指令集放到方法区,我们预期都是如我们代码顺序执行的,但是jdk编译、JVM执行引擎、 CPU 都有可能出于优化等目的,对于实际指令执行的顺序进行调整,这就是指令重排序 。目的都是提高处理速度,指令重排序在单线程是没有问题的,不会影响执行结果,而且还提高了性能。但是在多线程的环境下就不能保证一定不会影响执行结果了。 我们来举个例子
java
private int a;//非volatile修饰变量
private String b;//非volatile修饰变量
private volatile Object object;//volatile修饰变量
private void hello() {
a = 1; //语句1
b = "hello"; //语句2
object = new Object() ; //语句3
a = 4; //语句4
b = "hello ! "; //语句5
}
这写指令没有前后赋值依赖关系(happen-before关系规则,可以自行搜索哈,就不赘述了),那么指令是可以不同顺序执行的,但是语句3因为有了volatile修饰,只能在语句1和语句2之后执行,在语句4和语句5之前执行。但是语句1和语句2可以不同顺序执行,语句4和语句5可以不同顺序执行。 那么java中的关键字volatile关键字是怎么实现禁止指令重排序的呢 ?先说结论: 通过内存屏障(memory barrier) 来禁止重排序的。
什么是内存屏障?
内存屏障(Memory Barrier),也被称作内存栅栏,是一种在计算机系统中用来确保内存操作按照特定的顺序执行的同步机制。在多核处理器系统中,为了提高性能,处理器通常会采用各种优化手段,如指令重排序、写缓冲等。这些优化手段可能导致多线程环境中内存的读写顺序与程序代码中指定的顺序不一致,从而引发同步问题。内存屏障用来阻止这种优化,确保在特定的点上内存读写操作有一个清晰定义的顺序。 内存屏障的分类 内存屏障大致可以分为两大类:硬件内存屏障和软件内存屏障。
-
软件内存屏障 软件内存屏障一般是建立在硬件内存屏障之上的,通过编程语言提供的抽象来使用硬件内存屏障。在Java中,volatile关键字就是一种软件内存屏障,读取一个volatile变量相当于执行了一个读屏障,写入一个volatile变量相当于执行了一个写屏障。在C++中通过关键字比如'std:memory_order_acquire'(读屏障),'std:memory_order_release'(写屏障)。
-
硬件内存屏障 硬件内存屏障直接由处理器的架构提供,通常分为四种主要类型:
屏障类型 屏障指令示例 说明 Load Barrier(读屏障或Load Load Barrier) load1;LoadLoad;老load2; 确保屏障之前的所有读操作完成后,屏障之后的读操作才能开始。 Store Barrier(写屏障或Store Store Barrier) store1;StoreStore;store2 确保屏障之前的所有写操作完成后,屏障之后的写操作才能开始。 Load-Store Barrier(读写屏障或Load Store Barrier) load1;LoadStore;store2 保证屏障之前的所有读操作完成之后,屏障之后的写操作才能开始。 Store-Load Barrier(写读屏障或Store Load Barrier) store1;StoreLoad;load2 是最强的一种内存屏障,确保屏障之前的所有写操作对屏障之后的读操作都是可见的。
内存屏障的作用:
- 阻止指令重排序 编译器和处理器不能重排序跨越内存屏障的指令,这保证了执行顺序符合编程时的预期。
- 强制更新缓存 它强迫处理器更新其所做的任何缓存操作,这意味着在屏障之后执行的操作能够"看到"屏障之前操作的结果。
- 数据一致性 在多核处理器中,内存屏障帮助维护数据在不同处理器缓存中的一致性,这对于实现多线程之间的正确同步非常关键。
Intel 处理器上的 lock
前缀指令可以用来实现内存屏障的效果。 lock
前缀指令确保指令携带的内存操作的原子性,这意味着当 lock
前缀与某些指令一起使用时,被执行的操作会原子地完成,中间状态不会被其他处理器核心观察到。
lock
前缀通常与下列指令一起使用:ADD
,OR
,ADC
,SBB
,AND
,SUB
,XOR
,NOT
,NEG
,INC
,DEC
,BTS
,BTR
,BTC
,XADD
,CMPXCHG
等 当使用 lock
前缀时,它会强制处理器确保以下两个事项:
- 被锁住的操作是原子执行的,这意味着在操作完成之前,其他处理器无法访问相同的内存地址。
lock
操作确保了与它相关操作的内存读取和写入立即对其他处理器可见,这类似于完全内存屏障(Full Memory Barrier),确保了指令序列中该点之前的所有内存读写操作完成,而后续的读写操作不会被提前。
lock
前缀指令可以实现与内存屏障相似的功能,确保了内存操作的有序性和可见性,防止了编译器和处理器的指令重排序。在多核处理器或多处理器系统中,当多个处理器核心共享内存并发执行时,lock
前缀指令非常关键,因为它能防止针对相同内存地址的竞态条件。
六、总结
- 并发的情况下各个线程的工作内存不会共享变量副本,并不知道主内存的共享变量被修改,volatile修饰的变量修改以后对其他线程都是可见的,其他线程知道变量被修改之后,副本变量失效,将强制重新从主存读取一次。
- 执行引擎或者cpu指令优化出现指令重排序,多线程下运行结果出现非预期情况。volatile修饰的变量,对读写都有内存屏障,即LoadLoad,StoreStore指令前后顺序不可变,保证执行顺序符合预期。
总结,Java中的volatile关键字修饰的变量,底层是通过硬件环境也就是处理器汇编lock前缀指令,提供一种内存屏障功能,来达到防止指令重排,并禁止cpu使用高速缓存,解决了原子操作可见性。