volatile 的实现原理
上面聊了这么多,你可能都要忘了这篇文章的故事主角了吧?主角永远存在于我们心中 ……
其实上面聊的这些,都是在为 volatile 做铺垫。
在并发编程中,最需要处理的就是线程之间的通信
和线程间的同步
问题,上面的可见性、原子性、有序性也是这两个问题带来的。
可见性
而 volatile 就是为了解决这些问题而存在的。Java 语言规范对 volatile 下了定义:Java 语言为了确保能够安全的访问共享变量,提供了 volatile 这个关键字,volatile 是一种轻量级同步机制,它并不会对共享变量进行加锁,但在某些情况下要比加锁更加方便,如果一个字段被声明为 volatile,Java 线程内存模型能够确保所有线程访问这个变量的值都是一致的。
一旦共享变量被 volatile 修饰后,就具有了下面两种含义
- 保证了这个字段的可见性,也就是说所有线程都能够"看到"这个变量的值,如果某个 CPU 修改了这个变量的值之后,其他 CPU 也能够获得通知。
- 能够禁止指令的重排序
下面我们来看一段代码,这也是我们编写并发代码中经常会使用到的
boolean isStop = false; while(!isStop){ ... } isStop = true;
在这段代码中,如果线程一正在执行 while 循环,而线程二把 isStop 改为 true 之后,转而去做其他事情,因为线程一并不知道线程二把 isStop 改为 true ,所以线程一就会一直运行下去。
如果 isStop 用 volatile 修饰之后,那么事情就会变的不一样了。
使用 volatile 修饰了 isStop 之后,在线程二把 isStop 改为 true 之后,会强制将其写入内存,并且会把线程一中 isStop 的值置为无效(这个值实际上是在缓存在 CPU 中的缓存行里),当线程一继续执行代码的时候,会从内存中重新读取 isStop 的值,此时 isStop 的值就是正确的内存地址的值。
volatile 有下面两条实现原则,其实这两条原则我们在上面介绍的时候已经提过了,一种是总线锁的方式,我们后面说总线锁的方式开销比较大,所以后面设计人员做了优化,采用了锁缓存的方式。另外一种是 MESI 协议的方式。
- 在 IA-32 架构软件开发者的手册中,有一种 Lock 前缀指令,这种指令能够声言 LOCK# 信号,在最近的处理器中,LOCK# 信号用于锁缓存,等到指令执行完毕后,会把缓存的内容写回内存,这种操作一般又被称为缓存锁定。
- 当缓存写回内存后,IA-32 和 IA-64 处理器会使用 MESI 协议控制内部缓存和其他处理器一致。IA-32 和 IA-64 处理器能够嗅探其他处理器访问系统内部缓存,当内存值修改后,处理器会从内存中重新读取内存值进行新的缓存行填充。
由此可见,volatile 能够保证线程的可见性。
那么 volatile 能够保证原子性吗?
原子性
我们还是以 i = i + 1 这个例子来说明一下,i = i + 1 分为三个操作
- 读取 i 的值
- 自增 i 的值
- 把 i 的值写回内存
我们知道,volatile 能够保证修改 i 的值对其他线程可见,所以我们此时假设线程一执行 i 的读取操作,此时发生了线程切换,线程二读取到最新 i 的值是 0 ,然后线程再次发生切换,线程一把 i 的值改为 1,线程再次切换,因为此时 i 的值还没有应用到内存,所以线程 i 同样把 i 的值改为 1 后,线程再次发生切换,线程一把 i 的值写入内存后,再次发生切换,线程二再次把 i 的值写会内存,所以此时,虽然内存值改了两次,但是最后的结果却不是 2。
那么 volatile 不能保证原子性,那么该如何保证原子性呢?
在 JDK 5 的 java.util.concurrent.atomic 包下提供了一些原子操作类,例如 AtomicInteger、AtomicLong、AtomicBoolean,这些操作是原子性操作。它们是利用 CAS 来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的 CMPXCHG
指令实现的,而处理器执行 CMPXCHG 指令是一个原子性操作。
详情可以参考笔者的这篇文章 一场 Atomic XXX 的魔幻之旅。
那么 volatile 能不能保证有序性呢?
这里就需要和你聊一聊 volatile 对有序性的影响了
有序性
上面提到过,重排序分为编译器重排序、处理器重排序和内存重排序。我们说的 volatile 会禁用指令重排序,实际上 volatile 禁用的是编译器重排序和处理器重排序。
下面是 volatile 禁用重排序的规则
从这个表中可以看出来,读写操作有四种,即不加任何修饰的普通读写和使用 volatile 修饰的读写。
从这个表中,我们可以得出下面这些结论
- 只要第二个操作(这个操作就指的是代码执行指令)是 volatile 修饰的写操作,那么无论第一个操作是什么,都不能被重排序。
- 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能进行重排序。
- 当第一个操作是 volatile 写之后,第二个操作是 volatile 读/写都不能重排序。
为了实现这种有序性,编译器会在生成字节码中,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。