Skip to content

Java 内存模型

现代硬件模型有哪些特点?

  • 多CPU:一个现代计算机通常由两个或者多个CPU。其中一些CPU还有多核。这意味着,如果程序是多线程的,多个线程可能同时(并行)执行。
  • CPU寄存器:每个CPU都包含一系列的寄存器,它们是CPU内内存的基础。CPU访问寄存器的速度远大于主存。
  • 高速缓存cache:现代计算机系统都加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中。CPU访问缓存层的速度快于访问主存的速度,但通常比访问内部寄存器的速度还要慢一点。每个CPU可能有一个CPU缓存层,一些CPU还有多层缓存。在某一时刻,一个或者多个缓存行(cache lines)可能被读到缓存,一个或者多个缓存行可能再被刷新回主存。
  • 内存:一个计算机还包含一个主存。所有的CPU都可以共享访问主存。主存通常比CPU中的缓存大得多。
  • 运作原理:通常情况下,当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。

主要产生两大问题:

  • 缓存一致性问题:在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也引入了新的问题:缓存一致性(CacheCoherence)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol,等等:
  • 指令重排序问题:为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。因此,如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化。

什么是伪共享(false sharing)?

伪共享(False Sharing)是指在多线程编程环境中,由于CPU缓存系统的工作机制导致的一种性能问题。具体来说,伪共享发生在以下情况下:

  1. 缓存行(Cache Line)共享:CPU缓存系统通常以缓存行为单位存储数据,而不是以单个变量为单位。当多个线程访问各自独立的变量时,如果这些变量恰好位于同一个缓存行中,伪共享现象就可能发生。
  2. 缓存行失效:由于CPU缓存的粒度是缓存行,而不是单个变量,当一个线程修改了其访问的变量(位于共享缓存行中)时,根据缓存一致性协议(如MESI),该缓存行会被标记为失效或修改状态。这导致其他线程在访问同一缓存行中的其他变量时,需要从更高一级的缓存或主内存中重新加载数据,从而降低了性能。
  3. 性能影响:伪共享会导致不必要的缓存行无效和重新填充现象,进而增加了线程间的同步开销和内存访问延迟。在高并发的场景中,这可能导致程序并行执行的耗时比串行执行还要长。

为了避免伪共享问题,可以采取以下措施:

  • 缓存行填充:通过在变量前后填充额外的占位变量(如使用填充字段或类继承),将独立变化的变量分隔到不同的缓存行中,从而避免它们被填充到同一个缓存行中,可能被编译器优化导致填充失败
  • 使用注解:在某些编程环境中(如Java的JDK 8及以上版本),可以使用特定的注解(如@sun.misc.Contended)来自动处理缓存行填充问题。这些注解可以帮助编译器或JVM自动将类实例或字段分配到不同的缓存行中。

Java内存模型是什么?

Java内存模型(Java Memory Model, JMM)是Java语言规范中定义的一种规范,它描述了在多线程环境中,如何处理线程之间的内存可见性、原子性和有序性问题。JMM的目的是为了屏蔽各种硬件和操作系统内存访问的差异,为Java程序员提供一致的内存访问视图,保证了程序在多线程环境下的正确执行。

以下是Java内存模型的主要特点:

  1. 主内存与工作内存

    • Java内存模型规定,所有变量都存储在主内存(Main Memory)中,每个线程都有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用的变量的副本。

    • 当线程需要读取或修改共享变量时,它首先会在自己的工作内存中操作这个变量的副本,然后再将修改后的值同步回主内存。

    • 线程之间的通信必须通过主内存来完成,即线程A对变量的修改要想被线程B看到,必须将修改后的值写回主内存,线程B再从主内存中读取该变量的最新值。

  2. 内存访问规则

    • Java内存模型通过Happens-Before规则来定义这些操作的可见性和顺序性。这些规则确保了某些操作在并发环境下是可见的,并且保证了操作的顺序性。
  3. 三个主要特性

    • 原子性(Atomicity):一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。Java内存模型提供了lock和unlock操作来满足原子性需求,synchronized关键字就是基于这一机制实现的。

    • 可见性(Visibility):当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。volatile关键字和synchronized关键字都可以提供可见性保证。

    • 有序性(Ordering):Java内存模型的有序性是指程序执行的顺序按照代码的先后顺序执行。编译器和处理器可能会对指令进行重排序,但Java内存模型通过Happens-Before规则来定义指令之间的顺序关系,确保程序按照预期的顺序执行。

  4. 内存屏障

    • 为了保证内存的可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令,以禁止特定类型的处理器重排序。内存屏障指令分为LoadLoad、LoadStore、StoreStore和StoreLoad四种类型,分别对应不同的内存访问顺序。
  5. as-if-serial语义

    • Java内存模型保证单线程中的程序按照程序的顺序来执行(as-if-serial semantics)。也就是说,不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。

Java内存模型为Java程序提供了在多线程环境下的内存访问规则和机制,确保了共享变量的正确访问和同步,从而保证了并发程序的正确性和性能。

JMM 对内存进行了怎样的划分?

Java内存模型(JMM)从逻辑上对内存进行了抽象划分,主要分为两大部分:

  • 主内存(Main Memory):

​ 主内存是所有线程共享的内存区域,在JMM中,这里存储了Java程序中所有实例对象、静态变量、数组等。当线程执行时,它并不直接操作主内存中的变量,而是将需要操作的变量从主内存中读取到自己的工作内存中,进行操作后再将结果写回到主内存中。不同线程间无法直接访问对方工作内存中的变量,线程通信必须经过主内存

  • 工作内存(Working Memory / Local Memory):

​ 每个线程都有自己独立的工作内存区域。线程对变量的操作(读取、赋值等)都是在自己的工作内存中完成的。工作内存中存储了该线程使用到的变量的副本(并非原始数据的拷贝,而是变量的副本,包括了普通变量和共享变量)。线程在工作内存中执行代码时,首先会从主内存中读取变量的最新值到工作内存,执行完操作后再将结果同步回主内存,这样就确保了线程间数据的一致性。

注意,JMM的这种划分是逻辑上的抽象,并不是直接对应到JVM的具体内存区域(如堆、栈、方法区等)。

例如,主内存大致对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的一些区域,尤其是局部变量表。但是,这样的映射不是一对一的,因为JMM的目的是提供一种高层次的内存交互规则,而不是直接描述硬件或JVM的具体实现细节。

从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

主内存与工作内存交互操作有哪些?

主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存,Java内存模型中定义了以下8种操作来完成。Java虚拟机必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许有例外)

操作作用范围作用
lock(锁定)主内存变量把一个变量标识为一条线程独占的状态
unlock(解锁)主内存变量把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取)主内存变量把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
load(加载)工作内存变量把read操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用)工作内存变量把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值)工作内存变量把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
store(存储)工作内存变量把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
write(写入)主内存变量把store操作从工作内存中得到的变量的值放入主内存的变量中。

主内存与工作内存交互操作有哪些规定?

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 要按顺序执行read和load操作,按顺序执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说read与load之间、store与write之间是可插入其他指令的,
  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程没有发生过任何assign操作就把数据从线程的工作内存同步回主内存中。
  • 不允许在工作内存中直接使用一个未被初始化的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行load和assign操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

long和double的非原子性协定是什么?

long和double的非原子性协定(Nonatomic Treatment of double and long Variables)指的是在Java内存模型中,对于64位的数据类型(long和double),特别定义了一条相对宽松的规定。具体来说,这个协定允许Java虚拟机(JVM)将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。这意味着JVM可以选择不保证64位数据类型的loadstorereadwrite这四个操作的原子性。

然而,需要注意以下几点:

  1. 平台差异:这一规定主要是针对32位JVM的。在64位JVM虚拟机中,由于物理地址空间本身是64位的,因此不存在将64位数据划分为两次32位操作的问题。
  2. 商业虚拟机的实现:在目前主流平台下商用的64位Java虚拟机中并不会出现非原子性访问行为,但是对于32位的Java虚拟机,譬如比较常用的32位x86平台下的HotSpot虚拟机,对long类型的数据确实存在非原子性访问的风险。从JDK 9起,HotSpot增加了一个参数-XX:+AlwaysAtomicAccesses来约束虚拟机对所有数据类型进行原子性的访问。
  3. volatile关键字的影响:如果long或double变量被volatile修饰,那么它们的读写操作将被保证为原子性,从而避免了由于非原子性协定可能带来的并发问题。
  4. 罕见性:尽管非原子性协定在理论上存在,但在实际编程中,由于现代JVM的优化和商用JVM的实现方式,某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变量”的情况非常罕见。而针对double类型,由于现代中央处理器中一般都包含专门用于处理浮点数据的浮点运算器(Floating Point Unit,FPU),用来专门处理单、双精度的浮点数据,所以哪怕是32位虚拟机中通常也不会出现非原子性访问的问题。在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的long和double变量专门声明为volatile。

总结:long和double的非原子性协定是Java内存模型中的一个特性,它允许JVM将64位数据的读写操作划分为两次32位的操作。然而,在实际的编程和JVM实现中,由于多种因素(如平台差异、商业JVM的优化等),这一特性很少会导致实际的并发问题。

什么是指令重排序?

指令重排序(Instruction Reordering)是指在现代计算机系统中,编译器或处理器为了提高程序的执行效率和性能,对指令的执行顺序进行优化调整的过程。包括以下 3 种:

  1. 编译器优化的重排

    • 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。这种重排主要基于编译器对程序执行逻辑的理解和分析,通过减少指令间的依赖关系,优化指令的执行顺序,提高CPU的指令级并行处理能力。

    • 例如,编译器可能将没有依赖关系的指令进行重排,以便更好地利用CPU的流水线技术,提高执行效率。

  2. 指令级并行的重排

    • 现代CPU为了提高执行效率,支持指令流水线技术。在指令流水线中,处理器可以同时处理多条指令的不同阶段(如取指令、译码、执行等)。因此,指令级并行的重排是指处理器在不影响程序结果的前提下,调整指令在流水线中的执行顺序。

    • 通过指令级并行的重排,可以进一步减少指令间的依赖关系,提高CPU的利用率和程序的执行效率。

  3. 内存系统的重排(也称为存储子系统重排序或内存重排序):

    • 在多核处理器中,主存和本地缓存之间的数据可能不一致。内存系统的重排主要涉及到主存和缓存之间的数据同步问题。

    • 例如,在Java中,Java内存模型(JMM)规定了线程间的可见性和有序性,以避免内存重排带来的问题。Java程序员可以使用volatile关键字、synchronized关键字以及显式的内存屏障来防止指令重排序,确保程序的正确性。

对程序性能的影响

  • 指令重排序是现代计算机系统优化性能的重要手段之一。通过优化指令的执行顺序,可以减少指令间的依赖关系,提高程序的执行效率和性能。
  • 然而,在多线程环境下,指令重排序可能导致程序行为不可预测。因此,程序员需要了解指令重排序的原理和规则,并使用适当的同步机制来确保程序的正确性。

总结来说,指令重排序是现代计算机系统中优化性能的一种手段,通过改变语句的执行顺序来提高指令的并行度,从而提高执行效率。然而,在多线程环境下,指令重排序可能导致程序行为不可预测。因此,程序员需要了解指令重排序的原理和规则,并使用适当的同步机制来确保程序的正确性。

as-if-serial 是什么?

as-if-serial是Java内存模型(Java Memory Model, JMM)中的一个核心概念,也是一个编译器和JVM(Java虚拟机)的优化原则。它指的是编译器和虚拟机可以对程序进行各种优化,只要最终的执行结果与按照程序顺序执行的结果相同,就可以认为是合法的。这个原则允许编译器和虚拟机对代码进行重排序、消除冗余计算和执行其他优化,以提高程序的性能。

具体来说,as-if-serial原则包含以下几个关键点:

  1. 保持单线程语义:尽管编译器和虚拟机可以进行各种优化,但必须确保在单线程中程序的行为与原始的程序顺序执行结果相同。这意味着程序不会在单线程情况下出现未定义的行为。
  2. 无法观察到优化:如果程序中的其他线程无法观察到优化引起的行为变化,那么这些优化是合法的。这意味着程序的可见性和同步行为必须在多线程环境下保持一致。
  3. 优化不改变程序的语义:编译器和虚拟机可以进行各种优化,但不能改变程序的语义。也就是说,优化后的程序执行结果必须与未优化的程序执行结果相同。

在实际应用中,as-if-serial原则确保了并发执行的程序具有与串行执行相同的可见性和顺序性。它提供了一种抽象,使得程序员在编写并发程序时,可以将其当作串行程序来处理。在复杂的并发系统中,as-if-serial原则可以帮助开发者理解和设计同步机制,如锁、信号量等,以确保线程间的协作和数据的一致性。

需要注意的是,as-if-serial原则只保证单线程环境,多线程环境下无效。在多线程环境中,需要额外的同步机制来确保程序的正确性和一致性。

happens-before 原则是什么?

先行发生原则,JMM 定义的两项操作间的偏序关系,是判断数据是否存在竞争的重要手段。使用happens-before的概念来阐述操作之间的内存可见性:在JMM中**,如果一个操作执行的结果需要对另一个操作可见(两个操作既可以是在一个线程之内,也可以是在不同线程之间),那么这两个操作之间必须要存在happens-before关系。**

Java内存模型规定了一些天然的先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。

  • 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  • 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。确保了锁的释放对于获取锁的线程是可见的。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。保证了volatile变量的修改对于其他线程是可见的。
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

这些规则为Java程序员提供了在并发编程中编写正确代码的指导,帮助确保线程间的数据可见性和操作的顺序性。通过遵循Happens-Before原则,程序员可以编写出在多线程环境下正确运行的代码,减少并发错误和竞争条件的发生。

final 可以保证可见性吗?

final 可以保证可见性JSR-133 为 final 域增加重排序规则

  • 写 final 域重排序规则禁止把 final 域的写重排序到构造方法之外,编译器会在 final 域的写后,构造方法的 return 前,插入一个 Store Store 屏障。确保在对象引用为任意线程可见之前,对象的 final 域已经初始化过。
  • 读 final 域重排序规则:在一个线程中,初次读对象引用初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作。编译器在读 final 域操作的前面插入一个 Load 屏障,确保在读一个对象的 final 域前一定会先读包含这个 final 域的对象引用

只要对象是正确构造的(被构造对象的引用在构造方法中没有逸出),那么不需要使用同步就可以保证任意线程都能看到这个 final 域初始化后的值

as-if-serial 和 happens-before 有什么区别?

as-if-serial和happens-before在Java内存模型(JMM)中扮演着不同的角色,它们之间的主要区别如下:

定义和目的

  • as-if-serial:这是一种语义,它保证单线程内程序的执行结果不会被改变,即使编译器和处理器为了提高并行度进行了重排序。其目的在于为编写单线程程序的程序员创造一个环境,使他们可以认为程序是按顺序执行的。
  • happens-before:这是一组规则,用于确定多线程环境中哪些线程操作对其他线程是可见的,以及这些操作之间的顺序关系。其目的在于确保多线程程序中的可见性和顺序性,从而避免数据竞争和不一致。

作用范围

  • as-if-serial:主要关注单线程程序中的重排序和可见性问题。
  • happens-before:主要关注多线程程序中的重排序、可见性和顺序性问题。

具体规则

  • as-if-serial:没有具体的规则,但编译器和处理器在优化时,必须保证单线程程序的执行结果不被改变。
  • happens-before:有一系列具体的规则,如程序次序规则、锁定规则、volatile变量规则、传递规则、线程启动规则、线程中断规则、线程终结规则和对象终结规则。

影响

  • as-if-serial:通过限制编译器和处理器的重排序行为,确保单线程程序的正确性。
  • happens-before:通过定义多线程环境中的可见性和顺序性规则,确保多线程程序的正确性。

总结

  • as-if-serial和happens-before都是为了在不改变程序执行结果的前提下,提高程序执行的并行度。但是,它们的作用范围、具体规则和影响是不同的。as-if-serial关注单线程程序,而happens-before关注多线程程序。在编写多线程程序时,程序员需要了解并遵循happens-before规则,以确保程序的正确性和一致性。

volatile修饰的变量有什么特点 ?

volatile 是 Java 中的一个关键字,用于修饰变量,当变量被定义为 volatile 后具备特性:

当一个变量被声明为 volatile 时,它具有以下特点:

  1. 可见性

    • 当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主内存,当有其他线程需要读取时,它会去主内存中读取新值。

    • 可见性是通过“内存屏障”或“内存栅栏”(Memory Barrier)来实现的,它会确保在指令执行时,对内存的访问顺序符合程序员的期望。

  2. 禁止指令重排序

    • 在 Java 内存模型中,为了优化程序的执行性能,编译器和处理器可能会对指令进行重排序。但是,当使用 volatile 修饰变量时,会禁止这种重排序,从而确保程序的“顺序一致性”(Sequential Consistency)。

其他特点:

  1. 不保证原子性

    • 尽管 volatile 保证了可见性,但它并不保证复合操作的原子性。例如,对于 volatile int count++ 这样的操作,它实际上包含了读取、修改和写入三个步骤,这三个步骤在多线程环境下可能不是原子的。

    • 如果需要保证复合操作的原子性,应该使用 synchronized、ReentrantLock、AtomicInteger 等同步机制。

  2. 适用于状态标志

    • volatile 常用于多线程中的布尔类型标志变量,以控制线程的执行流程。例如,一个线程可以检查某个 volatile 布尔变量是否为 true,如果是,则执行某些操作;另一个线程可以修改这个变量的值来通知第一个线程。
  3. 开销

    • 由于 volatile 变量需要保证可见性和禁止指令重排序,因此它可能会带来一些额外的开销。但是,这些开销通常比使用锁(如 synchronized)要小得多。
  4. 使用场景

    • 当多个线程共享某个变量,并且这个变量的值会被多个线程频繁修改时,可以考虑使用 volatile。但是,如果涉及到复合操作或需要保证操作的原子性,那么应该使用其他同步机制。

volatile 如何保证变量的可见性?

volatile 关键字可以保证变量的可见性,volatile 关键字它最原始的意义就是禁用 CPU 缓存。如果我们将一个变量使用 volatile 修饰,这就指示 编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

volatile 内存语义

  • 写一个 volatile 变量时,把该线程工作内存中的值刷新到主内存。
  • 读一个 volatile 变量时,把该线程工作内存值置为无效,从主内存读取。

volatile 如何禁止指令重排序?

volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

在 Java 中,Unsafe 类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:

java
public native void loadFence();
public native void storeFence();
public native void fullFence();

理论上来说,你通过这个三个方法也可以实现和volatile禁止重排序一样的效果,只是会麻烦一些。

volatile禁止指令重排的操作分3个层面:

  • 字节码层面:当代码中使用volatile修饰变量时,会在编译后的字节码中为变量添加ACC_VOLATILE标记,
  • JVM层面:JVM规范中有4个内存屏障,LoadLoad/StoreStore/LoadStore/StoreLoad,在读到ACC_VOLATILE标记时会在内存区读写之前都加屏障。
  • 操作系统和硬件层面:操作系统执行该程序时,查到有内存屏障,使用lock指令,在指令前后都加lock(屏障),保证前后不乱序。

JMM针对编译器制定的volatile重排序规则:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

什么是内存屏障?

Java中的内存屏障(Memory Barrier)是一种特殊的硬件指令,也称为内存栅栏或内存栅障,用于在多核处理器系统中同步对内存的访问。内存屏障的主要作用是确保内存操作的顺序性,防止指令重排序和缓存一致性问题,从而在多线程环境中保持数据的正确性和可见性。

具体来说,内存屏障的作用可以归纳为以下几点:

  1. 禁止处理器命令的重新排序:内存屏障是插入两个CPU命令之间的命令,禁止处理器命令的重新排序,以确保有序性。这意味着在内存屏障之前的所有读写操作都必须在内存屏障之后的任何操作之前完成。
  2. 确保数据的可见性:为了达到屏障的效果,在处理器写入、读取值之前,将主机的值写入缓存,清空无效的队列,保障可见性。这意味着当一个线程修改了某个共享变量的值后,其他线程能够立即看到这个修改后的值。

在Java中,内存屏障主要通过volatile关键字和synchronized代码块来实现。具体来说:

  • volatile关键字:在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。这些屏障确保了volatile变量的读写操作的顺序性和可见性。
  • synchronized代码块:synchronized关键字包含的代码区域,在线程进入该区域阅读变量信息时,确保阅读的是最新值。这是因为在同步区域内写入变量操作,离开同步区域时将目前线程内的数据更新到内存,数据的阅读也不能从缓存中阅读,只能从内存中阅读,保证数据的阅读效果。

Java内存模型(JMM)定义了四种类型的内存屏障:

  • LoadLoad屏障:确保在屏障指令之后的读操作都从主内存中读取数据,而不是从缓存中读取。
  • StoreStore屏障:确保在屏障指令之前的所有写操作都写入主内存,使得其他线程能够看到这些写操作的结果。
  • LoadStore屏障:确保在屏障指令之前的读操作都从主内存中读取数据,并且在屏障指令之后的写操作都将数据写入主内存。
  • StoreLoad屏障:这是最强的一种屏障,它确保在屏障指令之前的所有写操作都写入主内存,并且屏障指令之后的读操作都从主内存中读取数据。这种屏障用于阻止所有的重排序。

内存屏障的使用通常需要编程者或编译器具备相关知识,以便在关键代码段插入屏障指令,确保多线程之间的数据同步。在硬件层面,内存屏障通常由处理器提供,并且在执行时可能会伴随着缓存刷新和/或指令重排序的抑制。

基于 MIT 许可发布