不懂Java 内存模型,就先别扯什么高并发

你知道 Java 内存模型 JMM 吗?那你知道它的三大特性吗? Java 是如何解决指令重排问题的? 既然CPU有缓存一致性协议(MESI),为什么 JMM 还需要 volatile 关键字?

带着问题,尤其是面试问题的学习才是最高效的。加油,奥利给!
文章收录在 GitHub JavaKeeper

(opens new window) ,N线互联网开发必备技能兵器谱

前两天看到同学和我显摆他们公司配的电脑多好多好,我默默打开了自己的电脑,酷睿 i7-4770,也不是不够用嘛,4 核 8 线程的 CPU,也是杠杠的。

扯这玩意干啥,Em~~~~

介绍 Java 内存模型之前,先温习下计算机硬件内存模型

# 一、硬件内存架构

计算机在执行程序的时候,每条指令都是在 CPU 中执行的,而执行的时候,又免不了要和数据打交道。而计算机上面的数据,是存放在主存当中的,也就是计算机的物理内存。

计算机硬件架构简易图:

这些年,我们的 CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。CPU 和内存的速度差异可以形象地描述为:CPU 是天上一天,内存是地上一年(假设 CPU 执行一条普通指令需要一天,那么 CPU 读写内存得等待一年的时间)。内存和 I/O 设备的速度差异就更大了,内存是天上一天,I/O 设备是地上十年。

我们以多核 CPU 为例,每个 CPU 核都包含一组 「CPU 寄存器」,这些寄存器本质上是在 CPU 内存中。CPU 在这些寄存器上执行操作的速度要比在主内存(RAM)中执行的速度快得多。

因为CPU速率高, 内存速率慢,为了让存储体系可以跟上 CPU 的速度,所以中间又加上 Cache 层,就是我们说的 『CPU 高速缓存』

为了合理利用 CPU 的高性能,平衡 CPU 、内存、I/O 设备的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

CPU 增加了缓存,以均衡与内存的速度差异; 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异; 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

# CPU多级缓存

由于 CPU 的运算速度远远超越了 1 级缓存的数据 IO 能力,CPU 厂商又引入了多级的缓存结构。通常 L1、L2 是每个 CPU 核有一个,L3 是多个核共用一个。

# Cache Line

Cache 又是由很多个「缓存行」(Cache line) 组成的。Cache line 是 Cache 和 RAM 交换数据的最小单位。

Cache 存储数据是固定大小为单位的,称为一个Cache entry,这个单位称为 Cache lineCache block。给定 Cache 容量大小和 Cache line size 的情况下,它能存储的条目个数(number of cache entries)就是固定的。因为Cache 是固定大小的,所以它从主内存获取数据也是固定大小。对于 X86 来讲,是 64Bytes。对于 ARM 来讲,较旧的架构的 Cache line 是 32Bytes,但一次内存访存只访问一半的数据也不太合适,所以它经常是一次填两个 Cache line,叫做 double fill。

# 缓存的工作原理

这里的缓存的工作原理和我们项目中用 memcached、redis 做常用数据的缓存层是一个道理。

当 CPU 要读取一个数据时,首先从缓存中查找,如果找到就立即读取并送给CPU处理;如果没有找到,就去内存中读取并送给 CPU 处理,同时把这个数据所在的数据块(就是我们上边说的 Cache block)调入缓存中,即把临近的共 64 Byte 的数据也一同载入,因为临近的数据在将来被访问的可能性更大,可以使得以后对整块数据的读取都从缓存中进行,不必再调用内存。

这就增加了CPU读取缓存的命中率(Cache hit)了。

# 计算机层级存储

计算机存储系统是有层次结构的,类似一个金字塔,顶层的寄存器读写速度较高,但是空间较小。底层的读写速度较低,但是空间较大

# 缓存一致性

既然每个核中都有单独的缓存,那我的 4 核 8 线程 CPU 处理主内存数据的时候,不就会出现数据不一致问题了吗?

为了解决这个问题,先后有过两种方法:总线锁机制缓存锁机制

总线锁就是使用 CPU 提供的一个 LOCK# 信号,当一个处理器在总线上输出此信号,其他处理器的请求将被阻塞,那么该处理器就可以独占共享锁。这样就保证了数据一致性。

但是总线锁开销太大,我们需要控制锁的粒度,所以又有了缓存锁,核心就是“缓存一致性协议”,不同的 CPU 硬件厂商实现方式稍有不同,有 MSI、MESI、MOSI 等。

# 代码乱序执行优化

为了使得处理器内部的运算单元尽量被充分利用,提高运算效率,处理器可能会对输入的代码进行「乱序执行(Out-Of-Order Execution),处理器会在计算之后将乱序执行的结果重组,乱序优化可以保证在单线程下该执行结果与顺序执行的结果是一致的,但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。

乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化。在单核时代,处理器保证做出的优化不会导致执行结果远离预期目标,但在多核环境下却并非如此。

多核环境下, 如果存在一个核的计算任务依赖另一个核的计算任务的中间结果,而且对相关数据读写没做任何防护措施,那么其顺序性并不能靠代码的先后顺序来保证,处理器最终得出的结果和我们逻辑得到的结果可能会大不相同。

# 编译器指令重排

除了上述由处理器和缓存引起的乱序之外,现代编译器同样提供了乱序优化。之所以出现编译器乱序优化其根本原因在于处理器每次只能分析一小块指令,但编译器却能在很大范围内进行代码分析,从而做出更优的策略,充分利用处理器的乱序执行功能。

# 内存屏障

又称为内存栅栏,是一个 CPU 指令。尽管我们看到乱序执行初始目的是为了提高效率,但是在这多核时代效果好像不尽人意,其中的某些”自作聪明”的优化导致多线程程序产生各种各样的意外。因此有必要存在一种机制来消除乱序执行带来的坏影响,也就是说应该允许程序员显式的告诉处理器对某些地方禁止乱序执行。这种机制就是所谓内存屏障。不同架构的处理器在其指令集中提供了不同的指令来发起内存屏障,对应在编程语言当中就是提供特殊的关键字来调用处理器相关的指令,JMM 里我们再探讨。


# 二、Java 内存模型

Java 内存模型即 Java Memory Model,简称 JMM

这里的内存模型可不是 JVM 里的运行时数据区。

「内存模型」可以理解为在特定操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象

不同架构的物理计算机可以有不一样的内存模型,Java 虚拟机也有自己的内存模型。

Java 虚拟机规范中试图定义一种「 Java 内存模型」来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序。

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里的变量与我们写 Java 代码中的变量不同,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为他们是线程私有的,不会被共享。

Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存(解决可见性问题)和编译优化(解决有序性问题)的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则,这也正是本期的重点内容。

# JMM 组成

# JMM 与 JVM 内存结构

JMM 与 Java 内存区域中的堆、栈、方法区等并不是同一个层次的内存划分,两者基本没有关系。如果一定要勉强对应,那从变量、主内存、工作内存的定义看,主内存主要对应 Java 堆中的对象实例数据部分,工作内存则对应虚拟机栈的部分区域(与上图对应着看哈)。

# JMM 与计算机内存结构

Java 内存模型和硬件内存体系结构也没有什么关系。硬件内存体系结构不区分栈和堆。在硬件上,线程栈和堆都位于主内存中。线程栈和堆的一部分有时可能出现在高速缓存和 CPU 寄存器中。如下图所示:

当对象和变量可以存储在计算机中不同的内存区域时,这就可能会出现某些问题。两个主要问题是:

# 可见性问题(Visibility of Shared Objects)

如果两个或多个线程共享一个对象,则一个线程对共享对象的更新可能对其他线程不可见(当然可以用 Java 提供的关键字 volatile)。

假设共享对象最初存储在主内存中。在 CPU 1上运行的线程将共享对象读入它的 CPU 缓存后修改,但是还没来得及刷新回主内存,这时其他 CPU 上运行的线程就不会看到共享对象的更改。这样,每个线程都可能以自己的线程结束,就出现了可见性问题,如下

# 竞争条件(Race Conditions)

这个其实就是我们常说的「原子性问题」。

如果两个或多个线程共享一个对象,并且多个线程更新该共享对象中的变量,则可能出现竞争条件。

由于 IO 太慢,早期的操作系统就发明了多进程,即便在单核的 CPU 上我们也可以一边听着歌,一边写 Bug,这个就是多进程的功劳。

操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。

这里的进程在等待 IO 时之所以会释放 CPU 使用权,是为了让 CPU 在这段等待时间里可以做别的事情,这样一来 CPU 的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样 IO 的使用率也上来了。

是不是很简单的逻辑?但是,虽然看似简单,支持多进程分时复用在操作系统的发展史上却具有里程碑意义,Unix 就是因为解决了这个问题而名噪天下的。

早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。

Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如 count += 1,至少需要三条 CPU 指令。

指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器; 指令 2:之后,在寄存器中执行 +1 操作; 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。这样不同步的操作,就会出现 bug。

想象一下,如果线程 A 将一个共享对象的变量读入到它的 CPU 缓存中。此时,线程 B 执行相同的操作,但是进入不同的 CPU 缓存。现在线程 A 执行 +1 操作,线程 B 也这样做。现在该变量增加了两次,在每个 CPU 缓存中一次。

如果这些增量是按顺序执行的,则变量结果会是 3,并将原始值 +2 写回主内存。但是,这两个增量是同时执行的,没有适当的同步。不管将哪个线程的结果写回主内存,更新后的值只比原始值高 1,显然是有问题的。如下(当然可以用 Java 提供的关键字 Synchronized)

# 有序性问题

顾名思义,有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。

这个就是我们上文说到的代码乱序执行优化。

不过有时候编译器及解释器的优化可能导致意想不到的 Bug。

在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。

public class Singleton { static Singleton instance; static Singleton getInstance(){ if (instance == null) { synchronized(Singleton.class) { if (instance == null) instance = new Singleton(); } } return instance; } }

假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。

这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?

出在 new 操作上,我们以为的 new 操作应该是:

分配一块内存 M; 在内存 M 上初始化 Singleton 对象; 然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径可能是这样的:

分配一块内存 M; 将 M 的地址赋值给 instance 变量; 最后在内存 M 上初始化 Singleton 对象。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

# JMM 特性

JMM 就是用来解决如上问题的。 JMM是围绕着并发过程中如何处理可见性、原子性和有序性这 3 个 特征建立起来的

# 内存之间的交互操作

关于主内存和工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了 8 种 操作来完成,虚拟机实现必须保证每一种操作都是原子的、不可再拆分的(double 和 long 类型例外)

如果需要把一个变量从主内存复制到工作内存,那就要顺序地执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要顺序地执行 store 和 write 操作。注意,Java 内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说 read 与 load 之间、store 与write 之间是可插入其他指令的,如对主内存中的变量 a、b 进行访问时,一种可能出现顺序是 read a、read b、load b、load a。

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

# long 和 double 型变量的特殊规则

Java 内存模型要求 lock,unlock,read,load,assign,use,store,write 这 8 个操作都具有原子性,但对于64 位的数据类型( long 或 double),在模型中定义了一条相对宽松的规定,允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的load,store,read,write 这 4 个操作的原子性,即 long 和 double 的非原子性协定

以 32 位 CPU 上执行 long 型变量的写操作为例来说明这个问题,long 型变量是 64 位,在 32 位 CPU 上执行写操作会被拆分成两次写操作(写高 32 位和写低 32 位,如下图所示)。

如果多线程的情况下 double 或 long 类型并未声明为 volatile,可能会出现“半个变量”的数值,也就是既非原值,也非修改后的值。

虽然 Java 规范允许上面的实现,但商用虚拟机中基本都采用了原子性的操作,因此在日常使用中几乎不会出现读取到“半个变量”的情况,so,这个了解下就行。

# 先行发生原则

先行发生(happens-before)是 Java 内存模型中定义的两项操作之间的偏序关系,如果操作 A 先行发生于操作 B,那么 A 的结果对 B 可见

Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。

happens-before 关系的分析需要分为单线程和多线程的情况:

为了方便程序开发,Java 内存模型实现了下述的先行发生关系(“天然的”先行发生关系,无需任何同步器协助就存在):

# 内存屏障

上边的一系列操作保证了数据一致性,Java 中如何保证底层操作的有序性和可见性?可以通过内存屏障。

内存屏障是被插入两个 CPU 指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障可见性

eg:

Store1; 
Store2;   
Load1;   
StoreLoad;  //内存屏障
Store3;   
Load2;   
Load3;

对于上面的一组 CPU 指令(Store表示写入指令,Load表示读取指令),StoreLoad 屏障之前的 Store 指令无法与 StoreLoad 屏障之后的 Load 指令进行交换位置,即重排序。但是 StoreLoad 屏障之前和之后的指令是可以互换位置的,即 Store1 可以和 Store2 互换,Load2 可以和 Load3 互换。

常见的 4 种屏障

Java 中对内存屏障的使用在一般的代码中不太容易见到,常见的有 volatile 和 synchronized 关键字修饰的代码块,还可以通过 Unsafe 这个类来使用内存屏障。(下一章扯扯这些)

噢啦,Java 内存模型就是通过以上定义的这些来解决可见性、原子性和有序性问题的。

# 参考

《深入理解 Java 虚拟机》第二版

《Java 并发编程实战》

http://tutorials.jenkov.com/java-concurrency/java-memory-model.html https://juejin.im/post/5bf2977751882505d840321d#heading-5 http://rsim.cs.uiuc.edu/Pubs/popl05.pdf http://ifeve.com/wp-content/uploads/2014/03/JSR133中文版.pdf

展开阅读全文

页面更新:2024-04-23

标签:内存   屏障   线程   缓存   变量   指令   处理器   模型   操作   数据   工作

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2008-2024 All Rights Reserved. Powered By bs178.com 闽ICP备11008920号-3
闽公网安备35020302034844号

Top