在执行程序的过程当中,为了提升性能,编译器和处理器一般会对指令进行重排序。重排序主要分为三类java
编译器优化的重排序:编译器在不改变单线程语义的状况下,会对执行语句进行从新排序。git
指令集重排序:现代操做系统中的处理器都是并行的,若是执行语句之间不存在数据依赖性,处理器能够改变语句的执行顺序程序员
内存重排序:因为处理器会使用读/写缓冲区,出于性能的缘由,内存会对读/写进行重排序编程
也就是说,要想并发程序正确地执行,必需要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会致使程序运行不正确。数组
=================================================================================安全
上面聊了这么多,你可能都要忘了这篇文章的故事主角了吧?主角永远存在于咱们心中 …mybatis
其实上面聊的这些,都是在为 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 读/写都不能重排序。
为了实现这种有序性,编译器会在生成字节码中,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
这里咱们先来了解一下内存屏障的概念。
内存屏障也叫作栅栏,它是一种底层原语。它使得 CPU 或编译器在对内存进行操做的时候, 要严格按照必定的顺序来执行, 也就是说在 memory barrier 以前的指令和 memory barrier 以后的指令不会因为系统优化等缘由而致使乱序。
内存屏障提供了两个功能。首先,它们经过确保从另外一个 CPU 来看屏障的两边的全部指令都是正确的程序顺序;其次它们能够实现内存数据可见性,确保内存数据会同步到 CPU 缓存子系统。
不一样计算机体系结构下面的内存屏障也不同,一般须要认真研读硬件手册来肯定,因此咱们的主要研究对象是基于 x86 的内存屏障,一般状况下,硬件为咱们提供了四种类型的内存屏障。
它的执行顺序是 Load1 ; LoadLoad ;Load2 ,其中的 Load1 和 Load2 都是加载指令。LoadLoad 指令可以确保执行顺序是在 Load1 以后,Load2 以前,LoadLoad 指令是一个比较有效的防止看到旧数据的指令。
它的执行顺序是 Store1 ;StoreStore ;Store2 ,和上面的 LoadLoad 屏障的执行顺序类似,它也可以确保执行顺序是在 Store1 以后,Store2 以前。
它的执行顺序是 Load1 ; StoreLoad ; Store2 ,保证 Load1 的数据被加载在与这数据相关的 Store2 和以后的 store 指令以前。
它的执行顺序是 Store1 ; StoreLoad ; Load2 ,保证 Store1 的数据被其余 CPU 看到,在数据被 Load2 和以后的 load 指令加载以前。也就是说,它有效的防止全部 barrier 以前的 stores 与全部 barrier 以后的 load 乱序。
JMM 采起了保守策略来实现内存屏障,JMM 使用的内存屏障以下
下面是一个使用内存屏障的示例
class MemoryBarrierTest { int a, b; volatile int v, u; void f() { int i, j; i = a; j = b; i = v; j = u; a = i; b = j; v = i; u = j; i = u; j = b; a = i; } }
这段代码虽然比较简单,可是使用了很多变量,看起来有些乱,咱们反编译一下来分析一下内存屏障对这段代码的影响。
从反编译的代码咱们是看不到内存屏障的,由于内存屏障是一种硬件层面的指令,单凭字节码是确定没法看到的。虽然没法看到内存屏障的硬件指令,可是 JSR-133 为咱们说明了哪些字节码会出现内存屏障。
普通的读相似 getfield 、getstatic 、 不加 volatile 修饰的数组 load 。
普通的写相似 putfield 、 putstatic 、 不加 volatile 修饰的数组 store 。
volatile 都是能够被多个线程访问修饰的 getfield、 getstatic 字段。
volatile 写是能够被当个线程访问修饰的 putfield、 putstatic 字段。
这也就是说,只要是普通的读写加上了 volatile 关键字以后,就是 volatile 读写(呃呃呃,我好像说了一句废话),并无其余特殊的 volatile 独有的指令。
根据这段描述,咱们来继续分析一下上面的字节码。
a、b 是全局变量,也就是实例变量,不加 volatile 修饰,u、v 是 volatile 修饰的全局变量;i、j 是局部变量。
首先 i = a、j = b 只是把全局变量的值赋给了局部变量,因为是获取对象引用的操做,因此是字节码指令是 getfield 。
从官方手册就能够知晓缘由了。
地址在 docs.oracle.com/javase/spec…
由内存屏障的表格可知,第一个操做是普通读写的状况下,只有第二个操做是 volatile 写才会设置内存屏障。
继续向下分析,遇到了 i = v,这个是把 volatile 变量赋值给局部变量,是一种 volatile 读,一样的 j = u 也是一种 volatile 读,因此这两个操做之间会设置 LoadLoad 屏障。
下面遇到了 a = i ,这是为全局变量赋值操做,因此其对应的字节码是 putfield
地址在 docs.oracle.com/javase/spec…
因此在 j = u 和 a = i 之间会增长 LoadStore 屏障。而后 a = i 和 b = j 是两个普通写,因此这两个操做之间不须要有内存屏障。
继续往下面分析,第一个操做是 b = j ,第二个操做是 v = i 也就是 volatile 写,因此须要有 StoreStore 屏障;一样的,v = i 和 u = j 之间也须要有 StoreStore 屏障。
第一个操做是 u = j 和 第二个操做 i = u volatile 读之间须要 StoreLoad 屏障。
最后一点须要注意下,由于最后两个操做是普通读和普通写,因此最后须要插入两个内存屏障,防止 volatile 读和普通读/写重排序。
《Java 并发编程艺术》里面也提到了这个关键点。
从上面的分析可知,volatile 实现有序性是经过内存屏障来实现的。
=======================================================================
在 volatile 实现可见性和有序性的过程当中,有一些关键概念,cxuan 这里从新给读者朋友们唠叨下。
缓冲行:英文概念是 cache line,它是缓存中能够分配的最小存储单位。由于数据在内存中不是以独立的项进行存储的,而是以临近 64 字节的方式进行存储。
缓存行填充:cache line fill,当 CPU 把内存的数据载入缓存时,会把临近的共 64 字节的数据一同放入同一个 Cache line,由于局部性原理:临近的数据在未来被访问的可能性大。
缓存命中:cache hit,当 CPU 从内存地址中提取数据进行缓存行填充时,发现提取的位置仍然是上次访问的位置,此时 CPU 会选择从缓存中读取操做数,而不是从内存中取。
写命中:write hit ,当处理器打算将操做数写回到内存时,首先会检查这个缓存的内存地址是否在缓存行中,若是存在一个有效的缓存行,则处理器会将这个操做数写回到缓存,而不是写回到内存,这种方式被称为写命中。
内存屏障:memory barriers,是一组硬件指令,是 volatile 实现有序性的基础。
原子操做:atomic operations,是一组不可中断的一个或者一组操做。
======================================================================================
上面咱们聊了这么多 volatile 的原理,下面咱们就来谈一谈 volatile 的使用问题。
volatile 一般用来和 synchronized 锁进行比较,虽然它和锁都具备可见性,可是 volatile 不具备原子性,它不是真正意义上具备线程安全性的一种工具。
从程序代码简易性和可伸缩性角度来看,你可能更倾向于使用 volatile 而不是说,由于 volatile 写起来更方便,而且 volatile 不会像锁那样形成线程阻塞,并且若是程序中的读操做的使用远远大于写操做的话,volatile 相对于锁还更加具备性能优点。
不少并发专家都推荐远离 volatile 变量,由于它们相对于锁更加容易出错,可是若是你谨慎地听从一些模式,就可以安全的使用 volatile 变量,这里有一个 volatile 使用原则
只有在状态真正独立于程序内其余内容时才能使用 volatile。
下面咱们经过几段代码来感觉一下这条规则的力量。
1.状态标志
一种最简单使用 volatile 的方式就是将 volatile 做为状态标志来使用。
咱们老是喜欢瞻仰大厂的大神们,但实际上大神也不过凡人,与菜鸟程序员相比,也就多花了几分心思,若是你再不努力,差距也只会愈来愈大。实际上,做为程序员,丰富本身的知识储备,提高本身的知识深度和广度是颇有必要的。