headerphoto

每日一问:谈谈 volatile 关键字

2020-02-24 00:51

  前面我们讲到:Java 内存模型分为了主内存和工作内存两部分,其规定程序所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(赋值、读取等)都必须在工作内存中进行,而不能直接读取主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递都必须经过主内存的传递来完成。

  这样就会存在一个情况,工作内存值改变后到主内存更新一定是需要一定时间的,所以可能会出现多个线程操作同一个变量的时候出现取到的值还是未更新前的值。

  这样的情况我们通常称之为「可见性」,而我们加上volatile关键字修饰的变量就可以保证对所有线程的可见性。

  这里的可见性是什么意思呢?当一个线程修改了变量的值,新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候,也会从主内存中拉取最新的变量值。

  为什么volatile关键字可以有这样的特性?这得益于 Java 语言的先行发生原则(happens-before)。简单地说,就是先执行的事件就应该先得到结果。

  Java 里面的运算并非原子操作,比如i++这样的代码,实际上,它包含了 3 个独立的操作:读取i的值,将值加 1,然后将计算结果返回给i。这是一个「读取-修改-写入」的操作序列,并且其结果状态依赖于之前的状态,所以在多线程环境下存在问题。

  要解决自增操作在多线程下线程不安全的问题,可以选择使用 Java 提供的原子类,如AtomicInteger或者使用synchronized同步方法。

  原子性:在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量)才是原子操作。(变量之间的相互赋值不是原子操作,比如y = x,实际上是先读取x的值,再把读取到的值赋值给y写入工作内存)

  最开始看到「指令重排」这个词语的时候,我也是一脸懵逼。后面看了相关书籍才知道,处理器为了提高程序效率,可能对输入代码进行优化,它不保证各个语句的执行顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

  指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,却会影响到多线程的执行结果。比如下面的代码:

  但是,如果线程 A 执行的代码发生了指令重排,也就是上面的步骤 1 和步骤 2 调换了顺序,那线程 B 就会直接跳出循环,直接执行doAfterContextReady()方法导致出错。

  而volatile采用「内存屏障」这样的 CPU 指令就解决这个问题,不让它指令重排。

  比如下面的场景,就很适合使用volatile来控制并发,当shutdown()方法调用的时候,就能保证所有线程中执行的work()立即停下来。

  说了这么多,其实对于volatile我们只需要知道,它主要特性:保证可见性、禁止指令重排、解决 long 和 double 的 8 字节赋值问题。

  还有一个比较重要的是:它并不能保证并发安全,不要和synchronize混淆。

  细心的你还会发现,在 Kotlin 语言中,其实是没有volatile和synchronize这样的关键字的,那 Kotlin 是怎么处理并发问题的呢?感兴趣的一定要去看看。