Java 并发-5 Volatile

Posted by xflyme on August 30, 2015

简介

在多线程并发编程中 synchronized 和 volatile 都扮演着重要的角色,volatile 是轻量级的 synchronized,它在多处理器并发中保证了共享变量的‘可见性’。可见性意味着当一个线程修改共享变量时,另一个线程能读到这个变量的值。如果 volatile 修饰符使用恰当,它的使用成本将比 synchronized 更低,因为它不会引起上下文的切换和调度。

Volatile 特性

  • 可见性 对一个 Volatile 变量的读,总是能看到任意线程对这个 Volatile 变量最后的写入。
  • 原子性 对任意单个 Volatile 变量的读/写具有原子性。但 volatile++ 这类操作不具有原子性。

Volatile 的内存语义

Volatile 写的内存语义如下

当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。

Volatile 读的内存语义如下: 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

如果我们把 volatile 写和 volatile 读这两个步骤综合起来看的话,在读线程 B 读取一个 volatile 变量后,写线程 A 在写这个 volatile 变量之前所有可见的共享变量的值都将立即对线程 B 可见。

volatile 内存语义的实现

重排序可分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序,下面是 JMM 针对编译器指定的 volatile 重排序规则表:

图一

从上表可以看出,当第二个操作为 volatile 写时,无论第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不能重排序到 volatile 写之后。

当第一个操作为 volatile 读时,无论第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不能被重排序到 volatile 读之前。

为了实现 volatile 的内存语义,编译器在生成字节码时会在指令序列中插入内存屏障来限制特定类型的处理器重排序。

在 JR-133之前的旧 java 内存模型中,虽然不允许 volatile 变量之间重排序,但允许 volatile 变量和普通变量重排序。因此在旧的 java 内存模型中,volatile 写-读没有锁的释放-获取所具有的内存语义。为了提供一种比锁更轻量级的线程之间通信机制,JSR-133 专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量和普通变量的重排序,确保 volatile 写-读和锁的释放获取具有相同的内存语义。

Volatile 的定义与实现

Java 语言规范第三版中对 volatile 的定义如下:Java 编程语言允许线程访问共享变量,为了确保共享变量能够被准确和一致的更新,线程应该通过排他锁单独获取这个变量。Java 语言提供了 volatile ,在某些情况下比锁要方便,如果一个字段声明为 volatile,java 线程内存模型确保所有线程看到这个变量的是一致的。

为了提高处理速度,处理器不直接和内存通信,而是先将内存中的数据读到内部缓存后再进行操作,但操作完不知道何时再写回内存。如果对声明了 volatile 变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在的缓存行的数据写回到内存系统。但是就算写回内存,其他处理器的缓存数据还是旧的,再进行计算还是会有问题。所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。每个处理器嗅探总线上传播的数据来检查自己的缓存值是不是过期了。当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效,当处理器对这个数据进行修改的时候,会重新从内存中把数据读到处理器缓存里。

Volatile 两条实现原则

  • Lock 前缀指令会引起处理器缓存回写到内存

Lock 前缀指令导致执行指令期间,声言处理器的 Lock 信号。在多处理器环境中,Lock 指令会确保在声言改信号期间,处理器可以独占共享内存。但是在最近的处理器中,Lock 信号一般不锁总线,而是锁缓存,毕竟锁总线的开销比较大。

  • 一个处理器缓存回写到内存会导致其他处理器的缓存无效

IA32 处理器使用 MESI 控制协议去维护内部缓存和其他处理器的缓存一致。在多喝处理器系统中进行操作时,处理器使用嗅探技术保证它内部的缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。