可见性
在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那 这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。但是在多线程 环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最 新的值。这就是所谓的可见性
造成不可见性的原因
造成不可见的原因有两种一个是缓存一致性,还一个就是指令重排序
缓存一致性
我们可以看一下操作系统的缓存架构如下图所示
最初的系统设计师为了能够提高数据访问速度和性能设计了三级缓存(因为缓存离cpu近所以访问速度快,并且三级缓存的频率和带宽更高且都在cpu芯片上而主存是需要通过总线等连接方式与处理器通信,访问延迟相对较高)
有了这个三级缓存之后为了提高性能每个线程都有一个自己的工作内存也就是缓存,当一个线程修改了一个共享变量的值时,它可能首先将该值存储在自己的工作内存中,并不会立即写回主内存。其他线程在读取该共享变量时,可能会从自己的工作内存中读取值,而不是从主内存中获取最新值。这种情况下,如果一个线程修改了共享变量的值,其他线程可能无法立即感知到这个变化,导致不可见性问题。
为了解决缓存一致性带来的不可见性,系统层面提出了总线锁和缓存锁
总线锁:当其中一个处理器要对共享内存进行操作的时候,在总线上发出 一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定 的开销比较大,这种机制显然是不合适的。
缓存锁:就是指内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执 行锁操作回写到内存时,不再总线上加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作 的原子性。缓存锁的实现方式有多种,其中比较常见的是MESI(Modified, Exclusive, Shared, Invalid)协议。MESI协议定义了缓存行的四种状态:修改(Modified)、独占(Exclusive)、共享(Shared)和无效(Invalid)。处理器在执行读写操作时,会根据缓存行的状态来确定是否需要使用缓存锁,以保证数据的一致性
下面是MESI协议的四种状态和其含义:
-
Modified(修改):缓存行被修改且未写回内存。在该状态下,缓存行是处理器私有的,其他处理器无法缓存该数据。如果该缓存行被写回内存,状态会转换为Shared或Invalid。
-
Exclusive(独占):缓存行只存在于当前处理器的缓存中,未被其他处理器缓存。该数据是一致的,其他处理器可以通过缓存一致性协议来读取数据。
-
Shared(共享):缓存行被多个处理器缓存,且数据是一致的。多个处理器可以同时缓存该数据,读取操作不会修改缓存行的内容。如果某个处理器要修改数据,则会将缓存行状态转换为Modified,并阻止其他处理器的读访问。
-
Invalid(无效):缓存行无效,需要从内存中读取最新数据。这种状态发生在其他处理器修改了共享数据,并将其标记为Invalid,通知其他处理器需要重新从内存中获取最新数据。
MESI协议的实现原理如下:
-
当处理器读取一个缓存行时,会首先检查缓存行的状态:
- 如果状态是Modified或Exclusive,表示缓存中的数据是一致的,可以直接读取。
- 如果状态是Shared,表示其他处理器也在缓存该数据,可以直接读取。
- 如果状态是Invalid,表示缓存行无效,需要从内存中获取最新数据。
-
当处理器要写入一个缓存行时,会根据以下情况进行处理:
- 如果状态是Modified,表示缓存中的数据已被修改,可以直接写入。
- 如果状态是Exclusive,表示缓存中的数据是一致的,可以直接写入,并将状态转换为Modified。
- 如果状态是Shared,表示其他处理器也在缓存该数据,需要进行协调。
- 处理器会发出一个写的信号,通知其他处理器将该数据的缓存行状态转换为Invalid,从而使其他处理器重新从内存中读取最新数据。
- 处理器将缓存行状态转换为Modified,表示该数据被修改,并且只有自己能够缓存该数据。
-
在状态转换时,缓存一致性协议会使用总线或其他互联机制来进行通信,以确保各个处理器的缓存状态保持一致。
指令重排序
针对上面的缓存一致性协议我们提出一个这样的例子比如两个cpu异步访问两个参数如下伪代码:
executeToCPU0(){
x = 1;
flag = true;
}
executeToCPU1(){
while(flag){
assert(x==1)
}
}
复制代码
可以发现有可能会抛出异常,安装我们正常的思维while
循环进来的时候 x
应该是等于1的所以应该没问题。为什么会有可能抛出异常呢?就是因为处理器对内存写入操作的效率的提高引出了存储缓冲器(Store Buffers):
存储缓冲器的工作原理如下:
- 当处理器执行写操作时,写入的数据和目标内存地址会被缓存到存储缓冲器中,而不是立即写入内存。
- 处理器可以继续执行后续的指令,而不需要等待写入操作完成。
- 在后续的阶段,处理器会根据一定的策略将存储缓冲器中的写操作提交到内存中。这个提交的过程通常发生在特定的点,如内存屏障指令、条件分支或是其他内部机制触发的时候。
- 内存子系统负责将存储缓冲器中的写操作同步到实际的内存位置。
因为Store Buffers
的存在也就有可能出现flag = true;
先执行x = 1;
后执行也就是指令重排序 如下图所示:
这个Store Buffers
通俗点讲就是一个mq
就是我们把写的操作丢到mq
里面不耽误下面代码的执行
针对于指令重排序的问题系统又提出了内存屏障来禁止指令重排序。
内存屏障分为两种类型:读屏障(Load Barrier)和写屏障(Store Barrier)。
-
读屏障(Load Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏 障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
-
写屏障(Store Barrier):告诉处理器在写屏障之前的所有已经存储在存储缓存(store Buffers)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
-
全屏障 (Full Barrier) ,确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障 后的读写操作
executeToCPU0(){
x = 1;
//storeBarrier()写屏障,写入到内存
flag = true;
}
executeToCPU1(){
while(flag){
//loadBarrier(); //读屏障
assert(x==1)
}
}
复制代码
这样也就可以保证指令不会被重排
JMM
JMM(Java Memory Model,Java内存模型)是Java语言规范中定义的一种规范,用于描述Java程序在多线程环境下的内存访问行为。JMM 定义了线程如何与主内存和工作内存进行交互,以及如何保证多线程程序的正确性。
JMM 主要关注以下几个方面:
- 主内存(Main Memory):主内存是所有线程共享的内存区域,包含了程序的变量和数据。所有线程都可以读写主内存中的数据。(也就是内存)
- 工作内存(Working Memory):每个线程都有自己的工作内存,工作内存是线程私有的内存区域。线程执行时,它的读写操作都是在工作内存中进行的。(对应这cpu高度缓存)
- 内存间的交互:线程之间通过主内存进行通信。当一个线程修改了共享变量的值时,它必须将该值写回主内存。其他线程在读取该共享变量时,会从主内存中获取最新的值。
- 原子性、可见性和有序性:JMM 定义了一系列规则和特性来保证多线程程序的正确性。其中包括原子性(Atomicity):对基本类型的读写具有原子性;可见性(Visibility):一个线程对共享变量的修改对其他线程可见;有序性(Ordering):程序的执行结果与代码的编写顺序保持一致。
JMM 提供了一套规范,确保多线程程序在不同的平台和实现中表现一致。同时,它也提供了一些同步机制(如锁、volatile关键字、synchronized关键字、原子类等)来帮助程序员编写正确且高效的多线程代码。
需要注意的是,虽然 JMM 提供了一定的保证,但在编写多线程程序时,仍然需要程序员根据具体情况使用适当的同步机制,以确保线程安全性和正确性。
vloatile原理
上文说了那么多其实大致的原理大家应该也清楚了无非就是告诉系统需要添加内存屏障,使用系统的内存屏障来实现防止指令重排序。
这里我们可以简单的写一个demo验证一下:
public class TestVolatile {
public static volatile int x = 1;
public static void main(String[] args){
x = 2;
System.out.println(x);
}
}
复制代码
我们看一下编译后的字节码文件
发现使用vloatile
修饰的变量会多一个ACC_VOLATILE
的标记
我们再看一下字节码命令如下: 这个时候我们再去jvm源码里面看putstatic
源码如下(具体位置/hotspot/src/share/vm/interpreter
路径下的bytecodeInterpreter.cpp
) 至此vloatile原理也就比较清晰了
Happens-Before模型
Happens-Before模型是Java内存模型(JMM)中定义的一种偏序关系,用于描述并发程序中不同操作之间的可见性和顺序性规则。它是JMM中的一个重要概念,用于指导程序员编写正确且具有可预测行为的多线程代码。
Happens-Before模型的基本原则是,如果一个操作" happens-before"另一个操作,那么第一个操作的结果对于第二个操作是可见的,而且第一个操作一定在第二个操作之前执行。
Happens-Before关系的规则包括:
- 程序次序规则(Program Order Rule):同一个线程中的操作,按照程序的顺序执行,前一个操作的结果对后续操作可见。
- 管程锁定规则(Monitor Lock Rule):一个unlock操作 happens-before 后续的lock操作,确保共享变量的可见性。
- volatile变量规则(Volatile Variable Rule):对于一个volatile变量的写操作 happens-before 后续的对该变量的读操作,确保volatile变量的可见性。
- 线程启动规则(Thread Start Rule):Thread对象的start()方法的调用 happens-before 新线程中的任意操作。
- 线程终止规则(Thread Termination Rule):线程中的任意操作 happens-before 对该线程的终止检测,即Thread.join()的完成。
- 传递性规则(Transitive Rule):如果操作A happens-before 操作B,操作B happens-before 操作C,那么操作A happens-before 操作C。
这些规则为程序员提供了一些有序性和可见性的保证,以帮助编写正确的多线程代码。通过遵守Happens-Before模型的规则,程序员可以确保多线程程序的执行结果是可预测的,避免出现数据竞争和不确定的行为。
需要注意的是,Happens-Before模型是一种约束性规范,确保程序在不同的平台和实现中表现一致。但它并不代表真实的操作执行顺序,具体的执行顺序由处理器、编译器和运行时环境等因素决定。
案例说明
我们先看一段代码如下图所示:
private static boolean stop;
public static void main(String[] args) throws InterruptedException {
stop = false;
Thread thread=new Thread(()->{
int i=0;
while(!stop){
i++;
}
});
thread.start();
Thread.sleep(1000);
stop=true;
}
复制代码
我们可以通过正常思路分析一下,我们可以看到的是首先会开辟一个线程运行i++的操作知道stop是true的时候,下面的代码呢是隔了一秒之后会将stop置为true所以这段代码会在一秒后执行完成,实际结果是这段代码不会终止。
需要注意的是这里很多人认为是高速缓存造成的不可见性,其实不是的自动有了缓存一致性协议,不可见只是暂时的不会一直不可见,造成这个结果的根本原因是JIT优化,上述的代码在执行一段时间后编译器会把上面的代码判定为热点代码优化成如下所示的代码(这个和JDK版本有关的)
if(stop){
while(true){
}
}
复制代码
这里有几种解决方案:
- 在线程内添加输出
- 添加Thread.sleep(0)
- 添加volatile关键字
JIT将解释器中的热点代码(频繁执行的代码段)编译成机器代码,以提高程序的执行效率 上述的三种方案可以防止JIT优化
作者:Potato_土豆
链接:https://juejin.cn/post/7229517949970890812