原子性
由于java内存模型来直接保证的原子性变量操作包括read、load、assign、user、store、和write这六个,我们可以认为基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性协定)
如果需要更大范围的原子性保证,java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。这两个字节码指令反映到java代码中就是同步块–synchronized关键字。
可见性
可见性就是指当一个线程修改了共享变量时,其他线程能够立即得知这个修改,java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从内存刷新变量值这种依赖于主内存作为传递媒介的方式来实现可见性的,无论是普通的变量还是lolatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性。
除了volatile外,java还有两个关键字能实现可见性,它们是synchronized和final。
有序性
java提供了volatile和synchronized两个关键字来保证线程间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
volatile的原理
普通的变量只会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与代码中的执行顺序。
用volatile修饰的变量,赋值后指令了一个lock前缀的操作,这个操作的作用相当于一个内存屏障,在重排序时不能把后面的指令重排序到内存屏障的前面,但是如果只有一个处理器访问内存的时候并不需要内存屏障,如果有多个处理器访问同一块内存,就需要内存屏障来保证一致性了。
lock前缀的指令在多核处理器下会进行以下的处理:
将当前处理器缓存行的数据写回到系统内存。
写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过检查总线上传来的数据判断自己缓存的值是不是过期了,当处理器发现自己缓存的行对应的内存地址被修改,就会将当前处理器的缓存设置为无效的状态。当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
volatile就是通过这样的机制使得每个线程都能获得该变量的最新值。
Volatile的两项使命
保证变量对所有的线程的可见性
我们使用volatile修饰变量的时候,能够了保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,其它线程是立即得知的。而普通的变量因为缓存的原因可能会造成不能拿到最新值的情况。
但是其实volatile变量在并发环境下运算一样是不安全的,我们可以看下下面的场景
说明:使用线程池对a进行a++操作,对此操作我们的期望值是20,但是我们得到的值往往都是小于20,这个是什么情况呢。
问题出在a++操作当中,在反编译的时候发现a++是由多条字节码指令构成,不能保证原子性,当一个线程在执行操作的时候可能另外一个线程也在执行同样的操作。
所以volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁来保证原子性:
1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
2. 变量不需要与其他的状态变量共同参与不变约束。
禁止指令重排序的优化
我们都知道,为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想阻止重排序要怎么办了?答案是可以添加内存屏障
JVM会针对编译器制定volatile重排序规则表
" NO " 表示禁止重排序。
为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。
在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
happens-before
规则中有一条是volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
下面是java内存模型中一些先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。
程序次序规则: 在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是代码的顺序,因为要考虑分支、循环等结构。
管程锁定规则: 一个unlock操作先行发生于后面对同一个锁的lock操作,这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。
volatile变量规则: 对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是值时间上的。
线程启动规则: Thread对象的start()方法先行发生于线程的每一个动作。
线程终止规则: 线程中的所有操作都先行发生于对此线程的终止检测。
线程中断规则: 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
对象终结规则: 一个对象的初始化完成先行发生于它的finalize()方法的开始。
传递规则: 如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
双重校验锁为什么要用volatile修饰?
我们创建一个对象的流程是这样的:
- 加载指定的字节码文件进内存
- 通过new在堆中开辟空间,分配首地址。
- 对对象的属性进行默认初始化。
- 调用与之对应的构造函数,构造函数压栈。
- 构造函数中执行隐式的super()语句,访问父类的构造函数。
- 对对象的属性进行显示初始化。
- 调用类中的构造代码块。
- 执行构造函数中自定义的初始化代码。
- 初始化完毕,将地址赋值给指定的引用。
当两条线程进来时,线程A在执行singleton = new Singleton(),这时线程B在判断singleton == null,因为重排序的原因,他判断singleton不为null。但实际上线程B此时拿到的是一个不完整的对象。
因此我们需要使用volatile来禁止指令重排序优化,从而安全的实现单例。
参考文献:
《深入理解Java虚拟机》
《java并发编程的艺术》