五、JVM - Java 内存模型

一、硬件的效率与一致性

大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个 I/O 操作就是很难消除的(无法仅靠寄存器来完成所有运算任务)。由于计算机的存储设备与处理器的运算数度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写数度尽可能接近处理器运算数度的高速缓存来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之是,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但也为计算机系统带来更高的复杂度,引入了一个新的问题:缓存一致性(Cache Coherence)。在多路处理器中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),这种系统称为共享内存多核系统(Shared Memory Multiprocessors System)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为了解决一致性问题,各个处理器访问缓存都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly 及 Dragon Protocol 等。

内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而 Java 虚拟机也有自己的内存模型。
在这里插入图片描述

二、缓存行

数据在缓存中不是以独立的项来存储的,缓存是由缓存行组成的,通常是64字节,并且它有效地引用主内存中的一块地址。一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。
非常奇妙的是如果你访问一个long数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个。因此你能非常快地遍历这个数组。
因此如果数据结构中的项在内存中不是彼此相邻的(链表),你将得不到免费缓存加载所带来的优势。并且在这些数据结构中的每一个项都可能会出现缓存未命中。

伪共享问题 (缓存行对齐提高效率)

但这种加载有一个弊端。设想long类型的数据不是数组的一部分,它只是一个单独的变量。让我们称它为head。然后再设想有另一个变量tail紧挨着它。当加载head到缓存的时候同时也加载了tail。
tail正在被你的生产者写入,而head正在被你的消费者写入。这两个变量实际上并不是密切相关的,而事实上却要被两个不同内核中运行的线程所使用。

设想消费者更新了head的值。缓存中的值和内存中的值都被更新了,而其他所有存储head的缓存行都会都会失效。请记住我们必须以整个缓存行作为单位来处理,不能只把head标记为无效。
现在如果一些正在其他内核中运行的进程只是想读tail的值,整个缓存行需要从主内存重新读取。那么一个和你的消费者无关的线程读一个和head无关的值,它被缓存未命中给拖慢了。

三、硬件层数据一致性

MESI(Modified Exclusive Shared Or Invalid)–CPU缓存一致性协议, 是一种广泛使用的支持写回策略的缓存一致性协议。

MESI协议中的状态

CPU中每个缓存行(caceh line)使用4种状态进行标记,CPU在每个cache line 额外两位标记四种状态(使用额外的两位(bit)表示):

M: 被修改(Modified)
该缓存行只被缓存在该CPU的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回(write back)主存。 当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。

E: 独享的(Exclusive)
该缓存行只被缓存在该CPU的缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态(shared)。同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。

S:共享的(Shared)
该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。

I: 无效的(Invalid)
该缓存是无效的(可能有其它CPU修改了该缓存行)。

四、Java 内存模型

Java 内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。(包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的,不被共享)

Java 内存模型规定了所有变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中还保存了被该线程使用的变量的主存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aRT0zqR2-1597066249938)(/Users/apple/Documents/md/jvm/五、Java 内存模型.assets/线程、主内存、工作内存三者的交互关系.png)]

这里的主内存、工作内存、与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的对内存的划分,这两者是基本上没有任何关系的。如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主是对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

五、内存间交互操作

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

  1. lock(锁定)作用于主内存的变量,把一个变量标识为一条线程独占的状态。

  2. unlock(解锁)作用于主内存的变量,把一个处理锁定状态的变量释放出来。

  3. read(读取)作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便后面的 load 动作使用。

  4. load(载入)作用于工作内存的变量,把 read 操作从主内存得到的变量值放入工作内存的变量副本中。

  5. use(使用)对于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

  6. assign(赋值)作用于工作内存的变量,把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  7. store(存储)作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用

  8. write(写入)作用于主内存的变量,把 store 操作从工作内存中得到的变量的值放入主内存的变量中。


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

  1. 不允许 read 和 load、store 和 write 操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或工作内存发起回写了但主内存不接受的情况出现。
  2. 不允许一个线程丢弃它最近的 assign 操作,即变量是在工作内存中改变了之后必须把该变化同步回主内存。
  3. 不允许一个线程原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
  4. 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初使化(load 或 assgin)的变量,就是对一个变量实施 use、store 操作之前,必须先执行 assgin 和 load 操作。
  5. 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 之后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  6. 如果一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assgin 操作以初使化变量的值。
  7. 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其它线程锁定的变量。
  8. 对一个变量执行 unlock 操作之前,必须先把些变量同步回主内存中(执行 store、write 操作)

六、Volatile 型变更的特殊规则

Volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制,Java 内存模型为 Volatile 专门定义了一些特殊的访问规则。当一个变量被定义成 volatile 之后,它将具备两项特性

  • 保证此变量对所有线程的可见性,指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

    由于 Volatile 只能保证可见性,但是 Java 的运算操作符并非原子操作,这导致 Volatile 变量的运算在并发下一样是不安全的,在不符合以下两条规则的场景中,仍然要通过加锁(使用 synchronized、java.util.concurrent 中的锁或原子类)来保证原子性:

    • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
    • 变量不需要与其它状态变量共同参与不变约束。
    //volatile 的使用场景
    volatile boolean shutdownRequested;
    public void shutdown() {
    	shutdownRequested = true;
    }
    
    public void dowork(){
    		while (!shutdownRequested) {
    			//do somethin...
    		}
    }
    
  • 禁止指令重排序,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

    从硬件架构上讲,指令重排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。处理器必须能正确处理指令依赖情况保障程序能得出正确的执行结果。

Java 内存模型中对 volatile 变量定义的特殊规则的定义:

  1. 在工作内存中,每次使用 volatile 变量都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量所做的修改。
  2. 在工作内存中,每次修改 volatile 变量后都必须立刻同步回主内存中,用一保证其他线程可以看到自己对变量所做的修改。
  3. volatele 修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。

七、Long 和 Double 型变量的特殊规则

Java 内存模型要求 lock、unlock、read、load、use、assgin、store、write 这8种操作都具有原子性,但是对于64位的数据类型(long 和 double),在模型中特别定义了一条宽松的规定:允许虚拟机实现自行选择是否要保证64位数据类型的 load、store、read 和 write 这四个操作的原子性,这就是所谓的”long 和 double 的非原子性协定“(Non-Atomic Treatment of double and long variables)。

如果有多个线程共享一个并未声明为 volatile 的 long 或 double 类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个即不是原值,也不是其他线程修改的值,“半个变量”的数值。经过实际测试在目前主流平台商用的64位 Java 虚拟机中并不会出现非原子性访问行为,但是对于32位的 Java 虚拟机,如常用的32位 x86平台下的 Hotspot 虚拟机,对 long 类型的数据确实存在非原子性访问的风险。

八、原子性、可见性与有序性

​ Java 内存模型是围绕着并发过程中如何处理原子性、可见性和有序性这三个特征来建立的。

1. 原子性

​ 基础数据类型的访问、读写都是具备原子性的(例外就是 long和double 的非原子性协定)。如果应用场景需要一个更大范围的原子性保证,Java 内存模型还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块 synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。

2. 可见性

​ 可见性就是当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此。普通变量与 volatile 变量的区别是,volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。

​ 除了 volatile 之外,synchronized 和 final 两个关键字也可以实现可见性。synchronized 同步块的可见性是由“一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)”这条规则获得的。而 final 的可见性是指:被 final 修饰的字段在构造器中一旦被初使化完成,并且构造器没有把"this"的引用传递出去,那么其他线程就能看见 final 字段的值。

//final 与可见性
public static int i;
public final int j; 
static {
	i = 0;
}
{
	//也可以在构造函数中初使化
	j=0;
}

3. 有序性

​ Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由“一个变量在同一时刻只允许一条线程对其进行 lock 操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

猜你喜欢

转载自blog.csdn.net/huanghuitan/article/details/107922605
今日推荐