目录
什么是JMM模型
概念
Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
JVM的工作
JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据。
JMM的工作
- Java 内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问。
- 但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝。
- 前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
总结
- JVM是以线程为单位进行操作的,Java内存模型规定了所有变量都在主内存。所以每个线程对数据的操作,都要先取到自己的内存再同步到主内存。
JMM不同于JVM内存区域模型
- JMM是一组规则,通过它控制程序中各个变量在共享数据区域和私有数据区域的访问方式。
- JMM是围绕原子性、有序性、可见性展开
主内存
- 主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(局部变量),也包括了共享的类信息、常量、静态变量。
- 这里的区域是共享的,多条线程对同一个变量进行访问可能会发生线程安全问题。
工作内存
- 存储当前方法的所有本地变量信息(存储着主内存中变量副本拷贝)
- 每个线程只能访问自己的工作内存,即线程中本地变量对其它线程不可见。
数据同步八大原子操作
- lock
- 作用于主内存的变量,把一个变量标记为一条线程独占状态。
- unlock
- 作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定。
- read
- 作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load
- 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use
- 作用于工作内存变量,把工作内存中的一个变量值传递给执行引擎。
- assign
- 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量。
- store
- 作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write操作。
- write
- 作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中。
- JMM只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
同步规则分析
- 不允许一个线程无原因地(没有发生过任何assign)把数据从工作内存同步到主内存。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化的变量,即对一个变量实施use和store操作之前,必须先自行assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
- 如果一个变量事先没有被lock锁定,则不允许对它执行unlock,也不允许取unlock一个被其它线程锁定的变量
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中
并发编程的可见性、原子性于有序性问题
原子性
- 指的是一个操作不可中断,在多线程环境中,一个操作一旦开始就不会被其它线程影响。
- 在32位操作系统中,long和double是64位的,因此读写并不原子,其它数据类型是原子操作。
x=10;//原子性
y = x;//变量赋值,不是原子
可见性
- 当一个线程修改了某个共享变量的值,其它线程是欧能够马上知道这个修改的值。
有序性
- 可以通过volatile、synchronized和lock来保证有序性。
指令重排序
- 只要程序的最终结果与它顺序化(as-if-seial)情况的结果相等,那么指令的执行顺序可以与代码顺序不一致。
as-if-seial语义
- 不管怎么重排序,单线程程序的执行结果不能被改变。
- 为了保证正确性,编译器和处理器不会对存在数据依赖关系的操作做重排序。
happens-before原则
- 只靠
sychronized
和volatile
关键字来保证原子性、可见性、有序性,那么编写程序会十分麻烦。 - 从JDK5开始、Java使用新的
JSR-133
内存模型,提供了happens-before
原则来辅助保证程序执行的原子性、可见性、有序性等问题,它是判断数据是否存在竞争、线程是否安全的依据。 - 程序顺序原则
- 在一个线程内必须保证语义串行性,也就是按照代码顺序执行。
- 锁规则
- 解锁必然发生在加锁之前
- volatile原则
- volatile变量的写发生于读,保证了可见性
- volatile变量每次被线程访问,都强迫从主内存中读取该变量的值。
- 当变量发生变化时,又强迫将最新的值刷回主内存。
- 线程启动规则
- 线程的start方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见。
- 传递性原则
- 传递性A先于B,B先于C,那么A必然先于C
- 线程终止原则
- 线程的所有操作先于线程的终结,
Thread.join()
方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
- 线程的所有操作先于线程的终结,
- 线程中断原则
- 对线程
interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()
方法检测线程是否中断。
- 对线程
- 对象终结原则
- 对象的构造函数执行,结束先于
finalize()
方法。
- 对象的构造函数执行,结束先于
volatile内存语义
- 保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰的共享变量的值,新值总是可以被其它线程立即得知。
- 禁止指令重排序优化。
volatile的可见性
- 被volatile修饰的变量对所有线程总是立即可见的,对volatile变量的所有写操作总是能立即返应到其它线程中。
volatile无法保证原子性
//示例
public class VolatileVisibility {
public static volatile int i =0;
public static void increase(){
i++;
}
}
- 在并发场景下,变量i的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时调用increase()方法的话,就会出现线程安全问题,毕竟i++;操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败。
- 因此对于increase方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就完全可以省去volatile修饰变量。
volatile禁止重排优化
硬件层内存屏障
- lfence,一种Load Barrier读屏障
- sfence,一种Store Barrier写屏障
- mfence,一种全能型屏障,具备lfence和sfence的能力
- Lock前缀,Lock不是一种内存屏障,但是能完成类似的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。
JVM内存屏障
Java内存模型屏蔽了底层硬件平台的差异,统一由JVM来提供内存屏障
屏障类型 |
指令示例 |
说明 |
LoadLoad |
Load1;LoadLoad;Load2 |
保证load1的读取操作在load2及后续读取操作之前执行 |
StoreStore |
Store1;StoreStore;Store2 |
保证Store1的写操作已刷新到主内存,才会调用Store2及其后的写操作 |
LoadStore |
Load1;LoadStore;Store2 |
保证Load1的读操作结束,才会调用Store2及其后的写操作 |
StoreLoad |
Store1;StoreLoad;Load2 |
保证Store1的写操作已刷新到主内存,才会调用Load2及其后的读操作 |
- 内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个
- 一是保证特定操作的执行顺序。
- 由于编译器和处理器都能执行指令重排优化,因此在指令间插入一条Memory Barrier则会告诉编译器和处理器,不管什么指令都不能对其进行重排序。
- 二是保证某些变量的内存可见性。
- 内存屏障强制刷出各种CPU的缓存数据,因此任何CPU的线程都能读取到这些数据的最新版本。
- 一是保证特定操作的执行顺序。
举例
public class DoubleCheckLock {
private static DoubleCheckLock instance;
private DoubleCheckLock(){}
public static DoubleCheckLock getInstance(){ //第一次检测
if (instance==null){
//同步
synchronized (DoubleCheckLock.class){
if (instance == null){
//多线程环境下可能会出现问题的地方
instance = new DoubleCheckLock();
}
}
}
return instance;
}
}
- 上述代码在单线程环境中没有任何问题,但是在多线程就会出现线程安全问题。
- 原因在于某一线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
-
instance = new DoubleCheckLock();
可以分为3步完成-
memory = allocate();//1、分配对象内存空间 instance(memory);//2、初始化对象 instance = memoery;//3、设置instance指向刚分配的内存地址,此时instance != null
-
- 由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后,程序在单线程(as-if-serial)中的执行结果不会改变,所以这种重排优化是允许的。
- 当A线程执行到步骤3时、因为指令重排此时步骤3在步骤2之前执行,但是
instance
还没被初始化,只是实例化。 - 当B线程执行到第一次检测
if (instance==null)
,会误认为instance
实例已经是一个完整的实例了,然后继续执行就有可能在多线程环境下造成线程安全问题。 - 解决方法:可以采用
volatile
禁止instance
变量执行指令重排优化即可private volatile static DoubleCheckLock instance;
volatile内存语义的实现
第一个操作 |
第二个操作:普通读写 |
第二个操作:volatile读 |
第二个操作:volatile写 |
普通读写 |
可以重排 |
可以重排 |
不可以重排 |
volatile读 |
不可以重排 |
不可以重排 |
不可以重排 |
volatile写 |
可以重排 |
不可以重排 |
不可以重排 |
- 举例来说,第二行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
- 从上图可以看出:
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读或写时,不能重排序。
- 为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
- 上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
- 下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图
-
- 上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
- 这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile 读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个 写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。
- 下图是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图
- 上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
- 上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
举例
class VolatileBarrierExample {
int a;
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写
}
}
- 针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化。
- 注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编 译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插 入一个StoreLoad屏障。
- 上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模 型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为例,图3-21 中除最后的StoreLoad屏障外,其他的屏障都会被省略。
- 前面保守策略下的volatile读和写,在X86处理器平台可以优化成如下图所示。前文提到过,X86处理器仅会对写-读操作做重排序。X86不会对读-读、读-写和写-写操作 做重排序,因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。在X86中,JMM仅需 在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在 X86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。