Java
volatile
关键字用于将Java
变量标记为“存储在主内存中”。更准确地说,这意味着对volatile
变量的每次读取都将从计算机的主内存中读取,而不是从CPU
缓存中读取,而且对volatile
变量的每次写入都将写入主内存,而不仅仅是CPU
缓存。
实际上,由于Java 5
中的volatile
关键字保证了volatile
变量被写入主内存并从主内存中读取
Java
volatile
关键字保证跨线程更改变量的可见性。在线程操作非volatile
变量的多线程应用程序中,出于性能原因,每个线程在处理变量时,都可以将变量从主内存复制到CPU
缓存中。如果您的计算机包含多个CPU
,每个线程可能运行在不同的CPU
上。这意味着,每个线程可以将变量复制到不同CPU
的CPU
缓存中。如下图所示:
对于非volatile
变量,不能保证Java
虚拟机(JVM
)何时将数据从主内存读入CPU
缓存,或何时将数据从CPU
缓存写入主内存。这可能会导致几个问题:
假设有这样一种情况,两个或多个线程访问一个包含计数器变量的共享对象,该对象声明如下:
public class SharedObject {
public int counter = 0;
}
再想象一下,只有线程1递增计数器变量,但实际是线程1和线程2可能会不时读取计数器变量。
如果计数器变量没有声明为volatile
,则无法保证计数器变量的值何时从CPU
缓存写入主内存。这意味着,CPU
缓存中的计数器变量值可能与主内存中的不一样。这种情况说明如下:
由于变量的最新值还没有被另一个线程写回主内存,所以线程看不到该变量的最新值,这个问题称为“可见性”问题。一个线程的更新对其他线程不可见。
Java
volatile
关键字的目的是解决可变的可见性问题。通过声明计数器变量volatile
,对计数器变量的所有写操作都将立即被写回主内存。此外,计数器变量的所有读取都将直接从主内存中读取。
下面是计数器变量的volatile
声明:
public class SharedObject {
public volatile int counter = 0;
}
因此,声明一个变量volatile
可以保证其他线程对该变量的写操作的可见性。
在上面给出的场景中,一个线程(T1
)修改计数器,另一个线程(T2
)读取计数器(但从不修改计数器),声明计数器变量volatile
足以保证T2
对计数器变量的写操作的可见性。
但是,如果T1
和T2
都在增加计数器变量,那么仅仅声明计数器变量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
。
完整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
的值时,months
和years
的值也被读入主内存。因此,您可以确保使用上面的读取序列看到days
、months
和years
的最新值。
只要指令的语义保持不变,就允许Java VM
和CPU
出于性能原因对程序中的指令进行重新排序。例如:
int a = 1;
int b = 2;
a++;
b++;
这些指令可以重新排序到以下顺序,而不会失去程序的语义:
int a = 1;
a++;
int b = 2;
b++;
然而,当其中一个变量是volatile
变量时,指令重新排序就会带来挑战。让我们看看这个Java 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
,那么新写入的值也将写入主内存。但是,如果Java VM
像这样重新排序指令会怎么样呢?
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}
在修改days
变量时,month
和years
的值仍然写入主内存,但这一次是在将新值写入month
和years
之前。因此,新值对其他线程不可见。重新排序的指令的语义发生了变化。
为了解决指令重新排序的挑战,Java volatile
关键字除了提供可视性保证之外,还提供了一个“happens-before
”保证。happens-before
保证担保如下:
如果对volatile
变量的读/写最初发生在对volatile
变量的写之前,那么对其他变量的读/写不能在对volatile
变量的写之后重新排序。对volatile
变量的写之前的读/写保证在对volatile
变量的写之前发生。请注意,仍然有可能,例如,对volatile
的写操作之后的其他变量的读/写操作被重新排序,以在对volatile
的写操作之前执行。只是不是反过来。从后到前是允许的,但从前到后是不允许的。
如果对其他变量的读/写最初发生在volatile
变量的读/写之后,则不能在volatile
变量的读/写之前重新排序。请注意,在读取volatile
变量之前发生的其他变量的读取可以重新排序为在读取volatile
变量之后发生的读取。只是不是反过来。从前到后是允许的,但从后到前是不允许的。
上面的happens-before
保证确保了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。即使线程最终将共享计数器变量的值写回主内存,该值也将是错误的。
正如我前面提到的,如果两个线程同时读写一个共享变量,那么仅使用volatile
关键字是不够的。在这种情况下,需要使用synchronized
来确保变量的读写是原子性的。读取或写入volatile
变量不会阻塞线程的读取或写入。要实现这一点,必须在关键部分周围使用synchronized
关键字。
作为synchronized
代码块的替代,您还可以使用java.util.concurrent
包中的许多原子数据类型。例如,AtomicLong
或AtomicReference
或者其它。
如果只有一个线程读写volatile
变量的值,而其他线程只读取变量,那么读取线程将确保看到写入volatile
变量的最新值。如果不使用volatile
标记该变量,就不能保证这一点。
volatile
关键字保证可以处理32位和64个变量。
volatile
变量的读取和写入将导致变量被读取或写入主内存。从主存读取和写入比访问CPU
缓存更昂贵。访问volatile
变量还可以防止指令重新排序,这是一种常见的性能增强技术。因此,您应该只在真正需要强制变量可见性时才使用volatile
变量。