关于java:并发编程Bug起源可见性有序性和原子性问题

30次阅读

共计 3951 个字符,预计需要花费 10 分钟才能阅读完成。

以前古老的 DOS 操作系统,是单进行的零碎。零碎每次只能做一件事件,实现了一个工作能力持续下一个工作。每次只能做一件事件,比方在听歌的时候不能关上网页。所有的工作操作都依照串行的形式顺次执行。

这类服务器毛病也很显著,期待操作的过长,无奈同时操作多个工作,执行效率很差。

当初的操作系统都是多任务的操作系统,比方听歌的时候能够做关上网页,还能关上微信和敌人聊天。这几个工作能够同时进行,大大增加执行效率。

并发提高效率

一个残缺服务器,都有 CPU 内存IO, 三者之间的运行速度存在显著的差别:

  • CPU相干的操作,执行指令以及读取 CPU 缓存等操作,根本都是 纳秒 级别的。
  • CPU读取内存,耗时是 CPU 相干操作的 千倍 ,根本都是 微秒 级别。CPU和内存之间的速度差别。
  • IO操作根本是毫秒的级别,是内存操作的千倍,内存 IO之间存在速度的差别。

CPU -> 内存 -> SSD -> 磁盘 -> 网络
纳秒 -> 微秒 -> 毫秒 -> 毫秒 -> 秒

程序中大部分的语句都要拜访内存,有些还要拜访的 IO 读写。为了正当的利用 CPU 的高性能,高效的均衡三者的速度差别,操作系统、编译器次要做了以下改良:

  • CPU减少了 CPU 缓存,用来平衡CPU内存 的速度差别。
  • 操作系统减少了多过程、多线程,用来分时复用 CPU, 从而平衡CPUIO设施之间的差别。
  • 编译优化程序执行程序,充分利用缓存。

做了以上操作之后,CPU读取或者批改数据之后,将数据缓存在 CPU 缓存 中,CPU不须要每次都从内存中获取数据,极大的进步了 CPU 的运行速度。多线程是将时间段切成一个个小段,多个线程在上下文切换中,执行完工作,而不必等后面的线程都执行结束之后再执行。比方做一个计算,CPU耗时 1 纳秒, 而从内存读取数据要 1 微秒,没有多线程的话,N个线程要耗时 N 微秒,此时CPU 高效性就无奈体现进去。有了多线程之后,操作系统将 CPU 时间段切成一个一个小段,多线程上下文切换,线程执行计算操作,无需期待 内存读取操作

尽管并发能够进步程序的运行效率,然而凡事无利也有弊,并发程序也有很多诡异的bug, 本源有以下几个起因。

缓存导致可见性问题

一个线程对共享变量的批改,另外线程能立即看到,称为 可见性

在单核时代,所有的线程都是在同一个 CPU 上运行,所有的线程都是操作同一个线程的 CPU 缓存,一个线程批改缓存,对另外一个线程来说肯定是可见的。比方在下图中, 线程 A 线程 B 都是操作同一个 CPU 缓存,所以 线程 A 更新了 变量 V 的值,线程 B 再拜访 变量 V 的值,获取的肯定是 V 的最新值。所以变量 V 对线程都是 可见的

在多核 CPU 下,每个 CPU 都有本人的缓存。当多个线程执行在不同的 CPU 时,这些线程的操作也是在对应的 CPU 缓存 上。这时候就会呈现问题了,在下图中,线程 A 运行在 CPU_1 上,首先从 CPU_1 缓存获取 变量 V ,获取不到就获取内存的值,而后操作 变量 V 线程 B 也是同样的形式在 CPU_2 缓存中获取 变量 V

线程 A 操作的是 CPU_1 的缓存,线程 B 操作的是 CPU_2 的缓存,此时 线程 A 变量 V 的操作对于 线程 B 不可见的 。多核 CPU 一方面进步了运行速度,然而另一方面也可能会造成 线程不平安 的问题。

上面应用一段代码来测试多核场景下的可见性。首先创立一个累加的办法 add10k 办法,循环 10000count+=1的操作。而后在 test 办法外面创立两个线程,每个线程都调用 add10k 办法,后果是多少呢?

public class VisibilityTest {

    private  static int count = 0;

    private void add10k() {
        int index = 0;
        while (index++ < 10000) {count += 1;}
    }

    @Test
    public void test() throws InterruptedException {VisibilityTest test = new VisibilityTest();
        Thread thread1 = new Thread(() -> test.add10k());
        Thread thread2 = new Thread(() -> test.add10k());
        // 启动两个线程
        thread1.start();
        thread2.start();
        // 期待两个线程执行完结
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

依照直觉来说后果是 20000, 因为在每个线程累加10000, 两个线程就是20000。然而理论后果是介于10000~20000 的之间,每次执行后果都是这个范畴内的随机数。

因为线程 A 和线程 B 同时开始执行,第一次都会将 count=0 缓存到本人的 CPU 缓存 中,执行完 count += 1 之后,写入本人对应的 CPU 缓存 中,同时写入内存中,此时内存中的数是 1,而不是冀望的2。之后CPU 再取到本人的 CPU 缓存 再进行计算,最初计算出来的 count 值都是小于20000, 这就是缓存的可见性问题。

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

下面提到,因为 CPU 内存 IO 之间的速度存在很大的差别,在单过程零碎中,须要等速度最慢的 IO 操作实现之后,能力接着实现下一个工作,CPU的高性能也无奈体现进去。但操作系统有了多过程之后,操作系统将 CPU 切成一个一个小片段,在不同的工夫片段内执行不同的过程的,而不须要期待速度慢的 IO 操作,在单核或者多核的 CPU 上能够一边的听歌,一边的聊天。

操作系统将工夫切成很小片,比例 20 毫秒, 开始的 20 毫秒执行一个过程,下一个 20 毫秒切换执行另外一个线程,20毫秒成为 工夫片, 如下图所示:

线程 A 线程 B 来回的切换工作。

如果一个进行 IO 操作,例如读取文件,这个时候该过程就把本人标记为 休眠状态 并让出 CPU 的使用权,等实现 IO 操作之后,又须要应用 CPU 时又会把休眠的过程唤醒,唤醒的过程就能够期待 CPU 的调用了。让出 CPU 的使用权之后,CPU就能够对其余过程进行操作,这样 CPU 的使用率就进步上了,零碎整体的运行速度也快了很多。

并发程序大多数都是基于多线程的,也会波及到 线程上下文的切换 ,线程的切换都是在很短的工夫片段内实现的。比方下面代码中count += 1 尽管有一行语句,但这外面就有三条 CPU 指令。

  • 指令 1:把变量 V 从内存加载到 CPU 寄存器中。
  • 指令 2:在寄存器中执行 +1 操作。
  • 指令 3:将后果写入内存(也可能是写入CPU 缓存中)。

任何一条 CPU 指令都可能产生 线程切换 。如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 依照下图程序执行,那么咱们会发现两个线程都执行count += 1 的操作,然而最初后果却是1,而不是2

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

有序性是指程序依照代码的先后顺序执行,编译器为了优化性能,在不影响程序的最终后果的状况下,编译器调整了语句的先后顺序,比方程序中:

a = 2;
b = 5;

编译器优化后可能变成:

b = 5;
a = 2;

尽管不影响程序的最初后果,然而也会引起一些意想不到的 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;
  }
}

在获取实例 getInstance 办法中,首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次查看 instance 是否为空,如果还为空就创立一个 Singleton 实例。

假如两个线程,线程 A 线程 B 同时调用 getInstance 办法。此时 instance == null,同时对Singleton.class 加锁,JVM保障只有一个线程能加锁胜利,假如是 线程 A 加锁胜利,另一个线程就会处于期待状态,线程 A 会创立一个实例,而后开释锁,线程 B 被唤醒,再次尝试加锁,此时胜利加锁,而此时 instance != null, 曾经创立过实例,所以 线程 B 就不会创立实例了。

看起来没有什么问题,但实际上也有可能问题呈现在 new 操作上,原本 new 操作应该是:

  • 1、调配一块内存。
  • 2、在内存上初始化对象。
  • 3、内存的地址赋值给 instance 变量。

但理论优化后的执行程序却是如下:

  • 1、调配一块内存。
  • 2、将内存地址赋值给 instance 变量。
  • 3、在内存上初始化对象。

优化之后会产生什么问题呢?首先假如 线程 A 先执行 getInstance 办法,也就是先执行 new 操作,当执行完指令 2 时产生了线程切换,切换到 线程 B 上,此时线程 B 执行 getInstance 办法,执行判断时会发现 instance != null, 所以就返回instance,而此时的instance 是没有初始化的,如果这时拜访 instance 就可能会触发空指针异样。

总结

操作系统进入多核、多过程、多线程时代,这些降级会很大的进步程序的执行效率,但同时也会引发 可见性 原子性 有序性 问题。

  • 多核 CPU,每个 CPU 都有各自的 CPU 缓存,每个线程更新变量会先同步在CPU 缓存 中,而此时其余线程,无奈获取最新的 CPU 缓存值,这就是不可见性。
  • count += 1含有多个 CPU 指令。当产生线程切换,会导致原子问题。
  • 编译优化器会调整程序的执行程序,导致在多线程环境,线程切换带来有序的问题。

开始学习并发,常常会看到 volatilesynchronized 等并发关键字,而理解并发编程的有序性、原子性、可见性等问题,就能更好的了解并发场景下的原理。

参考

可见性、原子性和有序性问题:并发编程 Bug 的源头

正文完
 0