一、并发问题
1.1 问题描述
先看一段代码,这段代码的目的是使用ConcurrentHashMap作为缓存,如果键值存在则直接取对象,如果不存在的话就创建新的对象存储到ConcurrentHashMap中。我们这样做是为了延迟初始化,采用延迟初始化来降低初始化类和创建对象的开销。
public static ConcurrentHashMap<Integer,M_ProductionProcessCondition> pMap=new ConcurrentHashMap<>();
public static M_ProductionProcessCondition getCopyedProductionProcess(int PU_ID) {
if(pMap.containsKey(PU_ID)){
M_ProductionProcessCondition p =pMap.get(PU_ID);
return ObjectUtil.cloneByStream(p);
}else{
return initProductionProcess(PU_ID);
}
}
public static void initProductionProcess(int PU_ID) {
M_ProductionProcessCondition p = new M_ProductionProcessCondition();
pMap.put(PU_ID,p);
}
但是当并发请求时就可能出现从ConcurrentHashMap中取出的对象有可能还没有完成初始化的问题。同样的问题也出现在双重检查锁定(Double-Checked Locking)中,代码如下:
public class DoubleCheckedLocking { // 1
private static Instance instance; // 2
public static Instance getInstance() { // 3
if (instance == null) { // 4:第一次检查
synchronized (DoubleCheckedLocking.class) { // 5:加锁
if (instance == null) // 6:第二次检查
instance = new Instance(); // 7:问题的根源出在这里
} // 8
} // 9
return instance; // 10
} // 11
}
1.2 问题根源
我们在初始化时会创建了一个对象。这一行代码可以分解为如下的3行伪代码。
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址
上面3行伪代码中的2和3之间,可能会被重排序。2和3之间重排序之后的执行时序如下
memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象
因此如果发生重排序,另一个并发执行的线程就有可能在判断ConcurrentHashMap中该对象已存在。线程接下来将访问这个对象,但此时这个对象可能还没有完全初始化!然后调用后抛出NullException异常。
1.3 问题解析
以上问题涉及到了JAVA底层的内存模型,所以我们需要先一点点的回顾总结JAVA的内存模型,然后再分析解决问题。
二、JVM架构
2.1 什么是JVM
JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
2.2 JVM整体架构
根据 JVM 规范,JVM 内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。
JVM分为五大模块: 类装载器子系统 、 运行时数据区 、 执行引擎 、 本地方法接口 和 垃圾收集模块 。
2.3 JVM运行时内存
针对JDK8虚拟机内存详解
功能简介
名称 | 功能 |
---|---|
虚拟机栈 | 线程私有的。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。 |
本地方法栈 | 与虚拟机栈所发挥的作用是非常相似的, 其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码) 服务, 而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。 |
堆 | Java堆(Java Heap) 是虚拟机所管理的内存中最大的一块。 Java堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建。 此内存区域的唯一目的就是存放对象实例。 |
方法区 | 方法区(Method Area) 与Java堆一样, 是各个线程共享的内存区域, 它用于存储已被虚拟机加载 的类型信息、常量、 静态变量、 即时编译器编译后的代码缓存等数据。 |
三、JMM内存模型
3.1 什么是JMM
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
我们能发现JMM模型与CPU内存模型的架构很相似,因为JMM是在CPU内存模型的基础上进行了定制。JAVA中使用happens-before的概念来阐述操作之间的内存可见性。
Java内存模型的抽象示意如下所示
**
3.2 内存模型的理论介绍
顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性。
1) 一个线程中的所有操作必须按照程序的顺序来执行。
2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
顺序一致性内存模型为程序员提供的视图如下所示
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。从上面的示意图可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。
3.3 处理器的内存模型
顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型在设计时通常会以顺
序一致性内存模型为参照。在设计时,JMM和处理器内存模型会对顺序一致性模型做一些放
松,因为如果完全按照顺序一致性模型来实现处理器和JMM,那么很多的处理器和编译器优
化都要被禁止,这对执行性能将会有很大的影响。
根据对不同类型的读/写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为如下几种类型。
- 放松程序中写-读操作的顺序,由此产生了Total Store Ordering内存模型(简称为TSO)。
- 在上面的基础上,继续放松程序中写-写操作的顺序,由此产生了Partial Store Order内存模型(简称为PSO)。
- 在前面两条的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了Relaxed Memory Order内存模型(简称为RMO)和PowerPC内存模型。
了常见处理器内存模型的细节特征如下
所有处理器内存模型都允许写-读重排序,原因是:它们都使用了写缓存区。写缓存区可能导致写-读操作重排序。
我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区。由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己写缓存区中的写。
表中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计得会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。
各种CPU内存模型的强弱对比示意图如下:
3.4 CPU内存模型
下图为x86架构下CPU缓存的布局,即在一个CPU 4核下,L1、L2、L3三级缓存与主内存的布局。每个核上面有L1、L2缓存,L3缓存为所有核共用。
因为存在CPU缓存一致性协议,例如MESI,多个CPU核心之间缓存不会出现不同步的问题,不会有“内存可见性”问题。
缓存一致性协议对性能有很大损耗,为了解决这个问题,又进行了各种优化。例如,在计算单元和L1之间加了Store Buffer、Load Buffer(还有其他各种Buffer),如下图:
操作系统内核视角下的CPU缓存模型:
四、happens-before
4.1 重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
重排序类型:
- 编译器重排序。
对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。 - CPU指令重排序。
在指令级别,让没有依赖关系的多条指令并行。 - CPU内存重排序。
CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。
内存屏障
为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier)。这也正是JMM和happen-before规则的底层实现原理。
编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。
内存屏障是很底层的概念,对于 Java 开发者来说,一般用 volatile 关键字就足够了。但从JDK 8开始,Java在Unsafe类中提供了三个内存屏障函数,如下所示
public final class Unsafe {
// ...
public native void loadFence();
public native void storeFence();
public native void fullFence();
// ...
}
4.2 数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型
上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
4.3 as-if-serial
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
4.4 重排序的原则是什么?
什么场景下可以重排序,什么场景下不能重排序呢?
1)单线程程序的重排序规则
无论什么语言,站在编译器和CPU的角度来说,不管怎么重排序,单线程程序的执行结果不能改变,这就是单线程程序的重排序规则。
对于单线程程序来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可见性问题。
2)多线程程序的重排序规则
编译器和CPU的这一行为对于单线程程序没有影响,但对多线程程序却有影响。
对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性并据此做出最合理的优化。
编译器和CPU只能保证每个线程的as-if-serial语义。线程之间的数据依赖和相互影响,需要编译器和CPU的上层来确定。上层要告知编译器和CPU在多线程场景下什么时候可以重排序,什么时候不能重排序。
4.5 happen-before
java内存模型(JMM)是一套规范,在多线程中,一方面,要让编译器和CPU可以灵活地重排序;
另一方面,要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重
排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过
volatile、synchronized等线程同步机制来禁止重排序。
关于happen-before:
如果A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。A happen before B不代表A一定在B之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确定的。happen-before只确保如果A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约束,也就定义了一系列重排序的约束。
基于happen-before的这种描述方法,JMM对开发者做出了一系列承诺:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作(也就是 as-if-serial语义保
证)。 - 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
- Join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
- 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
- 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
happen-before简而言之就是对重排序进行约束的约束规则。
五、问题分析与解决
5.1 问题分析
我们的初始化代码中并没有共享变量,但是我们将对象实例化后放入到ConcurrentHashMap中时可能会被另外一个线程访问到。但是此时因为重排序的原因,可能会导致NullException异常。
public static void initProductionProcess(int PU_ID) {
M_ProductionProcessCondition p = new M_ProductionProcessCondition();
pMap.put(PU_ID,p);
}
5.2 问题解决
- 我们可以使用加锁的办法,防止重排序。但是这种办法会导致初始化的方法阻塞其他想要初始化的线程,这样会严重影响运行效率。
- 提前将所有需要的对象初始化存储到ConcurrentHashMap中,这样就不会出现异常访问。但是还是需要解决这种多线程懒加载的问题。
- 将M_ProductionProcessCondition 声明对属性,添加volatile声明。
- 使用Unsafe 类中的内存屏障函数。
六、参考
参考《java并发编程的艺术》