前言
在类似于电商、大数据分析平台等,往往都要面临极高的并发量,而这些情况下,数据往往会错乱,不一致,但在这些场景下,往往不需要完全满足ACID规范,因为这样会严重影响业务的并发量。为此,这类场景只需要保证最终数据一致性即可。而类似于金融等,这种对数据的一致性要求极高,为此会选择牺牲一定的并发量来保证数据的一致性。那么在Java中,是如何保证数据的一致性呢?那就是Java内存模型。
1.JMM试图解决什么问题?
在没有内存模型之前,程序运行依赖于处理器的内存一致性模型,而不同处理器之间又有很大差异,导致同一个程序运行在不同机器上表现不一致。而JMM就是为了解决这种不一致,同时保证多线程程序运行时的正确性。接下来我们开始进入正题,介绍JMM相关的原理。
2.Java内存模型(JMM)
Java 内存模型是抽象的概念,描述的是程序间变量的访问规则(多线程程序允许表现出的行为),Java线程内存模型与CPU缓存模型类似,它是标准化的,用于屏蔽掉各种硬件和操作系统的内存访问差异。
举个例子,如我们多个线程在访问内存中某个共享变量的时候,往往不是直接访问内存中的共享变量,而是将共享变量拷贝到线程工作内存中,这个变量即为共享变量的副本。而这个行为即是内存模型的一个抽象出来的规范。
image.jpg2.CPU多级缓存
CPU每次从主存中读取数据太慢,现代CPU通常被设计为多级缓存,CPU读主存按照空间局部性加载原则,load局部区块的数据到缓存。
多级缓存3.CPU缓存一致性原理详解
我们来看下如下简单例子,我们测试线程A是否可以嗅探到线程B对initFlag的修改。
public class VolatileVisibilitySample {
private static boolean initFlag = false;
//private static volatile boolean initFlag = false;
public static void refresh(){
System.out.println("refresh data--------");
initFlag = true ;
System.out.println("refresh data success---------");
}
public static void main (String[] args){
Thread threadA = new Thread( ()->{
while (!initFlag){
}
System.out.println("线程:" + Thread.currentThread().getName() + "当前线程嗅探到initFlag的状态改变");
} ,"threadA");
threadA.start();
try{
Thread.sleep(500);
}catch(InterruptedException e){
e.printStackTrace();
}
Thread threadB = new Thread( ()->{
refresh();
},"threadB");
threadB.start();
}
}
其运行结果如下图所示:
image.png
从运行结果我们发现,线程A一直在while循环中,程序一直没有结束,这是为什么呢?在解释原理之前我们先来认识一下JMM中的8大数据原子操作。
3.1 JMM八大数据原子操作
-
lock (锁定)
:作用于主内存变量,把一个变量标记为一条线程独占状态; -
unlock (解锁)
:作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定; -
read (读取)
:把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用; -
load(载入)
:它把read操作从主内存中得到的变量值放入工作内存变量的副本中; -
use(使用)
:把工作内存中的一个变量值传递给执行引擎; -
assign (赋值)
:将计算好的值重新赋值到工作内存中; -
store(存储)
:把工作内存中的一个变量的值传送到主内存中,以便随后的write操作; -
write(写入)
:把store操作从工作内存中的一个变量的值传送到主内存的变量中。
3.2 代码详解
从示例代码中可以看到,线程A先于线程B启动。
线程A在启动时,先通过read(读取)原子操作将initFlag这个共享变量从主内存中读取,再通过load(载入)原子操作将read操作从主内存中得到的变量值放入工作内存的变量副本中。当线程A在执行到while时,会去工作内存中查找initflag的变量副本。
同样,线程B加载共享变量initFlag的过程与线程A类似。但我们在线程B中,对initFlag进行了赋值操作,线程B要将该值写到主内存中。我们来看下这个写回主内存的过程:首先,通过assign(赋值)原子操作,将修改后的值写入到线程的工作内存中,再通过store(存储)将工作内存中的变量值传送到主内存中(预传送),最后通过write(写入)原子操作,将变量值最终写入到主内存中。
整个过程示例图如下:
数据交互示例.jpg然而,虽然主内存中initFlag的值虽然已经被修改了,但是线程A却无法知道该值已经被修改,仍然使用的是工作内存中的initFlag=false的值。
我们做如下修改,给initFlag前加上关键字volatile,如下所示:
private static volatile boolean initFlag = false;
再次运行的结果如下图所示:
image.png
volatile关键字可以帮助我们解决这个问题,这是为什么呢?接下来我们来详细了解这个实现原理。
4.volatile可见性底层实现原理
volatile的可见性实现原理:
- 底层实现:通过汇编lock前缀指令触发底层缓存锁定机制(如缓存一致性协议(MESI)、总线锁)。
例如触发MESI协议,lock指令会触发锁定变量缓存行区域并写回主内存,这个操作被称为"缓存锁定":
- 缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据(MESI协议)
- 一个处理器的缓存回写到内存会导致其它处理器的缓存无效(MESI协议)IA-32架构
4.1总线锁
总线锁会在CPU与内存条之间的总线加入锁,当CPU某一核心成功在总线上加锁后可以无障碍的去读写主内存中存储的数据,但其余核心是无法访问主内存的任何数据的(类似于synchronized关键字、悲观锁)。
总线锁缺点效率极低,但其也作为缓存一致性协议的辅助方式,当缓存一致性协议无效时,底层依然会使用总线锁。
4.2缓存一致性协议(MESI)
缓存一致性协议(MESI):
-
M:修改
; -
E:独占
; -
S:共享
; -
I:invalid 无效
。
在该协议下,虽然L3级缓存在CPU中是各核心共享的,但是各核心在读取主内存数据时,在L3级缓存上都拥有各自的副本。我们通过下图来解释加入volatile关键字后,底层是如何实现数据可见的。
总线锁与缓存一致性协议.jpg总线嗅探机制(每个核心会监听总线上的数据交互,消息)
、消息发布机制
假设核心0中的线程0先于核心1的线程1读取主内存中的数据initFlag。
1)核心0一开始读取主内存中的initFlag时,需要发送总线读消息
到总线上,沿着总线传输,若无其他核嗅探该总线读消息,那么核心0将initFlag从主内存中复制到L3缓存,数据initFlag被标记为E(独占状态);
2)若此时核心1也需要读取该数据,那么它将往总线上发送总线读消息,嗅探到有其它核心已经读取过该数据,此时核心1将复制一份主内存数据x到L3缓存中,其它所有在L3缓存中的initFlag数据副本的状态都被标记为共享状态;
3)两个核心得到副本后,都会逐级将副本往上复制(L3->L2->L1);
4)假设此时,核心0要修改该数据initFlag。核心0会往总线上发送总线本地写消息
进行加锁(缓存行锁
:CPU缓存的最小存储单元)锁定变量,拥有该变量并在使用的核心1嗅探到有其他核心在给该变量加锁,则认为被加锁变量很有可能无效了,为此会将该变量数据标记为I(失效状态)。而核心0中的数据状态被标记为M(修改状态)。
5)在将修改的数据同步写回主内存前,核心0会发送一个总线写回消息
,该消息沿着总线传播,其它拥有该变量数据的线程在嗅探到该消息后,会去主内存中拉取新的数据副本,并逐级复制到工作内存中。
注:在核心1中变量副本失效后,执行的while语句中的initFlag由于不存在,可能会发生上下文切换,并且有可能发生指令重排
。
在第4)步中,我们可以想象,由于CPU执行速度很快,那么极有可能两个线程同时
要修改数据initFlag,那么这个时候是谁成功的给变量加锁呢?这个时候就依靠于总线裁决
了。
5 指令重排
在编程中,我们往往会想到的是程序按顺序执行(即从上往下执行),但在高并发场景下,往往会发生指令重排。我们先来看下如下例子来验证指令会发生重排:
public class VolatileReOrderSample {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException{
int i = 0;
for (;;){
i++;
x = 0; y =0;
a = 0; b = 0;
Thread t1 = new Thread(new Runnable(){
public void run(){
a = 1;
x = b;
}
});
Thread t2 = new Thread (new Runnable(){
public void run(){
b = 1;
y = a;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0){
System.err.println(result);
break;
}else{
System.out.println(result);
}
}
}
}
在main函数中,我们编写了一个死循环,循环中每次都会初始化x,y,a,b变量,且每次创建两个线程,分别对x,y,a或b赋值,当遇到x=0和y=0这种情况时,退出循环。假设没有指令重排这种机制,我们先考虑下程序运行过程中可能出现的赋值情况:
线程执行情况 | x | y | a | b |
---|---|---|---|---|
t1 执行完后t2 执行 |
0 | 1 | 1 | 1 |
t2 执行完后t1 执行 |
1 | 0 | 1 | 1 |
t1 执行a = 1后t2 执行b=1,后续执行顺序t1 先或t2 先执行完剩余代码 |
1 | 1 | 1 | 1 |
我们来看下代码运行结果:
image.png
我们可以发现,出现了在没有指令重排假设时的其它情况。那么为什么会出现这种情况呢?我们看下下图:
image.jpg 运行时可能出现的情况- 在线程
t1
的线程栈中,有a=1
与x=b
的字节码以及在线程t2
中的b=1
和y=a
的字节码,它们经过字节码执行引擎执行,再通过JIT及时编译器编译成汇编指令,最后由CPU执行; - 而JIT由于存在会根据线程上下文分析按何种顺序执行指令会达到更高效,所以会存在交换指令先后顺序的情况;
- 假设在到达cpu执行阶段前,
t1
线程中的a=1
与x=b
的指令未发生顺序交换,而t2
线程的执行顺序同样未变(即b=1
先于y=a
)。再假设t1
执行了a=1
指令后,t2
开始执行。此时,a =1
已经加载到cpu的缓存行中,而x = b
尚未加载,当cpu执行b = 1指令时,由于指令y=a
此时的a已经在缓存中,而b这个变量的值需要再主内存中获取,由于cpu的快速运转特性,这个过程会影响它的执行效率,为此,可能会优先执行y =a
的指令,同时在内存中拉取变量b的值,达到提高效率的效果。
那我们可能会想,那么既然很有可能发生指令重排,那么我们写的代码是不是就不会按预期的执行了,也就得不到我们想要的正确结果。答案很明显,指令重排同样需要遵循一定的原则,如happens-before、as-if-serial等。以此来保证程序运行的正确性。同样,可以使用volatile关键字来实现禁止指令重排。
6.总结
以上只是对多线程并发编程中简单概述了关于java内存模型中的工作模型,以及相关的缓存一致性协议(MESI)和指令重排机制。初步认识了并发编程的可见性、原子性与有序性。但值得注意的是volatile保证可见性与有序性,但是不保证原子性,保证原子性还需要其它相关的锁机制,如重量级锁synchronized。(如有错,望指出)
网友评论