多线程原理分析A. 初步认识 Volatile[Java] 纯文本查看 复制代码 public class ThreadDemo {
private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
int i = 0;
while (!stop) i++;
System.out.println("i = " + i);
}).start();
TimeUnit.SECONDS.sleep(2);
stop = true;
}
}
[Java] 纯文本查看 复制代码 public class ThreadDemo2 {
private volatile static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
int i = 0;
while (!stop) i++;
System.out.println("i = " + i);
}).start();
TimeUnit.SECONDS.sleep(2);
stop = true;
}
}
- volatile 可以使得在多处理器环境下保证共享变量的可见性
- 加了 volatile 之后变量会多了 lock 的汇编指令
- lock 是一种控制指令,在多处理器环境下,lock 汇编指令可以基于【总线锁】或【缓存锁】机制达到可见性效果
- 多线程环境下,读和写发生在不同的线程中的时候,可能会出现
- 读线程不能即使的读取到其他线程写入的最新的值
- 这就是所谓的可见性,必须使用一些机制来实现,volatile就是这样的一种机制
B. 什么是可见性- 硬件层面
- 计算机由【CPU】、【内存】、【硬盘IO】组成
- 其中它们的执行速度是差距很大的,程序的性能由最慢的部门所决定
- 为了最大化利用 CPU 资源
- CPU 增加了高速缓存
- 引入了线程和进程
- 指令优化->重排序
- JMM 层面
C. CPU 的高速缓存- 线程是 CPU 调度的最小单元,线程设计的目的最终仍然是更充分利用计算机处理的效能
- 但绝大部分的运算任务不能只以拷处理去计算完成,处理器还需要与内存交互
- 而 CPU 与存储设备运算差距非常大
- 所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲
- 通过高速缓存很好解决了处理器与内存的速度矛盾
- 但是也为计算机系统带来了更高的复杂度
- 因为引入了一个新的问题:缓存一致性
- CPU 的高速缓存主要分为 3 种
- L1/L2 属于 CPU 私有的
- CPU 还有一个寄存器,也会缓存一部分数据
- L3 缓存是多个 CPU 之间共享的
- 问题
- 如果多个 CPU 同时操作内存中的某个变量
- 此时多核 CPU 都会把变量缓存到自己的缓存里
- 我们对变量进行修改的时候,CPU 会把修改同步给缓存,然后再同步到主内存
- 此时如果别的 CPU 没有去主内存中同步新的值,则拿到的依然是旧的值
- 这就导致数据错乱,这就是可见性导致的
- CPU 的高速缓存会代理数据不一致的问题
- 每次 CPU 从内存中获取数据的时候都需要经过总线
- 所以我们可以在总线上加锁
- 就相当于数据访问的互斥特性
- 但是总线锁会代理性能问题
- 在总线锁的基础上引入了缓存锁
- 我们要处理的是被缓存的资源,而不是整个内存
- 缓存锁是为了降低锁的控制粒度,去控制被多个 CPU 缓存的数据达到一致性
- 在 CPU 中通过缓存一致性协议
- 常用的的协议是(MESI)
- 什么是 MESI?
- MESI 表示缓存行的 4 种状态
- Modify: 修改状态
- Exclusive: 独占,只有这个 CPU 缓存了这个变量
- Shared: 共享状态
- Invalide: 失效状态
- 写之前先要把其他 CPU 的缓存值为无效后才能写
- 在 CPU 里面,每一个缓存控制器不仅仅控制自己的读写操作,同时通过嗅探协议去监听其他缓存的读写操作
- MESI 状态下的缓存都可以被读取
- 基于 MESI 可以解决缓存一致性问题
- 但是不意味着可见性问题解决了,因为如果解决了就没有必要加 volatile
- MESI 是基于 CPU 硬件层面的实现
- MESI 协议会带来一个问题
- CPU0 要修改变量时,需要发送通知通知其他 CPU 把缓存的这个变量状态由【Shared】-->【Invalide】
- 在 CPU0 接收到其他 CPU 响应之前这个过程都是阻塞的
- 所以 CPU 做了优化引入了 StoreBuffer
- 存储缓冲
- 在发送写操作之后不阻塞,而是接着做其他事情,提升程序性能
- 把数据下入 StoreBuffer 和发送 Invalidate 就继续做别的事情,不阻塞
- 但是这样也会出现断站的可见性问题
- 因为数据写入到 StoreBuffer 后只有等到 ACKS 之后才会把数据同步到缓存行
- 然后再同步到主内存
[Java] 纯文本查看 复制代码 value = 3;
"CPU0执行操作"
void cpu0() {
value = 10;
isFinish = true;
}
"CPU1执行操作"
void cpu1() {
if(isFinish) { // true
assert value == 10;// false
}
}
- 上面代码会发现数据错乱了
- CPU0 的 value 由 S --> M,把数据写入到 StoreBuffer,然后通知其他 CPU 把 value 置为 I 状态
- CPU0 的 isFinish 为 E 状态
- CPU 的乱序执行
- StoreBuffer 导致异步的过程存在乱序
- 也可以认为是重排序
- 重排序会导致可见性问题
- 硬件方面怎么优化也不能避免可见性问题,所以 CPU 层面提供了一个指令 --> 内存屏障
- MESI 会导致 CPU 层面的阻塞问题,所以引入了 StoreBuffer
- 但是优化后又导致指令重排序的问题
- 所以 CPU 硬件层面解决不了可见性问题,所以提供了内存屏障
- 我们修改上述代码解释内存屏障
[Java] 纯文本查看 复制代码 value = 3;
"CPU0执行操作"
void cpu0() {
value = 10;
"屏障指令,强制把缓存的变量同步到主内存里面"
storeMemoryBarrier();
isFinish = true;
}
"CPU1执行操作"
void cpu1() {
if(isFinish) { // true
"强制从主内存获取最新的值"
loadMemoryBarrier();
assert value == 10; // true
}
}
- 内存屏障就是将 StoreBuffers 中的指令写入到内存,从而使得其他访问同一共享内存的可见性
- CPU 层面提供了 3 中屏障
- 写屏障: 写屏障之前的结果都要刷新到主内存中
- 读屏障: 处理器在读屏障之后的读操作都可以读到最新的数据
- 全屏障: 读屏障 + 写屏障 = 全屏障
- Vloatile 源码里有一个 lock 汇编指令,相当于内存屏障的作用
- 内存屏障,重排序和平台以及硬件有关系
- Java 是跨平台的变成语言
- 所以引出了 JMM 的内存模式
D. JMM(Java Memory Model)- 导致可见性问题的根本原因是
- JMM 提出了合理去禁止缓存和禁止重排序去解决可见性问题
- JMM 是语言级别的抽象内存模型
- 假设现在有 Thread_A 和 Thread_B
- 它们都会存在本地工作内存
- 它的工作模型跟我们 CPU 的工作模型是一样的
- 线程要访问主内存前需要经过工作内存
- 使用 vloatile/synchronized/final/happens-before 来解决可见性有序问题
- 源代码重排序-->编译器的重排序-->CPU层面的重排序(指令集,内存)-->最终执行的指令
- 不是所有的程序都会进行重排序
- 不管你怎么重排序,对于单个线程的执行结果不能变
- JMM 的内存屏障
- 语言级别内存屏障(编译器级别)
- CPU 层面内存屏障
E. Happens-Before 规则(只是一个概念)- HappenBefore 表示前一个操作的结果对后续操作时可见的,所以它时一种表达多个线程之间对于内存的可见性
- 所谓可见性保障,处理使用 volatile 以外,还提供了其他方法
- JMM 里面有哪些操作可以建立 Happens-Before 的 6 大原则
- 程序的顺序规则
- volatile
- 传递性规则
- start 规则
- Join 规则
- synchronized 监视器锁规则
- 前一个线程释放锁以后一定 Happens-Before 后一个线程
- 以上 6 种情况不需要考虑可见性问题
F. vloatile 不解决原子性问题- volatile 只解决可见性问题,不解决原子性问题
- synchronized 可以解决可见性,有序性,原子性问题
|