【漫画】JAVA并发编程三大Bug源头(可见性、原子性、有序性)

原创声明:本文转载自公众号【胖滚猪学编程】​java

某日,胖滚猪写的代码致使了一个生产bug,奋战到凌晨三点依旧没有解决问题。胖滚熊一看,只用了一个volatile就解决了。并告知胖滚猪,这是并发编程致使的坑。这让胖滚猪坚决了要学好并发编程的决心。。因而,开始了咱们并发编程的第一课。编程

序幕

con2

BUG源头之一:可见性

刚刚咱们说到,CPU缓存能够提升程序性能,但缓存也是形成BUG源头之一,由于缓存能够致使可见性问题。咱们先来看一段代码:缓存

private static int count = 0;
public static void main(String[] args) throws Exception {
    Thread th1 = new Thread(() -> {
        count = 10;
    });
    Thread th2 = new Thread(() -> {
        //极小几率会出现等于0的状况
        System.out.println("count=" + count);
    });
    th1.start();
    th2.start();
}

按理来讲,应该正确返回10,但结果却有多是0。微信

一个线程对变量的改变另外一个线程没有get到,这就是可见性致使的bug。一个线程对共享变量的修改,另一个线程可以马上看到,咱们称为可见性。多线程

那么在谈论可见性问题以前,你必须了解下JAVA的内存模型,我绘制了一张图来描述:并发

JAVA_

主内存(Main Memory)性能

主内存能够简单理解为计算机当中的内存,但又不彻底等同。主内存被全部的线程所共享,对于一个共享变量(好比静态变量,或是堆内存中的实例)来讲,主内存当中存储了它的“本尊”。优化

工做内存(Working Memory)操作系统

工做内存能够简单理解为计算机当中的CPU高速缓存,但准确的说它是涵盖了缓存、写缓冲区、寄存器以及其余的硬件和编译器优化。每个线程拥有本身的工做内存,对于一个共享变量来讲,工做内存当中存储了它的“副本”。线程

线程对变量的全部操做都必须在工做内存中进行,而不能直接读写主内存中的变量。
线程之间没法直接访问对方的工做内存中的变量,线程间变量的传递均须要经过主内存来完成

如今再回到刚刚的问题,为何那段代码会致使可见性问题呢,根据内存模型来分析,我相信你会有答案了。当多个线程在不一样的 CPU 上执行时,这些线程操做的是不一样的 CPU 缓存。好比下图中,线程 A 操做的是 CPU-1 上的缓存,而线程 B 操做的是 CPU-2 上的缓存
image

因为线程对变量的全部操做都必须在工做内存中进行,而不能直接读写主内存中的变量,那么对于共享变量V,它们首先是在本身的工做内存,以后再同步到主内存。但是并不会及时的刷到主存中,而是会有必定时间差。很明显,这个时候线程 A 对变量 V 的操做对于线程 B 而言就不具有可见性了 。

con3_1

private volatile long count = 0;
​
private void add10K() {
    int idx = 0;
    while (idx++ < 10000) {
        count++;
    }
}
​
public static void main(String[] args) throws InterruptedException {
    TestVolatile2 test = new TestVolatile2();
    // 建立两个线程,执行 add() 操做
    Thread th1 = new Thread(()->{
        test.add10K();
    });
    Thread th2 = new Thread(()->{
        test.add10K();
    });
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    // 介于1w-2w,即便加了volatile也达不到2w
    System.out.println(test.count);
}
​

con3_2

原创声明:本文转载自公众号【胖滚猪学编程】​

原子性问题

一个不可分割的操做叫作原子性操做,它不会被线程调度机制打断的,这种操做一旦开始,就一直运行到结束,中间不会有任何线程切换。注意线程切换是重点!

咱们都知道CPU资源的分配都是以线程为单位的,而且是分时调用,操做系统容许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操做系统就会从新选择一个进程来执行(咱们称为“任务切换”),这个 50 毫秒称为“时间片”。而任务的切换大多数是在时间片断结束之后,

_

那么线程切换为何会带来bug呢?由于操做系统作任务切换,能够发生在任何一条CPU 指令执行完!注意,是 CPU 指令,CPU 指令,CPU 指令,而不是高级语言里的一条语句。好比count++,在java里就是一句话,但高级语言里一条语句每每须要多条 CPU 指令完成。其实count++包含了三个CPU指令!

  • 指令 1:首先,须要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:以后,在寄存器中执行 +1 操做;
  • 指令 3:最后,将结果写入内存(缓存机制致使可能写入的是 CPU 缓存而不是内存)。

小技巧:能够写一个简单的count++程序,依次执行javac TestCount.java,javap -c -s TestCount.class获得汇编指令,验证下count++确实是分红了多条指令的。

volatile虽然能保证执行完及时把变量刷到主内存中,但对于count++这种非原子性、多指令的状况,因为线程切换,线程A刚把count=0加载到工做内存,线程B就能够开始工做了,这样就会致使线程A和B执行完的结果都是1,都写到主内存中,主内存的值仍是1不是2,下面这张图形象表示了该历程:

_

image

原创声明:本文转载自公众号【胖滚猪学编程】​

有序性问题

JAVA为了优化性能,容许编译器和处理器对指令进行重排序,即有时候会改变程序中语句的前后顺序:

例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”只是在这个程序中不影响程序的最终结果。

有序性指的是程序按照代码的前后顺序执行。可是不要望文生义,这里的顺序不是按照代码位置的依次顺序执行指令,指的是最终结果在咱们看起来就像是有序的。

重排序的过程不会影响单线程程序的执行,却会影响到多线程并发执行的正确性。有时候编译器及解释器的优化可能致使意想不到的 Bug。好比很是经典的双重检查建立单例对象。

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

你可能会以为这个程序完美无缺,我两次判断是否为空,还用了synchronized,刚刚也说了,synchronized 是独占锁/排他锁。按照常理来讲,应该是这么一个逻辑:
线程A和B同时进来,判断instance == null,线程A先获取了锁,B等待,而后线程 A 会建立一个 Singleton 实例,以后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时加锁会成功,而后线程 B 检查 instance == null 时会发现,已经建立过 Singleton 实例了,因此线程 B 不会再建立一个 Singleton 实例。

但多线程每每要有很是理性的思惟,咱们先分析一下 instance = new Singleton()这句话,根据刚刚原子性说到的,一句高级语言在cpu层面实际上是多条指令,这也不例外,咱们也很熟悉new了,它会分为如下几条指令:
一、分配一块内存 M;
二、在内存 M 上初始化 Singleton 对象;
三、而后 M 的地址赋值给 instance 变量。

若是真按照上述三条指令执行是没问题的,但通过编译优化后的执行路径倒是这样的:
一、分配一块内存 M;
二、将 M 的地址赋值给 instance 变量;
三、最后在内存 M 上初始化 Singleton 对象

假如当执行完指令 2 时刚好发生了线程切换,切换到了线程 B 上;而此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,因此直接返回 instance,而此时的 instance 是没有初始化过的,若是咱们这个时候访问 instance 的成员变量就可能触发空指针异常,如图所示:

_

con4

总结

并发程序是一把双刃剑,一方面大幅度提高了程序性能,另外一方面带来了不少隐藏的无形的难以发现的bug。咱们首先要知道并发程序的问题在哪里,只有肯定了“靶子”,才有可能把问题解决,毕竟全部的解决方案都是针对问题的。并发程序常常出现的诡异问题看上去很是无厘头,可是只要咱们可以深入理解可见性、原子性、有序性在并发场景下的原理,不少并发 Bug 都是能够理解、能够诊断的。
总结一句话:可见性是缓存致使的,而线程切换会带来的原子性问题,编译优化会带来有序性问题。至于怎么解决呢!欲知后事如何,且听下回分解。

原创声明:本文转载自公众号【胖滚猪学编程】​

本文转载自公众号【胖滚猪学编程】 用漫画让编程so easy and interesting!欢迎关注!形象来源于微信表情包【胖滚家族】喜欢能够下载哦~

相关文章
相关标签/搜索