> 文档中心 > Volatile和高速缓存的关系

Volatile和高速缓存的关系

“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 : 1Got Change for COUNTER : 1Incrementing COUNTER to : 2Got Change for COUNTER : 2Incrementing COUNTER to : 3Got Change for COUNTER : 3Incrementing COUNTER to : 4Got Change for COUNTER : 4Incrementing COUNTER to : 5Got 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 : 1Incrementing COUNTER to : 2Incrementing COUNTER to : 3Incrementing COUNTER to : 4Incrementing 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 : 1Sleep 5ms, Got Change for COUNTER : 1Incrementing COUNTER to : 2Sleep 5ms, Got Change for COUNTER : 2Incrementing COUNTER to : 3Sleep 5ms, Got Change for COUNTER : 3Incrementing COUNTER to : 4Sleep 5ms, Got Change for COUNTER : 4Incrementing COUNTER to : 5Sleep 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)

Volatile和高速缓存的关系
最简单的写策略,每次数据都写主内存。
写入前,先判断数据是否已在Cache:

  • 已在Cache
    先把数据写入更新到Cache,再写主内存
  • 数据不在Cache
    只更新主内存

实现简单,但性能很慢。无论数据是否在Cache,都要把数据写主内存。这有点像volatile关键字,始终都要把数据同步到主内存。

5.2 写回(Write-Back)

Volatile和高速缓存的关系
既然读数据也默认从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