1. 由于cpu,内存和硬盘的速度不一致,为了平衡三者的速度差异,计算机体系结构引入了缓存来缓和矛盾,操作系统引入了进程线程以分时复用cpu,编译程序引入了编译优化来提高编译速度。但是在引入这些内容的时候如果不了解这些便利的底层逻辑,就很容易导致很多诡异的问题:

      1. 缓存导致的可见性问题

        1. 因为缓存是在cpu的一个核上的,所以对于多核cpu,不同的线程在不同cpu上对同一个共享变量进行操作就有可能导致得到的结果不一样。以下程序得到的结果就是10000-20000之间的一个随机数。
          1. public class Test {
          2. private static long count = 0;
          3. private void add10K() {
          4. int idx = 0;
          5. while(idx++ < 10000) {
          6. count += 1;
          7. }
          8. }
          9. public static long calc() {
          10. final Test test = new Test();
          11. // 创建两个线程,执行add()操作
          12. Thread th1 = new Thread(()->{
          13. test.add10K();
          14. });
          15. Thread th2 = new Thread(()->{
          16. test.add10K();
          17. });
          18. // 启动两个线程
          19. th1.start();
          20. th2.start();
          21. // 等待两个线程执行结束
          22. th1.join();
          23. th2.join();
          24. return count;
          25. }
          26. }
      2. 线程切换带来的原子性问题

        1. 我们现在使用的一条高级语言,往往需要多条cpu指令完成,如 count+=1;
          1. 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
          2. 指令 2:之后,在寄存器中执行 +1 操作;
          3. 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
        2. 操作系统做任务切换,可以发生在任何一条cpu指令执行完,如下的顺序就会导致结果是1,而不是我们期望的2。image.png
        3. 我们把一个或者多个操作在cpu执行的过程中不被中断的特性成为原子性。所以我们需要在高级语言层面保证操作的原子性
      3. 编译优化带来的有序性问题
        1. 编译器为了优化性能,有时候会改变程序中语句的先后顺序。
        2. 对于单例模式,本来的执行顺序为:
          1. 分配一块内存 M;
          2. 在内存 M 上初始化 Singleton 对象;
          3. 然后 M 的地址赋值给 instance 变量。
        3. 实际的优化后的执行顺序
          1. 分配一块内存 M;
          2. 将 M 的地址赋值给 instance 变量;
          3. 最后在内存 M 上初始化 Singleton 对象。
        4. 如果线程1在第二步失去cpu,线程2此时获得cpu,线程2执行到第一个判断null的地方,就会返回一个null的单例,可能会有bug
    1. public class Singleton {
    2. static Singleton instance;
    3. static Singleton getInstance(){
    4. if (instance == null) {
    5. synchronized(Singleton.class) {
    6. if (instance == null)
    7. instance = new Singleton();
    8. }
    9. }
    10. return instance;
    11. }
    12. }
    • 只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。
    1. 我们已经了解到导致可见性和有序性问题的原因是缓存和编译优化,所以直接禁用掉缓存和编译优化是最直接的方法。但是这样的话程序性能就不能够保证最优了,所以合理的解决办法就是合理禁用。按照我们自己的需求去禁用缓存和编译优化,java内存模型为我们提供了volatile,synchronized,final三个关键字和六项happens-before原则。
      1. 声明一个变量为volatile,即告诉编译器,对这个变量的读写要从内存中读取或写入,不能使用cpu缓存
      2. happens-before原则并不是谁发生在谁之前,而是说前面一个操作的结果对后续操作是可见的
      3. happens-before原则
        1. 在一个线程中,按照程序顺序,前面的操作happens-before后续的任意操作
        2. 对一个volatile变量的写操作,happens-before后续对这个volatile变量的读操作
        3. 传递性,如果a happens-before b,b happens-before c,则a happens-before c
        4. 对一个锁的解锁happens-before后续对这个锁的加锁
        5. 主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
        6. 主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作
      4. final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化