“volatile关键字有什么用?”
1 常见理解错误
- 把volatile当成一种锁机制,认为给变量加上了volatile,就好像是给函数加sychronized,不同的线程对于特定变量的访问会去加锁
- 把volatile当成一种原子化的操作机制,认为加了volatile之后,对于一个变量的自增的操作就会变成原子性
// 一种错误的理解,是把volatile关键词,当成是一个锁,可以把long/double这样的数的操作自动加锁
private volatile long synchronizedValue = 0;
// 另一种错误的理解,是把volatile关键词,当成可以让整数自增的操作也变成原子性的
private volatile int atomicInt = 0;
amoticInt++;
很多工程师容易把volatile关键字,当成和锁或者数据数据原子性相关的知识点。volatile最核心要关系JMM。
JMM是JVM这个进程级虚拟机里的一个内存模型,但该内存模型和计算机组成里的CPU、高速缓存和主内存组合在一起的硬件体系类似。理解JMM,可更容易理解计算机组成里CPU、高速缓存和主内存之间的关系。
2 “隐身”的变量
dzone.com代码段,后续修改这段代码来进行各种小实验。
2.1 demo1
public class VolatileTest {
private static volatile int COUNTER = 0;
public static void main(String[] args) {
new ChangeListener().start();
new ChangeMaker().start();
}
static class ChangeListener extends Thread {
@Override
public void run() {
int threadValue = COUNTER;
while ( threadValue < 5){
if( threadValue!= COUNTER){
System.out.println("Got Change for COUNTER : " + COUNTER + "");
threadValue= COUNTER;
}
}
}
}
static class ChangeMaker extends Thread{
@Override
public void run() {
int threadValue = COUNTER;
while (COUNTER <5){
System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");
COUNTER = ++threadValue;
try {
Thread.sleep(500);
} catch (InterruptedException e) { e.printStackTrace(); }
}
}
}
}
先定义了一个volatile的int类型的变量,COUNTER。
然后,分别启动两个独立线程:
- ChangeListener
先取到COUNTER当前值,然后一直监听该COUNTER值。一旦COUNTER值变化,就把新值打印。直到COUNTER的值达到5。这监听过程,通过while死循环的忙等待实现 - ChangeMaker
取到COUNTER的值,在COUNTER小于5的时候,每隔500毫秒,就让COUNTER自增1。在自增前,通过println方法把自增后的值打印
输出结果并不让人意外。ChangeMaker函数会一次一次将COUNTER从0增加到5。因为这个自增是每500毫秒一次,而ChangeListener去监听COUNTER是忙等待的,所以每一次自增都会被ChangeListener监听到,然后对应的结果就会被打印出来。
Incrementing COUNTER to : 1
Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Got Change for COUNTER : 5
2.2 demo2
把上面的程序小小地修改一行代码,把定义COUNTER变量时的volatile去掉,会咋样?
private static int COUNTER = 0;
ChangeMaker还是能正常工作,每隔500ms仍然能够对COUNTER自增1。但ChangeListener不再工作。在ChangeListener眼里,它似乎一直觉得COUNTER的值还是一开始的0。似乎COUNTER的变化对ChangeListener彻底“隐身”。
Incrementing COUNTER to : 1
Incrementing COUNTER to : 2
Incrementing COUNTER to : 3
Incrementing COUNTER to : 4
Incrementing COUNTER to : 5
2.3 demo3
不再让ChangeListener进行完全的忙等待,而是在while循环里小等5ms
static class ChangeListener extends Thread {
@Override
public void run() {
int threadValue = COUNTER;
while ( threadValue < 5){
if( threadValue!= COUNTER){
System.out.println("Sleep 5ms, Got Change for COUNTER : " + COUNTER + "");
threadValue= COUNTER;
}
try {
Thread.sleep(5);
} catch (InterruptedException e) { e.printStackTrace(); }
}
}
}
虽然COUNTER变量仍没设置volatile这个关键字,但ChangeListener似乎“睡醒了”。在通过Thread.sleep(5)在每个循环里“睡“5ms后,ChangeListener又能够正常取到COUNTER的值了。
Incrementing COUNTER to : 1
Sleep 5ms, Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Sleep 5ms, Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Sleep 5ms, Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Sleep 5ms, Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Sleep 5ms, Got Change for COUNTER : 5
这些现象就来自于 JMM 及关键字volatile的含义。volatile究竟代表什么?
它确保我们对该变量的读取和写入,一定同步到主内存,而非从Cache读取。
3 如何理解这句话?
3.1 有volatile
因所有数据的读、写都来自主内存。自然ChangeMaker和ChangeListener之间,看到的COUNTER值一样。
3.2 无volatile
这时,ChangeListener又是一个忙等待的循环,它尝试不停获取COUNTER值,这样就会从当前线程的“Cache”获取。于是,这线程就没有时间从主内存同步更新后的COUNTER值。这样,它就一直卡死在COUNTER=0的死循环。
3.3 虽无volatile,但短短5ms的Thead.Sleep给了这线程喘息之机
既然这个线程没有这么忙了,它就有机会把最新数据从主内存同步到自己的高速缓存。于是,ChangeListener在下一次查看COUNTER值的时候,就能看到ChangeMaker变化。
虽然JMM是个隔离了硬件实现的虚拟机内的抽象模型,但它给出“缓存同步”问题示例。若数据在不同线程或CPU核里更新,因不同线程或CPU核有各自缓存,很可能在A线程的更新,B线程看不见。
4 CPU高速缓存的写入
可将Java内存模型和计算机组成里的CPU结构对照。
Intel CPU多核。每个CPU核里都有独属的L1、L2 Cache,再有多个CPU核共用的L3 Cache、主内存。
因为CPU Cache访问速度>>主内存,而CPU Cache里,L1/L2 Cache也比L3 Cache快。所以,CPU始终尽可能从CPU Cache获取数据,而非每次都从主内存读数据:
这层级结构就像在JMM里,每个线程都有属于自己的线程栈。线程读取COUNTER时,其实是从本地的线程栈的Cache副本读,而非从主内存读。若对数据仅只是读,问题还好。Cache Line组成及如何从内存里把对应数据加载到Cache。
但不光要读,还要去写入修改数据。问题就来了:写入Cache的性能也比写主内存快,那写数据,到底写到Cache还是主内存?若直接写主内存,Cache里的数据是否会失效?
先看两种
5 写入策略
5.1 写直达(Write-Through)
最简单的写策略,每次数据都写主内存。
写入前,先判断数据是否已在Cache:
- 已在Cache
先把数据写入更新到Cache,再写主内存 - 数据不在Cache
只更新主内存
实现简单,但性能很慢。无论数据是否在Cache,都要把数据写主内存。这有点像volatile关键字,始终都要把数据同步到主内存。
5.2 写回(Write-Back)
既然读数据也默认从Cache加载,能否不用把所有写入都同步到主内存?只写入CPU Cache是不是就够?可以!这就是写回(Write-Back)策略,不再是每次都把数据写主内存,而只写到CPU Cache。只有当CPU Cache里的数据要被“替换”,才把数据写主内存。
过程
若发现要写入的数据,就在CPU Cache,就只更新CPU Cache的数据。同时标记CPU Cache里的这个Block是脏(Dirty)的:指此时CPU Cache里的这个Block的数据,和主内存不一致。
如发现要写入的数据所对应的Cache Block里,放的是别的内存地址的数据,就要看那个Cache Block里的数据是否被标记成脏:
- 如果是脏,先把这个Cache Block里面的数据,写入主内存。再把当前要写入的数据,写入Cache,同时把Cache Block标记成脏
- 如果Block里面的数据没有被标记成脏的,直接把数据写入Cache,然后再把Cache Block标记成脏
用写回策略后,在加载内存数据到Cache时,也要多出一步同步脏Cache的动作。若加载内存数据到Cache时,发现Cache Block里有脏标记,也要先把Cache Block里的数据写回主内存,才能加载数据覆盖Cache。
该策略里,若大量操作都能命中缓存,则大部分时间里,无需读写主内存,性能比写直达效果好太多!
但无论是写回or写直达,都没解决volatile程序问题:多个线程或多个CPU核的缓存一致性问题。
这也就是在写入修改缓存后,需要解决的第二个问题。
要解决这个问题,需引入MESI协议,维护缓存一致性的协议。不仅可用在CPU Cache之间,也可广泛用于各种需要使用缓存,同时缓存之间需要同步的场景下。
总结
volatile程序可以看到,在有缓存的情况下会遇到一致性问题。volatile这个关键字可以保障我们对于数据的读写都会到达主内存。
Java内存模型和CPU、CPU Cache以及主内存的组织结构非常相似。在CPU Cache里,对于数据的写入,我们也有写直达和写回这两种解决方案。写直达把所有的数据都直接写入到主内存里面,简单直观,但是性能就会受限于内存的访问速度。而写回则通常只更新缓存,只有在需要把缓存里面的脏数据交换出去的时候,才把数据同步到主内存里。在缓存经常会命中的情况下,性能更好。
但是,除了采用读写都直接访问主内存的办法之外,如何解决缓存一致性问题?
下文分解。
参考
- Fixing Java Memory Model
- 《计算机组成与设计:硬件/软件接口》5.3.3