java 中的 volatile 关键字真的与缓存有关吗?
does volatile keword in java really have to do with caches?
据我所知,java 中的 "volatile" 关键字确保线程始终获取特定指针的最新值,通常是 reading/writing直接from/to内存,避免缓存不一致。
但为什么需要这个?据我所知,这已经在硬件层面上完成了。如果我从我的系统架构 class 中没记错的话,更新内存位置的处理器核心会向其他处理器的缓存发送无效信号,迫使它们在需要时从内存中获取这些行。或者,如果是另一种方式 - 如果处理器获取内存,它将强制首先将其他缓存的缓存(但未写入)行刷新到内存。
我唯一的理论是,尽管我已经阅读了所有解释,但这实际上与缓存完全无关。它与 JVM 中的数据可以驻留在两个地方有关 - 线程的本地堆栈和堆。 java 线程可以将其堆栈用作一种缓存。我会买那个,但这也意味着对驻留在堆上的数据使用 volatile 是无用的,因为它由所有线程共享并遵守硬件实现的一致性?
例如:
public final int[] is = new int[10];
访问 is 的数据总是会得到最新的数据,因为数据驻留在堆上。然而,指针是一个原始指针,可能会成为堆栈问题的牺牲品,但由于它是最终的,我们没有这个问题。
我的假设是否正确?
编辑:据我所知,这不是重复的。所谓的重复线程是那些声称它与缓存一致性有关的误导性答案之一。我的问题不是 volatile 的用途,也不是如何使用它。它正在测试一种理论,而且更深入。
您的假设对于 int 是正确的 - 实际上对于适合 32 位宽的一个 JVM 堆栈帧的任何数据类型都是正确的。
相同的保证不适用于溢出的多头、双打 e.t.c。 volatile
告诉 JVM,在任何字节码优化中都不得对给定值的任何 load/store 操作重新排序,以确保您不会进入大型类型由 32 位组成的状态一个写操作和 32 个来自后面的一个 - 即使这意味着在 CPU 缓存级别使用锁定原语。只要您避免缓存未命中,读取 易失性值应该pretty much the same 与 x86 上的常规值一样(尽管这在很大程度上取决于体系结构)。
But why is this needed? To my knowledge, this is already done on a hardware level.
这是不正确的。在现代多核系统中,简单的内存写入指令不一定会写入主内存(当然,不会立即),内存读取指令也不能保证从主内存/其他缓存中读取最新值。如果内存读/写指令总是那样做,内存缓存就是浪费时间。
为了保证这些事情,(本机代码)编译器需要在导致缓存写通或缓存失效的关键点发出指令。
My only theory is that this actually has nothing to do with caches at all ...
这是不正确的。这与缓存有关。问题是您误解了典型现代多核处理器上的典型指令如何处理缓存。
ISA 的设计使得缓存使单线程代码 运行 快速...通过避免进入主内存。如果只有一个线程在给定地址读取和写入值,则处理器缓存中的值副本比主内存中的副本更新这一事实无关紧要。
但是当有多个线程时,您可以让两个线程 运行在不同的内核上使用不同的内存缓存。如果有 N 个核心,则给定地址-> 值关联可能有 N+1 个不同的“版本”。那是混乱的。在Java中有两种处理方式:
将变量声明为 volatile
,它告诉编译器使用(昂贵的)缓存刷新 and/or 缓存失效指令(或刷新或失效的指令) effect)来实现读写。
使用适当的同步,并依靠 happens-before 关系来通知编译器在何处放置内存屏障。典型的同步操作还将涉及根据需要刷新和/或使缓存无效的指令。这通常是提供内存屏障的原因。
然而,这都是 ISA 特定的,使用的特定机器指令将取决于 JIT 编译器。
参考:
- https://fgiesen.wordpress.com/2014/07/07/cache-coherency/ - 特别要注意直写缓存和回写缓存之间的区别,并考虑这对多线程应用程序的影响 运行ning不一定共享缓存。
另一件需要注意的事情是,在典型的编译程序中会发生另一种缓存:在寄存器中缓存临时变量。
Java 内存模型没有直接讨论硬件的行为:内存缓存和寄存器。相反,它指定了保证多线程应用程序使用共享内存的情况。但这背后的原因远不止“撕字”。
最后,JVM 抽象机中的“表达式堆栈”实际上只是一种用于指定操作语义的技巧。当字节码被编译为本机代码时,值将存储在硬件机器寄存器或硬件内存位置中。表达式堆栈不再存在。当然,存在调用堆栈/本地帧。但是它们是作为普通内存实现的。
我做了一些研究并得出以下结论:
Volatile 变量受到两种影响。
以这个java为例:
public int i = 0;
public void increment(){
i++;
}
如果没有 volatile,JIT 将在增量方法中发出以下伪指令:
LOAD R1,i-address
... arbitrary number of instructions, not involving R1
ADDI R1,1
... arbitrary number of instructions, not involving R1
... this is not guaranteed to happen, but probably will:
STORE R1, i-address
为什么是乱指令?因为优化。流水线将填充不涉及 R1 的指令,以避免流水线停顿。换句话说,你得到乱序执行。也可以防止将 i 重写到内存中。如果优化器发现这是不必要的,它就不会这样做,它可能会错过我是从另一个线程访问的事实,到那时我仍然是 0。
当我们将 i 更改为 volatile 时,我们得到:
第 1 步
LOAD R1,i-address
ADDI R1,1
STORE R1, i-address
Volatile 防止乱序执行,不会试图填充管道来解决危险。并且永远不会将 i 存储在本地,我所说的本地是指在寄存器或堆栈框架中。它将保证对 i 的任何操作都将涉及它的加载和存储,换句话说,就是获取和写入内存。然而,内存并不转化为主内存或 RAM 或诸如此类的东西,它暗示了内存层次结构。 LOADS 和 STORES 用于所有变量,volatile 与否,但程度不同。如何处理它们取决于芯片架构师。
第 2 步
LOAD R1,i-address
ADDI R1,1
LOCK STORE R1, i-address
lock 指令发出内存屏障,这意味着任何其他线程试图读取或写入 i 的地址都必须等到存储操作完成。这确保了 i 的实际回写是原子的。
请注意 java 行 "i++" 不是原子的。在 LOAD 和 STORE 指令之间仍然会发生一些事情。这就是为什么您通常需要显式锁,这些锁是用 volatiles 实现的,以便能够真正对 i 进行原子操作。举个例子:
volatile int i = 0;
THREAD A
{
for (int j = 0; j < 1000; j++)
i++;
}
THREAD B
{
for (int j = 0; j < 1000; j++)
i++;
}
将产生不可预知的多核处理器结果。并且需要这样解决:
private volatile int i = 0;
public synchronized incrementI(){
i++;
}
来源:
https://docs.oracle.com/javase/tutorial/essential/concurrency/atomic.html
来源:https://docs.oracle.com/cd/E19683-01/806-5222/codingpractices-1/index.html
结论:
根据 Intel 和 AMD 的说法,缓存一致性由硬件管理,因此 volatile 与缓存无关。 "volatiles are forced to live in main memory" 是一个神话。但是,它确实可能间接导致额外的缓存失效,因为 STORE 的使用频率更高。
不过,我对 volatile 会导致模糊架构的直写持开放态度。
据我所知,java 中的 "volatile" 关键字确保线程始终获取特定指针的最新值,通常是 reading/writing直接from/to内存,避免缓存不一致。
但为什么需要这个?据我所知,这已经在硬件层面上完成了。如果我从我的系统架构 class 中没记错的话,更新内存位置的处理器核心会向其他处理器的缓存发送无效信号,迫使它们在需要时从内存中获取这些行。或者,如果是另一种方式 - 如果处理器获取内存,它将强制首先将其他缓存的缓存(但未写入)行刷新到内存。
我唯一的理论是,尽管我已经阅读了所有解释,但这实际上与缓存完全无关。它与 JVM 中的数据可以驻留在两个地方有关 - 线程的本地堆栈和堆。 java 线程可以将其堆栈用作一种缓存。我会买那个,但这也意味着对驻留在堆上的数据使用 volatile 是无用的,因为它由所有线程共享并遵守硬件实现的一致性?
例如:
public final int[] is = new int[10];
访问 is 的数据总是会得到最新的数据,因为数据驻留在堆上。然而,指针是一个原始指针,可能会成为堆栈问题的牺牲品,但由于它是最终的,我们没有这个问题。
我的假设是否正确?
编辑:据我所知,这不是重复的。所谓的重复线程是那些声称它与缓存一致性有关的误导性答案之一。我的问题不是 volatile 的用途,也不是如何使用它。它正在测试一种理论,而且更深入。
您的假设对于 int 是正确的 - 实际上对于适合 32 位宽的一个 JVM 堆栈帧的任何数据类型都是正确的。
相同的保证不适用于溢出的多头、双打 e.t.c。 volatile
告诉 JVM,在任何字节码优化中都不得对给定值的任何 load/store 操作重新排序,以确保您不会进入大型类型由 32 位组成的状态一个写操作和 32 个来自后面的一个 - 即使这意味着在 CPU 缓存级别使用锁定原语。只要您避免缓存未命中,读取 易失性值应该pretty much the same 与 x86 上的常规值一样(尽管这在很大程度上取决于体系结构)。
But why is this needed? To my knowledge, this is already done on a hardware level.
这是不正确的。在现代多核系统中,简单的内存写入指令不一定会写入主内存(当然,不会立即),内存读取指令也不能保证从主内存/其他缓存中读取最新值。如果内存读/写指令总是那样做,内存缓存就是浪费时间。
为了保证这些事情,(本机代码)编译器需要在导致缓存写通或缓存失效的关键点发出指令。
My only theory is that this actually has nothing to do with caches at all ...
这是不正确的。这与缓存有关。问题是您误解了典型现代多核处理器上的典型指令如何处理缓存。
ISA 的设计使得缓存使单线程代码 运行 快速...通过避免进入主内存。如果只有一个线程在给定地址读取和写入值,则处理器缓存中的值副本比主内存中的副本更新这一事实无关紧要。
但是当有多个线程时,您可以让两个线程 运行在不同的内核上使用不同的内存缓存。如果有 N 个核心,则给定地址-> 值关联可能有 N+1 个不同的“版本”。那是混乱的。在Java中有两种处理方式:
将变量声明为
volatile
,它告诉编译器使用(昂贵的)缓存刷新 and/or 缓存失效指令(或刷新或失效的指令) effect)来实现读写。使用适当的同步,并依靠 happens-before 关系来通知编译器在何处放置内存屏障。典型的同步操作还将涉及根据需要刷新和/或使缓存无效的指令。这通常是提供内存屏障的原因。
然而,这都是 ISA 特定的,使用的特定机器指令将取决于 JIT 编译器。
参考:
- https://fgiesen.wordpress.com/2014/07/07/cache-coherency/ - 特别要注意直写缓存和回写缓存之间的区别,并考虑这对多线程应用程序的影响 运行ning不一定共享缓存。
另一件需要注意的事情是,在典型的编译程序中会发生另一种缓存:在寄存器中缓存临时变量。
Java 内存模型没有直接讨论硬件的行为:内存缓存和寄存器。相反,它指定了保证多线程应用程序使用共享内存的情况。但这背后的原因远不止“撕字”。
最后,JVM 抽象机中的“表达式堆栈”实际上只是一种用于指定操作语义的技巧。当字节码被编译为本机代码时,值将存储在硬件机器寄存器或硬件内存位置中。表达式堆栈不再存在。当然,存在调用堆栈/本地帧。但是它们是作为普通内存实现的。
我做了一些研究并得出以下结论:
Volatile 变量受到两种影响。
以这个java为例:
public int i = 0;
public void increment(){
i++;
}
如果没有 volatile,JIT 将在增量方法中发出以下伪指令:
LOAD R1,i-address
... arbitrary number of instructions, not involving R1
ADDI R1,1
... arbitrary number of instructions, not involving R1
... this is not guaranteed to happen, but probably will:
STORE R1, i-address
为什么是乱指令?因为优化。流水线将填充不涉及 R1 的指令,以避免流水线停顿。换句话说,你得到乱序执行。也可以防止将 i 重写到内存中。如果优化器发现这是不必要的,它就不会这样做,它可能会错过我是从另一个线程访问的事实,到那时我仍然是 0。
当我们将 i 更改为 volatile 时,我们得到:
第 1 步
LOAD R1,i-address
ADDI R1,1
STORE R1, i-address
Volatile 防止乱序执行,不会试图填充管道来解决危险。并且永远不会将 i 存储在本地,我所说的本地是指在寄存器或堆栈框架中。它将保证对 i 的任何操作都将涉及它的加载和存储,换句话说,就是获取和写入内存。然而,内存并不转化为主内存或 RAM 或诸如此类的东西,它暗示了内存层次结构。 LOADS 和 STORES 用于所有变量,volatile 与否,但程度不同。如何处理它们取决于芯片架构师。
第 2 步
LOAD R1,i-address
ADDI R1,1
LOCK STORE R1, i-address
lock 指令发出内存屏障,这意味着任何其他线程试图读取或写入 i 的地址都必须等到存储操作完成。这确保了 i 的实际回写是原子的。
请注意 java 行 "i++" 不是原子的。在 LOAD 和 STORE 指令之间仍然会发生一些事情。这就是为什么您通常需要显式锁,这些锁是用 volatiles 实现的,以便能够真正对 i 进行原子操作。举个例子:
volatile int i = 0;
THREAD A
{
for (int j = 0; j < 1000; j++)
i++;
}
THREAD B
{
for (int j = 0; j < 1000; j++)
i++;
}
将产生不可预知的多核处理器结果。并且需要这样解决:
private volatile int i = 0;
public synchronized incrementI(){
i++;
}
来源: https://docs.oracle.com/javase/tutorial/essential/concurrency/atomic.html 来源:https://docs.oracle.com/cd/E19683-01/806-5222/codingpractices-1/index.html
结论: 根据 Intel 和 AMD 的说法,缓存一致性由硬件管理,因此 volatile 与缓存无关。 "volatiles are forced to live in main memory" 是一个神话。但是,它确实可能间接导致额外的缓存失效,因为 STORE 的使用频率更高。
不过,我对 volatile 会导致模糊架构的直写持开放态度。