Java内存模型详解
硬件内存架构
理解内存模型架构以及Java内存模型如何与它协同工作也是非常重要的。
现代计算机硬件架构的简单图示:
多CPU:一个现代计算机通常由两个或者多个CPU。其中一些CPU还有多核。从这一点可以看出,在一个有两个或者多个CPU的现代计算机上同时运行多个线程是可能的。每个CPU在某一时刻运行一个线程是没有问题的。这意味着,如果你的Java程序是多线程的,在你的Java程序中每个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)优化。
Java内存模型
硬件模型的目标是为了让汇编代码能够运行在一个具有一致性的内存视图上,随着高级语言的流行,工程师们开始设计编程语言级别的内存模型,这是为了能够使用该语言编程进行编程的时候也能拥有一致性内存视图。
于是在硬件内存模型之上,还存在着一个为编程语言设计的内存模型,比如java内存模型,就屏蔽掉了各种硬件和操作系统的内存访问差异,实现了让Java程序能够在各个硬件平台下,都能按照预期的方式运行。
它的抽象,如图所示,概括来说每个工作线程都拥有独占的本地内存,本地内存中存储的是私有变量以及共享变量的副本,并且使用一定机制来控制本地内存和主内存之间读写数据时的同步问题。
如果再具象一些,我们将工作线程和本地内存具象为线程栈(thread stack),将主存具象堆(heap)。
Thread stack中有两种类型的变量,其中
原始类型变量,如int、char等,总是存储在线程栈中。
对象类型变量,引用本身存储在线程栈上,引用指向的对象存储在堆中。
在Heap中,存储对象本身持有对象引用的线程就能访问该对象,Heap并不关心那个线程正在访问对象。
Java线程模型中的线程栈和堆都是对物理内存的抽象,可以让开发者开发过程中只需关心线程栈和堆等而不用关心更下层的CPU缓存、寄存器等等。
由于工作线程大部分情况下都是在读取本地内存,那么对本地内存的速度要求更高,所以本地内存大部分是用寄存器、CPU缓存来实现。而堆需要存储大量的对象,需要更大容量,所以更经常是用主存实现。
JMM模型的线程通信
线程间通信必须要经过主内存。
如下,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤:
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)线程B到主内存中去读取线程A之前已更新过的共享变量。
关于主内存与工作内存之间的具体交互协议,一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了八种操作来完成,这里我们不需要全部掌握,只需要大概理解即可。
Java内存模型解决的问题
这些问题,被总结为三个要素:可见性、原子性、有序性
可见性
可见性:当一个线程修改共享变量的值,其他线程需要能立刻得知这个修改。
线程缓存导致的可见性问题
线程A修改了数据D,线程B需要读到修改后最新的D。
static int a = 1;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (a != 2) {
// do nothing
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
a = 2;
}
});
t1.start();
Thread.sleep(1000);
t2.start();
}
解决方法
- 通过 volatile 关键字保证可见性。
- 通过 内存屏障保证可见性。
- 通过 synchronized 关键字保证可见性。
- 通过 Lock保证可见性。
- 通过 final 关键字保证可见性
- 通过 CAS保证可见性
有序性
重排序导致的有序性问题
线程B需要读到被修改的变量D,线程A应该修改,但是因为重排序导致线程A没有及时修改变量D。
class ReorderExanple {
int a =0;
boolean flag = false;
public void writer() {
a= 1; //1
flag = true; //2
}
public void reader(){
if (flag){// 3
int i =a*a;// 4
}
}
}
flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?
答案是:不一定能看到。
由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。
原子性
一个操作是不可中断的,要么全部成功,要么全部失败。
如果两个或者更多的线程共享一个对象,多个线程在这个共享对象上更新变量,就有可能发生多线程竞争问题(race conditions)。
解决方法
- 单指令原子操作。由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store、write,我们大致可以认为基本数据类型变量、引用类型变量、声明为volatile的任何类型变量的访问读写是具备原子性的(long和double的非原子性协定:对于64位的数据,如long和double,Java内存模型规范允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这四个操作的原子性,即如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。但由于目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此在编写代码时一般也不需要将用到的long和double变量专门声明为volatile)。这些类型变量的读、写天然具有原子性,但类似于 “基本变量++” / “volatile++” 这种复合操作并没有原子性。
- 利用锁的组合指令原子操作。
- 通过 synchronized 关键字保证原子性。
- 通过 Lock保证原子性。
- 通过 CAS保证原子性。
拓展
as-if-serial语义:
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。(编译器、runtime和处理器都必须遵守as-if-serial语义)
happens before:
从JDK 5开始,Java使用新的JSR-133内存模型,JSR-133使用happens-before的概念来阐述操作之间的内存可见性:在JMM中,如果一个操作执行的结果需要对另一个操作可见(两个操作既可以是在一个线程之内,也可以是在不同线程之间),那么这两个操作之间必须要存在happens-before关系:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。(存在依赖关系的代码)
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。(及时刷新主存+原子化一组指令)
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。(及时刷新主存+禁止与前面的指令重排)
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
一个happens-before规则对应于一个或多个编译器和处理器重排序规则
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性:
- volatile关键字本身就包含了禁止指令重排序的语义
- synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
原创文章,作者:506227337,如若转载,请注明出处:https://blog.ytso.com/273874.html