Java并发编程系列(一)——Volatile

LZ水平有限,如果发现有错误之处,欢迎大家指出,或者觉得那块说的不好,欢迎建议。希望和大家一块讨论学习
LZ QQ:1310368322


在讨论Volatile关键字之前,我们先来聊聊并发
什么是并发?为什么需要并发?并发会产生什么问题、是如何解决的?接下来我们就看看这些问题
什么是并发?
并发简单来说就是在一个CPU上(也可以是多个CPU),在一段时间之内,同时启动了多个进程或线程,在宏观上看好像多个进程或线程在同时执行,其实在一个确定的时刻,一个CPU上只有一个线程或者进程在运行。
为什么需要并发?
①提高资源利用率:如果说进程之间串行执行的话,如果一个进程在进行磁盘I/O操作的话,因为CPU的执行速度远大于磁盘I/O的读写速度,所以这时候如果是串行的话,当前进程就会占着CPU等待磁盘的读取结果,然后交给CPU进行运算。有的人可能会问,如果终止当前进程,那这个磁盘读取还会进行吗?答案是肯定的。当进程进入IO操作,OS会启动DMA从硬盘copy数据到内存,这个时候线程就可以让出CPU,让别的线程来运行了。注:DMA传输不需要CPU的介入,只需要CPU“通知”一下DMA就可以了
②划分模块:实现目标和时机的解耦,它把我们要做什么(目标)和是什么时候做(时机)分离开来
并发会产生什么问题?
当多个线程对同一共享资源在进行操作的时候,就会出现数据的“误读”或“误写”,比如共享变量 i = 0; 线程 A 和 线程 B都是给 变量 i 做加 1 操作,线程在给 变量加一的时候,先把变量 i 从内存中读到 寄存器中,然后加 1, 此时 寄存器的值为1,这时候线程 A 时间片到期,切换线程 B,线程 B从内存中读取共享变量 i 的值【此时变量 i 的值还是 0】,在对变量 i 进行读取后,在寄存器中加 1 ,赋值给内存后,共享变量 i 变为 1,这个时候 线程 A 来把它之前保存的寄存器中的值给了寄存器,然后又赋给了内存,这个时候,内存的值还是 1,这个结果显然是错误的。 大概过程如下
这里写图片描述
关于进程/线程的并发的详细问题,请参考操作系统之进程与线程
操作系统在处理这类问题时,会有一些方案,比如加锁、信号量等等。
接下来我们聊聊Java中的Volatile
可见性
首先我们看看Java的内存模型
这里写图片描述
这里的工作内存其实相当于CPU中的缓存,上面的寄存器也是一种缓存。
每一个线程在对主内存中的变量值做更改的时候,都要拷贝一份到自己的工作内存中去,然后才去运算,这就导致了一个很严重的问题—对于每一个线程而言,变量的修改对另一个线程“不可见”,什么是不可见呢?其实这个和前面的例子非常像,这里的线程私有工作内存就相当于线程中维护的寄存器,还是刚才的那个例子, 假如在 主内存中有 一个 共享变量 i = 0, 线程 1 和 线程 2 都要对共享变量做加一操作,首先线程1从主内存中copy变量 i 的值到工作内存1中,然后对其进行 加一 操作,假设该计算机为双核,于此同时,线程2也从主内存中copy共享变量 i, 线程一对其进行加一操作完成之后,将主内存的i值进行更新,但是这个时候线程2完全不知道,因为线程1对线程2不可见,这就导致了线程2对它之前从主内存中获得的变量 i = 0 进行加一,而不是对线程1对变量 i 的具体操作后的变量加一,这就出现了错误。
我们看一段代码来验证一些我们的这个理论:

public class TestThread extends Thread{

    boolean stop = false;// 共享变量
    int value = 0;  
    public void run(){
        while(!stop){
            value++;
        }
    }

    public static void main(String[] args) throws 
InterruptedException {

        TestThread thread = new TestThread();
        thread.start(); // 让在主线程中的 新建的线程就绪
        Thread.sleep(2000);// 让正在执行的线程休眠
        thread.stop = true;// 主线程在自己的工作内存中设置共享变量 stop 为 true,然后写回主内存,然而子线程“这个时候不知道”,子线程一直在自己的工作内存中对变量进行操作(value++)
        System.out.println("value= " + thread.value);
        Thread.sleep(2000);
        System.out.println("value= " + thread.value);

    }
}

这里写图片描述
大家可以看到执行的结果是两个value的值不相同,这是因为main线程创建的子线程(thread),他有一份变量的拷贝[创建时stop的值为false],然后就一直在自己的工作内存中工作,不再去主内存读值了,后来main线程在执行的时候,他也要从主内存中copy一份到自己的工作内存中来。然后进行操作[thread.stop],然后把stop的值写回主内存,但是子线程不会在从主内存存读取数据了,这个数据对子线程不可见。
最后值得一提的是,这个程序是死循环,这是因为子线程一旦创建,就和main线程没有关系了,main线程执行完,但是子线程里的while死循环使得子线程一直循环着
如果给共享变量stop加上volatile关键字,那么,任何一个线程修改了它,其他线程都是立即得知的,即:volatile变量对所有线程是立即可见的,当读写一个volatile变量的时候,每次都从主内存读写,而不是工作内存
原子性
volatile修饰的变量虽然能保证变量的可见性,但不保证对变量的操作时原子性的,这意味着对于并发情况下加了volatile关键字有可能出错。这是因为有的操作并不是原子性的,下来我们来看一段代码

package com.thread.Volatile;

public class TestAtomicity {

    public static volatile int value = 0;

    public static void increase(){
        value++;
    }

    public static void main(String[] args) {

        final TestAtomicity test = new TestAtomicity();

        for (int i = 0; i < 10; i++) {// 启动十个线程
            new Thread(){
                public void run(){
                    for (int j = 0; j < 1000; j++) {
                        increase();
                    }
                }
            }.start();// 让每个线程对value累加1000次
        }

        // 如果还有子线程在运行,主线程就让出CUP,直到所有的子线程都运行完了,主线程再继续往下执行
        while(Thread.activeCount()>1){// 线程的活动数
            Thread.yield();// 使当前线程从运行状态变为就绪状态
        }

        System.out.println("number: " + test.value);
    }
}

这个程序的某一次运行结果:number: 9219
本来的结果应该是10000,为什么会小于10000呢?这里就是因为value++这一句话在字节码层次(更准确地说是在机器码层次)不是单个的指令,而是多个指令,也就是说这个操作不是原子性的。
我们用javap 工具反编译这个类,得到字节码,其中increase函数的执行的字节码如下:
这里写图片描述
其中我们可以看到,原本在源码中写的一句value++,在编译成字节码后并不是一个指令,而是多条指令,简单地介绍一下:
①getstatic: 指令是获取指定类的静态域,并将其压入栈顶,这里就是获取value的值,然后压入操作数栈
②iconst_1: 将int型1推送至栈顶,就是把 1 压入操作数栈
③iadd: 将操作数栈中的元素弹出,相加,并将计算结果压入栈中
④putstatic: 为指定的类的静态域赋值,就是给把栈顶的值赋给value
我们从字节码的层次去分析它,很容易就知道这样的操作在并发的情况下是不安全的,当一个线程去执行increase++的时候,它先得到value的值(比如10)[volatile关键字保证了此时的value值是正确的],压入自己的操作数栈,然后另一个线程对value操作之后把值写回了主内存,虽然value对所有变量是可见的,但是这个时候,第一个线程没有去读value的值,后面的iconst_1 iadd putstatic都是执行引擎在操作,根本不知道value的值变了,所以后面的putstatic就有可能把错误的值放到value中
过程如下:
这里写图片描述
我们看看这个错误的本质是什么?其根本原因就是value++不是原子操作,导致线程从主内存中读取值之后,后面还有几个指令,就在执行引擎执行后面的指令的时候,其他线程会改变主内存的值
这里我们在字节码层次对value++做了分析,其实跟严谨的做法是在汇编指令层次进行分析,但是在字节码层次足以说明问题,在此就不详细讨论了
有序性
volatile的第二个语义是禁止指令重排序优化
什么是指令重排序?其实很简单,就是说我们的代码被翻译成指令的时候,CPU会将这些指令在不影响其最终结果的前提下,对这些指令进行重新排序,举个很简单的例子,假设内存地址为0x001地址处的值为1,指令A对地址0x001处的内存加1,指令B对地址为0x001处的内存加2,指令A和B的执行顺序不影响最终的结果(4),所以CPU就可以对其进行重新排列。而一个变量如果加上volatile关键字的时候,就禁止了这种重排,更准确地说,不允许对一个volatile变量的赋值操作 与其之前的任何读写操作 重新排序(之前的指令如果不互相依赖就可以进行重排序),也不允许将 读取一个volatile 变量的操作与其之后的任何读写操作重新排序,这句话有点拗口,其实是说,对volatile变量的赋值操作和 读取一个volatile变量的操作这两个指令相当于一个屏障,不管前面的操作如何,我都要保证我赋值操作和读取操作的位置不能变
下面我们看一个实例来分析一下

package com.thread.Volatile;

public class Singleton {

    private static  volatile Singleton instance;

    private Singleton(){    
    }

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

这段代码看起来没有问题,其实是有问题的,原因就在于 new Singleton();这句话,这句话其实真正执行的时候是要进行以下步骤的:
1. 分配内存
2. 调用Singleton的构造函数
3. 给instance赋值,指向新创建的对象
如果给 instance 引用不加 volatile 关键字,那么上面的几步就有可能不按顺序执行
如下图:
这里写图片描述
这样的话,就会出现线程1执行了给instance赋值但没有调用Singleton构造函数,如果线程2执行的话就会出错(intance不为null,直接使用instance),如果加上 volatile 关键字就确保了在给 instance 引用赋值的时候,前面的任何操作已经执行完毕,这里的给 instance 引用赋值就相当一个屏障,任何之前的操作不能越过这个屏障,必须在之前执行完

总结: Volatile关键字有两个语义:
① 保证 所修饰的变量对所有线程可见
② 保证 修饰的变量的赋值以及读取操作的指令不可重排,也就是禁止指令重排序

注:本博客部分选自《深入理解Java虚拟机 》和 刘欣老师(码农翻身公众号的作者)的讲课

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章