美文网首页
4.线程安全问题

4.线程安全问题

作者: nieniemin | 来源:发表于2021-07-31 16:18 被阅读0次

    在第二节线程生命周期时有提到过线程执行start(),并不一定会立刻执行。有可能线程处于等待CPU分配资源阶段。我们制定线程是交替执行,那么当多个线程同时共享,同一个全局变量或静态变量,做写的操作时,可能会发生数据冲突问题,也就是线程安全问题。但是做读操作是不会发生数据冲突问题。

    接下来通过代码模拟一下,多线程的线程安全问题。MyRunnable类重写run方法,多个线程共同访问同一个对象中的实例变量判断当index>1000时,退出方法。因此正常情况下index最终值应该为1000。

    public class MyRunnable implements Runnable {
    
        private int index = 0;
        private static Integer MAX_NUM = 1000;
    
        @Override
        public void run() {
            while (true) {
                if (index > MAX_NUM) {
                    break;
                }
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "当前的值:" + index++);
            }
        }
    }
    
        public static void main(String[] args) {
            MyRunnable myRunnable = new MyRunnable();
    
            Thread t1 = new Thread(myRunnable, " 线程1");
            t1.start();
    
            Thread t2 = new Thread(myRunnable, " 线程2");
            t2.start();
            
            Thread t3 = new Thread(myRunnable, " 线程3");
            t3.start();
        }
      最终打印结果  
     ********************************************************************
     线程1当前的值:998
     线程3当前的值:999
     线程2当前的值:1000
     线程1当前的值:1001
     
    
    

    测试main方法中,启动三个线程t1、t2、t3执行结果发现index值为10001,大于1000。这样写带来了线程安全的问题。通过上面的小例子我们明白线程安全机制就是用于保证多个线程访问数据时的一致性。造成并发问题的源头是什么呢?
    我们知道线程安全问题体现在三个方面:
    1、原子性: 原子操作是不可分割的,体现在两个方面: 一个线程对数据的操作对于其他的线程来说是原子的,要么操作完成,要么什么也没做;当一个线程在操作数据时,不允许其他的线程参与.
    2、可见性:线程对共享数据的访问是否对其他的线程可见. A线程对共享数据做了修改, B线程不一定能够立即读取到A线程修改后的数据。
    3、有序性:指令重排序与内存重排序指令重排序是指CPU执行指令的顺序与程序的顺序可能不一样; 内存重排序是指内存访问顺序与感知顺序可能不一样。
    下面通过大佬的解释我们熟悉下造成这种bug的原因,下面的内容部分摘抄自王宝令老师的文章:

    随着CPU技术的发展,核数由单核变为多核,CPU的执行速度越来越快,CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。CPU 和内存的速度差异可以形象地描述为:CPU 是天上一天,内存是地上一年(假设 CPU 执行一条普通指令需要一天,那么 CPU 读写内存得等待一年的时间)。内存和 I/O 设备的速度差异就更大了,内存是天上一天,I/O 设备是地上十年。

    根据木桶原理,程序的性能取决于短板I/O。为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

    1.CPU 增加了缓存,以均衡与内存的速度差异;

    2.操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;

    3.编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

    3.1 缓存可见性问题

    上面提到了CPU增加了缓存,以均衡与内存的速度差异。先解释下CPU缓存,CPU缓存(SRAM)和内存(SDRAM)都是一种断电即掉的非永久随机存储器RAM。SRAM储存了主内存的映象,使CPU可以直接通过访问SRAM来完成数据的读写。由于SRAM的速度与CPU的速度相当,从而大大缩短了数据读写的等待时间,当运算结束后再从缓存同步到内存之中,系统的整体速度也自然得到提高。

    CPU高速缓存的结构: 分为L1,L2,L3三级缓存,L1和L2是CPU私有的,其中L1最小,L1又分为数据缓存和指令缓存。

    CPU三级缓存

    参考自:https://blog.csdn.net/weixin_43954926/article/details/104334834

    任务管理器

    继续看王宝令老师提到的

    一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

    每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。

    多核 CPU 的缓存与内存关系图

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

    线程A在执行一会儿(还没有执行完成),就出让CPU让线程B执行。这样的操作在操作系统中有很多,牺牲切换线程的极短耗时,来提高CPU的利用率,从而在整体上提高系统性能;操作系统的这种操作就被称为“时间片”切换。


    线程切换示意图

    任务切换的时机大多数是在时间片结束的时候,高级语言里一条语句往往需要多条 CPU 指令完成。

    操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。
    我们潜意识里面觉得 count+=1 这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在 count+=1 之前,也可以发生在 count+=1 之后,但就是不会发生在中间。我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。


    非原子操作的执行路径示意图

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

    为了提升执行性能,编译器,处理器,都会对程序进行重排序的操作,再加上主内存和处理器的缓存,导致执行多线程的程序可能在乱序执行。

    在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。

    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 实例。这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:

    1.分配一块内存 M;

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

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

    但是实际上优化后的执行路径却是这样的:

    1.分配一块内存 M;

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

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

    优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

    双重检查创建单例的异常执行路径

    4-2如何解决可见性和有序性问题
    4-3如何解决原子性问题

    相关文章

      网友评论

          本文标题:4.线程安全问题

          本文链接:https://www.haomeiwen.com/subject/gqefvltx.html