都是缓存惹的祸

缓存有效的解决了速度不同的设备之间的访问问题,但是它也带来了更多的问题,今天介绍一个缓存引起的数据不一致问题以及对应的解决方法。

先给自己挖个坑,三部曲如下:

volatile 三部曲之可见性

volatile 三部曲之有序性

volatile 三部曲之经典应用

今天讲可见性,废话不多说,开始。

友情提示:本文基于 Java 语言,CPU 基于 x86 架构。


有一个内存,在其 0x400 位置处,存储着数字 1

有一个处理器,从内存中读数据到寄存器时,会将读到的数据在缓存中存储一份。

现在,这个处理器读取到了三条机器指令,将内存中的数字改写为了 2

我们看到,这个写的过程被细化成了两步,需要先写到处理器缓存,再从缓存刷新到内存。

同样对于读来说,也需要先读缓存,如果读不到再去内存中获取,同时更新缓存。

这样,对于单个处理器来说,由于缓存的存在,读写效率都有所提升。

 

可见性

 

可是,如果有另一个处理器呢?

场景一:处理器 1 未及时将缓存中的值刷新到内存,导致处理器 2 读到了内存中的旧值。

场景二:处理器 1 及时刷新缓存到了内存,但处理器 2 读的是自己缓存中的旧值。

可以看到,这两种场景,都是处理器 1 认为,已经将共享变量改写为了 2,但处理器 2 读到的值仍然是 1。

换句话说,处理器 1 对这个共享变量的修改,对处理器 2 来说"不可见"。

现在我们加入线程的概念,假设线程 1 运行在处理器 1,线程 2 运行在处理器 2。

那么就可以说:

线程 1 对这个共享变量的修改,对线程 2 来说"不可见"。

这个问题,就被称为可见性问题。

 

LOCK

 

假如线程 1 对共享变量的修改,线程 2 立刻就能够看到。

那么就可以说,这个共享变量,具有可见性。

那如何做到这一点呢?

我们首先想想看,刚刚的两个场景,为什么不可见。

1. 线程 1 对共享变量的修改,如果刚刚将其值写入自己的缓存,却还没有刷新到内存,此时内存的值仍为旧值。

2. 即使线程 1 将其修改后的值,从缓存刷新到了内存,但线程 2 仍然从自己的缓存中读取,读到的也可能是旧值。

所以,问题就出在这两个地方。

那要解决这个问题也非常简单,只需要在线程 1 将共享变量进行写操作时,产生如下两个效果即可。

1. 线程 1 将新值写入缓存后,立刻刷新到内存中。

2. 这个写入内存的操作,使线程 2 的缓存无效。若想读取该共享变量,则需要重新从内存中获取。

这样,该共享变量,就具有了可见性。

那如何使得,一个线程在进行写操作时,有上述两个效果呢?

答案是 LOCK 指令。

假如,线程 1 执行了如下指令,将内存中某地址处的值+1。

add [某内存地址], 1

现在这个写操作,不会立即刷新到内存,也不会将其他处理器中的缓存失效,也即不具备可见性。

那只需要加上一个 LOCK 前缀。

lock add [某内存地址], 1

这样,这个操作就会使得:

1. 立即将该处理器缓存(具体说是缓存行)中的数据刷新到内存。

2. 使得其他处理器缓存(具体说是缓存了该内存地址的缓存行)失效。

第一步将缓存刷新到内存后,使得其他处理器缓存失效,也就是第二步的发生,是利用了 CPU 的缓存一致性协议

而为了实现缓存一致性协议,每个处理器通常的一个做法是,通过监听在总线上传播的数据来判断自己的缓存值是否过期,这种方式叫总线嗅探机制

总之,这两个效果一出,在程序员或者线程的眼中,就变成了可见性的保证。

 

JMM

 

现在,让我们来到 Java 语言的世界。

上面那些处理器、寄存器、缓存等,都是硬件层面的概念,如果把这些无聊的、难学的细节,暴露给程序员,估计 Java 就无法流行起来了吧。

Java 可不希望这种情况发生,于是发明了一个简单的、抽象的内存模型,来屏蔽这些硬件层面的细节。

这个内存模型就叫做 JMM,Java Memory Module。

一个线程写入一个共享变量时,需要先写入自己的本地内存,再刷新到主内存。默认情况下,JMM 并不会保证什么时候刷新到主内存。

同样,一个线程读一个共享变量时,需要先读取自己的本地内存,如果读不到再去主内存中读取,同时更新到自己的本地内存。

有同学就要问了,这个本地内存,是在内存中开辟的一块空间么?一个线程读一个内存中的数据,还需要从内存一个地方拷贝到另一个地方?

为啥上面有个×?因为怕有的人把这个图当成正解了...

注意,JMM 是语言级的内存模型,所以你千万不能把这个模型中的概念,同真实的硬件层的概念相关联,这也是很多同学对此感到迷惑的根源。

JMM 的出现,就是为了让程序员不要去想硬件上的细节,但这样的命名方式,反而使程序员理解起来更加困惑了。

如果非要对应硬件上的原理,那不准确地说,这里的本地内存实际上在并不真实存在,是由于处理器中的缓存机制而产生的抽象概念。这么说可能稍稍解决你的一点点困惑。

之所以说不准确,一是因为处理器有很多不同的架构,并不一定所有的架构都有缓存。二是因为除了缓存之外,还有其他硬件和编译器的优化,可以导致本地内存这个概念的存在。

所以从某种程度上说,JMM 还确实是大大简化和屏蔽了程序员对于硬件细节的了解。

 

volatile

 

根据 JMM 向程序员提供的抽象模型,我们可以推测出如下问题。

此时线程 2 并没有读到线程 1 写入的最新值,a=2,而是读到了主内存中的旧值,a=1。

也即,线程 1 对共享变量的写入,对线程 2 不可见。

那么在 Java 中,如何让一个共享变量具有上述的可见性呢?

答案是加一个 volatile 即可。

在 jls 里是这样描述 volatile 的。

The Java programming language allows threads to access shared variables. As a rule, to ensure that shared variables are consistently and reliably updated, a thread should ensure that it has exclusive use of such variables by obtaining a lock that, conventionally, enforces mutual exclusion for those shared variables.

The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes.

简单说,Java 语言为了确保共享变量得到一致和可靠的更新,可以通过锁,也可以通过更轻量的 volatile 关键字

比如在一个变量 a 前面加上了 volatile 关键字

volatile int a;

那么在写这个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新到主内存。

相应地,当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

以上两点,就是 volatile 的内存语义。

而这两点的实质上,是完成了一次线程间通信,即线程 1 向线程 2 发送了消息。

有的同学可能又要问了,内存语义,那真的是写的时候刷新到主内存,而读的时候让本地内存失效么?

这里我还是要强调,JMM 是语言级的内存模型,无论它硬件层面上是怎么去保证的,在你站在语言层面去学习 JMM 时,就不要去想硬件细节。

为了解决部分同学的困惑,我还是用不准确的语言来说一下,volatile 的底层会被转化成上面所说的 LOCK 指令,写这个共享变量时,就既做了刷新到主内存,同时也将其他处理器缓存失效的操作,并不是写的时候刷新缓存,读的时候再去将本地内存失效。

但在语言层去描述 volatile 的内存语义时,刚刚的说法完全没错,只要程序员按照 JMM 这个内存模型和 volatile 的内存语义去编程,能够方便理解,且能够达到预期的效果,即可。至于是不是准确表达了硬件层面的原理,这个是不重要的。

这让我想到了之前看过的一个演讲,我记得叫“眼见为实”,是说我们看到的,并不一定是这个宇宙的真实面貌,只是能让我们更好地生存并延续后代,而已。

后记

写这篇文章时真的是瑟瑟发抖,一是因为网上讲这个知识点的实在太多了,二是我发现 volatile 这个知识点水很深,从底层硬件一直到上层语言,每一层都有实现原理,层层抽象直到上层表现为我们看到的样子。

我甚至觉得不可能有人对这个知识点完全理解透彻。缓存一致性和总线嗅探,你需要了解 CPU 硬件的原理吧?JMM 内存模型,你需要了解 JVM 虚拟机实现吧?

或者不说实现的事儿,就单单是 JMM 说了什么,很多人觉得懂了,但你看过 JSR133 文档对 JMM 模型的正式规范么?很长,给大家随便截取一小段。

所以随着不断研究这个知识点,我发现我越来越不懂 volatile 了。

但我还是写下了这篇文章,并给自己挖了个坑。

这篇文章我尽全力把网上一些混乱的概念讲解,重新理清楚,且尽量把和可见性无关的东西去掉。

但我还是写的很不满意,也很郁闷。

因为我觉得,我离 volatile 的真相,还很遥远。

  • 6
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值