文章目录
- 1. Java内存模型
- 2. 内存交互
- 3. 三大特性
- 3.1 可见性
- 3.1.1 可见性问题
- 3.1.2 原因
- 3.1.3 解决方法
- 3.2 原子性
- 3.3 有序性
- 4. 指令重排
- 5. JMM 与 happens-before
- 5.1 happens-before关系定义
- 5.2 happens-before 关系
在继续学习JUC之前,我们现在这里介绍一下Java内存模型,也就是JMM,进而引出关键字volatile的使用条件。
1. Java内存模型
Java 内存模型是 Java Memory Model(JMM),本身是一种抽象的概念,实际上并不存在,描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式
JMM 作用:
- 屏蔽各种硬件和操作系统的内存访问差异,实现让 Java 程序在各种平台下都能达到一致的内存访问效果
- 规定了线程和内存之间的一些关系
根据 JMM 的设计,系统存在一个主内存(Main Memory),Java 中所有变量都存储在主存中,对于所有线程都是共享的;每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是先对变量进行拷贝,然后在工作内存中进行,不能直接操作主内存中的变量;线程之间无法相互直接访问,线程间的通信(传递)必须通过主内存来完成
主内存和工作内存:
- 主内存:计算机的内存,也就是经常提到的 8G 内存,16G 内存,存储所有共享变量的值
- 工作内存:存储该线程使用到的共享变量在主内存的的值的副本拷贝
JVM 和 JMM 之间的关系:JMM 中的主内存、工作内存与 JVM 中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来:
- 主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域
- 从更低层次上说,主内存直接对应于物理硬件的内存,工作内存对应寄存器和高速缓存
2. 内存交互
Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作,每个操作都是原子的
非原子协定:没有被 volatile 修饰的 long、double 外,默认按照两次 32 位的操作
- lock:作用于主内存,将一个变量标识为被一个线程独占状态(对应 monitorenter)
- unclock:作用于主内存,将一个变量从独占状态释放出来,释放后的变量才可以被其他线程锁定(对应 monitorexit)
- read:作用于主内存,把一个变量的值从主内存传输到工作内存中
- load:作用于工作内存,在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
- use:作用于工作内存,把工作内存中一个变量的值传递给执行引擎,每当遇到一个使用到变量的操作时都要使用该指令
- assign:作用于工作内存,把从执行引擎接收到的一个值赋给工作内存的变量
- store:作用于工作内存,把工作内存的一个变量的值传送到主内存中
- write:作用于主内存,在 store 之后执行,把 store 得到的值放入主内存的变量中
3. 三大特性
3.1 可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
3.1.1 可见性问题
存在不可见问题的根本原因是由于缓存的存在,线程持有的是共享变量的副本,无法感知其他线程对于共享变量的更改,导致读取的值不是最新的。但是 final 修饰的变量是不可变的,就算有缓存,也不会存在不可见的问题。
如下面的代码所示:
@Slf4j(topic = "c.Test20")
public class Test1 {
static boolean run = true; //添加volatile
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
log.debug("我想停下t");
run = false; // 线程t不会如预想的停下来
}
}
可以看到,上面的代码在主线程的最后将 run
变量设置为了 false
,但是,线程t并没有像我们预期的一样停下来,这就是由于缓存存在的原因。
3.1.2 原因
我们可以分析下上面的流程:
-
初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存
-
因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。
-
1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。
3.1.3 解决方法
-
使用volatile
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存,修改如下:@Slf4j(topic = "c.Test20") public class Test1 { volatile static boolean run = true; //添加volatile public static void main(String[] args) throws InterruptedException { Thread t = new Thread(()->{ while(run){ // .... } }); t.start(); sleep(1); log.debug("我想停下t"); run = false; // 线程t不会如预想的停下来 } }
-
使用synchronized锁
@Slf4j(topic = "c.Test20") public class Test1 { volatile static boolean run = true; //添加volatile final static Object lock = new Object(); public static void main(String[] args) throws InterruptedException { Thread t = new Thread(()->{ while(run){ synchronized (lock){ if(!run){ break; } } } }); t.start(); sleep(1); log.debug("我想停下t"); synchronized (lock){ run = false; } } }
3.2 原子性
原子性:不可分割,完整性,也就是说某个线程正在做某个具体业务时,中间不可以被分割,需要具体完成,要么同时成功,要么同时失败,保证指令不会受到线程上下文切换的影响
定义原子操作的使用规则:
- 不允许 read 和 load、store 和 write 操作之一单独出现,必须顺序执行,但是不要求连续
- 不允许一个线程丢弃 assign 操作,必须同步回主存
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步会主内存中
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(assign 或者 load)的变量,即对一个变量实施 use 和 store 操作之前,必须先自行 assign 和 load 操作
- 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁,lock 和 unlock 必须成对出现
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新从主存加载
- 如果一个变量事先没有被 lock 操作锁定,则不允许执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量
- 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)
3.3 有序性
JAVA 运行时有的操作是无序的,无序是因为发生了指令重排序。
CPU 的基本工作是执行存储的指令序列,即程序,程序的执行过程实际上是不断地取出指令、分析指令、执行指令的过程,为了提高性能,编译器和处理器会对指令重排,一般分为以下三种:
源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令
比如下面的代码:
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
如果执行上面的两个线程,那么可能的结果有哪些呢?,很明显有下面两种:
- 先执行线程1再执行线程2,那么结果是1
- 先执行线程2再执行线程1,那么结果是4
- 但是,除此之外,还有一种发生重排的情况,即线程2的指令重排为先执行
ready = true;
再执行num = 2;
,这样的话,结果就是0
如果我们希望指令不进行重排,那么可以再定义变量时加一个volatile修饰变量 ready
,这样,可以使得ready
代码之前的部分不发生重排,所以 num
就不用加 volatile修饰了。
4. 指令重排
上面我们提到了,有时候指令并不按照我们编写代码的顺序来进行执行,这是因为发生了指令重排,那么为什么要发生指令重排呢?不发生指令重排程序不是更加显明吗?
其实不是这样的,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令 1 还没有执行完,就可以开始执行指令 2,而不用等到指令 1 执行结束后再执行指令 2,这样就大大提高了效率,指令流水线如下图所示:
上述流水线技术能够很好的提高CPU的效率,但是,流水线术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
我们分析一下下面这段代码的执行情况:
a = b + c;
d = e - f ;
先加载 b、c(注意,有可能先加载 b,也有可能先加载 c),但是在执行 add(b,c) 的时候,需要等待 b、c 装载结束才能继续执行,也就是需要增加停顿,那么后面的指令(加载 e 和 f)也会有停顿,这就降低了计算机的执行效率。
为了减少停顿,我们可以在加载完 b 和 c 后把 e 和 f 也加载了,然后再去执行 add(b,c) ,这样做对程序(串行)是没有影响的,但却减少了停顿。换句话说,既然 add(b,c) 需要停顿,那还不如去做一些有意义的事情(加载 e 和 f)。
因此,指令重排对于提高 CPU 性能十分必要,但也带来了乱序的问题。
指令重排一般分为以下三种:
- 编译器优化重排,编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
- 指令并行重排,现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
- 内存系统重排,由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。
5. JMM 与 happens-before
5.1 happens-before关系定义
一方面,我们开发者需要 JMM 提供一个强大的内存模型来编写代码;另一方面,编译器和处理器希望 JMM 对它们的束缚越少越好,这样它们就可以尽可能多的做优化来提高性能,希望的是一个弱的内存模型。
JMM 考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。
对于我们开发者来说,JMM 提供了happens-before 规则(JSR-133 规范),满足了我们的诉求——简单易懂,并且提供了足够强的内存可⻅性保证。 换言之,我们开发者只要遵循 happens-before 规则,那么我们写的程序就能保证在 JMM 中具有强的内存可⻅性。
JMM 使用 happens-before 的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程内,也可以是不同的线程种。
happens-before 关系的定义如下:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可⻅,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么JMM 也允许这样的重排序。
总之,如果操作 A happens-before 操作 B,那么操作 A 在内存上所做的操作对操作 B 都是可⻅的,不管它们在不在一个线程。
5.2 happens-before 关系
在Java中,有以下天然的happens-before 关系:
-
程序次序规则 (Program Order Rule):一个线程内,逻辑上书写在前面的操作先行发生于书写在后面的操作 ,因为多个操作之间有先后依赖关系,则不允许对这些操作进行重排序
-
锁定规则 (Monitor Lock Rule):一个 unlock 操作先行发生于后面(时间的先后)对同一个锁的 lock 操作,所以线程解锁 m 之前对变量的写(解锁前会刷新到主内存中),对于接下来对 m 加锁的其它线程对该变量的读可见
-
volatile 变量规则 (Volatile Variable Rule):对 volatile 变量的写操作先行发生于后面对这个变量的读
-
传递规则 (Transitivity):具有传递性,如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C
-
线程启动规则 (Thread Start Rule):Thread 对象的 start()方 法先行发生于此线程中的每一个操作
static int x = 10;//线程 start 前对变量的写,对该线程开始后对该变量的读可见 new Thread(()->{ System.out.println(x); },"t1").start();
-
线程中断规则 (Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
-
线程终止规则 (Thread Termination Rule):线程中所有的操作都先行发生于线程的终止检测,可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行
-
对象终结规则(Finaizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始