你不看绝对血亏的Volatile全方位解析!(面试篇

可以发现线程t2,虽然把stop设置为true了,但是线程t1对t2的stop变量视而不可见,因此,它一直在死循环running中。如果给变量stop加上volatile修饰,线程t1是可以停下来的,运行结果如下:

volatile boolean stop = false;

2. vlatile修饰变量的作用

从以上例子,我们可以发现变量stop,加了vlatile修饰之后,线程t1对stop就可见了。其实,vlatile的作用就是:保证变量对所有线程可见性。当然,vlatile还有个作用就是,禁止指令重排,但是它不保证原子性

所以当面试官问你volatile的作用或者特性,都可以这么回答:

  • 保证变量对所有线程可见性;
  • 禁止指令重排序
  • 不保证原子性

3. 现代计算机的内存模型(计算机模型,MESI协议,嗅探技术,总线)

为了更好理解volatile,先回顾一下计算机的内存模型与JMM(Java内存模型)吧~

计算机模型

计算机执行程序时,指令是由CPU处理器执行的,而打交道的数据是在主内存当中的。

由于计算机的存储设备与处理器的运算速度有几个数量级的差距,总不能每次CPU执行完指令,然后等主内存慢悠悠存取数据吧, 所以现代计算机系统加入一层读写速度接近处理器运算速度的高速缓存(Cache),以作为来作为内存与处理器之间的缓冲。

在多路处理器系统中,每个处理器都有自己的高速缓存,而它们共享同一主内存。计算机抽象内存模型如下:

  • 程序执行时,把需要用到的数据,从主内存拷贝一份到高速缓存。
  • CPU处理器计算时,从它的高速缓存中读取,把计算完的数据写入高速缓存。
  • 当程序运算结束,把高速缓存的数据刷新会主内存。

随着科学技术的发展,为了效率,高速缓存又衍生出一级缓存(L1),二级缓存(L2),甚至三级缓存(L3);

当多个处理器的运算任务都涉及同一块主内存区域,可能导致缓存数据不一致问题。如何解决这个问题呢?有两种方案

  • 1、通过在总线加LOCK#锁的方式。
  • 2、通过缓存一致性协议(Cache Coherence Protocol)
总线

总线(Bus)是计算机各种功能部件之间传送信息的公共通信干线,它是由导线组成的传输线束, 按照计算机所传输的信息种类,计算机的总线可以划分为数据总线、地址总线和控制总线,分别用来传输数据、数据地址和控制信号。

CPU和其他功能部件是通过总线通信的,如果在总线加LOCK#锁,那么在锁住总线期间,其他CPU是无法访问内存,这样一来,效率就比较低了

MESI协议

为了解决一致性问题,还可以通过缓存一致性协议。即各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。比较著名的就是Intel的MESI(Modified Exclusive Shared Or Invalid)协议,它的核心思想是:

当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

CPU中每个缓存行标记的4种状态(M、E、S、I),也了解一下吧:

缓存状态描述
M,被修改(Modified)该缓存行只被该CPU缓存,与主存的值不同,会在它被其他CPU读取之前写入内存,并设置为Shared
E,独享的(Exclusive)该缓存行只被该CPU缓存,与主存的值相同,被其他CPU读取时置为Shared,被其他CPU写时置为Modified
S,共享的(Shared)该缓存行可能被多个CPU缓存,各个缓存中的数据与主存数据相同
I,无效的(Invalid)该缓存行数据是无效,需要时需重新从主存载入

MESI协议是如何实现的?如何保证当前处理器的内部缓存、主内存和其他处理器的缓存数据在总线上保持一致的?多处理器总线嗅探

嗅探技术

在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据库读到处理器缓存中。

4. Java内存模型(JMM)

  • Java虚拟机规范试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。
  • Java内存模型类比于计算机内存模型。
  • 为了更好的执行性能,java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存打交道,也没有限制编译器进行调整代码顺序优化。所以Java内存模型会存在缓存一致性问题和指令重排序问题的
  • Java内存模型规定所有的变量都是存在主内存当中(类似于计算机模型中的物理内存),每个线程都有自己的工作内存(类似于计算机模型的高速缓存)。这里的变量包括实例变量和静态变量,但是不包括局部变量,因为局部变量是线程私有的。
  • 线程的工作内存保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接操作操作主内存。并且每个线程不能访问其他线程的工作内存。

举个例子吧,假设i的初始值是0,执行以下语句:

i = i+1;

首先,执行线程t1从主内存中读取到i=0,到工作内存。然后在工作内存中,赋值i+1,工作内存就得到i=1,最后把结果写回主内存。因此,如果是单线程的话,该语句执行是没问题的。但是呢,线程t2的本地工作内存还没过期,那么它读到的数据就是脏数据了。如图:

Java内存模型是围绕着如何在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的,我们再来一起回顾一下~

5.并发编程的3个特性(原子性、可见性、有序性)

原子性

原子性,指操作是不可中断的,要么执行完成,要么不执行,基本数据类型的访问和读写都是具有原子性,当然(long和double的非原子性协定除外)。我们来看几个小例子:

i =666; // 语句1
i = j; // 语句2
i = i+1; //语句 3
i++; // 语句4

  • 语句1操作显然是原子性的,将数值666赋值给i,即线程执行这个语句时,直接将数值666写入到工作内存中。
  • 语句2操作看起来也是原子性的,但是它实际上涉及两个操作,先去读j的值,再把j的值写入工作内存,两个操作分开都是原子操作,但是合起来就不满足原子性了。
  • 语句3读取i的值,加1,再写回主存,这个就不是原子性操作了。
  • 语句4 等同于语句3,也是非原子性操作。
可见性
  • 可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
  • Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。
  • volatile变量,保证新值能立即同步回主内存,以及每次使用前立即从主内存刷新,所以我们说volatile保证了多线程操作变量的可见性。
  • synchronized和Lock也能够保证可见性,线程在释放锁之前,会把共享变量值都刷回主存。final也可以实现可见性。
有序性

Java虚拟机这样描述Java程序的有序性的:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中,观察另一个线程,所有的操作都是无序的。

后半句意思就是,在Java内存模型中,允许编译器和处理器对指令进行重排序,会影响到多线程并发执行的正确性;前半句意思就是as-if-serial的语义,即不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不会被改变。

比如以下程序代码:

double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C

步骤C依赖于步骤A和B,因为指令重排的存在,程序执行顺讯可能是A->B->C,也可能是B->A->C,但是C不能在A或者B前面执行,这将违反as-if-serial语义。

看段代码吧,假设程序先执行read方法,再执行add方法,结果一定是输出sum=2嘛?

bool flag = false;
int b = 0;

public void read() {
b = 1; //1
flag = true; //2
}

public void add() {
if (flag) { //3
int sum =b+b; //4
System.out.println(“bb sum is”+sum);
}
}

如果是单线程,结果应该没问题,如果是多线程,线程t1对步骤1和2进行了指令重排序呢?结果sum就不是2了,而是0,如下图所示:

这是为啥呢?指令重排序了解一下,指令重排是指在程序执行过程中,为了提高性能, 编译器和CPU可能会对指令进行重新排序。CPU重排序包括指令并行重排序和内存系统重排序,重排序类型和重排序执行过程如下:

实际上,可以给flag加上volatile关键字,来保证有序性。当然,也可以通过synchronized和Lock来保证有序性。synchronized和Lock保证某一时刻是只有一个线程执行同步代码,相当于是让线程顺序执行程序代码了,自然就保证了有序性。

实际上Java内存模型的有序性并不是仅靠volatile、synchronized和Lock来保证有序性的。这是因为Java语言中,有一个先行发生原则(happens-before):

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

根据happens-before的八大规则,我们回到刚的例子,一起分析一下。给flag加上volatile关键字,look look它是如何保证有序性的,

volatile bool flag = false;
int b = 0;

public void read() {
b = 1; //1
flag = true; //2
}

public void add() {
if (flag) { //3
int sum =b+b; //4
System.out.println(“bb sum is”+sum);
}
}

  • 首先呢,flag加上volatile关键字,那就禁止了指令重排,也就是1 happens-before 2了
  • 根据volatile变量规则,2 happens-before 3
  • 程序次序规则,得出 3 happens-before 4
  • 最后由传递性,得出1 happens-before 4,因此妥妥的输出sum=2啦~

6.volatile底层原理

以上讨论学习,我们知道volatile的语义就是保证变量对所有线程可见性以及禁止指令重排优化。那么,它的底层是如何保证可见性和禁止指令重排的呢?

图解volatile是如何保证可见性的?

在这里,先看几个图吧,哈哈~

假设flag变量的初始值false,现在有两条线程t1和t2要访问它,就可以简化为以下图:

如果线程t1执行以下代码语句,并且flag没有volatile修饰的话;t1刚修改完flag的值,还没来得及刷新到主内存,t2又跑过来读取了,很容易就数据flag不一致了,如下:

flag=true;

如果flag变量是由volatile修饰的话,就不一样了,如果线程t1修改了flag值,volatile能保证修饰的flag变量后,可以立即同步回主内存。如图:

细心的朋友会发现,线程t2不还是flag旧的值吗,这不还有问题嘛?其实volatile还有一个保证,就是每次使用前立即先从主内存刷新最新的值,线程t1修改完后,线程t2的变量副本会过期了,如图:

显然,这里还不是底层,实际上volatile保证可见性和禁止指令重排都跟内存屏障有关,我们编译volatile相关代码看看~

DCL单例模式(volatile)&编译对比

DCL单例模式(Double Check Lock,双重检查锁)比较常用,它是需要volatile修饰的,所以就拿这段代码编译吧

public class Singleton {
private volatile static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}

小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Java工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Java开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
img
img
img

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频

如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注Java)
img

知其然不知其所以然,大厂常问面试技术如何复习?

1、热门面试题及答案大全

面试前做足功夫,让你面试成功率提升一截,这里一份热门350道一线互联网常问面试题及答案助你拿offer

2、多线程、高并发、缓存入门到实战项目pdf书籍

3、文中提到面试题答案整理

4、Java核心知识面试宝典

覆盖了JVM 、JAVA集合、JAVA多线程并发、JAVA基础、Spring原理、微服务、Netty与RPC、网络、日志、Zookeeper、Kafka、RabbitMQ、Hbase、MongoDB 、Cassandra、设计模式、负载均衡、数据库、一致性算法 、JAVA算法、数据结构、算法、分布式缓存、Hadoop、Spark、Storm的大量技术点且讲解的非常深入

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

、Cassandra、设计模式、负载均衡、数据库、一致性算法 、JAVA算法、数据结构、算法、分布式缓存、Hadoop、Spark、Storm的大量技术点且讲解的非常深入**

[外链图片转存中…(img-rcNmxvcz-1710410454501)]

[外链图片转存中…(img-HRQmGveZ-1710410454501)]

[外链图片转存中…(img-9quy4VUd-1710410454502)]

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

  • 21
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值