大家好,又见面了,我是你们的朋友全栈君。
这几天学习 Java
内存模型,查看文章:JSR 133 (Java Memory Model) FAQ
里面介绍了新的 Java
内存模型对 volatile
关键字的修订,因为只是一个 FAQ
,并没有很详细的解析 volatile
关键字的用法,找到一篇文章
详细的介绍了 volatile
适用的场景以及不适用的场景,翻译一下
主要内容:
- 引言
- 变量可见性问题(
Variable Visibiltiy Problems
) Java volatile
可见性保证(The Java volatile Visibility Guarantee
)- 指令重排序挑战(
Instruction Reordering Challenges
) Java volatile Happens-before
保证(The Java volatile Happens-before Guarantee
)volatile
并不总是足够(volatile is Not Always Enough
)- 什么情况下
volatile
就足够了?(When is volatile Enough?
) volatile
性能考虑(Performance Considerations of volatile
)
引言
引言
Java
关键字 volatile
被用于标记一个 Java
变量,表示 “存储在主内存”。更精确的说,对 volatile
变量的每一次读都来自于主内存而不是缓存;对 volatile
变量的每一次写也将会刷新到主内存,而不是保存在缓存中。
事实上,自从 Java 5
以来,volatile
关键字有了更多的作用,不仅仅是对 volatile
变量的读写都来自内存。我将在接下来的章节介绍。
变量可见性问题
变量可见性问题
Java volatile
关键字保证了线程之间对变量改变的可见性。这听起来有点抽象,让我来详细说明。
在多线程应用中,因为性能关系,线程操作的非 volatile
变量都会从主内存复制到缓存中。如果是多 CPU
机器,那么每个线程运行在不同的 CPU
上。这样的话,每个线程都会复制变量到不同的 CPU
缓存中,如下图所示:
非 volatile
变量无法保证 JVM
何时从主内存读数据或者何时将缓存数据刷新回主内存。这将造成下面几个问题。
想象这种场景,多个线程访问同一个共享对象,该对象包含了一个计数器变量:
public class SharedObject {
public int counter = 0;
}
如果只有线程 1
会对变量 counter
执行增量操作,线程 1
和 线程 2
偶尔会读取变量 counter
。
如果 counter
没有被定义为 volatile
,那么无法保证写入的 counter
变量从缓存刷新回内存。这也意味着,缓存中的 counter
变量的值和主内存中的不一样。如下图所示:
因为变量的最新值没有被写入主内存,导致线程无法看见变量的最新值的问题称为可见性问题 – 即一个线程的更新对其它线程不可见。
Java volatile
可见性保证
Java volatile
可见性保证Java volatile
关键字旨在解决变量可见性问题。通过声明变量 counter
为 volatile
,所有写入 counter
变量的数据将马上被刷新到主内存。同样的,所有读取 counter
变量的数据都来自主内存。
修改后的代码如下:
public class SharedObject {
public volatile int counter = 0;
}
声明变量为 volatile
,因此保证了线程对变量的可见性。
在上面的场景中,线程 T1
修改计数器然后线程 T2
读取计数器,声明 counter
变量为 volatile
足够保证 T2
对写入 counter
变量数据的可见性。
然而,如果 T1
和 T2
同时对 counter
变量进行增量操作,那么仅仅声明 counter
变量为 volatile
是不够的。
完整的 volatile
可见性保证(Full volatile Visibility Guarantee
)
事实上,Java volatile
的可见性保证超出了 volatile
变量本身。可见性保证如下:
- 如果线程
A
写入变量volatile
,随后线程B
读取相同的volatile
变量,那么在写入volatile
变量之前对线程A
可见的变量,同样对读取volatile
变量后的线程B
可见;(If Thread A writes to a volatile variable and Thread B subsequently reads the same volatile variable, then all variables visible to Thread A before writing the volatile variable, will also be visible to Thread B after it has read the volatile variable.
) - 如果线程
A
读取一个volatile
变量,那么对线程A
可见的变量在读取volatile
变量的时候重新从主内存读取。(If Thread A reads a volatile variable, then all all variables visible to Thread A when reading the volatile variable will also be re-read from main memory.
)
通过一段代码验证:
public class MyClass {
private int years;
private int months
private volatile int days;
public void update(int years, int months, int days){
this.years = years;
this.months = months;
this.days = days;
}
}
方法 update
更新了 3
个变量,其中变量 days
是 volatile
的。
完整的 volatile
可见性保证的含义是当 days
写入一个值,那么所有对这个线程可见的变量都将写入主内存。所以,当 days
写入一个值的时候,变量 years
和 months
的值都将写入主内存。
修改代码,加入读取函数:
public class MyClass {
private int years;
private int months
private volatile int days;
public int totalDays() {
int total = this.days;
total += months * 30;
total += years * 365;
return total;
}
public void update(int years, int months, int days){
this.years = years;
this.months = months;
this.days = days;
}
}
注意,totalDays()
方法首先读取 days
的值到变量 total
。当读取 days
值的时候,也会从主内存读取 months
和 years
的值。所以就能保证在上面读函数的中得到最新的 days
,months
和 years
的值。
指令重排序挑战
指令重排序挑战
JVM
和 CPU
可以通过重排序程序指令来提高性能,只要指令的语义仍旧保持不变。举个例子,看下面指令:
int a = 1;
int b = 2;
a++;
b++;
这些指令可以被重排序为下面序列,而且并没有失去程序语义:
int a = 1;
a++;
int b = 2;
b++;
然而,如果其中一个变量是 volatile
,指令重排序将带来一个挑战。之前的示例代码如下:
public class MyClass {
private int years;
private int months
private volatile int days;
public void update(int years, int months, int days){
this.years = years;
this.months = months;
this.days = days;
}
}
一旦方法 update
写入变量 days
一个值,那么新写入变量 years
和 months
的值也将刷新回内存。但是,如果 JVM
重排序了指令如下:
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}
值 months
和 years
的值仍旧会在变量 days
修改的时候写回主内存,但是这个动作发生在 months
和 years
修改之前。因此,新的值不能正确的显示给其它线程。这一次,重排序指令的语义改变了。
Java
针对这个问题提出一个解决方案,如下节所示。
Java volatile Happens-before
保证
Java volatile Happens-before
保证为了解决指令重排序的挑战,Java volatile
关键字除了可见性保证外,还给予了一个 "happens-before"
保证。这个 happens-before
保证如下:
- 如果对其它变量的读写原先就发生在
volatile
变量的写操作之前,那么它们不会被重排序到volatile
变量的写操作之后。在volatile
变量的写操作之前的读写操作保证happens-before
对这个volatile
变量的读操作。注意,本身就在volatile
变量写操作之后的读写操作仍旧有可能被重排序到之前执行。所以,在volatile
变量读操作之后的读取操作重排序到之前执行是允许的,但是在volatile
变量读操作之前的读取操作重排序到之后执行是不允许的。 volatile
变量读操作之后的读取操作不允许重排序到之前发生。同样的,volatile
变量读操作之前的读取操作是可能被重排序到之后发生。
上面的 happens-before
保证确保了 volatile
关键字的可见性保证被强制执行。
volatile
并不总是足够
volatile
并不总是足够即使 volatile
关键字保证了所有对 volatile
变量的读写操作都从主内存读取数据,仍旧存在仅声明变量为 volatile
不能保证安全性的场景。
之前的场景中,只有线程 1
写入共享变量 counter
,所以声明 counter
变量为 volatile
能够确保线程 2
总是看见最新的值。
在多线程读取和写入 volatile
变量的短时间间隔内会创造一个竞争条件,即多线程可能会读取到同一个 volatile
变量值,生成了一个变量的新值,当新值写回到主内存时会被重载。
当多线程对同一个计数器进行递增操作,那么仅靠一个 volatile
不能满足这种场景。下面小节将详细讨论这种情况。
想象一下,线程 1
读取了共享变量 counter
的值 0
到缓存,进行递增操作;在线程 1
将数据写回主内存之前线程 2
也读取了 counter
,同样进行递增操作。如下图所示:
线程 1
和 2
实际上没有进行同步。变量 counter
值应该是 2
,但是每个线程在缓存中的结果为 1
,而内存中的值为 0
。即使线程将数据写回到主内存,结果仍旧是错误的。
个人见解
上面这种情况表明 volatile
无法保证非原子性操作不会受到其他线程的干扰(同步能保证),所以,遇到这种情况应该进行同步操作。
什么情况下 volatile
就足够了?
什么情况下
volatile
就足够了?将向我之前提到的,如果两个线程对一个共享变量进行读写操作,那么仅使用 volatile
关键字不能保证安全性。在这种情况下,需要使用 synchronized
保证对变量的读写是原子性的。对 volatile
变量的读写不会阻塞线程,需要在临界区使用 synchronized
关键字。
除了使用 synchronized
以外,还可以使用 java.util.concurrent
包中的任何一种原子数据类型(atomic data type
)。比如,AtomicLong
或者 AtomicReference
等等。
如果仅有一个线程对 volatile
变量执行读写操作,其它线程仅执行读操作,那么使用 volatile
即可保证每次线程读的变量数据都是最新值。如果没有 volatile
声明,无法保证安全性。
volatile
关键字保证可以工作在 32
位和 64
位变量(各种基本数据类型均可使用)。
个人见解
如果多线程应用,仅有一个线程进行写操作,其它进行读操作,那么只需要声明变量为 volatile
就能保证线程安全。
除了这种情况,其它情况下仅声明 volatile
不能保证线程安全,有两种方法:
- 使用
synchronized
关键字保证原子性; - 使用原子数据类型
volatile
性能考虑
volatile
性能考虑对 volatile
变量的读写将造成从主内存的读写操作,这比访问缓存更加耗费时间。对 volatile
变量的访问也阻止了指令重排序,影响了性能的优化。因此,只有强制需要变量的可见性情况下,才使用 volatile
声明。
发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/161291.html原文链接:https://javaforall.cn
【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛
【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...