Java并发专题(三)深入理解volatile关键字

前言

  上一章节简单介绍了线程安全以及最基础的保证线程安全的方法,建议大家手敲代码去体会。这一章会提到volatile关键字,虽然看起来很简单,但是想彻底搞清楚需要具备JMM、CPU缓存模型的知识。不要小看这个关键字,它在整个并发包(concurrent包)使用的非常广泛,掌握volatile关键字是非常重要的。

   如果你是一个急性子,请看下面3点就行:

  • 保证了多线程读取变量的可见性,一个线程修改volatile修饰的变量,另外一个线程会立即读取到新的值
  • 禁止指令重排序
  • volatile关键字不会像synchronized关键字一样造成线程阻塞,也就是说无锁

1.1 初识volatile关键字

  我先写一个例子,在主线程启动2个线程,一个线程负责写,一个线程负责读,读写的该变量就是共享变量,那么结果是你想的那样吗?

/**
 * volatile第一个演示Demo类。
 *
 * @author GrimMjx
 */
public class VolatileDemo1 {

    //i的初始值为0
    public static int i;
    //i的最大值为3
    public static int MAX = 3;

    public static void main(String[] args) {
        //读线程
        new Thread(() -> {
            int index = i;
            while (index < MAX) {
                if (i != index) {
                    System.out.println("i = " + i);
                    index = i;
                }
            }
        }).start();

        //写线程
        new Thread(() -> {
            int index = i;
            while (index < MAX) {
                System.out.println("new i = " + ++i);
                index = i;
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

  我贴上一份我执行的结果:

new i = 1
i = 1
new i = 2
new i = 3

  程序不会停,需要手动停。那么问题来了,为什么明明写入了i了,读线程还是无法读到新的i的值呢?读线程压根没有感知到i的变化!只要我们把变量i的定义改变一下,那么就可以解决这个问题。

public volatile static int i;

  改好之后再试一下,确实如我们预料的执行了且读线程也不会死循环。只是一个关键字的差别,会发生很大的不同。那接下来请带着疑问去学习。

new i = 1
i = 1
new i = 2
i = 2
new i = 3
i = 3

 

1.2 机器CPU

  所有的指令都是CPU寄存器完成的,CPU指令的过程中涉及到数据的写入和读取。CPU能访问的所有数据只能是RAM(计算机内存)。虽然CPU频率不断提升,但是RAM访问速度没有很大突破,因此CPU处理速度和内存的访问速度差距巨大,一次主内存的访问通常在几十到几百个甚至上千个时钟周期,一次L1高速缓存的读写需2个左右时钟周期,一次L2高速缓存的读写需要几十个时钟周期。

1.2.1 CPU Cache模型

  可以直观看到两边的速度严重不对等,于是有了在CPU和主内存之间增加缓存,最靠近CPU的缓存成为L1高速缓存,其次是L2,L3和主内存。我们先看一张各级缓存之间响应时间差距,以及内存到底有多慢。

 

  接下来我们看一下CPU Cache模型:

1.2.2 CPU缓存一致性

  缓存大大提高了访问速度,但是同时也引入了缓存不一致的问题,比如i++;这个操作。具体的过程如下:

  1. 读取主内存的i到CPU Cache中
  2. 将i+1
  3. 将结果写回CPU Cache
  4. 将数据刷新回主内存

  i++在单线程完全不会有问题,但是多线程的时候就会有问题,每个线程都有自己的工作内存(本地内存),如果在2个线程都执行i++;操作,A线程和B线程此时的工作内存中的i都是0,加1之后都变成1。最后经过计算再写入主内存可能结果还是1。这就是缓存不一致问题。如果想要解决这个问题,主流方法是通过缓存一致性协议(MESI协议)。这个协议的大致思想就是如果当CPU在操作Cache中的数据时,其他Cache也存在一份副本,那么会进行如下操作:

  1. 读取操作,不做任何处理,只是将Cache中的数据读取到寄存器
  2. 写入操作,发出信号通知其他CPU将其变量中的Cache line置为无效状态,其他CPU在进行该变量的读取时候不得不到主内存中再次获取

1.3 Java内存模型

  JMM指定了JVM和计算机RAM如何进行工作的,同时也决定了一个线程对共享变量的写入何时对其他线程可见,有以下几个要点:

  • 每个线程都有自己的工作内存,也成为本地内存
  • 工作线程只存储线程对共享变量的副本
  • 线程不能直接操作主内存,只能操作工作内存
  • 工作内存和JMM都是一个抽象的概念,实际并不存在,覆盖了寄存器,编译器优化等等

  主内存和工作内存的关系和CPU与CPUCache之间的关系是非常类似的,所以通过图示和讲解,我们发现理解volatile关键字会比synchronized关键字困难很多,需要了解机器CPU还有JMM。volatile在JDK5以后的concurrent包运用非常广泛,所以掌握volatile关键字很重要。

 

1.4 深入理解volatile关键字

  说到并发,有三大特性,原子性,有序性和可见性,那我们从三个方面来介绍

1.4.1 原子性

  volatile不具备原子性

  原子性的意思就是在一次操作中,所有的操作全部执行或者都不执行,就像名字一样是不可分割的。Java中,对变量的读取和赋值操作都是源自的,但是多个原子性的操作在一起,不一定是个原子操作。JMM只保证了基本的读取和赋值的原子性,其他的均不保证。说回volatile,如果在上一章节的UnsafeAdd的例子,用volatile修饰变量i,是否可以解决多线程并发问题呢,结果是不可以的,可以自己去试试。

  就像是i++操作,他其实包含了3步骤

  1.从主内存获取i,缓存到工作内存

  2.在工作内存中进行+1

  3.刷回主内存。

  这也就是刚刚说的多个原子性的操作在一起,不一定是个原子操作

  Java中想要保证原子性,需要使用synchronized关键字,concurrent包的lock,原子封装类和循环CAS的方式(原子变量是一种更好的volatile,后面会讲到)

1.4.2 可见性

  volatile具备可见性

  读取:当一个变量被volatile关键字修饰时,当其他线程对此变量进行了修改,则会迫使其他线程的工作内存中的该变量失效,所以必须从主内存重新获取。(使用的是机器指令lock)

  写入:当然是先修改工作内存,修改后立即将其刷新到主内存中。

  Java中volatile,synchronized关键字和显式锁lock都保证可见性

1.4.3 有序性

  volatile具备有序性

  首先volatile遵循happens-before原则:对一个变量的写操作要早于这个变量之后的读操作。也就是说,如果一个变量使用volatile关键字修饰,一个线程对这个变量进行写操作,另外一个线程对这个变量进行读操作。那么写操作肯定要先发生于读操作。

  volatile对顺序性非常霸道,直接禁止JVM和处理器进行指令重排序,但是对于volatile前后无依赖关系的执行可以随便排序。

  Java中volatile,synchronized关键字和显式锁lock都保证有序性

 

1.5 volatile的正确打开姿势

  • 确保它们所引用状态的可见性
  • 标识一些重要的程序生命周期事件发生(init,destroy)
  • 确保只有一个线程更新变量的值
  • 不会用就不要用:)

 

posted @ 2018-12-25 22:15  GrimMjx  阅读(431)  评论(0编辑  收藏  举报