Java-Note-Concurrency-volatile关键字

  • volatile关键字

    • 要理解volatile为什么能确保可见性, 就要先理解Java中的内存模型. 关于内存模型详见本目录下的Java-Note-JVM-内存模型.md
    • Java内存模型规定了所有的变量都存储在主内存中. 每条线程还有自己的工作内存用来保存被该线程所使用的变量(这些变量是从主内存拷贝而来).
    • 线程对变量的所有操作(读取/赋值)都在工作内存中进行.
    • 不同的线程之间无法直接访问对方工作内存中的变量, 线程间变量值的传递需要通过主内存来完成
    • 这种内存模型产生了多线程编程中的数据”脏读”等问题.
      • 例: 两个线程同时对一个共享变量进行+1操作, 就有可能出现这种情况:
        1. 初始时, 两个线程把值分别读入自己的工作内存
        2. 然后线程1进行+1操作, 把结果写入共享内存
        3. 线程2进行+1操作, 把结果写入共享内存, 此次的结果就覆盖了2中的结果, 导致结果出错
    • 解决”脏读”问题:

      1. 了解并发编程的三大概念: 原子性/可见性/有序性, 如果三个性质中有一个没有保证, 就有可能导致程序运行不正确

        1. 原子性:

          • 定义: 一个操作(如i = 10)或多个操作(如synchronized修饰的代码块)
          • Java中的原子性:

            • 规则:

              1. 对基本数据类型的变量的读取和赋值操作是原子性操作. 例:

                1
                2
                3
                4
                x = 10
                y = x
                x++
                x = x + 1

                以上四条语句中只有语句1是原子性操作:

              • 语句1是直接将数值10赋值给x, 也就是线程执行这个语句会直接将10写入工作内存
              • 语句2先要读取x的值, 再将x的值写入工作内存
              • 34包含三个操作: 读取x的值/进行+1操作/赋新值给x
          • 总结: Java内存模型只保证基本读取和赋值是原子性操作, 大范围操作的原子性, 需要通过synchronizedLock来实现
        2. 可见性:

          • 定义: 可见性是指当多个线程访问同一个变量时, 一个线程修改了这个变量的值, 其它线程能够立即看得到修改的值.
          • 例子:

            1
            2
            3
            4
            5
            6
            //线程1执行的代码
            int i = 0;
            i = 10;

            // 线程2执行的代码
            j = i

            当线程1执行i = 10这句时, 会先把i的初始值加载到工作内存中, 然后赋值为10, 此时在线程1的工作内存当中i的值变为10, 但没有立即写入主存当中.
            此时线程2执行j = i, 则j的值为0, 结果出错
            这就是可见性问题, 线程1对变量i修改了以后, 线程2没有立即看到线程1的修改

          • Java中的可见性:
            • 规则:
              • Java提供了volatile关键字来保证可见性
              • 当一个共享变量被volatile修饰时, 它会保证修改的值会立即被更新到主存, 当有其它线程需要读取时, 它会去内存中国读取新值.
              • 通过synchronized和Lock也能保证可见性, 在释放锁之前会将对变量的修改刷新到主存当中. 因此可以保证可见性
        3. 有序性

          • 定义: 程序执行的顺序按照代码的先后顺序执行
          • 规则: JVM在真正执行一段代码时会发生指令重排序

            • 指令重排序:

              • 定义: 处理器为了提高程序的运行效率, 可能对输入的代码进行优化, 它不保证程序中的各个语句的执行先后顺序一致, 但是会保证程序最终执行结果和代码顺序执行的结果一致
                • 如何保证: 处理器在进行重排序时会考虑指令间的数据依赖性, 如果instruction2必须用到instruction1的结果, 那么处理器会保证instruction1在instruction2之前执行
              • 多线程中的指令重排序

                • 例子:

                  1
                  2
                  3
                  4
                  5
                  6
                  7
                  8
                  9
                  // 线程1:
                  context = loadContext();
                  inited = true;

                  // 线程2:
                  while(!inited) {
                  sleep()
                  }
                  doSomethingwithconfig(context);

                  上述代码中, 由于语句1和2没有数据依赖性, 可能会重排序, 如果发重排序, 在线程1中先执行语句2, 而此时线程2会认为初始化已完成, 而执行doSomethingwithConfig(context), 而此时context没有被初始化, 从而导致程序错误

                • 总结:
                  • 从上面的例子可以看出, 指令重排序不会影响单个线程的执行, 但是会影响线程并发执行的正确性
                  • 如果一个线程依赖于另一个线程中语句的执行次序, 如果不做处理, 则并发时会出错.
          • Java中的有序性:
            • 规则:
              • 在Java内存模型中, 允许编译器和处理器对指令进行重排序, 但是重排序过程不会影响到单线程程序的执行, 却会影响到多线程并发执行的正确性
              • 通过volatile关键字可以保证一定的有序性, 另外可以通过synchronized和Lock保证有序性
              • Java内存模型先天有序性: happens-before原则, 如果两个操作的执行次序无法从happens-before原则推导出来, 则无法保证其可见性, 虚拟机可以随意对他们进行重排序: 参考文档
                • happens-before:
                  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
                    • 解释: 虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,但是虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。
                  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
                    • 解释: 一个锁如果处在被锁定的状态, 必须先对锁进行释放操作, 再进行lock操作
                  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
                    • 解释: 如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
                  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作
                  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
                  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
                  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
                  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
      2. 深入了解volatile关键字

        1. volatile保证可见性: 当一个共享变量(类的成员变量/类的静态成员变量)被volatile修饰后, 有两层语义:
          1. 保证不同线程对这个变量进行操作的可见性
            • 注意: JVM只能保证对volatile修饰的变量在加载时得到最新值, 而如果在变量加载后线程被打断而共享变量发生了变化, 则被打断的线程中的工作变量不会被改变
            1. 使用volatile关键字会强制将修改的值立即写入主存
            2. 使用volatile关键字, 当线程2进行修改时, 会导致线程1的工作内存中缓存变量的缓存行无效(反映到硬件层的话, 就是cpu的L1或L2缓存中对象的缓存行无效)
            3. 由于线程1中的estop工作内存的变量的缓存行无效, 所以线程1会再次到主存读取变量, 从而读取最新的值
          2. 禁止进行指令重排序
        2. volatile不保证原子性

          • 例子:

            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13
            14
            15
            16
            17
            18
            19
            20
            21
            22
            23
            public class Test {
            public volatile int inc = 0;

            public void increase() {
            inc++;
            }

            public static void main(String[] args) {
            final Test test = new Test();
            for(int i=0;i<10;i++){
            new Thread(){
            public void run() {
            for(int j=0;j<1000;j++)
            test.increase();
            };
            }.start();
            }

            while(Thread.activeCount()>1) //保证前面的线程都执行完
            Thread.yield();
            System.out.println(test.inc);
            }
            }

            在上述代码中, 由于volatile不保证原子性, 如果线程1在将inc的值存入工作区后被线程2打断, 线程2改变inc的值并存入共享变量, 由于JVM只能保证对volatile修饰的变量在加载时得到最新值, 而如果在变量加载后线程被打断而共享变量发生了变化, 则被打断的线程中的工作变量不会被改变, 则此时再继续运行线程1, 其依然使用原来的变量, 因此会报错

          • 解决方案: 使用synchronized或Lock, 也可以通过AtomicInteger
            • AtomicInteger: JDK1.5下的java.util.concurrent.atomic包提供了一些原子操作类, 即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。
        3. volatile保证有序性

          • volatile关键字禁止重排序, 从而一定程度上保证有序性

            1. 当程序执行到volatile变量的读或写操作时, 在其前面的操作的更改肯定全部已经进行, 且结果对后面的操作可见; 在其后面的操作肯定还没有进行
            2. 在进行指令优化时, 不能讲在对volatile变量的读/写操作放在后面语句的前面, 也不能把后面的语句放在其前面执行

              • 例子:

                1
                2
                3
                4
                5
                6
                7
                8
                //x、y为非volatile变量
                //flag为volatile变量

                x = 2; //语句1
                y = 0; //语句2
                flag = true; //语句3
                x = 4; //语句4
                y = -1; //语句5

                语句4不能放在1/2前面, 但1,2之间的顺序不做保证. 同时执行3时, 1/2一定已经执行完毕, 且1/2的执行结果对3/4/5都是可见的

      3. volatile的实现原理

        • 可见性: 处理器为了提高处理速度,不直接和内存进行通讯,而是将系统内存的数据独到内部缓存后再进行操作,但操作完后不知什么时候会写到内存。

          如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存。 这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。

          但这时候其他处理器的缓存还是旧的,所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的。

        • 有序性: Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。