1.Java 内存模型(JMM)
1.1概念上划分
- 本地内存:每个线程均有自己的本地内存,是线程私有的。
- 主内存:存储共享变量存储,是线程共享的。
JMM与Java 内存区域的划分不同的概念层次,一个是规范、一个是实现。如果硬说有关系的化: - 主内存 属于共享数据区域,从某个程度上讲应该包括了堆和方法区。通常存被描述为堆内存。
- 工作内存 数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。通常存被描述为栈内存。
运作原理:
- 当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中,然后从缓存读取数据,或者将缓存中的部分内容读到内部寄存器中,再在寄存器中执行操作
- 当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。
存在问题:
-
缓存一致性问题:
在多处理器系统中,每个处理器都有自己的高速缓存,但它们又共享同一主内存,因此当多个处理器的操作都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况。 -
指令重排序问题:
为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,再在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。
解决方案:
- 缓存一致性解决方案:
- 加锁:通过在总线加 LOCK# 锁的方式
- 缓存一致性协议(如 MESI 协议)
- 指令重排序解决方案
- 内存屏障
2.三大性质
JMM 规范了 Java 虚拟机与计算机内存是如何协同工作的,主要规范了 可见性、有序性、原子性;
2.1 基本概念
-
原子性:
指一个操作是不可中断的,即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。JMM规范不仅保证了对变量读写的原子性还保证了对代码块执行的原子性。 -
可见性:
-指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。 -
有序性:
重排序是引起有序性的原因。重排序由一下几种机制引起:- 编译器优化:对于没有数据依赖关系的操作,编译器在编译的过程中会进行一定程度的重排。
- 指令重排序:CPU 优化行为,也是会对不存在数据依赖关系的指令进行一定程度的重排。
-
重排序:
在执行程序时,为了提高性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,需要满足以下两个条件:- 在单线程环境下,不能改变程序运行的结果
- 存在数据依赖关系的情况下,不允许重排序。
- 所有的操作均可以为了优化而被重排序,但是你必须要保证重排序后执行的结果不能被改变,编译器、runtime、处理器都必须遵守 as-if-serial 语义
- as-if-serial 只保证单线程环境,多线程环境下无效
2.2 八种操作
- lock(锁定):
作用于主内存的变量,把一个变量标识为一条线程独占状态。 - unlock(解锁):
作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。 - read(读取):
作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用 - load(载入):
作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。 - use(使用):
作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。 - assign(赋值):
作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。 - store(存储):
作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。 - write(写入):
作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
规则:
- 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行 read 和 load 操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行,比如 read a、read b、load b、load a。
- 不允许 read 和 load 、 store 和 write 操作之一单独出现
- 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
2.3 happens-fore
用于阐述操作之间的内存可见性,在JMM中,如果一个操作执行的结果需要对另一个操作可见(两个操作既可以是在一个线程之内,也可以是在不同线程之间),那么这两个操作之间必须要存在happens-before关系。
Java 满足的 happens-before 关系规则:
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作,happens-before 于书写在后面的操作。
- 锁定规则:一个 unLock 操作,happens-before 于后面对同一个锁的 lock 操作。
- volatile 变量规则:对一个变量的写操作,happens-before 于后面对这个变量的读操作。
- 传递规则:如果操作 A happens-before 操作 B,而操作 B happens-before 操作C,则可以得出,操作 A happens-before 操作C
- 线程启动规则:Thread 对象的 start 方法,happens-before 此线程的每个一个动作。
- 线程中断规则:对线程 interrupt 方法的调用,happens-before 被中断线程的代码检测到中断事件的发生。
- 线程终结规则:线程中所有的操作,都 happens-before 线程的终止检测,我们可以通过Thread.join() 方法结束、Thread.isAlive() 的返回值手段,检测到线程已经终止执行。
- 对象终结规则:一个对象的初始化完成,happens-before 它的 finalize() 方法的开始
推导出其他满足 happens-before 的规则:
- 将一个元素放入一个线程安全的队列的操作,happens-before 从队列中取出这个元素的操作。
- 将一个元素放入一个线程安全容器的操作,happens-before 从容器中取出这个元素的操作。
- 在 CountDownLatch 上的 countDown 操作,happens-before CountDownLatch 上的 await 操作。
- 释放 Semaphore 上的 release 的操作,happens-before 上的 acquire 操作。
- Future 表示的任务的所有操作,happens-before Future 上的 get 操作。
- 向 Executor 提交一个 Runnable 或 Callable 的操作,happens-before 任务开始执行操作。
结论
- 如果操作 A happens-before 操作 B,那么操作 A 在内存上的操作结果对操作 B 都是可见的
2.4 一致性协议
CPU 缓存一致性保证:
- 嗅探:( 一致性协议消息传递基础)
- 它的基本思想是:缓存本身是独立的,但是内存是共享资源,所以所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线,CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。
- 只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效。
- 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。
- MSEI 协议(每个缓存行有4个状态,可用2个bit表示)
状态 | 描述 |
---|---|
M(Modified) | 这行数据有效,数据被修改了,内存中的数据不一致,数据只存在与本Cache中 |
E(Exclusive) | 这行数据有效,数据和内存中一致,数据只存在与本Cache 中 |
S(Shared) | 这行数据有效,数据和内存中一致,数据存在于很多Cache 中 |
I(Invaid) | 这行数据无效 |
写数据:
- 开始修改某块内存之前,只有当缓存行处于 E 或者 M 状态时,处理器才能去写它,即写时处理器是独占这个缓存行的。只有在获得独占权后,处理器才能开始修改数据,因为这个缓存行只有一份拷贝,并且是在自己的缓存里,所以不会有任何冲突。
- 但如果处理器想写某个缓存行时,如果它没有独占权,它必须先发送一条”我要独占权”的请求给总线,这会通知其它处理器把它们拥有的同一缓存段的拷贝失效(如果有)。
读数据:
- 如果有其它处理器想读取这个缓存行(马上能知道,因为一直在嗅探总线),独占或已修改的缓存行必须先回到 ” 共享 ” 状态。如果是已修改的缓存行,那么还要先把内容回写到内存中
Lock 前缀指令:
- 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存
- lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据
- 不是内存屏障却能完成类似内存屏障的功能,阻止屏障两遍的指令重排序
2.5 volatile
被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。
volatile 的内存语义:
- 当写一个 volatile 内存变量的时候,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
- volatile轻量级,只能修饰变量,只能保证数据的可见性,不能用来同步,因为多个线程并发访问volatile修饰的变量不会阻塞。
volatile实现原理:
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现 缓存一致性 协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期 了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。因此,经过分析我们可以得出如下结论:
- Lock前缀的指令会引起处理器缓存写回内存;
- 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
- 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。
2.6 内存屏障
多核处理器需使用内存屏障指令来确保一致性。
内存屏障指令(Memory Barrier):
- 写内存屏障(Store Memory Barrier):处理器将存储缓存值写回主存(阻塞方式)。
- 读内存屏障(Load Memory Barrier):处理器,处理失效队列(阻塞方式)。
内存屏障的种类:
屏障类型 | 指令实例 | 说明 |
---|---|---|
LoadLoad Barrier | Load1;LoadLoad;Load2 | 确保Load1数据的装载先于Load2及所有后续装载指令的装载 |
StoreStore Barrier | Store1;StoreStore ;Store2 | 确保Store1数据对其他处理器可见(刷新到内存)先于Store2 及所有后续的存储指令的存储 |
LoadStore Barrier | Load1;LoadStore;Store2 | 确保Load1数据的装载先于Store2及所哟后续的存储指令的刷新到内存 |
StoreLoad Barrier | Store1;StoreLoad;Load2 | 确保Store1数据对其他处理器变得可见(刷新到内存)先于Load2及所有后续装载指令的装载 |
StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
volatile 的重排序规则如下:
- 如果第一个操作为 volatile 读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile 读之后的操作,不会被编译器重排序到 volatile 读之前;
- 如果第二个操作为 volatile 写,则不管第一个操作是啥,都不能重排序。这个操作确保volatile 写之前的操作,不会被编译器重排序到 volatile 写之后;
- 当第一个操作 volatile 写,第二个操作为 volatile 读时,不能重排序。
volatile 内存屏障:
- 在每一个 volatile 读操作后面,插入一个 LoadLoad 屏障, 禁止处理器把上面的该volatile读,与下面的所有读操作重排序。
- 在每一个 volatile 读操作后面,插入一个 LoadStore 屏障,禁止处理器把上面的该 volatile读,与下面的所有写重排序。
- 在每一个 volatile 写操作前面,插入一个 StoreStore 屏障,确保在该 volatile 写之前,其前面的所有写操作,都已经刷新到主内存中。
- 在每一个 volatile 写操作后面,插入一个 StoreLoad 屏障, 确保该 volatile 写及之前写缓冲都已刷新到主内存,并且不会与后面任意操作重排序。
2.7示例
示例1:
public class Example {
int a = 0;
boolean flag = false;
// A线程执行
public void writer() {
a = 1; // 1
flag = true; // 2
}
// B线程执行
public void read(){
if (flag) { // 3
int i = a + a; // 4
}
}
}
问:
A 线程先执行 #writer(),线程 B 后执行 #read(),线程 B 在执行时能否读到 a = 1 呢?
答案:
不一定( 注:x86 CPU 不支持写写重排序,如果是在 x86 上面操作,这个一定会是 a = 1 )。
由于操作 1 和操作 2 之间没有数据依赖性,所以可以进行重排序处理。
操作 3 和操作 4 之间也没有数据依赖性,他们亦可以进行重排序,但是操作 3 和操作 4 之间存在控制依赖性,只有操作 3 成立操作 4 才会执行。
当代码中存在控制依赖性时,会影响指令序列的执行的并行度,所以编译器和处理器会采用猜测执行来克服控制依赖对并行度的影响。假如操作 3 和操作 4 重排序了,操作 4 先执行,则先会把计算结果临时保存到重排序缓冲中,当操作 3 为真时,才会将计算结果写入变量 i 中。
结论:
重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义
示例2:
public class VolatileExample {
int a = 0;
volatile boolean flag = false;
// A线程执行
public void writer() {
a = 1; // 1
flag = true; // 2
}
// B线程执行
public void read(){
if (flag) { // 3
int i = a + a; // 4
}
}
}
问:
A 线程先执行 #writer(),线程 B 后执行 #read(),线程 B 在执行时能否读到 a = 1 呢?
答案:
b 一定可以读到 a = 1
- 由 依据 happens-before 原则分析
程序顺序原则:操作 1 happens-before 操作 2 ,操作 3 happens-before 操作 4 。
volatile 原则:操作 2 happens-before 操作 3 。
传递性原则:操作 1 happens-before 操作 4
内存屏障优化示例:
public class VolatileBarrierExample {
int a = 0;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite(){
int i = v1; //volatile读
int j = v2; //volatile读
a = i + j; //普通读
v1 = i + 1; //volatile写
v2 = j * 2; //volatile写
}
}
优化步骤:
2:禁止下面所有写与上面的 volatile 读重排序,但是由于存在第二个 volatile 读,所以读根本无法越过第二个 volatile 读。可以省略。
3:下面已经不存在读了,可以省略。
6:下面跟着一个 volatile 写,可以省略
总结:优化掉重复的或者不存在被预防操作的。