资讯专栏INFORMATION COLUMN

[Java并发-1]入门:并发编程Bug的源头

xiguadada / 3085人阅读

摘要:所以这情况下,当线程操作变量的时候,变量并不对线程可见。总结,缓存引发的可见性问题,切换线程带来的原子性问题,编译带来的有序性问题深刻理解这些前因后果,可以诊断大部分并发的问题

背景介绍

如何解决并发问题,首先要理解并发问题的实际源头怎么发生的。

现代计算机的不同硬件的运行速度是差异很大的,这个大家应该都是知道的。

计算机数据传输运行速度上的快慢比较:
CPU > 缓存 > I/O

如何最大化的让不同速度的硬件可以更好的协调执行,需要做一些“撮合”的工作

CUP增加了高速缓存来均衡与缓存间的速度差异

操作系统增加了 进程,线程,以分时复用CPU,进而均衡CPU与I/O的速度差异(当等待I/O的时候系统切换CPU给系统程序使用)

现代编程语言的编译器优化指令顺序,使得缓存能够合理的利用

上面说来并发才生问题的背景,下面说下并发产生的具体原因是什么

并发产生的原因 缓存导致的可见性问题

先看下单核CPU和缓存之间的关系:

单核情况下,也是最简单的情况,线程A操作写入变量A,这个变量A的值肯定是被线程B所见的。因为2个线程是在一个CPU上操作,所用的也是同一个CPU缓存。

这里我们来定义

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

多核CPU时代下,我们在来看下具体情况:

很明显,多核情况下每个CPU都有自己的高速缓存,所以变量A的在每个CPU中可能是不同步的,不一致的。
结果程A刚好操作来CPU1的缓存,而线程B也刚好只操作了CPU2的缓存。所以这情况下,当线程A操作变量A的时候,变量并不对线程B可见。

我们用一段经典的代码说明下可见性的问题:

    private void add10K() {
        int idx = 0;
        while (idx++ < 100000) {
            count += 1;
        }
    }

    @Test
    public void demo() {

        // 创建两个线程,执行 add() 操作
        Thread th1 = new Thread(() -> {
            add10K();
        });
        Thread th2 = new Thread(() -> {
            add10K();
        });
        // 启动两个线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束

        try {
            th1.join();
            th2.join();
        } catch (Exception exc) {

            exc.printStackTrace();
        }

        System.out.println(count);

    }

大家应该都知道,答案肯定不是 200000
这就是可见性导致的问题,因为2个线程读取变量count时,读取的都是自己CPU下的高速缓存内的缓存值,+1时也是在自己的高速缓存中。

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

进程切换最早是为了提高CPU的使用率而出现的。

比如,50毫米操作系统会重新选择一个进程来执行(任务切换),50毫米成为“时间片”

早期的操作系统是进程间的切换,进程间的内存空间是不共享的,切换需要切换内存映射地址,切换成本大。

而一个进程创建的所有线程,内存空间都是共享的。所以现在的操作系统都是基于更轻量的线程实现切换的,现在我们提到的“任务切换”都是线程切换。

任务切换的时机大多数在“时间片”结束的时候。

现在我们使用的基本都是高级语言,高级语言的一句对应多条CPU命令,比如 count +=1 至少对应3条CPU命令,指令:

1, 从内存加载到CPU的寄存器
2, 在寄存器执行 +1
3, 最后,讲结果写回内存(缓存机制导致可能写入的是CPU缓存而不是内存)

操作系统做任务切换,会在 任意一条CPU指令执行完就行切换。所以会导致问题

如图所示,线程A当执行完初始化count=0时候,刚好被线程切换给了线程B。线程B执行count+1=1并最后写入值到缓存中,CPU切换回线程A后,继续执行A线程的count+1=1并再次写入缓存,最后缓存中的count还是为1.
一开始我们任务count+1=1应该是一个不能再被拆开的原子操作。

我们把一个或多个操作在CPU执行过程中的不被中断的特性称为 原子性。

CPU能够保证的原子性,是CPU指令级别的。所以高级语言需要语言层面 保证操作的原子性。

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

有序性。顾名思义,有序性指的是程序按照代码的先后顺序执行。

编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:a=6;b=7;编译器优化后可能变成b=7;a=6;,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。

Java中的经典案例,双重检查创建单例对象;

public class Singleton {

  static Singleton instance;

  static Singleton getInstance(){

    if (instance == null) {

      synchronized(Singleton.class) {

        if (instance == null)

          instance = new Singleton();

        }

    }
    return instance;
  }
}

看似完美的代码,其实有问题。问题就在new上。

想象中 new操作步骤:
1,分配一块内存 M
2,在内存M上 初始化对象
3,把内存M地址赋值给 变量

实际上就行编译后的顺序是:
1,分开一块内存 M
2,把内存M地址赋值给 变量
3,在 内存M上 初始化对象

优化导致的问题:

如图所示,当线程A执行到第二步的时候,被线程切换了,这时候,instance未初始化实例的对象,而线程B这时候执行到instance == null ?的判断中,发现instance已经有“值”了,导致了返回了一个空对象的异常。

总结

1,缓存引发的可见性问题
2,切换线程带来的原子性问题
3,编译带来的有序性问题

深刻理解这些前因后果,可以诊断大部分并发的问题!

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/73764.html

相关文章

  • Java 并发学习笔记(一)——原子性、可见性、有序性问题

    摘要:最后,总结一下,导致并发问题的三个源头分别是原子性一个线程在执行的过程当中不被中断。可见性一个线程修改了共享变量,另一个线程能够马上看到,就叫做可见性。 计算机的 CPU、内存、I/O 设备的速度一直存在较大的差异,依次是 CPU > 内存 > I/O 设备,为了权衡这三者的速度差异,主要提出了三种解决办法: CPU 增加了缓存,均衡和内存的速度差异 发明了进程、线程,分时复用 CP...

    Chao 评论0 收藏0
  • Java多线程学习(七)并发编程中一些问题

    摘要:因为多线程竞争锁时会引起上下文切换。减少线程的使用。举个例子如果说服务器的带宽只有,某个资源的下载速度是,系统启动个线程下载该资源并不会导致下载速度编程,所以在并发编程时,需要考虑这些资源的限制。 最近私下做一项目,一bug几日未解决,总惶恐。一日顿悟,bug不可怕,怕的是项目不存在bug,与其惧怕,何不与其刚正面。 系列文章传送门: Java多线程学习(一)Java多线程入门 Jav...

    yimo 评论0 收藏0
  • [Java并发-10] ReadWriteLock:快速实现一个完备缓存

    摘要:此时线程和会再有一个线程能够获取写锁,假设是,如果不采用再次验证的方式,此时会再次查询数据库。而实际上线程已经把缓存的值设置好了,完全没有必要再次查询数据库。 大家知道了Java中使用管程同步原语,理论上可以解决所有的并发问题。那 Java SDK 并发包里为什么还有很多其他的工具类呢?原因很简单:分场景优化性能,提升易用性 今天我们就介绍一种非常普遍的并发场景:读多写少场景。实际工作...

    nevermind 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<