Java并发编程之可见性、原子性和有序性解析

2019-07-21  本文已影响0人  花醉霜寒

古语有云,天上的一天,地上的一年,当年玉帝妹子私自下凡间,与杨天佑结为夫妇,有一天玉帝突然想起,妹妹呢,咋好几天没见到她了,虽然在天上只是几天时间,而在凡间玉帝妹子和杨君都有了仨孩子啦,这也才有了后来二郎真君劈山救母的故事。

劈山救母

言归正传,其实在计算机的世界里同样存在这样的矛盾,那就是CPU、内存和I/O设备之间速度差异。根据木桶效应,即一个木桶能装多少水取决于它最短的那块木板,I/O设备的瓶颈制约着软件的性能。

为了应对这个问题,从计算机体系结构层面、操作系统层面和程序编译层面都有相应的优化措施:
1)计算机体系层面,CPU增加缓存,均衡了CPU与内存之间的速度差异;
2)操作系统层面,引入了进程和线程,以时分复用的方式均衡CPU与I/O设备之间的速度差异;
3)编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

然而没有一劳永逸的方法,在享受这些便利的时候,我们也要承受它给我们带来的困扰,这些优化就是很多并发编程中诡异问题的根源所在,主要表现为三个方面:可见性问题、原子性问题和有序性问题。

1. 可见性问题

可见性,即一个线程对共享变量的修改,另一个线程能够立即看到,在多核时代,每个CPU都有自己的缓存,如下图所示,线程1操作CPU01的缓存,线程2操作CPU02的缓存,显然线程1对共享变量的操作对于线程2来说就不具备可见性。

可见性问题

我们可以通过如下程序验证这个问题

public class CurrencyTest {
    int count = 0;
    public void countAdd() {
        for(int i = 0; i < 10000; i++)
        count+=1;
    }
    public static void main(String[] args) throws InterruptedException {
        CurrencyTest currencyTest = new CurrencyTest();
        Thread thread1 = new Thread(() -> currencyTest.countAdd());
        Thread thread2 = new Thread(() -> currencyTest.countAdd());
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(currencyTest.count);
    }
}

运行上面的代码,其结果是10000到20000之间的一个随机数,这就是可见性问题引起的,每个CPU中都有共享变量count,自己玩自己的,每个线程都是根据各自CPU中的缓存值操作,最后就会出现数据不一致的问题。

2. 原子性问题

即使是在单核系统中,仍然能够边上网边听歌,这就得益于多线程时分复用的出现,当年Unix也是因为这个而名扬天下的。它解决了I/O等待时间长阻塞线程而浪费CPU资源的问题,多线程时分复用的原理如下图所示,将CPU划分为时间片,在一个时间片内,如果一个进程进行一个 IO 操作,例如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让 CPU的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。

原子性问题

Java中的并发编程是基于多线程的,会涉及到任务切换(任务切换通常指的就是线程切换),线程切换也是诡异Bug的源头之一,线程的切换可以发生在程序运行的任何一条指令,注意这里强调的是指令而不是Java中的一条代码,例如我们熟悉的i++操作就是🈶️三条指令完成的,
1)把变量i的值加载到CPU的寄存器中;
2)在寄存器中执行+1操作;
3)将结果写入内存,缓存机制可能导致结果写入CPU缓存而不是内存中。
由于存在线程的切换,i++的操作可能被中断,引起数据不一致的问题,我们把一个或者多个操作在CPU中执行的过程中不被中断的特性称为原子性。

3. 有序性问题

编译阶段的指令重排序会导致顺序性问题,从硬件架构上来看,指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送各相应电路单元处理的方式,但并不是说指令任意排序,指令重排序不能影响正确的执行结果。周志明老师《深入理解Java虚拟机》一书中总结道:Java程序中天然的有序性可以总结为一句话,如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的,前半句指的是线程内表现为串行的语义,后半句指的是指令重排序现象和工作内存与贮存同步延迟的现象。举个例子来说明,单例模式的双重检查锁

public class SingletonDemo {

    private /** volatile*/ static SingletonDemo instance;

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

这里为什么不能缺少volatile关键字呢?主要在于instance = new Instance()这个语句实际上包含了三个操作:
1)分配对象内存空间;
2)初始化对象;
3)设置instance指向刚分配的内存地址

前文中提到一个线程内看其他线程中的指令执行顺序可能是乱序的,有可能是如下顺序:
1)分配对象内存;
2)设置instance指向刚分配的内存;
3)初始化对象
那么其他线程可能取得的是没有初始化的对象,出现诡异的并发bug。

总结

本文主要分析了并发编程中诡异bug的三个来源,可见性问题、原子性问题和有序性问题。

上一篇下一篇

猜你喜欢

热点阅读