前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >一文读懂 Volatile 三板斧,面试高薪就不远了

一文读懂 Volatile 三板斧,面试高薪就不远了

作者头像
猿芯
发布2020-07-06 14:11:01
3330
发布2020-07-06 14:11:01
举报

前言

在 Java 多线程并发编程中,经常遇到 volatile 使用场景,如 JDK 并发容器 ConcurrentHashMap 的 Node 类,其 V 和 next 属性就是用 volatile 修饰的。

  static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;
        ....
}

所以 Volatile 在 Java 并发编程的重要性就不言而喻了。

在 Java 高级职位面试中,Volatile 关键字也是必问的环节。如果掌握了 Volatile 三板斧的原理,必然在面试的时候得心应手,面试官也会对你刮目相看,高薪也就不远了。

Volatile 概念

volatile 是变量的修饰符,其修饰的变量具有可见性,为了加快程序的运行效率,Java 对一些变量的操作通常是在寄存器或是 cpu 缓存上进行的,之后才会同步到内存中,而加了 volatile 修饰符的变量则是直接读写内存。可见性也就说一旦某个线程修改了该变量,其他线程读值时可以立即获取修改之后的值。

Java 语言规范第三版中对 volatile 的定义如下:

java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。

一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:

1. 可见性

2. 禁止进行指令重排序

三板斧之 Volatile 的可见性

举个栗子,伪代码奉上:

//线程1
boolean stop = false;
while(!stop){
 doSomething();
}
//线程2修改stop值
stop = true;

这是一段很典型的多线程代码片段,那么这段代码会发生什么情况呢?

当线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

聪明的你想必已经猜到了,大声说出来:“while循环无法停止”。对,你没猜错!是不是感觉很神奇?

其实这里涉及到JMM(Java内存模型)

JMM规定

所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。

正因为不同的线程之间也无法直接访问对方工作内存中的变量,所以volatile闪亮登场了。

当线程2对被volatile修饰的stop变量进行赋值时并把值写进主内存,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效),所以线程1再次读取变量stop的值时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值,那么线程1读取到的就是最新的正确的值。

这就是volatile的可见性。

volatile的可见性是指当多个线程访问同一个变量(共享变量)时,如果在这期间有某个线程修改了该共享变量的值,那么其他线程能够立即看得到修改后的值。

为什么其他线程可以访问到共享变量修改后的值呢?

这里涉及到jvm运行时刻内存的分配:

其中有一个内存区域是jvm虚拟机栈,每一个线程运行时都有一个线程栈,线程栈保存了线程运行时候变量值信息。当线程访问某一个对象的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存变量的具体值load到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。这样在堆中的对象的值就产生变化了

也就是说,当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主内存,当有其他线程需要读取时,它会去主内存中读取最新值。相反,普通的共享变量被修改之后,不能保证及时更新到主内存,导致某些线程读取时还是旧值,因此无法保证其可见性。

那么计算机处理器是怎么保证其可见性的呢?

举个栗子,代码如下:

public class MySingleton {
 private static volatile MySingleton instance = null;
 public static MySingleton getInstance() {
 if (instance == null) {
 instance = new MySingleton();
 }
 return instance;
 }
 public static void main(String[] args) {
 MySingleton.getInstance();
 }
}

汇编代码

0x00000000027df0d5: lock add dword ptr [rsp],0h ;*putstatic instance
 ; - com.dunzung.demo.MySingleton::getInstance@13 (line 9)

有volatile变量修饰的共享变量进行写操作的时候会多第一行加lock的汇编代码,通过查IA-32架构软件开发者手册可知,lock前缀的指令在多核处理器下会引发了两件事情。

第一、将当前处理器缓存行的数据会写回到系统内存

Lock前缀指令导致在执行指令期间,发送处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占使用任何共享内存。(因为它会锁住总线,导致其他CPU不能访问总线,不能访问总线就意味着不能访问系统内存),但是在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销比较大。对于Intel486和Pentium处理器,在锁操作时,总是在总线上发送LOCK#信号。但在P6和最近的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会发送LOCK#信号。相反地,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。

第二、这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效

IA-32处理器和Intel 64处理器使用MESI(修改,独占,共享,无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32 和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。它们使用嗅探技术保证它的内部缓存,系统内存和其他处理器的缓存的数据在总线上保持一致。例如在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处理共享状态,那么正在嗅探的处理器将无效它的缓存行,在下次访问相同内存地址时,强制执行缓存行填充。

处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完之后不知道何时会写到内存。

如果对声明了Volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

一个处理器的缓存回写到内存会导致其他处理器的缓存无效。IA-32处理器和Intel64处理器使用MESI(修改,独占,共享,无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32 和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。它们使用嗅探技术保证它的内部缓存,系统内存和其他处理器的缓存的数据在总线上保持一致。

例如在Pentium和P6family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处理共享状态,那么正在嗅探的处理器将无效它的缓存行,在下次访问相同内存地址时,强制执行缓存行填充。

总结前一章节 volatile 的可见性,我们知道 volatile 保证其可见性的简单来说有两点:

1、JMM层

在jvm虚拟机栈中,每一个线程运行时都有一个线程栈,线程栈保存了线程运行时候变量值信息。当线程访问某一个对象的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存变量的具体值load到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。这样在堆中的对象的值就产生变化了

2、计算机处理器层:lock指令

那么问题来了,Volatile是如何保证变量读和写的呢?

我们先来了解两个概念

1. Happens-before原则

2. 内存屏障(Memory Fence)

Happens-before原则(先行发生原则)

先行发生原则说的是Java内存模型中两个操作之间的执行顺序关系。

举个栗子

int a = 1 操作A
int b = a 操作B
a = 2

假设操作A先于操作B发生,意味着a=1的赋值影响到b=a=1的赋值。在单线程环境下,java线程天生就是有序执行的;但是在多线程环境下,b未必等于1,有可能为2。

为什么呢?

在上一章节-可见性,我们讲过两个概念:线程的工作内存和主内存。

也就是说,多线程环境下,如果一个线程更新了主内存,会导致其他线程工作内存的共享变量失效,那么当其他线程检测到工作内存中共享变量的值失效时,会去主内存重新拷贝新值到工作内存中。

为什么在多线程环境下,程序执行会看似无序呢?

先行发生原则规则如下:

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

那怎么保证先行发生原则呢?

这里涉及到一个内存屏障指令(Memory Fence)的概念。

内存屏障指令(Memory Fence)

内存屏障是一种CPU指令,是指重排序时不能把后面的指令重排序到内存屏障之前的位置。

是不是听了很晕?

白话来讲就是往你的一些代码里面动态插入一些屏障指令,目的是防止在多线程环境下,变量赋值错乱问题。

是不是好像明白点了?

这个就好比你家种的白菜,你是不是会担心白菜叶子会被鸡吃掉,那你会怎么做呢?想到什么了?对,用围栏把菜园子围起来,这样可以防止鸡偷吃白菜叶子。

常见的内存屏障分为以下几种:

LoadLoad屏障指令:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

StoreStore屏障指令:对于这样的语句Store1; StoreStore;Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。

LoadStore屏障指令:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

StoreLoad屏障指令:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

说了这么多,其实就是想说明为什么volatile要禁止指令重排序。

三板斧之 Volatile 有序性:指令重排序

先说一个概念有序性

即程序执行的顺序按照代码的先后顺序执行。

Java程序中,如果是本线程内,所有操作都是有序的;如果是多线程环境下,则是无序的。前半句指的是线程内表现为串行语义,后半句指的是“指令重排序”和“工作内存与主内存同步延迟” 现象。

Java语言提供了volatile和synchronized两个关键字来保证线程之间的有序性

1、volatile关键字本身就包含了指令重排序的语义2、synchronized由一个变量在同一时刻只允许一个线程对其进行lock操作,这条规定的获得,规定了持有同一个锁的两个同步块只能串行地进入。

栗子奉上

//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。

什么是指令重排序?

一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子,代码清单如下:

int a = 10; //语句1
int r = 2; //语句2
a = a + 3; //语句3
r = a*a; //语句4

这段代码有4个语句,那么可能的一个执行顺序是:

语句2 > 语句1 > 语句3 > 语句4

那么可不可能是这个执行顺序呢:

语句2 > 语句1 > 语句4 > 语句3

不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?代码清单如下:

//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

所以,基于以上论述,volatile为了保证可序性,就必须要禁止指令重排序。

由 前面的 volatile 可见性和指令重排序章节,我们了解到被 volatile 修饰的共享变量通过处理器 lock 指令和内存屏障指令保证了共享变量的可见性和禁止指令重排序。

这时候有同学就要问了,被 volatile 修饰的共享变量的读写能保证具有原子性吗?

答案是不能!

三板斧之 Volatile 原子性

程序的原子性:

是指一个操作是不可中断的,要么全部执行成功要么全部执行失败。

从volatile语义来讲,任意单个volatile变量的读写具有原子性,其原理是利用了计算机处理器lock指令和内存屏障指令保证其在多线程环境下共享变量的赋值操作具有原子性。

但是有个例外,栗子奉上:

public class VolatileThreads {
 volatile int i = 1;
 public static void main(String[] args) {
 VolatileThreads t = new VolatileThreads();
 for (int j = 0; j < 10; j++) {
 int finalJ = j;
 new Thread(() -> {
 t.incr(finalJ);
 }).start();
 }
 }
 public void incr(int j) {
 i++;
 System.out.println("线程" + j + ", i=" + i);
 }
}

输出:

线程0, i=2
线程4, i=3
线程3, i=5
线程1, i=5
线程5, i=7
线程7, i=7
线程8, i=8
线程9, i=9
线程2, i=10
线程6, i=11

我们发现线程1,线程3,i的值为5;同样线程5,线程7,i的值为7;i=6没有出现。

这是因为在多线程环境下, i++操作不能保证其原子性!

我们来分析incr方法:

首先、获取volatile变量的初始值1

然后、将该变量的值加1

i +1

最后,将i变量的值2写会到主内存

i =2

那问题出在哪一步了?问题处在i+1上!

因为i+1和i=2是复合操作,由程序的原子性定义可知:是指一个操作是不可中断的,要么全部执行成功要么全部执行失败。因此我们知道被volatile修饰的变量i=2是原子操作,但是i+1不是,因为i+1并没有加lock指令和内存屏障指令保证其原子性。

解决i++原子性问题呢?

其实很简单,在incr方法前加上synchronized关键字就可以了。

public synchronized void incr(int j) {
 i++;
 System.out.println("线程" + j + ", i=" + i);
}

输出:

线程0, i=2
线程1, i=3
线程3, i=4
线程4, i=5
线程5, i=6
线程7, i=7
线程8, i=8
线程9, i=9
线程6, i=10
线程2, i=11

四、Volatile 使用场景

1. 状态标识

2. 双重检查(Double-Check) ,JDK1.5之后才稳定下来

需要注意的是volatile无法替代synchronized的。因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

1)对变量的写操作不依赖于当前值

2)该变量没有包含在具有其他变量的不变式中

五、参考资料

《深入理解Java虚拟机》

《IA-32架构软件开发者手册》

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-04-05,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 架构荟萃 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 三板斧之 Volatile 的可见性
  • Happens-before原则(先行发生原则)
  • 内存屏障指令(Memory Fence)
  • 三板斧之 Volatile 有序性:指令重排序
  • 三板斧之 Volatile 原子性
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档