本文是针对java内存模型JMM的入门级简介,通过学习JMM可以让我们加深对java代码执行的过程,及java并发编程中常见的问题的了解。例如 volatile
、synchronized
等在JMM中的具体体现。
Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
为什么CPU要引入缓存系统:CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而CPU的高度运算需要高速的数据。为了解决这个问题,CPU厂商在CPU中内置了少量的高速缓存以解决I\O速度和CPU运算速度之间的不匹配问题。
缓存一致性问题:在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也引入了新的问题:缓存一致性(CacheCoherence)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol,等等:
什么是MESI
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:
本章节通过三个并发编程中常见的问题引入JMM为我们日常开发提供的帮助。
package jmm.demo;
public class VisibilityTest {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Worker1());
Thread t2 = new Thread(new Worker2());
t1.start();
Thread.sleep(1000);
t2.start();
t1.join();
t2.join();
System.out.println("main down");
}
static class Worker1 implements Runnable {
@Override
public void run() {
while (flag) {
}
System.out.println("worker1 down");
}
}
static class Worker2 implements Runnable {
@Override
public void run() {
flag = false;
}
}
}
package jmm.demo;
import java.util.ArrayList;
import java.util.List;
/***
* Description: 原子性问题
*/
public class AtomicityProblem {
/***共享变量*/
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
num++;
}
};
List threads = new ArrayList<>();
//10个线程各执行1000次num++
for (int i = 0; i < 10; i++) {
Thread t = new Thread(increment);
t.start();
threads.add(t);
}
//确保10个线程都走完
for (Thread thread : threads) {
thread.join();
}
System.out.println("10个线程执行后的结果为:" + num);
}
}
int a=0, b=0;
public void method1() {
int r2 = a;
b = 1;
}
public void method2() {
int r1 = b;
a = 2;
}
单线程情况下
Thread1
r2=a=0
b=1
r1=b=1
a=2
(1,0)
Thread1
r1=b=0
a=2
r2=a=2
b=1
(0,2)
多线程情况下
Thread1 Thread2
r1=b=0 |
| r2=a=0
| b=1
a=2 |
(0,0)
Thread1 Thread2
r1=b=0 |
a=2 |
| r2=a=2
| b=1
(0,2)
Thread1 Thread2
r1=b=0 |
| r2=a=0
a=2 |
| b=1
(0,0)
Thread1 Thread2
r1=b=0 |
| r2=a=0
a=2 |
| b=1
(0,0)
Thread1 Thread2
| r2=a=0
r1=b=0 |
a=2 |
| b=1
(0,0)
Thread1 Thread2
| r2=a=0
| b=1
r1=b=1 |
a=2 |
(1,0)
那么会不会出现 (1,2)的情况?
Thread1 Thread2
| b=1 #重排序或乱序执行
r1=b=1 |
a=2 |
| r2=a=2
线程2的执行结果出现了重排序:
重排序是针对程序顺序而言的,如果指令执行顺序与程序顺序不同,就说明这段指令被重排序了。
目前重排序的方式有三种
为什么要重排序,而上述的现象属于哪种情况?
CPU乱序执行
大多数现代微处理器都会采用将指令乱序执行(out-of-order execution,简称OoOE或OOE)的方法,在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待。通过乱序执行的技术,处理器可以大大提高执行效率。
JIT编译器重排序
JIT编译器:将java字节码指令翻译成本地机器码。在翻译的过程当中会对字节码的执行顺序进行优化,甚至删除一部分指令。
int a = 3;
int b = 2;
a = a + 1;
b++;
0: iconst_3 #将int型3推送至栈顶
1: istore_1 #将栈顶int型数值存入第二个本地变量
2: iconst_2 #将int型2推送至栈顶
3: istore_2 #将栈顶int型数值存入第三个本地变量
4: iload_1 #将第2个int型本地变量推送至栈顶
5: iconst_1 #将int型1推送至栈顶
6: iadd #将栈顶两int型数值相加并将结果压入栈顶
7: istore_1 #将栈顶int型数值存入第二个本地变量
8: iinc 2, 1 #将指定int型变量增加指定值
数据依赖性
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1;b = a; | 写一个变量之后,再读这个位置。 |
写后写 | a = 1;a = 2; | 写一个变量之后,再写这个变量。 |
读后写 | a = b;b = 1; | 读一个变量之后,再写这个变量。 |
as-if-serial语义
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。(编译器、runtime和处理器都必须遵守as-if-serial语义)
Java内存模型与 happens-before规则
Java的目标是成为一门平台无关性的语言,即Write once, run anywhere。但是不同硬件环境下指令重排序的规则不尽相同。例如,x86下运行正常的Java程序在IA64下就可能得到非预期的运行结果。为此,JSR-1337制定了Java内存模型(Java Memory Model, JMM),旨在提供一个统一的可参考的规范,屏蔽平台差异性。从Java 5开始,Java内存模型成为Java语言规范的一部分。
根据Java内存模型中的规定,可以总结出以下几条happens-before规则。Happens-before的前后两个操作不会被重排序且后者对前者的内存可见。
翻译一下:
那么如何解决(1,2)这个问题?答案是,将 a 或者 b 设置为 volatile 字段。
int a=0;
volatile int b=0;
public void method1() {
int r2 = a;
b = 1;
}
public void method2() {
int r1 = b;
a = 2;
}
比如说将 b 设置为 volatile 字段。假设 r1 能够观测到 b 的赋值结果 1。显然,这需要 b 的赋值操作在时钟顺序上先于 r1 的赋值操作。根据 volatile 字段的 happens-before 关系,我们知道 b 的赋值操作 happens-before r1 的赋值操作。
根据同一个线程中,字节码顺序所暗含的 happens-before 关系,以及 happens-before 关系的传递性,我们可以轻易得出 r2 的赋值操作 happens-before a 的赋值操作。
这也就意味着,当对 a 进行赋值时,对 r2 的赋值操作已经完成了。因此,在 b 为 volatile 字段的情况下,程序不可能出现(r1,r2)为(1,2)的情况。
由此可以看出,解决这种数据竞争问题的关键在于构造一个跨线程的 happens-before 关系 :操作 X happens-before 操作 Y,使得操作 X 之前的字节码的结果对操作 Y 之后的字节码可见。
在理解了 Java 内存模型的概念之后,我们现在来看看它的底层实现。Java 内存模型是通过内存屏障(memory barrier)来禁止重排序的。
为什么会有内存屏障
每个CPU都会有自己的缓存(有的甚至L1,L2,L3),缓存的目的就是为了提高性能,避免每次都要向内存取。但是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存值不同。
用volatile关键字修饰变量可以解决上述问题,那么volatile是如何做到这一点的呢?那就是内存屏障,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由jvm来生成内存屏障的指令。
硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。
内存屏障有两个作用:
java的内存屏障通常所谓的四种即LoadLoad,StoreStore,LoadStore,StoreLoad实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。
volatile语义中的内存屏障
volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:
- 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
- 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;
由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。
final语义中的内存屏障
对于final域,编译器和CPU会遵循两个排序规则:
新建对象过程中,构造体中对final域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序;
初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序;(晦涩,意思就是先赋值引用,再调用final值)
总之上面规则的意思可以这样理解,必需保证一个对象的所有final域被写入完毕后才能引用和读取。这也是内存屏障的起的作用:
X86处理器中,由于CPU不会对写-写操作进行重排序,所以StoreStore屏障会被省略;而X86也不会对逻辑上有先后依赖关系的操作进行重排序,所以LoadLoad也会变省略。
参考资料:
一文解决内存屏障
为何内存重排序
JAVA内存模型
JVM并发来看CPU内存指令重排序
+