java并发之 volatile关键字 详详详解

什么是volatile关键字

volatile关键字用于修饰变量,被该关键字修饰的变量可以保证可见性与有序性。
但是它不能实现原子性。
可以把它看做一个弱化版、轻量级的Synchronized关键字。
用于同步。

下面我们就先从上面提到的三个特性来往下叙述。

三个特性

可见性、原子性、有序性是整个java并发的基础。

  • 可见性:即当一个线程修改了某个共享变量的值,在这个操作之后,其他线程读该变量,读取到的都是已经修改过的新数据,而不是旧的数据。
  • 原子性:一个操作,它是不可分割也不可打断的,它要么执行完毕,要么不执行,不可能说我执行到一半就停在那。
  • 有序性:比如我们的代码是按照相对顺序来执行的。前一行的代码先执行,后一行的代码后执行。为什么这么说呢?在单线程环境下,确实可以看做是按顺序来的,但在多线程的视角不一定。编译器、cpu都会为了执行的效率,在保证单线程结果正确的前提下,进行代码or指令的重新排序。对于单线程来说,倒不影响,但是对于多线程来说,这就出事了。

这三个特性可以说就是我们在java并发中希望解决的问题了。
针对于此,JMM(java内存模型)就是围绕这三个特征构建起来的。

下面,我们来介绍JMM

java内存模型

在我们的硬件底层,cpu需要与主存(内存)来进行交互。
但我们知道CPU中的寄存器速度很快,但主存的速度相对寄存器来说那是慢了太多了。
如果cpu直接与内存进行交互,那就太浪费时间了。而寄存器容量又小,成本太高。
于是底层便采用了缓存来连接寄存器(cpu)与主存。作为一个速度位于二者之间,价格也能接受。作为二者的缓存。
现在基本底层都采用这种模型。但是不同cpu有不同的模型,如果直接映射给程序员来使用,太麻烦了,程序员需要考虑的场景太多了。
于是java建立自己内存模型来封装这些模型,自定义一个默认的不变的逻辑提供给程序员。这就是java内存模型(JMM)。

也就是我们理解底层的内存情况,只需要考虑java内存模型即可,不需要考虑是什么cpu什么乱七八糟的。
在这里插入图片描述
JMM的示意图如上所示。
这里的模型是一个逻辑上概念,不一定真实存在,作为程序员,我们也不需要考虑其是否真实存在。

每个线程都私有一个工作线程,工作线程与主内存连接。
主内存是所有线程共享的,其中存储的就是共享的变量(几乎所有实例变量,静态变量,Class对象等)

  • 线程读操作:先将主内存中的共享变量刷新到自己本地内存,然后再从本地内存读取
  • 线程写操作:先将数据写入本地内存,然后再将数据刷新到主内存

这里需要注意,无论是读写,都不是原子的,它们是分隔的两个操作的符合操作。
线程所有的数据读写都要在本地内存中进行,不可直接操作主内存。

JMM的问题

这种内存模型因为实现了缓存,让cpu的吞吐量尽量大,不用去等待缓慢的内存,有了好处,自然也会有坏处。
将读写操作都给分隔开,便会造成线程安全的问题。
例如下面的例子:

	static int i =0;
	两个线程同时执行以下代码,结果会怎样呢?
	线程A执行将i=2;
	线程B读取i值;

线程A先运行,线程B后运行,线程B读到的i是2吗?
不一定,可能是0.
线程A运行,将i=2变量放到本地内存时,线程B将主存中的i刷新到自己的本地内存中,此时主存中的i为0,然后线程A的本地内存将i刷新到主存。
于是,最终结果是,主存中i为2,线程B读取的结果为0.虽然按照逻辑A先运行,B后运行,结果应该为2才对。但事实不是这样,所以这便是JMM带来的问题。(这里线程AB在缓存中的i值是不一样的,所以就设计到了缓存一致性的问题)

指令重排序

除了上面的问题,还面临着指令重排序(代码,也可以理解成字节码指令,都差不多)带来的问题。

首先提问为什么会重排序?
对于jvm和编译期来说,当前的代码顺序并不一定效率更高,所以为了追求效率,需要将顺序打乱。
特别是在并发的环境下,重排序就显得比较重要。

举这样一个例子
现代CPU通常都会使用流水线技术。
因为需要执行多个指令,每个指令也可以分解为不同的步骤,每个步骤用的寄存器(不如说资源)不同,如果同一个时刻只执行一条指令的一个部分,那么除了它所占用的资源,其他的资源都浪费了。
于是,我们采用流水线技术,比如第一个时刻执行指令1的a部分,同时执行指令2的b部分,同时执行指令3的d部分。如此同一时刻,多条指令同时运行,效率高了很多。
同时,如果对于一条指令来说,如果其中两个步骤可以调换顺序的话,那么我们就不必按顺序非得步骤3等待步骤2(这样会阻塞),我可以按照最优来选择先执行步骤2或步骤3.如此,进行指令重新排序,会让效率更高。

和这个例子很像,我们这里代码重排序也是一样,为了效率。

例如:

int i = 0;//1
int j = 1;//2
int a = i+j;//3

比如下面的例子,我们一定要按照123的顺序来执行吗?
不一定,如果更快的话,我们可以采用213的顺序,此时结果也不变。

那么你可能要问了,为什么这里不能312呢?
很简单,因为3依赖于12,12必须先于3执行,而12的相对顺序无所谓。
我们人可以很简单的看出来,而jvm如何确定呢?

通过定义好的happens before 规则来确定。

Happens Before规则

这是提前定义好的规则,在jvm进行优化(重排序)的时候,是不能违背的。

  1. 程序次序原则:一个线程内,按照程序代码顺序,书写在前面的操作先发生于书写在后面的操作。
  2. volatile 规则:volatile 变量的写,先发生于读,这保证了 volatile 变量的可见性。
  3. 锁规则:解锁(unlock) 必然发生在随后的加锁(lock)前。
  4. 传递性:A先于B,B先于C,那么A必然先于C。
  5. 线程的 start 方法先于他的每一个动作。
  6. 线程的所有操作先于线程的终结。
  7. 线程的中断(interrupt())先于被中断的代码。
  8. 对象的构造函数,结束先于 finalize 方法。

第1条规则程序顺序规则是说在一个线程里,所有的操作都是按顺序的,但是在JMM里其实只要执行结果一样,是允许重排序的,这边的happens-before强调的重点也是单线程执行结果的正确性,但是无法保证多线程也是如此。 第2条规则监视器规则其实也好理解,就是在加锁之前,确定这个锁之前已经被释放了,才能继续加锁。 第3条规则,就适用到所讨论的volatile,如果一个线程先去写一个变量,另外一个线程再去读,那么写入操作一定在读操作之前。 第4条规则,就是happens-before的传递性。 后面几条就不再一一赘述了。

在单个线程中,重排序是无所谓的,因为实际结果不变,但多线程,那就问题大了。

例如下面的问题:

int a = 0;
bool flag = false;

public void write() {
   a = 2;              //1
   flag = true;        //2
}

public void multiply() {
   if (flag) {         //3
       int ret = a * a;//4
   }
}

线程A先执行writer方法,线程B后执行multiply方法。
结果是4吗,不一定,如果进行了重排序,如下所示

	线程A		线程B
	2			
				3
				4
	1

这里1,2是程序顺序规则,可以重排序的。
而3,4因为有相互依赖,所以3 happens-before 4,不能重排,重排出问题。
当是上面这种情况时,ret的结果是0.与我们预期的不一样。
所以这里就出现了问题,我们在代码中明明是希望结果是4的。

volatile关键字出场

为了解决上面的问题,主角登场。

首先对于第一个问题:
将静态变量i设置为volatile

	static volatile int i =0;
	两个线程同时执行以下代码,结果会怎样呢?
	线程A执行将i=2;
	线程B读取i值;

那么此时,读/写操作都是原子性的,所以线程A先进行写,(即将写入工作内存,工作内存刷新到主内存这两个步骤合二为一,写入工作内存之后立即刷新)
然后B线程进行读,也是一样获取的话也是直接刷新之后获取。
最终结果是2.


对于第二个问题:

int a = 0;
volatile bool flag = false;

public void write() {
   a = 2;              //1
   flag = true;        //2
}

public void multiply() {
   if (flag) {         //3
       int ret = a * a;//4
   }
}

这里只将flag定义为volatile。
来看下此时的hb顺序。
在write方法中,这里要说明一下:
volatile关键字会禁止重排序,所谓禁止是该代码之前的普通变量操作必须发生在自己之前,不能重排到自己后面,同理后面的不能到前面。volatile关键字这里相当于一个屏障,将自己上下的区域分割开了,你们自己区域重排不关我事,但是不能跨区域瞎搞。(实际也是使用内存屏障,比如在这个屏障之前的写操作全部刷新到主存中去)
因此,这限制了1-2的顺序
同时因为volatile的写hb于读,所以2-3
同时3与4有依赖关系,所以3-4

故,最终实现了1-2-3-4的顺序,便如我们所愿了。

这里便是分别实现了可见性,有序性了。


原子性呢?
我们前面说的volatile不是实现了读/写的原子性了嘛,那为啥不是原子性呢?

这里我们说的原子性是关于i++这种,对一个值进行原值基础上的修改。
这里不是简单的读or写。
逻辑是:

	先读
	修改
	写

是一个复合操作。volatile不能实现这种复合操作的原子性,便没有办法。

试想一下,对于i=0;
线程A执行i++;线程B也执行i++;
可能会出现什么情况

	线程A		线程B
	读			
				读
	修改	
				修改
	写			
				写

按照上面的顺序,则会导致i不是为2,而是为1,当线程A获取i=0时,之后线程B也读的0,当线程A写完了,线程B也早已读完,所以它写的时候也是1。所以与我们所期望的不同。
volatile是无法解决此问题。
(ps:这里可以考虑通过CAS来解决,或者加锁)

volatitle具体实现

volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令前会加入读屏障

通过内存屏障实现了

  • 可见性
    • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
    • 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中新数据
  • 有序性
    • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

这样基本就清除了volatile的原理了把

总结

volatile实现了

  • 可见性:即将写入工作内存,工作内存刷新到主内存这两个步骤合二为一,将主内存刷新到工作内存,cpu从工作内存中获取值也合二为一,于是使得volatile变量的读/写是原子的,所以能够保证可见。
    这里实现合二为一的操作是汇编中的lock前缀,使得本cpu的cache刷新入内存,同时,无效化其他的cache,从而别的cpu要重新获取cache。(或者说,写完,立刻刷新,读时也要立即刷新读取)
  • 有序性:volatile关键字会禁止指令重排序,使用内存屏障,屏障前后你代码怎么重排都无所谓,但是前不能到后,后不能到前。语义上是,内存屏障之前的写都要刷新入内存,,从而内存屏障之后的读可以获取之前写的结果。(所以内存屏障会降低新能,导致无法优化代码)

未实现:

  • 原子性:只实现了单个操作的原子性,像i++是先读,后修改,最后写,是一个复合操作,所以不保证原子性

参考资料

并发编程之Java内存模型+volatile关键字+HappenBefore规则
线程安全(上)–彻底搞懂volatile关键字
java面试官最爱问的volatile关键字

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
### 回答1: volatileJava中的一个关键字,用于修饰变量。它的作用是告诉编译器,该变量可能会被多个线程同时访问,因此需要特殊处理,以保证线程安全。 具体来说,volatile关键字有以下几个特点: 1. 可见性:当一个线程修改了volatile变量的值,其他线程能够立即看到这个修改。 2. 有序性:volatile变量的写操作会按照程序的顺序执行,不会被重排序。 3. 不保证原子性:虽然volatile变量能够保证可见性和有序性,但是它并不能保证多个线程同时修改变量时的原子性。 因此,如果需要保证原子性,需要使用synchronized关键字或者Lock接口来进行同步。 总之,volatile关键字Java中用于保证多线程访问变量的安全性的一种机制,它能够保证可见性和有序性,但是不能保证原子性。 ### 回答2: Java中的volatile关键字是一种轻量级的同步机制,用于确保多个线程之间的可见性和有序性。它可以用于修饰变量、类和方法。 1. 修饰变量:当一个变量被volatile修饰时,它会被立即写入到主内存中,并且每次取变量时都会从主内存中重新获取最新的值。这样可以保证多个线程操作同一个变量时的可见性和一致性。 2. 修饰类:当一个类被volatile修饰时,它的实例变量就会被同步,而且每个线程都会获取最新的变量值。这样可以保证多线程操作同一对象时的可见性和一致性。 3. 修饰方法:当一个方法被volatile修饰时,它的调用会插入内存栅栏(memory barrier)指令,这可以保证方法调用前的修改操作都已经被写入主内存中,而方法调用后的取操作也会重新从主内存中取最新值。这样可以确保多线程之间的调用顺序和结果可见性。 需要注意的是,volatile并不能完全取代synchronized关键字,它只适用于并发度不高的场景,适用于只写入不取的场景,不能保证复合操作的原子性。 总之,volatile关键字Java中具有广泛的应用,可以保证多线程之间的数据同步和可见性,但也需要谨慎使用,以免造成数据不一致和性能问题。 ### 回答3: Java中的volatile关键字意味着该变量在多个线程之间共享,并且每次访问该变量时都是最新的值。简单来说,volatile保证了线程之间的可见性和有序性。下面我们详细解释一下volatile的用法和作用。 1. 线程之间的可见性 volatile关键字保证了对该变量的写操作对所有线程都是可见的。在没有用volatile关键字修饰变量的情况下,如果多个线程并发访问该变量,每个线程都会从自己的线程缓存中取该变量的值,而不是直接从主存中取。如果一个线程修改了该变量的值,但是其他线程不知道,那么可能导致其他线程获取到的数据不是最新的,从而引发一系列问题。而用了volatile关键字修饰该变量后,每次修改操作都会立即刷新到主存中,其他线程的缓存中的变量值也会被更新,从而保证了线程之间的可见性。 2. 线程之间的有序性 volatile关键字也保证了线程之间的有序性。多个线程并发访问同一个volatile变量时,JVM会保证每个线程按照程序指定的顺序执行操作。例如,在一个变量被volatile修饰的情况下,多个线程同时对该变量进行写操作,JVM会保证先执行写操作的线程能够在后续的操作中获取到最新的变量值。这么做的好处是,可以避免出现线程间操作顺序的乱序问题,从而保证了程序的正确性。 需要注意的是,并不是所有的变量都需要用volatile关键字修饰。只有在多个线程之间共享变量并且对变量的写操作之间存在依赖关系的情况下,才需要使用volatile关键字。此外,volatile关键字不能保证原子性,如果需要保证操作的原子性,需要使用synchronized或者Lock等其他并发工具。 总之,volatile关键字Java中非常重要的关键字之一,它可以在多个线程之间保证可见性和有序性,从而保证了程序的正确性。在开发过程中,我们应该根据具体情况来选择是否使用volatile关键字,以及如何使用它。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值