Java并发编程(四)-Java并发机制的底层实现原理-volatile
参考文献
- <<Java并发编程艺术>>
- <<Java语言规范-基于Java SE 8>>
- CPU高速缓存行与内存关系 及并发MESI 协议
- [死磕 Java 并发] — Java内存模型之分析volatile
volatile
CPU术语
术语 | 英文 | 描述 |
---|---|---|
内存屏障 | memory barrier |
是一组处理器指令,用于实现对内存操作的顺序限制 |
缓存行 | cache line |
缓存中可以分配的最小存储单位.处理器填写缓存行时会加载整个缓存行,需要使用多个主内存读周期 |
原子操作 | atomic operations |
不可中断的一个或一系列操作 |
缓存行填充 | cache line fill |
当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3的或所有) |
缓存命中 | cache hit |
如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是内存读取 |
写命中 | write hit |
当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回内存,这个操作被称为写命中. |
写缺失 | write misses the cache |
一个有效的缓存行被写入到不存在的内存区域 |
内存屏障
- 可见性
- 写屏障(Sfence)保证在该屏障之前的对共享变量的改动,都同步到主存中.
- 读屏障(Ifence)保证在该屏障之后对共享变量的读取,加载的是主存中最新的数据
- 有序性
- 写屏障(Sfence)会保证指令重排序时,不会将写屏障之前的代码排在写屏障之后;
- 读屏障(Ifence)会保证指令重排序时,不会将读屏障之后的代码排在读屏障之前;
volatile
的作用
Java编程允许允许线程访问共享变量.作为规则,为了确保共享变量被一致并可靠地更新,线程应该确保独占地使用这种变量,其惯用的方式是通过获取锁来实现,即强制线程互斥地使用这些变量.
Java编程语言还提供了第二种进制,即
volatile
域,在某些方面,它比加锁机制要方便.域可以被声明为
volatile
,此时Java内存模型会确保所有线程看到的都是该变量的一致的值.如果
final
变量同时也被声明为volatile
,那么就是一个编译时错误.
-
它可以用来修饰成员变量和静态变量,可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取他的值,线程操作
volatile
变量都是直接操作主存,即一个线程对volatile
变量的修改,对另一个线程可见;volatile
仅仅保证了共享变量的可见性,让其他线程能够看到最新值,但不能解决指令交错问题(不能保证原子性);
-
只有当
volatile
变量能够简化实现和同步策略的验证时,才使用它们.当验证正确性必须推断可见性问题时,应该避免使用volatile
变量.正确使用volatile
变量的方法包括:- 用于确保它们所引用的对象状态的可见性.
- 或者用于标识重要的声明周期时间(比如初始化或关闭)的发生.
-
由于
volatile
变量只能保证可见性,在不符合以下两条规则的运算场景中,需要通过加锁来保证原子性:- 运算结果不依赖变量当前值,或者能够保证只有单一线程修改变量的值.
- 变量不需要与其他状态变量共同参与不变约束.
-
当写一个
volatile
变量时,JMM会把线程对应的本地内存的共享变量值立即刷回到主内存中。 -
当读一个
volatile
变量时,JMM会把线程对应的本地内存的共享变量值设置为无效,重新会主存中读取最新的共享变量 -
总结: 一个变量被定义成
volatile
之后,具有两个特性:- 保证了此变量对所有线程的可见性(当一个线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的).
- 禁止指令重排序优化.
volatile
的使用场景
如果写入变量值不依赖变量当前值,那么就可以用
volatile
或者能确保只有一个线程修改变量的值
volatile
保证可见性的实现原理
- 当用
volatile
变量修饰的共享变量进行写操作的时候会多出Lock
前缀的指令.Lock
前缀的指令在多核处理器下会发生下面两件事:- Lock前缀指令会引起处理器缓存回写到内存.
- 一个处理器的缓存回写到内存会导致其他处理器的缓存失效.
- 为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后在操作,但操作完不知道何时会写到内存.如果对声明了
volatile
的变量进行写操作,JVM就会向处理器发送一条Lock
前缀的指令.将这个变量所在的缓存行的数据写回到系统内存.但是,就算写回到内存,如果其他处理器缓存的值还是旧的,在执行计算操作就会有问题. - 所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对个数据进行修改操作的时候,会重新从系统内存中吧数据读到处理缓存里.
volatile
的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM采用了保守策略。如下:- 在每一个
volatile
写操作前面插入一个StoreStore
屏障;StoreStore
屏障可以保证在volatile
写之前,其前面的所有普通写操作都已经刷新到主内存中.
- 在每一个
volatile
写操作后面插入一个StoreLoad
屏障;StoreLoad
屏障的作用是避免volatile
写与后面可能有的volatile
读/写操作重排序.
- 在每一个
volatile
读操作后面插入一个LoadLoad
屏障LoadLoad
屏障用来禁止处理器把前面的volatile
读与后面的普通读重排序.
- 在每一个
volatile
读操作后面插入一个LoadStore
屏障LoadStore
屏障用来禁止处理器把上面的volatile
读与下面的普通写重排序。
- 在每一个
volatile
重排序规则表
是否能重排序 | 第二个操作 | 第二个操作 | 第二个操作 |
---|---|---|---|
第一个操作 | 普通读/写 | volatile 读 |
volatile 写 |
普遍读/写 | NO | ||
volatile 读 |
NO | NO | NO |
volatile 写 |
NO | NO |
volatile
的使用优化
- Java并发编程大师
Doug lea
在JDK7的并发包里新增一个队列集合类LinkedTransferQueue
,它在使用volatile
变量时,用一种追加字节的方式来优化队列出队和入队的性能. - 为什么追加64字节能够提高编程效率?
- 因为对于常见的处理器的L1,L2或L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,这就意味着,如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头,尾节点,当一个处理器视图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率.
volatile
与synchronized
的区别
volatile
本质是告诉JVM
当前变量在寄存器中的值是不确定的,需要从主存中读取,synchronized
则是锁定当前变量,只有当前线程可以访问改变量,其他线程被阻塞住;volatile
仅能使用变量级别,synchronized
则可以使用在变量,方法上;volatile
仅能实现变量的修改可见性,但不具备原子特性,而synchronized
则可以保证变量的修改可见性和原子性;volatile
不会造成线程阻塞,而synchronized
可能会造成线程阻塞;volatile
标记的变量不会被编译优化,而synchronized
标记的变量可以被编译优化;
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 HoleLin's Blog!