提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
- 1. volatile关键字介绍
- 2. volatile变量可见性问题
- 3. volatile 变量可见性保证
- 4. volatile 并不总是够用的
- 5. volatile怎样才能具备原子性?
- 6. volatile的性能考虑
- 7. 总结
1. volatile关键字介绍
在阅读本文的时候,需要有Java内存模型相关基础知识,如果不了解Java内存模型的朋友,请点击这里先了解Java内存模型,有助于您更好的理解本文对volatile的讲解。
Java volatile
关键字用于将 Java 变量标记为“存储在主内存中”。更准确地说,这意味着线程每次读取 volatile
变量都将从计算机的主内存中
读取,而不是从 CPU 寄存器
中读取,并且每次写入的 volatile
变量都将被写入主内存
,而不仅仅是 CPU 寄存器
。
实际上,从 Java 5 开始,volatile
关键字就起到保证 volatile 变量可以写入主内存和从主内存读取。
2. volatile变量可见性问题
volatile
关键字保证了跨线程变量更改的可见性。这听起来可能有点抽象,所以让我详细说明一下。
在线程对non-volatile
变量进行操作的多线程应用程序中,出于性能原因,每个线程在处理变量时都可以将变量从主内存复制到CPU寄存器中。如果您的计算机包含多个CPU,则每个线程可能在不同的CPU上运行。这意味着,每个线程都可以将变量复制到不同CPU的CPU寄存器中。如下图所示:
对于non-volatile
变量,无法保证
Java 虚拟机 (JVM) 何时
将数据从主内存读取到 CPU 寄存器,或将数据从 CPU 寄存器写入主内存。这可能会导致几个问题,我将在以下部分中解释这些问题。
想象一下这样一种情况,两个或多个线程可以访问一个共享对象,该对象包含如下声明的counter
变量:
public class SharedObject {
public int counter = 0;
}
想象一下,只有线程 1 会增加counter变量,但线程 1 和线程 2 都可能counter不时读取变量。
如果counter
变量未声明为volatile
,则无法保证counter变量的值
何时从CPU寄存器写回主存储器。这意味着CPU寄存器中的counter
变量值可能与主存储器中的不同。这种情况如下所示:
线程看不到变量的最新值
,因为它尚未被另一个线程写回主内存,这种问题称为“可见性
”问题,即一个线程的更新对其他线程表现为不可见。
3. volatile 变量可见性保证
Java volatile
关键字旨在解决变量可见性问题。通过声明counter变量为volatile,可以促使对counter变量的所有写入都将立即写回主内存。此外,counter变量的所有读取都将直接从主存储器中读取。
以下是counter变量的volatile声明:
public class SharedObject {
public volatile int counter = 0;
}
因此,声明了volatile的变量可以保证对该变量的其他写入线程的可见性
。
在上面给出的场景中,一个线程(T1)修改counter,另一线程(T2)读取counter(但从不修改),为counter变量声明volatile
足以保证T2线程
对计数器变量写入的可见性。
然而,如果T1和T2都在递增
counter变量,那么仅仅为counter变量声明volatile
却是不够的,无法保证counter变量最终的值是准确的
。
3.1 Full volatile完全易失性可见性保证
实际上,Java volatile的可见性保证超出了volatile变量本身。能见度保证如下:
如果线程A写入一个volatile变量,而线程B随后读取了相同的volatile参数,那么线程A在写入volatile之前可见的所有变量在线程B读取volatile后也将可见。
如果线程A读取了一个volatile变量,那么线程A在读取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;
}
}
udpate()
方法写入三个变量,其中只有days变量是声明了volatile的。
完全易失性(Full volatile
)可见性保证意味着,当一个值被写入days时,线程可见的所有变量也会被写入主存。这意味着,当一个值写入days时,years和months的值也会写入主存。
在读取years, months和days的值时,你可以这样做:
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的值时,years和months的值也会被读取到主存储器中。因此,您可以保证按照上述读取顺序看到最新的 years, months 和days值。
3.2 指令重新排序挑战
出于性能原因,Java VM 和 CPU 可以对程序中的指令进行重新排序,只要指令的语义保持不变。例如,查看以下指令:
int a = 1;
int b = 2;
a++;
b++;
这些指令在执行时,可以重新排序为以下顺序,而不会丢失程序的语义含义:
int a = 1;
a++;
int b = 2;
b++;
然而,当其中一个变量声明了volatile时,指令重新排序会带来挑战
。让我们看看本文前面示例中的MyClass类:
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和month值也会写入主存。但是,如果Java虚拟机对指令进行了重新排序
,如下所示:
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}
修改days
变量时,months
和years
的值仍会写入主内存,但这次是在新值写入months
和years
之前发生的。因此,新值不能正确地对其他线程可见。重新排序的指令的语义含义发生了变化。
Java为这个问题提供了一个解决方案,请继续阅读下一节。
3.3 volatile 的 Happens-Before 保证
为了应对指令重新排序的挑战,Java volatile
关键字除了提供可见性保证外,还提供了“happens-before
”的保证。担保前发生保证:
如果读取/写入最初发生在写入volatile
变量之前,则不能将对其他变量的读取和写入重新排序为在写入volatile变量之后发生。
在写入volatile
变量之前进行的读/写操作保证会发生在写入volatile
变量之前。请注意,例如,对位于写入volatile
变量之后的其他变量的读取/写入仍然有可能在写入volatile
之前重新排序。只是不是反过来。从后到前是允许的,但从前到后是不允许的。
如果读取/写入最初发生在读取volatile
变量之后,则不能将对其他变量的读取和写入重新排序为在读取volable
变量之前发生。请注意,在读取volatile
变量之前发生的其他变量的读取可能会被重新排序为在读取volable变量之后发生。只是不是反过来。从之前到之后是允许的,但从之后到之前是不允许的。
4. volatile 并不总是够用的
即使volatile
关键字保证对volatile
变量的所有读取都直接从主存读取,对volatile
变量的所有写入都直接写入主存,但仍然存在仅声明一个volatile
变量是不够的情况。
在前面解释的只有线程 1 写入共享变量counter的情况下,声明volatile的counter变量足以确保线程 2 始终看到最新的写入值。
事实上,如果写入volatile变量的新值不依赖于其先前的值,多个线程甚至可以写入共享变量,并且仍然将正确的值存储在主内存中。换句话说,如果将值写入共享volatile变量的线程不需要先读取其值来找出其下一个值。
一旦线程需要先读取volatile变量的值,然后根据该值为共享volatile变量生成新值,volatile变量就不足以保证正确的可见性。读取volatile 变量和写入新值之间的短暂时间间隔会产生竞争条件 ,其中多个线程可能会读取相同的变量值volatile,为变量生成新值,并在将值写回主内存时覆盖彼此的值。
多个线程增加同一个counter的情况正是volatile变量将表现不足的情况。以下部分将更详细地解释这种情况。
想象一下,如果线程1将值为0的共享counter变量读取到其CPU寄存器中,将其增量为1,而不是将更改后的值写回主内存。然后,线程2可以从主内存中读取相同的counter变量,其中变量的值仍然是0,并将其读取到自己的CPU寄存器中。然后,线程2也可以将counter递增到1,并且也不会将其写回主内存。这种情况如下图所示:
线程1和线程2现在几乎不同步。共享counter变量的实际值应该是2
,但每个线程在其CPU寄存器中的变量值都是1
,而在主存中的值仍然是0
。真是一团糟!即使线程最终将共享counter变量的值写回主内存,该值也会出错。
5. volatile怎样才能具备原子性?
正如我之前提到的,如果两个线程都在读写共享变量,那么使用volatile
关键字是不能保证的该共享变量的原子性
的。在这种情况下,您需要使用synchronized
来保证变量的读写是原子性
的。
作为同步块的替代方案,您还可以使用java.util.concurrent
包中的许多原子数据类型之一。例如,AtomicLong
或AtomicReference
或其中之一。
如果只有一个线程读取和写入volatile
变量的值,而其他线程只读取该变量,那么读取线程可以保证看到写入volatile
变量的最新值。如果不将变量设为 volatile
,就无法保证这一点,这就是volatile
的可见性特性。
6. volatile的性能考虑
读取和写入volatile
变量会导致变量被读取或写入主内存
。从主存储器读取和写入比访问CPU寄存器
更昂贵
。访问volatile变量还可以防止指令重新排序
,这是一种正常的性能增强技术。因此,只有在确实需要强制变量可见性
时,才应该使用volatile
变量。
在实践中,CPU寄存器值通常只会写入CPU L1缓存,这相当快。虽然不如写入CPU寄存器快,但仍然很快。从L1缓存到L2和L3缓存,再到主存储器(RAM)的同步是由与CPU不同的芯片完成的(据我所知),因此CPU没有负担。
即便如此,在实际需要时,尽管只使用volatile变量,这也将迫使您详细了解volatile
变量的工作原理!
7. 总结
通过本文对volatile关键字的剖析,我们详细了解了volatile的工作原理。
总体说来,volatile具有如下三大特性:
7.1 可见性
当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
7.2 不具备原子性
两个线程都在读写共享变量,那么使用volatile
关键字是不能保证的该共享变量的原子性
的。在这种情况下,您需要使用synchronized
来保证变量的读写是原子性
的。
7.3 有序性
有序性是指的volatile
变量会禁止前后语句发生指令重排序。因此多线程环境下要保证指令执行的有序性
(禁止指令被JVM出于性能优化而采取重排序机制),必须要使用volatile
关键字。