Java并发编程(一):可见性、有序性和原子性

208 阅读3分钟

可见性、有序性和原子性是三个十分重要的并发编程概念,可以说所有的并发编程bug都是由这三个性质没有保证引起的,那么秉持着解决问题先要理解问题的思想,我们先来了解下这三个性质。

可见性、有序性和原子性分别都是什么?

  • 可见性:指的在多并发程序中,一个线程对一个共享变量的修改另一个线程是能够知道的。
  • 有序性:指的是代码指令执行是有严格顺序的。
  • 原子性:指的是一件事要么没做,要么就做完,中间是不能打断的,当然这个定义是比较浅显的,后面的文章会深入说明。

都是什么导致了这三个性质的丢失呢?

  • cpu的缓存让可见性失效:单核cpu多线程还不会有这个问题,但一旦到了多核多线程,这个问题就暴露出来了:两个在不同cpu上的线程都会在他们各自的cpu缓存中操作共享变量,导致这两个线程之间共享变量是不互通的。
  • 编译器的优化破坏了有序性:编译器对代码会进行一定程度上的优化,这其中就包括了指令重排序,破坏了原本代码的有序性。
  • 线程切换破坏了原子性:比如i++这样的代码,翻译成cpu指令其实分三步:
    1. 读取i的值
    2. 将i+1
    3. 把第二步的值赋给i 可以想到若在第二步时发生了线程切换,然后另一个线程读取了i,这个时候i还是原来的值,这就发生了错误。
    我们来看两个经典的例子: 例1:
   public class Test {
       private int count = 0;

       public void addCount(int finalCount){
           for (int i = 0; i < finalCount; i++){
               count++;
           }
       }

       /**
        * 测试结果(testNum=10000):
        *          20000: 16274
        *          20000: 15446
        *          20000: 15901
        *测试结果(testNum=100000000):
        *          200000000: 102287062
        *          200000000: 100043983
        *          200000000: 100138896
        * 可以看到测试数据越大,可见性对于结果的影响就愈加明显。
        * 因为在测试数据小的时候,如10000,由于线程启动有时间差,
        * 在第二个线程启动时,第一个线程几乎已经完成了累加,第二个
        * 线程就会看到一个接近10000的数字,会有类似串行的效果,可
        * 见性也就影响不大。
        * 而当测试数据变大到像100000000,第二个线程启动时第一个
        * 线程还刚刚开始,故可见性影响十分明显。
        */
       private static void testVisibility(){
           final Test t = new Test();
           int testNum = 100000000;
           Thread th1 = new Thread(()->{t.addCount(testNum);});
           Thread th2 = new Thread(()->{t.addCount(testNum);});

           th1.start();
           th2.start();

           try {
               th1.join();
               th2.join();
           } catch (InterruptedException e) {
               e.printStackTrace();
           }

           System.out.println(2*testNum+": "+ t.count);

       }

       public static void main(String[] args) {
           Test.testVisibility();
       }
   }

该例则体现了可见性的破坏:不同核的线程对共享变量进行的操作都是对外不可见的。 例2:

public class Singleton {
    static Singleton instance;
    /**
     * 该方法在多线程环境下会有问题:
     * 假设有两个线程A和B,线程A先进入,
     * 判断instance == null,拿到了锁,
     * 开始对instance进行new操作,可new操作分三步走:
     * 1. 分配一块内存M
     * 2. 将M的地址赋给instance
     * 3. 在内存块M上初始化变量
     * 如果线程A在第二步到第三步时线程切换到了B,
     * 那么B会判断这个instance != null
     * 从而返回了一个未初始化的instance。
     */
    public static Singleton getInstance() {
        if (instance == null){
            synchronized(Singleton.class) {
                    if(instance == null){
                        instance = new Singleton();
                    }
            }
        }
        return instance;
    }
}

这个例子体现了有序性的破坏:指令重排导致初始化变量在赋值地址之后和原子性的破坏:在初始化变量之前发生线程切换,这样另一个线程就拿到了未初始化好的变量