• 多线程编程bug起源分析


    一、多线程的起源

    对于软件工程师,整个代码执行的过程中主要关注CPU、内存和I/O这三个方面;在计算机快速发展的阶段,主要是这三个方面在快速发展;但这三方面一直存在这一个严重的矛盾,即运行速度;CPU的运行速度是是最快的,内存次之,IO是最慢的;举个例子来说,CPU是天上一天,内存就是地上一年。若内存是天上一天,IO就是地上十年;
    
    根据木桶理论,限制程序运行速度的是IO。每次在执行IO操作时,CPU都会被闲置;为了最高效的利用CPU的计算资源,平衡这三者的速度差异。计算机体系结构、操作系统和编译程序都做了平衡,主要体现在以下三点:
    
    1、为了均衡CPU和内存的速度差异,给CPU配置专门的缓存;
    2、操作系统增加了线程和进程。以分时复用CPU来平衡CPU与IO的时间差;
    3、编译程序优化指令执行次序,确保缓存可以被合理的使用;
    

    二、多线程BUG源头

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

    1)在单核CPU的时代,所有的线程都是操作同一个CPU的缓存,所以对于所有的线程来说,CPU的缓存都是共享且可见的;但在多核CPU的时代,每个CPU都会有自己的缓存,不同的线程被分配到不同的CPU。就会导致多个线程在不同的CPU缓存中操作同一个变量时,该变量在每一个缓存中都是只对操作该CPU的线程可见,对其它CPU对应的线程是不可见的。不能保证该变量的强一致性;
    代码示例:   
    
        public  class Test1 {
            private static Long count = 0L;
    
            private static void sum10k(){
                int initNum = 0;
                while (initNum++ < 10000){
                    count += 1;
                }
            }
    
            public static void main(String[] args) {
    
                Thread t1 = new Thread(() -> {
                    Test1.sum10k();
                });
    
                Thread t2 = new Thread(() -> {
                    Test1.sum10k();
                });
    
                t1.start();
                t2.start();
    
                try {
                    t1.join();
                    t2.join();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(count);
    
            }
        }
    
    
    该代码最终的结果并不是和直觉得到的20000的结果,而是在10000~20000之间的一个随机值。原因就是,t1线程和t2线程对count这个变量的操作是在两个不同的CPU缓存上,相当于在操作两个count变量,各自累加各自的同时互相累加,就会导致两个同时操作时,前一个被后一个覆盖掉,最终只进行一次累加,导致结果不会是20000.
    

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

    CPU的计算操作是以CPU的指令为最小原子单位操作的,而不是以软件工程师的每行代码为一个原子来执行的;
    count+=1;这行代码可以拆分成三个CPU指令:
    1)将count的初始值从内存中加载到CPU的寄存器中;
    2)在CPU的寄存器中执行+1操作;
    3)将+1后的结果写入到内存中(由于CPU缓存的存在,结果写入到的地方应该是CPU的缓存,而不是直接写入到内存中);
    由于CPU执行指令是使用时间片(分时复用)来操作的。例如上面的count+=1这行代码,线程A执行时,可能只执行到了第一条指令(将数据加载到CPU1寄存器中)。此时时间片结束,CPU2现在开始操作线程B。线程B执行完完整的三行指令后,现在假设count值结果变成了1,而线程A加载到CPU1寄存器的值仍然是0,在执行完后结果仍是count结果变成了1。执行了两次,理想结果应该是2,但由于线程切换导致线程B的结果覆盖掉了线程A,导致最终结果仍然是1;

    3、编译优化带来的有序性问题

    在CPU的优化过程中,添加了一种指令重排序的规则。即指令在执行的过程中,指令并不是完全按照代码书写顺序的顺序进行执行的。例如定义int a=0; int b=1。CPU在执行的过程中有可能会先执行int b=1,再执行int a=0,这样执行并不影响最终的结果。但有些逻辑就会产生bug,例如再创建单例的使用双重检查的代码逻辑时逻辑:       
    
          public class Singleton(){
            private static Singleton instance;
            public Singleton getSingleton(){
                if(instance == null){
                    Synchronzed(Singleton.class){
                        if(instance == null){
                           instance = new Sinleton();
                        }
                    }  
                }
                return instance;
            } 
          }  
    

    在new对象的操作过程中,按照正常流程,代码会被分成三条指令被CPU执行:
    1)给即将new出来的对象分配一块内存M;
    2)在内存M上初始化Singleton对象;
    3)将初始完对象的内存M的地址赋值给instance变量;

    由于编译优化指令重排序,导致将第二步和第三步调换了位置;分析如下:
    线程A和线程B都来执行该段单例代码。在判断现在对象不存在的前提下,由于锁的存在。这两个线程只能有一个线程可以获取到锁。假设B线程获取到锁,线程B再次判断对象为空,然后执行创建对象(new 对象),执行到位置调换后的第二步(将内存M的地址赋值给instance变量)。此时发成了线程切换,开始执行线程A,线程A在最外层判断instance是否为空,发现不为空,就会直接返回一个没有初始化Sinleton对象的但已有内存地址的对象。产生错误情况;

  • 相关阅读:
    T450的Fn lock
    移民,不应该是走投无路后的选择
    门槛低的行业看天赋,门槛高的行业看毅力
    个人是时代的一朵浪花
    转载:XPath基本语法
    爪哇国新游记之三十四----Dom4j的XPath操作
    常去的论坛今天两个传统行业的坛友要下岗了
    异常中要了解的Throwable类中的几个方法
    感觉JVM的默认异常处理不够好,既然不好那我们就自己来处理异常呗!那么如何自己处理异常呢?
    JVM对异常的默认处理方案
  • 原文地址:https://www.cnblogs.com/wwcxBlog/p/12420772.html
Copyright © 2020-2023  润新知