learn-tech/专栏/深入浅出计算机组成原理/38高速缓存(下):你确定你的数据更新了么?.md
2024-10-16 09:22:22 +08:00

204 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
38 高速缓存(下):你确定你的数据更新了么?
在我工作的十几年里,写了很多 Java 的程序。同时,我也面试过大量的 Java 工程师。对于一些表示自己深入了解和擅长多线程的同学我经常会问这样一个面试题“volatile 这个关键字有什么作用?”如果你或者你的朋友写过 Java 程序,不妨来一起试着回答一下这个问题。
就我面试过的工程师而言,即使是工作了多年的 Java 工程师,也很少有人能准确说出 volatile 这个关键字的含义。这里面最常见的理解错误有两个,一个是把 volatile 当成一种锁机制,认为给变量加上了 volatile就好像是给函数加了 sychronized 关键字一样,不同的线程对于特定变量的访问会去加锁;另一个是把 volatile 当成一种原子化的操作机制,认为加了 volatile 之后,对于一个变量的自增的操作就会变成原子性的了。
// 一种错误的理解,是把 volatile 关键词,当成是一个锁,可以把 long/double 这样的数的操作自动加锁
private volatile long synchronizedValue = 0;
// 另一种错误的理解,是把 volatile 关键词,当成可以让整数自增的操作也变成原子性的
private volatile int atomicInt = 0;
amoticInt++;
事实上,这两种理解都是完全错误的。很多工程师容易把 volatile 关键字当成和锁或者数据数据原子性相关的知识点。而实际上volatile 关键字的最核心知识点,要关系到 Java 内存模型JMMJava Memory Model上。
虽然 JMM 只是 Java 虚拟机这个进程级虚拟机里的一个内存模型,但是这个内存模型,和计算机组成里的 CPU、高速缓存和主内存组合在一起的硬件体系非常相似。理解了 JMM可以让你很容易理解计算机组成里 CPU、高速缓存和主内存之间的关系。
“隐身”的变量
我们先来一起看一段 Java 程序。这是一段经典的 volatile 代码,来自知名的 Java 开发者网站dzone.com后续我们会修改这段代码来进行各种小实验。
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另一个线程我们叫 ChangeMaker
ChangeListener 这个线程运行的任务很简单它先取到 COUNTER 当前的值然后一直监听着这个 COUNTER 的值一旦 COUNTER 的值发生了变化就把新的值通过 println 打印出来直到 COUNTER 的值达到 5 为止这个监听的过程通过一个永不停歇的 while 循环的忙等待来实现
ChangeMaker 这个线程运行的任务同样很简单它同样是取到 COUNTER 的值 COUNTER 小于 5 的时候每隔 500 毫秒就让 COUNTER 自增 1在自增之前通过 println 方法把自增后的值打印出来
最后 main 函数里我们分别启动这两个线程来看一看这个程序的执行情况程序的输出结果并不让人意外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
这个时候我们就可以来做一个很有意思的实验如果我们把上面的程序小小地修改一行代码把我们定义 COUNTER 这个变量的时候设置的 volatile 关键字给去掉会发生什么事情呢你可以自己先试一试看结果是否会让你大吃一惊
private static int COUNTER = 0;
复制代码
没错你会发现我们的 ChangeMaker 还是能正常工作的每隔 500ms 仍然能够对 COUNTER 自增 1但是奇怪的事情在 ChangeListener 上发生了我们的 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
这个有意思的小程序还没有结束我们可以再对程序做一些小小的修改我们不再让 ChangeListener 进行完全的忙等待而是在 while 循环里面小小地等待上 5 毫秒看看会发生什么情况
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) 在每个循环里睡上5 毫秒之后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
这些有意思的现象其实来自于我们的 Java 内存模型以及关键字 volatile 的含义 volatile 关键字究竟代表什么含义呢它会确保我们对于这个变量的读取和写入都一定会同步到主内存里而不是从 Cache 里面读取该怎么理解这个解释呢我们通过刚才的例子来进行分析
刚刚第一个使用了 volatile 关键字的例子里因为所有数据的读和写都来自主内存那么自然地我们的 ChangeMaker ChangeListener 之间看到的 COUNTER 值就是一样的
到了第二段进行小小修改的时候我们去掉了 volatile 关键字这个时候ChangeListener 又是一个忙等待的循环它尝试不停地获取 COUNTER 的值这样就会从当前线程的Cache里面获取于是这个线程就没有时间从主内存里面同步更新后的 COUNTER 这样它就一直卡死在 COUNTER=0 的死循环上了
而到了我们再次修改的第三段代码里面虽然还是没有使用 volatile 关键字但是短短 5ms Thead.Sleep 给了这个线程喘息之机既然这个线程没有这么忙了它也就有机会把最新的数据从主内存同步到自己的高速缓存里面了于是ChangeListener 在下一次查看 COUNTER 值的时候就能看到 ChangeMaker 造成的变化了
虽然 Java 内存模型是一个隔离了硬件实现的虚拟机内的抽象模型但是它给了我们一个很好的缓存同步问题的示例也就是说如果我们的数据在不同的线程或者 CPU 核里面去更新因为不同的线程或 CPU 核有着自己各自的缓存很有可能在 A 线程的更新 B 线程里面是看不见的
CPU 高速缓存的写入
事实上我们可以把 Java 内存模型和计算机组成里的 CPU 结构对照起来看
我们现在用的 Intel CPU通常都是多核的的每一个 CPU 核里面都有独立属于自己的 L1L2 Cache然后再有多个 CPU 核共用的 L3 Cache主内存
因为 CPU Cache 的访问速度要比主内存快很多而在 CPU Cache 里面L1/L2 Cache 也要比 L3 Cache 所以上一讲我们可以看到CPU 始终都是尽可能地从 CPU Cache 中去获取数据而不是每一次都要从主内存里面去读取数据
这个层级结构就好像我们在 Java 内存模型里面每一个线程都有属于自己的线程栈线程在读取 COUNTER 的数据的时候其实是从本地的线程栈的 Cache 副本里面读取数据而不是从主内存里面读取数据如果我们对于数据仅仅只是读问题还不大我们在上一讲里已经看到 Cache Line 的组成以及如何从内存里面把对应的数据加载到 Cache
但是对于数据我们不光要读还要去写入修改这个时候有两个问题来了
第一个问题是写入 Cache 的性能也比写入主内存要快那我们写入的数据到底应该写到 Cache 里还是主内存呢如果我们直接写入到主内存里Cache 里的数据是否会失效呢为了解决这些疑问下面我要给你介绍两种写入策略
写直达Write-Through
最简单的一种写入策略叫作写直达Write-Through)。在这个策略里每一次数据都要写入到主内存里面在写直达的策略里面写入前我们会先去判断数据是否已经在 Cache 里面了如果数据已经在 Cache 里面了我们先把数据写入更新到 Cache 里面再写入到主内存里面如果数据不在 Cache 我们就只更新主内存
写直达的这个策略很直观但是问题也很明显那就是这个策略很慢无论数据是不是在 Cache 里面我们都需要把数据写到主内存里面这个方式就有点儿像我们上面用 volatile 关键字始终都要把数据同步到主内存里面
写回Write-Back
这个时候我们就想了既然我们去读数据也是默认从 Cache 里面加载能否不用把所有的写入都同步到主内存里呢只写入 CPU 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
可以看到在写回这个策略里如果我们大量的操作都能够命中缓存那么大部分时间里我们都不需要读写主内存自然性能会比写直达的效果好很多
然而无论是写回还是写直达其实都还没有解决我们在上面 volatile 程序示例中遇到的问题也就是多个线程或者是多个 CPU 核的缓存一致性的问题这也就是我们在写入修改缓存后需要解决的第二个问题
要解决这个问题我们需要引入一个新的方法叫作 MESI 协议这是一个维护缓存一致性协议这个协议不仅可以用在 CPU Cache 之间也可以广泛用于各种需要使用缓存同时缓存之间需要同步的场景下今天的内容差不多了我们放在下一讲仔细讲解缓存一致性问题
总结延伸
最后我们一起来回顾一下这一讲的知识点通过一个使用 Java 程序中使用 volatile 关键字程序我们可以看到在有缓存的情况下会遇到一致性问题volatile 这个关键字可以保障我们对于数据的读写都会到达主内存
进一步地我们可以看到Java 内存模型和 CPUCPU Cache 以及主内存的组织结构非常相似 CPU Cache 对于数据的写入我们也有写直达和写回这两种解决方案写直达把所有的数据都直接写入到主内存里面简单直观但是性能就会受限于内存的访问速度而写回则通常只更新缓存只有在需要把缓存里面的脏数据交换出去的时候才把数据同步到主内存里在缓存经常会命中的情况下性能更好
但是除了采用读写都直接访问主内存的办法之外如何解决缓存一致性的问题我们还是没有解答这个问题的解决方案我们放到下一讲来详细解说
推荐阅读
如果你是一个 Java 程序员我推荐你去读一读 Fixing Java Memory Model 这篇文章读完这些内容相信你会对 Java 里的内存模型和多线程原理有更深入的了解并且也能更好地和我们计算机底层的硬件架构联系起来
对于计算机组成的 CPU 高速缓存的写操作处理你也可以读一读计算机组成与设计硬件 / 软件接口 5.3.3 小节