点击上方"码之初"关注,···选择"设为星标"
与Java精品技术文章不期而遇
你若盛开,蝴蝶自来
面试系列结束了,昨天的面试终结篇竟然意外的受到了许多乡亲们的表扬,做这个公众号以来,第一次收到那么多在看,竟然还有打赏,码之初何德何能,虽然是无心插柳,却承蒙乡亲们如此厚爱,荣幸之至之余又感到受宠若惊,我开始真正意识到我可能是在做一件有意义的事。
你若盛开,蝴蝶自来。你若精彩,天自安排。所以,我只能不断学习,提高自己技能的同时,也努力为乡亲们带来更好的内容,和大家一起进步、成长,希望能和乡亲们互相陪伴,一起见证彼此从发展到发光。
学无止境,善始善终
学无止境,一段旅程结束了,短暂的休整之后我们就要重新踏上征程。 在做面试系列之前,码之初有两个未完的系列,一个是设计模式还有五六种没有更新完,一个是并发系列没有介绍完,做人要始终不渝,做事要善始善终,所以接下来的一段时间,码之初会将并发系列更新完,尽量在中间加一些有趣的内容,防止乡亲们觉得枯燥,弃我而去,哈哈。
Java volatile关键字用于将Java变量标记为“存储在主内存中”。更准确地说,这意味着每次对volatile变量的读取都将从计算机的主内存中读取,而不是从CPU缓存中读取,并且对volatile变量的每次写入都将被写入主内存中,而不仅是CPU缓存。
事实上,从Java 5开始,volatile关键字保证了volatile变量不仅仅是写入主内存或者是从主内存读取,我将从下面几个方面进行解释。
01、变量可见性问题
Java volatile关键字可确保跨线程更改变量的可见性。 这听起来有点抽象,下面来详细说明。
在多线程应用中,线程对非易失性变量进行操作,出于性能方面的考虑,每个线程在进行操作时都可以将主内存中的变量复制到CPU缓存中。 如果计算机包含多个CPU,则每个线程可能在不同的CPU上运行。 这意味着,每个线程可以将变量复制到不同CPU的CPU缓存中。 我们来看个例子:
对于非易失性变量,无法保证Java虚拟机(JVM)何时将数据从主存读取到CPU缓存,何时将数据从CPU缓存写入主存。 这可能会导致一些问题,这些都将在下面一起解释。
设想一种情况,有两个或多个线程可以访问一个共享对象,该共享对象包含一个声明为以下内容的计数器变量:
public class SharedObject {
想象一下,只有线程1递增计数器变量,但线程1和线程2都可能时不时地读取计数器变量。
如果计数器变量未声明为volatile,则无法保证计数器变量的值何时从CPU缓存写入主内存。 这意味着,CPU缓存中的计数器变量值可能与主内存中的不同。 这种情况如下所示:
线程看不到变量的最新值,因为它还没有被另一个线程写回主内存的问题,称为“可见性”问题。 一个线程的更新对其他线程不可见。
02、Java volatile可见性保证
Java volatile关键字旨在解决变量可见性问题。 通过声明计数器变量为volatile,所有对计数器变量的写操作将立即写回到主内存中。 同样,计数器变量的所有读取将直接从主内存中读取。
我们对上面的计数器变量加上volatile关键字声明,像这样:
public class SharedObject {
因此,将变量声明为volatile可以保证对该变量的其他写入线程的可见性。
在上面给出的场景中,一个线程(T1)修改计数器,另一个线程(T2)读取计数器(但从不修改),声明计数器变量volatile足以保证T2对计数器变量的写入可见性。
但是,如果T1和T2都在递增计数器变量,那么声明计数器变量volatile是不够的,这个以后再说。
完全易失的可见性保****证(Full volatile Visibility Guarantee)
实际上,Java volatile的可见性保证超出了volatile变量本身。可见性保证如下:
如果线程A写入易失性变量,并且线程B随后读取相同的易失性变量,则在写入易失性变量之前线程A可见的所有变量在线程B读取易失性变量后也将可见。
如果线程A读取了易失性变量,则在读取易失性变量时线程A可见的所有所有变量也将从主内存中重新读取。
我用代码示例来说明这一点:
public class MyClass {
udpate()方法写入三个变量,其中只有days是可变的。
完全易失的可见性保证意味着,当将值写入days时,线程可见的所有变量也将写入主内存。 这意味着,当将值写入days时,years和months的值也将写入主内存中。
在读取years,months和days的值时,可以这样:
public class MyClass {
注意:totalDays()方法首先将days的值读入total变量。 当读取days的值时,months和years的值也会被读入主内存,因此,可以保证按照上述读取顺序查看days,months和years的最新值。
03、指令重排挑战性
出于性能原因,允许Java VM和CPU对程序中的指令进行重新排序,只要指令的语义含义保持相同即可。 例如,看下面这个指令:
int a = 1;
这些指令可以重新排序为以下顺序,而不会丢失程序的语义:
int a = 1;
然而,当其中一个变量是volatile易失性变量时,指令重新排序是一个挑战。 让我们看一下前面示例中的MyClass类:
public class MyClass {
update()方法将值写入days后,新写入的years和months值也将写入主内存。 但是,如果Java VM重新对指令进行排列,比如:
public void update(int years, int months, int days){
当days变量被修改时,months和years的值仍会写入主内存,但这次是在新值写入months和years之前发生的。因此,新值对其他线程来说是不可见的。重新排序的指令的语义已更改。
Java有解决此问题的方法,我们将在后面看到。
04、Java易失性发生之前保证
为了解决指令重新排序的难题,除了可见性保证之外,Java volatile关键字还提供易失性发生之前(“happens-before”)保证。 发生之前保证保证了:
如果读/写最初发生在对volatile变量的写入之前,则不能将对其他变量的读/写重新排序为在对volatile变量的写入之后发生。在写入volatile变量之前的读/写保证在写入volatile变量之前“发生”。注意,例如,在对volatile的写操作之后的其他变量的读/写操作仍有可能在对volatile的写操作之前重新排序。但不是相反。允许从后到前,但不允许从前到后。
如果读取/写入最初发生在读取volatile变量之后,则不能将对其他变量的读取和写入重新排序为在读取volatile变量之前发生。注意,在volatile变量的读取之前发生的其他变量的读取可能会被重新排序为在volatile变量的读取之后发生。但不是相反。允许从前到后,但不允许从后到前。
上述“易失性发生之前(“happens-before”)确保强制执行volatile关键字的可见性保证。
05、声明volatile还不一定够
即使volatile关键字保证所有volatile变量的读取都直接从主存中读取,并且所有对volatile变量的写入都直接写入主存,但在某些情况下,声明变量volatile还不够。
在前面解释的只有线程1写入共享计数器变量的情况下,声明计数器变量为volatile,足以确保线程2始终看到最新的写入值。
实际上,如果写入变量的新值不依赖于先前的值,则多个线程甚至可能正在写入一个共享的volatile变量,并且仍将正确的值存储在主内存中。 换句话说,如果线程首先将值写入共享的volatile变量,则不需要先读取其值即可找出下一个值。
一旦线程需要首先读取volatile变量的值,并基于该值为共享的volatile变量生成新值,则volatile变量将不再足以保证正确的可见性。 读取volatile变量与写入新值之间的时间间隔很短,从而造成竞争状态,多个线程可能会读取volatile变量的同一个值,为该变量生成一个新值,并且在将该值写入主内存时 - 覆盖彼此的值。
多个线程递增同一个计数器的情况正是这样一种情况,即声明volatile变量还不够。 下面将更详细地解释此案例。
想象一下,如果线程1将一个值为0的共享计数器变量读入其CPU高速缓存中,将其递增为1,而不是将更改后的值写回到主内存中。 然后,线程2可以从主内存中(该变量的值仍为0)读取相同的计数器变量到其自己的CPU高速缓存中。 然后线程2还可将计数器增加到1,并且也不会将其写回主内存。 下图说明了这种情况:
线程1和线程2现在实际上不同步。 共享计数器变量的实际值应该是2,但每个线程的CPU缓存中都有该变量的值1,但在主内存中该值仍然为0。 这是糟糕的,即使线程最终将共享计数器变量的值写回主内存,该值也会出错。
06、什么时候声明volatile变量就足够
如前所述,如果两个线程都在读写一个共享变量,那么使用volatile关键字是不够的。 在这种情况下,您需要使用synchronized来保证变量的读写是原子的。 读取或写入volatile变量不会阻塞线程的读取或写入。 为此,必须在关键部分使用synchronized关键字。
作为同步块的替代方法,您还可以使用java.util.concurrent包中提供的许多原子数据类型之一。 例如,AtomicLong或AtomicReference或其他之一。
如果只有一个线程读取和写入volatile变量的值,而其他线程只读取该变量,那么读取线程将保证看到写入volatile变量的最新值。 如果不将变量声明volatile,就无法保证这一点。
volatile关键字保证可以在32位和64位变量上使用。
07、volatile的性能考虑
读写volatile变量会使该变量被读写到主存。 与访问CPU缓存相比,读写主内存的开销更大。 访问volatile变量还可以防止指令重新排序,这是一种常见的性能增强技术。 因此,只有在确实需要增强变量的可见性时,才应该使用volatile变量。
如果您觉得本文有参考价值,
麻烦您点“在看”鼓励一下,
或者点下文末在看支持一下,谢谢
往期精选
本文分享自微信公众号 - 码之初(ma_zhichu)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。