内存模型
JMM(Java 内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。
Java Memory Model(JMM),本身是一种抽象的概念,实际上并不存在,描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
在并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则来解决这个指令重排序问题。
JMM 作用:
- 屏蔽各种硬件和操作系统的内存访问差异,实现让 Java 程序在各种平台下都能达到一致的内存访问效果
- 规定了线程和内存之间的一些关系
Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中
Java 中所有变量都存储在主存中,对于所有线程都是共享的;每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是先对变量进行拷贝,然后在工作内存中进行,不能直接操作主内存中的变量;线程之间无法相互直接访问,线程间的通信(传递)必须通过主内存来完成。
本地内存只是JMM的抽象,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化
内存交互
Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作,每个操作都是原子的
- lock:作用于主内存,将一个变量标识为被一个线程独占状态(对应 monitorenter)
- unclock:作用于主内存,将一个变量从独占状态释放出来,释放后的变量才可以被其他线程锁定(对应 monitorexit)
- read:作用于主内存,把一个变量的值从主内存传输到工作内存中
- load:作用于工作内存,在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
- use:作用于工作内存,把工作内存中一个变量的值传递给执行引擎,每当遇到一个使用到变量的操作时都要使用该指令
- assign:作用于工作内存,把从执行引擎接收到的一个值赋给工作内存的变量
- store:作用于工作内存,把工作内存的一个变量的值传送到主内存中
- write:作用于主内存,在 store 之后执行,把 store 得到的值放入主内存的变量中
JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证
除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
- 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
内存模型三大特性
原子性
定义原子操作的使用规则:
- 不允许 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 操作)
可见性
可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。
主要有三种实现可见性的方式:
- volatile
- synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。
- final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。
有序性
有序性是指:在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。在 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;
}
I_Result 是一个对象,有一个属性 r1 用来保存结果
可能的结果有几种?
情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
情况4:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2
这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化
JMM中提供一下方式来保证有序性:
- happens-before原则:happens-before原则是java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,也就是说发生操作B之前,操作A产生的影响能被操作B观察到。这里的“影响”包括修改共享变量,方法调用。
- volatile机制:volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。
- synchronized机制:synchronized能够保证有序性是因为synchronized可以保证同一时间只有一个线程访问代码块,而单线程环境下,JMM能够保证代码的串行语义;虽然使用synchronized的代码块,还可以发生指令重排序,但是synchronized可以保证只有一个线程执行,所以最后的结果还是正确的。
总结JMM对特性提供的支持如下:
特性 | volatile关键字 | synchronized关键字 | Lock接口 | Atomic变量 |
---|---|---|---|---|
原子性 | 无法保障 | 可以保障 | 可以保障 | 可以保障 |
可见性 | 可以保障 | 可以保障 | 可以保障 | 可以保障 |
有序性 | 一定程度 | 可以保障 | 可以保障 | 无法保障 |
happens-before 原则
为了提高处理速度,JVM会对代码进行编译优化,也就是指令重排序优化,并发编程下指令重排序会带来一些安全隐患:如指令重排序导致的多个线程操作之间的不可见性。
如果让程序员再去了解这些底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。
从JDK5开始,提出了happens-before
的概念,通过这个概念来阐述操作之间的内存可见性。
JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。
happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:
- 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
Java 内存模型具有一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before
原则。如果两个操作的执行次序无法从 happens-before
原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
JMM 设计思想的示意图
了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
单一线程原则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作
管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作
volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
线程启动规则:Thread 对象的 start()
方法 happens-before 于此线程的每一个动作
线程加入规则:Thread 对象的结束先行发生于 join() 方法返回
线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C
如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。
private int value = 0;public void setValue(int value){this.value = value;
}public int getValue(){return value;
}
假设两个线程A和B,线程A先(在时间上先)调用了这个对象的setValue(1),接着线程B调用了getValue()方法,那么B的返回值是多少?
对照 happens-before 原则,上面的操作不满下面的条件:
- 不是同一个线程,所以不涉及:程序顺序规则。
- 不涉及同步,所以不涉及:监视器锁规则。
- 没有volatile,所以不涉及:volatile变量规则。
- 没有线程的启动和中断,所以不涉及:start()规则,join规则,程序中断规则。
- 没有对象的创建和终结,所以不涉及:finalizer规则。
- 更没有传递规则。
所以,一条规则都不满足,尽管线程A在时间上与线程B具有先后顺序,但是,却不满足happens-before原则,也就是有序性并不会保障,所以线程B获取到的数据是不安全的!!!这也反向说明了happens-before原则提到的关系和时间的先后顺序没有关系。
时间先后顺序与先行发生原则之间基本没有太大关系,所以我们衡量并发安全问题的时候不要收到时间顺序的干扰,一切必须以先行发生原则为准。只有真正满足了happens-before原则,才能保证安全
happens-before 与 JMM 的关系
内存屏障
内存屏障(Memory Barrier),也称为内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以执行此点之后的操作。大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。
语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个:
- 一是保证特定操作的执行顺序;
- 二是保证某些变量的内存可见性(利用该特性实现 volatile 的内存可见性)
由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条内存屏障则会告诉编译器和CPU,不管什么指令都不能和这条内存屏障指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。
内存屏障的另外一个作用是强制刷出各种 CPU 的缓存数据,因此任何 CPU 上的线程都能读取到这些数据的最新版本。
CPU层面的内存屏障分为三类:
写屏障(Store Memory Barrier):告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对写屏障之后的读或者写是可见的。
读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的。
全屏障(Full Memory Barrier):确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作。
在JMM中将内存屏障分为四类:LoadLoad Barrier;StoreStore Barrier;LoadStore Barrier;StoreLoad Barrier
volatile
volatile 是Java虚拟机提供的轻量级的同步机制。volatile 关键字有如下两个作用:
- 保证被 volatile 修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了被 volatile 修饰共享变量的值,新值总是可以被其他线程立即得知
- 禁止指令重排序优化
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他CPU中对应的缓存行无效。
Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock 会对 CPU总线和高速缓存加锁,可以理解为 CPU 指令级的一种锁。
下面是JMM针对编译器制定的 volatile 重排序规则表
为了实现 volatile 的内存语义,编译在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM 采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
- 在每个volatile写操作的前面插入一个StoreStore屏障;
- 在每个volatile写操作的后面插入一个StoreLoad屏障;
- 在每个volatile读操作的后面插入一个LoadLoad屏障;
- 在每个volatile读操作的后面插入一个LoadStore屏障;
volatile 写插入内存屏障后生成的指令序列示意图
volatile 读插入内存屏障后生成的指令序列示意图