cpu 三级缓存结构 和 JAVA 内存模型
CPU 三级缓存结构
以 WINDOWS 系统 CPU(Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz)为例
该 CPU 存在4个内核8个逻辑处理器,每个逻辑处理器拥有三级缓存(L1、L2、L3 缓存)
将 CPU 三级缓存抽象化成如下图示
假设存在一个如下 main 函数,CPU是如何执行的呢?
public static void main(String[] args) {
int a = 1;
a = a + 1;
System.out.println(a);
}
我们都知道 JVM 在运行时,将我们的变量和相关方法都保存在我们的内存中,CPU运行时必然需要将数据复制到寄存器中运行。那么cpu时如何获取数据的呢?
cpu 先查询一级缓存是否存在 int a
,如果没有,再查询二级缓存,然后查询三级缓存,如果缓存中均不存在,才会通过 BUS总线 连接内存,从内存中获取数据,并逐级加载到cpu缓存的缓存行中。最后再寄存器中进行计算并重新赋值。
JAVA 内存模型
JMM (JAVA 内存模型)就是基于这种规则和规范,而抽象出来的一种概念。它是一种程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
假设存在以下代码:
public static boolean flag = true;
public static void main(String[] args) {
Thread thread_A = new Thread(new Runnable() {
@Override
public void run() {
while (flag){}
System.out.println("flag修改状态,被线程A识别到");
}
});
thread_A.start();
Thread thread_B = new Thread(new Runnable() {
@Override
public void run() {
if (flag) {
flag = false;
System.out.println("flag 已修改");
}
}
});
thread_B.start();
}
main 函数启动后,线程A 和 线程B 也紧跟着启动.
线程A从内存中将flag属性read
到BUS总线内存空间中,然后load
到线程私有工作空间,并在线程A中use
。由于while条件不成立,所以一直等待。
线程B同样从内存中将flag属性read
到BUS总线内存空间中,然后load
到线程私有工作空间,在线程B被修改为false,然后assign
到工作内存中,通过store
将数据复制到主内存等待写入write
。
JMM 数据操作八大原子操作
- lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
- unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
- write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
并发下的可见性、原子性与有序性问题
可见性
可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。
在串行执行的代码中是不存在可见性问题的。因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。
但在多线程环境下就不一定了,根据JMM 内存模型来看线程只会操作私有工作内存里的数据,比如:上面代码中的 Thread_A 在 Thread_B 修改完flag属性并重写会共享内存 后,仍然没有感知到flag属性的变化,就是因为在 Thread_A 的私有工作内存中存在一个生效的flag,且Thread_A并不知道有其他线程修改了flag。即:flag在线程之间是不可见的。
如何解决可见性问题
volatile
关键字保证可见性。当一个共享变量被volatile
修饰时,由于CPU多级缓存一致性协议的存在,会将 Thread_B 修改的flag属性马上写入共享内存,并通知 Thread_A 将它私有工作内存的 flag 属性设置为失效状态。这样Thread_A就会从内存中重写读取数据
public static volatile boolean flag = true;
原子性
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
java 中对于基本数据类型的读写操作时具有原子性的(特例:在32位系统中,long 和 double不具备原子性。因为32位系统,每次读写32位是原子性,但是long/double是64位的,他的读写分两次完成,所以可能会出现只读写半个,另外半个被其他线程读写的情况)。
public static volatile int num = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 20; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
num++;
}
}
});
thread.start();
}
Thread.sleep(1000);
System.out.println(num);
}
在通过volatile修饰,排序可见性问题后,理论上 num 应该正常输出 20000 ,但是实际运行后我们发现得到的值往往少于 20000。
原因:num++
相当于num=num+1
,这个过程分为两步,读取 num 和 给 num 赋值。可能出现一种情况,初始num = 0
,线程A 对 num进行了读取,并执行num+1 ==》num=1
, 但是还没有写入共享内存(共享内存num = 0),此时线程b开启,对 num 进行了读取,读取到的数据是0,然后执行了num+1 ==》num = 1
,最后两个线程都将数据写入了内存,相当于执行了两次循环,只加了1。
如何解决原子性问题
通过synchronized
或Lock
实现原子性,synchronized
或Lock
保证同一时刻只有一个线程执行了代码块。这也就必然没有原子性问题。
synchronized (this){
num++;
}
有序性
对于单线程而言,我们认为代码执行都是按代码顺序依次执行的。
但对于多线程而言,则可能出现乱序现象。因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致。
如何解决有序性问题:
可以通过volatile关键字来保证一定的“有序性”,它禁止了指令重排。
Volatile
简介
Volatile 是java虚拟机提供的最轻量级的同步机制!
由Volatile修饰的变量在发生修改时,会通知所有拥有并使用它的线程更新最新的数据,防止出现数据的“脏读”,确保共享变量的可见性。
如何实现?
通过 HSDIS 将java代码翻译成汇编代码后,volatile修饰的共享变量在进行写操作的时候会加上lock前缀的指令。通过查询《IA-32架构软件开发人员手册》可以得知lock前缀指令会对总线加锁,确保通过volatile修饰的共享变量的修改是原子性的。
《IA-32架构软件开发人员手册》原文
由于早期的处理器收到lock前缀指令是总是会出发总线锁。所以在后面就引入了高速缓存锁【CPU多级缓存一致性协议(MESI)】,如果cpu存在缓存一致性协议且修改的数据只占一个缓存行,那么就会触发高速缓存锁。
CPU多级缓存一致性协议的4中状态(MESI)
M: 修改状态,表示共享变量已经被修改。
E: 独占状态,表示共享变量只有一个线程使用。
S: 共享状态,表示共享变量被多个线程使用。
I: 失效状态,表示共享变量在当前缓存行中已失效。
volatile 的工作流程
假设存在如下代码
public static volatile boolean flag = true;
public static void main(String[] args) {
Thread thread_A = new Thread(new Runnable() {
@Override
public void run() {
while (flag){}
System.out.println("flag修改状态,被线程A识别到");
}
});
thread_A.start();
Thread thread_B = new Thread(new Runnable() {
@Override
public void run() {
if (flag) {
flag = false;
System.out.println("flag 已修改");
}
}
});
thread_B.start();
}
下图就是flag变量在两个cpu中状态和变化 稍微解释下:
- 在main函数启动后,线程A 也随后启动,它从主内存将 flag变量 加载到 缓存空间,由于此时
线程B
未启动,所以当前flag状态为 E (独享状态),由于while (flag)
一直为true,所以线程A 进入循环状态。 线程B
随后也启动了,将 flag变量 从 主内存 又加载了一份到缓存空间里。此时flag 为 S (共享状态),同时将 线程A 工作内存的状态设置为 S。线程B
将缓存行中的flag加载到寄存器中,并对 flag 进行修改flag = false
,并将flag的状态改为 M(修改状态)- 修改完 flag 后,将 flag 写入store buffer中(cpu空出,调度其他线程),等待线程A响应 “已失效” 消息。
- 4进行的同时相BUS 总线发起本地写入指令,线程A 的总线嗅探机制,感知到
线程B
对flag的本地写指令后,会将线程A缓存行中的flag状态设置为 I(已失效)。并向BUS总线响应 “已失效” 消息。 线程B
通过总线嗅探机制,感知到线程A响应的 “已失效” 消息,将store buffer区的flag 写回共享内存。最后线程A重新获取flag。
相信大家看完以上内容,应该明白了为什么用volatile修饰的共享变量可以保证可见性。
不知大家是否有这样的疑问,根据你上面说的volatile修饰共享变量读-修改-写操作时原子性的,那按照这样讲volatile修饰的共享变量也可以确保原子性?
当然不是啦!我来举个栗子。还是这段代码。。。
public static volatile int num = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 20; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
num++;
}
}
});
thread.start();
}
Thread.sleep(1000);
System.out.println(num);
}
按照缓存一致性协议来看,在num++时,其他cpu会接收到一个触发高速缓存锁的cpu发起的本地写指令,并将自己cpu缓存中的num设置为失效,重新去共享内存中读取。这样看起来好像没啥问题。
但是,有一种特殊情况:假设有一个cpu(假设:cpu_X)在接收触发高速缓存锁的cpu发起的本地写指令前,已经将num加载到寄存器中了,缓存一致性协议 只会将缓存区的num设置为失效,却无法将寄存器中的对象失效。此时触发高速缓存锁的cpu进行了num++,cpu_X也进行了一次num++,此时他们得到的num是一致的,都会被写入内存,但是循环次数也相应的减少了一次。这种情况下 num++ 的总循环次数也就少了一次,num的数值也减少了。
码字不易,给哥们点个赞呗,意图升级的我万分感激~~~~