Volatile【Java】

来自智得网
跳转至: 导航、​ 搜索

简介

JMM内存模型

Volatile的中文释义是易变的;无定性的;无常性的;可能急剧波动的。

在计算机语言的实现中,不同线程之间传递参数可以分为可变参数和不可变参数。

不可变参数是函数编程语言常用的参数类型,变量一旦创建,在其生命周期之内的值都是固定的值,所以不可变参数在不同线程之间传递都是安全的。除了函数式编程语言之外,采用CSP(communicating sequential processes)并发模型的语言,一般通过消息的方式传递参数,也不存在参数变化之后的传播问题。

以内存作为共享变量实现的语言,在一个线程更新变量之后,因为CPU的体系结构以及指令重排问题,其他线程可以获取到过期的变量值。该问题一般称为内存可见性问题。

可见性问题是所有同步机制都需要解决的问题,例如Synchronized以及jdk1.5之后并发包提供的同步方式都需要解决同步问题。

volatile是jvm提供的轻量级同步机制,其主要解决的问题是数据的可见性问题。

原理

内存可见性一般因为CPU多级缓存和CPU指令重排问题而产生。

CPU多级缓存

CPU多级缓存

和应用程序相同,CPU也是有缓存的,多个CPU之间的数据也存在一致性问题。

计算机在执行程序的过程中,会从内存加载数据到CPU运行,但是因为CPU和内存读写速度的巨大鸿沟,每条指令都和内存交互会降低CPU的效率,所有CPU就设计了高速缓存,高速缓存根据访问速度和容量的差异又分为L1、L2、L3 cache。

在程序的运行过程中,内存的数据会被读取到CPU的高速缓存中,运算结束之后再写回内存。

多核CPU会有多个不同的内存缓存,所以会存在一致性问题,单核CPU其实也会存在类似的问题,因为线程切换的时候会保存线程上下文,线程上下文就包括这些缓存的内容。

实现可见性问题有两种方式:

第一种就是synchronized使用的总线锁方式,也就是在总线上执行LOCK#信号通过在总线上声明 LOCK# 信号,能够有效的阻塞其他 CPU 对于总线的访问,从而使得只能有一个 CPU 访问变量所在的内存。而且声明了 LOCK# 信号后,那么只有等待变量修改行执行完毕并应用到内存后,总线锁才会解开,其他 CPU 才能够继续访问内存中的变量,再继续执行后面的代码,这样就解决了缓存不一致问题。

LOCK#是IA-32 架构提供的一种指令类型,LOCK# 信号用于锁缓存,等到指令执行完毕后,会把缓存的内容写回内存,这种操作一般又被称为缓存锁定

第二种方式是MESI。

当缓存写回内存后,IA-32 和 IA-64 处理器会使用 MESI 协议控制内部缓存和其他处理器一致。IA-32 和 IA-64 处理器能够嗅探其他处理器访问系统内部缓存,当内存值修改后,处理器会从内存中重新读取内存值进行新的缓存行填充。

CPU指令重排

为了提升性能,编译器和处理器通常会对指令进行重排序。重排序主要分为三类

  • 编译器优化的重排序:编译器在不改变单线程语义的情况下,会对执行语句进行重新排序。
  • 指令集重排序:现代操作系统中的处理器都是并行的,如果执行语句之间不存在数据依赖性,处理器可以改变语句的执行顺序
  • 内存重排序:由于处理器会使用读/写缓冲区,出于性能的原因,内存会对读/写进行重排序指令重排的前提是不能破坏程序的语义。
  • JIT指令重排:在java即时编译的过程中,也可能在不影响程序语义的情况下进行指令重排。

指令重排可能会影响程序的执行结果,所以JMM抽象了Happen-before原则,定义了一些操作必须位于其他操作之前。JMM抽象的Happen-before一般可以分为下列几类:

  • 程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。
  • 监视器锁法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
  • volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。
  • 线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
  • 线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
  • 中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
  • 终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
  • 传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C


在程序次序原则下,单线程场景可以保证程序的语义和看到的程序语义是一致的,多线程下指令重排可能会导致一个线程修改的变量,其他线程不能及时发现,所以指令重排也是出现内存可见性的问题之一。

volatile规定了写入操作happens-before于每一个后续对同一个域的读写操作,其实现是基于内存屏障实现。

x86 硬件为我们提供了四种类型的内存屏障。

内存屏障 语义
LoadLoad 它的执行顺序是 Load1 ; LoadLoad ;Load2 ,其中的 Load1 和 Load2 都是加载指令。LoadLoad 指令能够确保执行顺序是在 Load1 之后,Load2 之前,LoadLoad 指令是一个比较有效的防止看到旧数据的指令。
StoreStore 它的执行顺序是 Store1 ;StoreStore ;Store2 ,和上面的 LoadLoad 屏障的执行顺序相似,它也能够确保执行顺序是在 Store1 之后,Store2 之前。
LoadStore 它的执行顺序是 Load1 ; StoreLoad ; Store2 ,保证 Load1 的数据被加载在与这数据相关的 Store2 和之后的 store 指令之前。
StoreLoad 它的执行顺序是 Store1 ; StoreLoad ; Load2 ,保证 Store1 的数据被其他 CPU 看到,在数据被 Load2 和之后的 load 指令加载之前。也就是说,它有效的防止所有 barrier 之前的 stores 与所有 barrier 之后的 load 乱序。

volatile通过内存屏障禁止指令重排序,主要遵循以下三个规则:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。