A股上市公司传智教育(股票代码 003032)旗下技术交流社区北京昌平校区

 找回密码
 加入黑马

QQ登录

只需一步,快速开始

多线程原理分析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;
    }
}

  • 如果我们加上关键字 volatile 就可以解决
[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 增加了高速缓存
      • 引入了线程和进程
        • CPU 通过时间片切换最大化提升 CPU 使用率
      • 指令优化->重排序
        • 更合理利用好 CPU 的高速缓存


  • JMM 层面
C. CPU 的高速缓存
  • 线程是 CPU 调度的最小单元,线程设计的目的最终仍然是更充分利用计算机处理的效能
    • 但绝大部分的运算任务不能只以拷处理去计算完成,处理器还需要与内存交互
    • 而 CPU 与存储设备运算差距非常大
      • 所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲

  • 通过高速缓存很好解决了处理器与内存的速度矛盾
    • 但是也为计算机系统带来了更高的复杂度
    • 因为引入了一个新的问题:缓存一致性
  • CPU 的高速缓存主要分为 3 种
    • L1/L2/L3
  • L1/L2 属于 CPU 私有的
    • L1 缓存又分为 2 种
      • 指令缓存
      • 数据缓存

  • CPU 还有一个寄存器,也会缓存一部分数据
  • L3 缓存是多个 CPU 之间共享的
  • 问题
    • 如果多个 CPU 同时操作内存中的某个变量
    • 此时多核 CPU 都会把变量缓存到自己的缓存里
    • 我们对变量进行修改的时候,CPU 会把修改同步给缓存,然后再同步到主内存
      • 此时如果别的 CPU 没有去主内存中同步新的值,则拿到的依然是旧的值
    • 这就导致数据错乱,这就是可见性导致的
  • CPU 的高速缓存会代理数据不一致的问题
    • CPU 层面会提供解决方案
    • 总线锁
    • 缓存锁
  • 每次 CPU 从内存中获取数据的时候都需要经过总线
    • 所以我们可以在总线上加锁
    • 就相当于数据访问的互斥特性
    • 但是总线锁会代理性能问题
      • 由并行转成串行

  • 在总线锁的基础上引入了缓存锁
    • 我们要处理的是被缓存的资源,而不是整个内存
    • 缓存锁是为了降低锁的控制粒度,去控制被多个 CPU 缓存的数据达到一致性
    • 在 CPU 中通过缓存一致性协议
      • 在不同 CPU 里面实现缓存一致性协议是不一样的
    • 常用的的协议是(MESI)
  • 什么是 MESI?
    • MESI 表示缓存行的 4 种状态
      • Modify: 修改状态
      • Exclusive: 独占,只有这个 CPU 缓存了这个变量
      • Shared: 共享状态
      • Invalide: 失效状态
    • 写之前先要把其他 CPU 的缓存值为无效后才能写

  • 在 CPU 里面,每一个缓存控制器不仅仅控制自己的读写操作,同时通过嗅探协议去监听其他缓存的读写操作
  • MESI 状态下的缓存都可以被读取
    • 只有 I 状态下的变量会从主内存中读取
  • 基于 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 中屏障
    • 写屏障: 写屏障之前的结果都要刷新到主内存中
      • StoreBarrier
    • 读屏障: 处理器在读屏障之后的读操作都可以读到最新的数据
      • LoadBarrier
    • 全屏障: 读屏障 + 写屏障 = 全屏障
      • FullBarrier

  • Vloatile 源码里有一个 lock 汇编指令,相当于内存屏障的作用
    • lock 会加入缓存锁
  • 内存屏障,重排序和平台以及硬件有关系
    • Java 是跨平台的变成语言
    • 所以引出了 JMM 的内存模式

D. JMM(Java Memory Model)
  • 导致可见性问题的根本原因是
    • 高速缓存
    • 重排序
  • JMM 提出了合理去禁止缓存和禁止重排序去解决可见性问题
    • 最核心价值是解决了可见性和有序性
  • JMM 是语言级别的抽象内存模型
    • 在 JMM 里面通过内存屏障是实现指令重排序
  • 假设现在有 Thread_A 和 Thread_B
    • 它们都会存在本地工作内存
      • 同时也会存在主内存
    • 它的工作模型跟我们 CPU 的工作模型是一样的
    • 线程要访问主内存前需要经过工作内存
      • 从而可见性问题跟 CPU 是一样的

  • 使用 vloatile/synchronized/final/happens-before 来解决可见性有序问题
    • 源代码重排序-->编译器的重排序-->CPU层面的重排序(指令集,内存)-->最终执行的指令
    • 不是所有的程序都会进行重排序
      • 数据依赖规则下不能重排序
    • 不管你怎么重排序,对于单个线程的执行结果不能变
  • JMM 的内存屏障
    • 语言级别内存屏障(编译器级别)
    • CPU 层面内存屏障

E. Happens-Before 规则(只是一个概念)
  • HappenBefore 表示前一个操作的结果对后续操作时可见的,所以它时一种表达多个线程之间对于内存的可见性
  • 所谓可见性保障,处理使用 volatile 以外,还提供了其他方法
    • A Happens-Before B
      • A 的操作对 B 是可见的

  • JMM 里面有哪些操作可以建立 Happens-Before 的 6 大原则
    • 程序的顺序规则
      • 单个线程中运行是可见的
    • volatile
      • volatile 修饰的变量是可见的
    • 传递性规则
    • start 规则
      • 线程启动后可以访问启动前的修改的变量
    • Join 规则
    • synchronized 监视器锁规则
      • 前一个线程释放锁以后一定 Happens-Before 后一个线程

  • 以上 6 种情况不需要考虑可见性问题
F. vloatile 不解决原子性问题
  • volatile 只解决可见性问题,不解决原子性问题
    • 解决指令重排序问题
  • synchronized 可以解决可见性,有序性,原子性问题






0 个回复

您需要登录后才可以回帖 登录 | 加入黑马