飞道的博客

JMM Volatile原理简述

337人阅读  评论(0)

先来了解下所需要的基本知识储备:

在计算机系统中,CPU高速缓存是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。
当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。
缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性(Locality)特征。这种局部性既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality)。有效利用这种局部性,缓存可以达到极高的命中率。
在处理器看来,缓存是一个透明部件。因此,程序员通常无法直接干预对缓存的操作。但是,确实可以根据缓存的特点对程序代码实施特定优化,从而更好地利用缓存。
— 维基百科

 

1、CPU高速缓存最小的单位 : 缓存行(Cache Line),当从内存中取单元到Cache中时,会一次取一个Cache Line大小的内存区域到Cache中,然后存进相应的Cache Line中。

 

2、为什么需要CPU cache?因为CPU的频率太快了,快到主存跟不上,这样在处理器时钟周期内,CPU常常需要等待主存,浪费资源。

 

所以cache的出现,是为了缓解CPU和内存之间速度的不匹配问题(结构:cpu -> cache -> memory)。

 

3、局部性:分时间 / 空间。

    A、时间局部性:如果某个数据被访问,那么在不久的将来它很可能被再次访问

    B、空间局部性:如果某个数据被访问,那么与它相邻的数据很快也可能被访问

 

4、在单核CPU结构中,为了缓解CPU指令流水中cycle冲突,L1分成了指令(L1P)和数据(L1D)两部分,而L2则是指令和数据共存。

 

缓存行 (Cache Line)

Cache是由很多个 Cache line 组成的。Cache line 是 cache 和 RAM 交换数据的最小单位,通常为 64 Byte。当 CPU 把内存的数据载入 cache 时,会把临近的共 64 Byte 的数据一起加载放入同一个Cache line,以此来提升效率( 空间局部性:临近的数据在将来被访问的可能性大)。

比如有下面代码:


  
  1. public void run() {
  2. int[] row = new int[ 16];
  3. for( int i = 0; i < 16; i++ ) {
  4. row[i] = i;
  5. }
  6. }

长度为16的row数组,在Cache Line 64字节数据块上内存地址是连续的,能被一次加载到Cache Line中。遍历的话也是直接从缓存行中读取,而不是主内存,效率极高。


  
  1. public static void main(String[] args) {
  2. long sum= 0;
  3. long c = 0;
  4. arr = new long[ 1024 * 1024][ 8];
  5. // 横向遍历
  6. long marked = System.currentTimeMillis();
  7. for ( int i = 0; i < 1024 * 1024; i += 1) {
  8. for ( int j = 0; j < 8; j++) {
  9. sum += arr[i][j];
  10. c++;
  11. }
  12. }
  13. System.out.println( "Loop times:" + (System.currentTimeMillis() - marked) + "ms ,循环次数:" + c);
  14. marked = System.currentTimeMillis();
  15. c = 0;
  16. // 纵向遍历
  17. for ( int i = 0; i < 8; i += 1) {
  18. for ( int j = 0; j < 1024 * 1024; j++) {
  19. sum += arr[j][i]; // 不连续的拿
  20. c++;
  21. }
  22. }
  23. System.out.println( "Loop times:" + (System.currentTimeMillis() - marked) + "ms,循环次数: "+c);
  24. }
  25. ==============打印结果
  26. Loop times: 10ms ,循环次数: 8388608
  27. Loop times: 38ms,循环次数: 8388608

以上两段代码引用知乎,个人觉得博主举例很好的解释这个原理以及现象。

两次循环都是一样的次数,所耗的时间却大大的不同,纵向遍历比横向遍历花费的时间要少,是因为第二层都在缓存行中进行的。在java中long类型都是8个字节,因此在缓存行中(64个字节)可以存8个long类型。

说完CPU的缓存,再来说说CPU中缓存的状态协议

CPU MESI

该协议是处理器比较经典的Cache一致性协议,在单核Cache中每个Cache Line有两个标志:dirty、valid。而在多核处理器中,MESI协议描述了共享的状态。

每个Cache Line有4个状态。

状态 描述
M(Modified) 数据和Memory不一致,存在当前Cache
E(Exclusive) 数据和Memory一致,存在当前Cache
S(Shared) 数据和Memory一致,存在多个Cache
I(Invalid) 无效数据

该图参考:https://blog.csdn.net/coslay/article/details/41790697

CPU切换状态阻塞解决-存储缓存(Store Bufferes)

如果你要修改当前缓存行,那么你必须将I(无效)状态通知到其他拥有该缓存行数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。因为这个等待远远比一个指令的执行时间长的多。

指令重排,store buffer(写队列)

指令重排是指程序在执行过程中,为了考虑性能,尽可能减少寄存器的读取、存储次数,重复复用寄存器的存储值,编译器和CPU会对指令重排序。

CPU会通过乱序的方式调整运行的效率,则会打乱程序原有顺序,但是绝对不可能出现两个线程被锁保护部分的指令交错执行的事情

CPU操作分为两种:load(读)、read(写)

那么读写会引发哪些问题?

JMM(Java内存模型)

JMM主要就是围绕着如何在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的,通过解决这三个问题,可以解除缓存不一致的问题。而volatile跟可见性和有序性都有关。

1、原子性:

Java中,对基本数据类型的读取和赋值操作是原子性操作,所谓原子性操作就是指这些操作是不可中断的。比如a=2,这个赋值是原子操作,但是i++就不是原子操作,需要借助synchronizedLock来保证原子性

2、可见性:

Java使用新的内存模型,使用happens-before的概念来阐述操作之间的内存可见性。

Java就是利用volatile来提供可见性的。 当一个变量被volatile修饰时,那么对它的修改会立刻刷新到主存,当其它线程需要读取该变量时,会去内存中读取新值。而普通变量则不能保证这一点。

其实通过synchronized和Lock也能够保证可见性,线程在释放锁之前,会把共享变量值都刷回主存,但是synchronized和Lock的开销都更大。

3、有序性:

JMM允许编译器和处理器对指令重排序


  
  1. int a = 1; //A
  2. int b = 2; //B
  3. int c = a * b; //C

执行顺序 A->B->C ,但是B->A->C的顺序也是可以,因为C依赖AB,AB可以重排序,但是C不可以排在AB前。JMM保证了单线程的执行,但是多线程就容易出问题。

比如:


  
  1. int a = 0;
  2. boolean flag = false;
  3. public void write() {
  4. a = 4; //1
  5. flag = true; //2
  6. }
  7. public void multiply() {
  8. if (flag) { //3
  9. int ret = a * a; //4
  10. }
  11. }

线程A执行write,然后线程B再执行multiply。最后的ret一定是16吗?

这时候可以给flag加上volatile,或者加上synchronized和Lock来保证有序性

JMM先天具有有序性,不需要任何手段就能保证有序性,通常称作happens-before原则,<<JSR-133:Java Memory Model and Thread Specification>>定义了如下happens-before规则:

1、程序顺序规则: 一个线程中的每个操作,happens-before于该线程中的任意后续操作

2、监视器锁规则:对一个线程的解锁,happens-before于随后对这个线程的加锁(在加锁之前,确定这个锁之前已经被释放了,才能继续加锁)

3、volatile变量规则: 对一个volatile域的写,happens-before于后续对这个volatile域的读

4、传递性:如果A happens-before B ,且 B happens-before C, 那么 A happens-before C (第一个例子就是这个规则)

5、start()规则: 如果线程A执行操作ThreadB_start()(启动线程B) , 那么A线程的ThreadB_start()happens-before 于B中的任意操作

6、join()规则: 如果A执行ThreadB.join()并且成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回

7、interrupt()原则: 对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生

8、finalize()原则:一个对象的初始化完成先行发生于它的finalize()方法的开始

 

说完这么多,该说说Volatile到底有何作用

举例:


  
  1. int a = 0;
  2. volatile boolean flag = false;
  3. public void write() {
  4. a = 4; //1
  5. flag = true; //2
  6. }
  7. public void multiply() {
  8. if (flag) { //3
  9. int ret = a * a; //4
  10. }
  11. }

happens-before原则,1在2前(volatile限制了指令重排序,1必须在2前),3在4前,2在3前,1在4前(传递性)

从JMM语义上来说

当写一个被volatile定义的变量,JMM会把变量从工作内存刷新到主内存

当读一个被volatile定义的变量,JMM会把工作内存中该变量失效,重新从主内存读取到自己的工作内存中

但是在多线程中,Volatile并不能保证原子性

Volatile的实现机制:

处理器有内存屏障----------

写内存屏障(Store Memory Barrier):处理器将存储缓存值写回主存(阻塞方式)。

读内存屏障(Load Memory Barrier):处理器,处理失效队列(阻塞方式)。

通过以上方式来保证操作之间数据的可见性。

volatile读前插读屏障,写后加写屏障,避免CPU重排导致的问题,实现多线程之间数据的可见性

那么

lock解锁时,jvm会强制刷新cpu缓存,导致当前线程更改,对其他线程可见

final:即时编译器在final写操作后,会插入内存屏障,来禁止重排序,保证可见性

 

 

部分代码引用:https://zhuanlan.zhihu.com/p/347384170


转载:https://blog.csdn.net/weixin_39082432/article/details/113620250
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场