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来解决,或者加锁)

总结

volatile实现了

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

未实现:

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

参考资料

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

猜你喜欢

转载自blog.csdn.net/qq_34687559/article/details/114329619