zoukankan      html  css  js  c++  java
  • 并发编程学习篇_01 并发原理

     

     本系列是 极客时间王宝令老师《JAVA 并发编程实战》课程的学习笔记,目的在于学习之后的思考与总结,将学到的东西转换成自己的东西,输出出来。

    架构图如下:

     


    导致并发的原因有三种:

        

    • 缓存导致的可见性问题

    • 线程切换带来的原子性问题

    • 编译优化带来的顺序性问题

    并发源头之一:缓存导致的可见性问题

     

    说到可见性,什么是可见性呢?

    可见性是指一个线程对共享变量的修改另一个线程能够立刻看到。

    那么对于单核 CPU 来说不会存在可见性问题,因为所有线程都在同一 CPU 上执行,CPU 缓存与内存缓存都是共用的。

    而多核 CPU 则会有可见性问题,每一个线程都有自己的 CPU 缓存,如果同步不及时,很容易出现问题,如下是单核 CPU 到多核 CPU 的变化:

    很明显,在多核时如果 线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,这个时候线程 A 对变量 V 的操作对线程 B 就不可见了。

    比较经典的例子是 A B 两个线程,执行一次循环 10000次 count += 1 的方法,count 初始为 0,每个线程都调用一次上述方法,得到的 count 的值是在 10000 ~ 20000 之间的数,并不是我们期望的 20000 。

    我们分析一下,假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count = 0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。

    并发源头之二:线程切换导致的原子性问题

    所谓的原子性,并不是指高级语言里的一行代码,如上述的 count += 1 是需要多条 CPU 指令完成的,至少需要三条指令:

    • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;

    • 指令 2:之后,在寄存器中执行 +1 操作;

    • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

    我们知道,进程之间是通过时间片来相互切换执行的,现在操作系统都是基于更轻量的线程来调度,提到的任务切换都是指线程切换。线程切换就会破坏程序的原子性,导致本应该同时执行的CPU指令被迫中断,从而产生问题。

    所以原子性指定是一个或者多个操作在 CPU 执行的过程中不被中断的特性。

    再来看上面 count += 1 的例子,我们潜意识是认为 count += 1 是原子性的,其实不然,这段代码很有可能出现如下线程切换带来的问题:

    并发源头之三:编译优化带来的顺序性问题

    其实编译器为了优化性能,有时候会改变程序中语句的先后顺序。

    Java 中一个经典的案例就是利用双重检查创建单例对象,代码如下:

    public class Singleton {
      static Singleton instance;
      static Singleton getInstance(){
        if (instance == null) {
          synchronized(Singleton.class) {
            if (instance == null)
              instance = new Singleton();
            }
        }
        return instance;
      }
    }
    

      

    假设有两个线程 A B 同时调用 getInstance() 方法,他们会同时发现 instance == null,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。

    这看上去很完美,其实是有问题的,问题就出在 new 操作上,我们以为 new 操作应该是:

    1. 分配一块内存 M;

    2. 在内存 M 上初始化 Singleton 对象;

    3. 然后 M 的地址赋值给 instance 变量。

    但是实际优化后的路径却是:

    1. 分配一块内存 M;

    2. 将 M 的地址赋值给 instance 变量;

    3. 最后在内存 M 上初始化 Singleton 对象。

    优化有的程序就有问题了,如图:

    假如在图中位置出现了线程切换,B 线程判断 instance != null,就会返回未初始化的引用,就会出现问题。

    总结:

    缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题是并发问题的源头,后续我们会继续学习该课程,巩固基础,打好并发这场硬仗。

    参考资料 :   《JAVA 并发编程实战》

     

  • 相关阅读:
    【Leetcode周赛】从contest-81开始。(一般是10个contest写一篇文章)
    【LeetCode】抽样 sampling(共4题)
    【LeetCode】拓扑排序 topological-sort(共5题)
    【LeetCode】几何学 geometry(共2题)
    【读书笔记】C/C++程序员面试秘籍
    【读书笔记】程序员面试笔记
    【sql】牛客网练习题 (共 61 题)
    HDU 6119 小小粉丝度度熊 双指针
    2017多校第7场 HDU 6128 Inverse of sum 推公式或者二次剩余
    2017多校第7场 HDU 6121 Build a tree K叉树,思维
  • 原文地址:https://www.cnblogs.com/fanyi0922/p/11486611.html
Copyright © 2011-2022 走看看