JMM+Volatile 理论

本文大部分都是理论知识,要持看公司前台的耐心来观看。

CPU 高速缓存( CPU Cache

CPU的频率太快,快到主存跟不上,这样在处理器时钟周期内,CPU经常需要等待主存,浪费资源。所以缓存的出现,是为了缓解CPU和内存间速度的不匹配问题。(结论:CPU>缓存>主存)

当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。

上图最简单的高速缓存的架构,数据的读取和存储都经过高速缓存,CPU 核心与高速缓存有一条特殊的快速通道;主存与高速缓存都连在系统总线上(BUS),这条总线还用于其他组件的通信。简而言之,CPU 高速缓存就是位于 CPU 操作和主内存之间的一层缓存。


缓存行 (Cache Line)

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

比如有下面代码:

public void run() {
    int[] row = new int[16];
    for(int i = 0; i < 16; i++ ) {
        row[i] = i;
    }
}

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

在比如这段代码:

public static void main(String[] args) {
	long sum=0;
	long c = 0;
	arr = new long[1024 * 1024][8];
	// 横向遍历
	long marked = System.currentTimeMillis();
	for (int i = 0; i < 1024 * 1024; i += 1) {
		for (int j = 0; j < 8; j++) {
			sum += arr[i][j];
			c++;
		}
	}
	System.out.println("Loop times:" + (System.currentTimeMillis() - marked) + "ms ,循环次数:" + c);
	marked = System.currentTimeMillis();
	c = 0;
	// 纵向遍历
	for (int i = 0; i < 8; i += 1) {
		for (int j = 0; j < 1024 * 1024; j++) {
			sum += arr[j][i]; // 不连续的拿
			c++;
		}
	}
	System.out.println("Loop times:" + (System.currentTimeMillis() - marked) + "ms,循环次数: "+c);
}

==============打印结果
Loop times:10ms 循环次数:8388608
Loop times:38ms循环次数: 8388608

我们发现循环次数一样, 但是纵向遍历 比横向遍历 硬是多了这么多时间。 原因是横向遍历第二层for 都是在缓存行里面操作的。 一个 Java 的 long 类型是 8 字节,因此在一个缓存行( 64 字节)中可以存 8 个 long 类型的变量。

有了高级缓存和缓存行,在多核cpu中,就会出现每个核的缓存行数据不一致问题,于是就有了 缓存一致性协议


CPU MESI 缓存一致性协议

由于现在都是多核cpu,每个cpu都有自己的cache,难免就有各CPU之间 缓存行 数据不一致的情况,为了解决这个情况就有了MESI 。

MESI 是指4个状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:

  • Modified :代表当前Cache行的数据是修改过的(Dirty),并且只在当前CPU的Cache中是修改过的;此时该Cache行的数据与其他Cache中的数据不同,与内存中该行的数据也不同。
  • Exclusive :代表当前Cache行的数据是有效数据,其他CPU的Cache中没有这行数据;并且当前Cache行数据与内存中的数据相同。
  • Shared :代表多个CPU的Cache中均缓存有这行数据,并且Cache中的数据与内存中的数据一致;
  • Invalid :表示当前Cache行中的数据无效;

如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态。


举个例子

假设有三个CPU A、B、C,对应三个缓存分别是cache a、b、 c。在主内存中定义了x的引用值为0。

双核读取 流程

  • CPU A 发出了一条指令,从主内存中读取x。
  • CPU A 从主内存通过bus读取到 cache a 中并将该 cache line 设置为 E状态
  • CPU B 发出了一条指令,从主内存中读取x。
  • CPU B 试图从主内存中读取x时,CPU A 检测到了地址冲突。这时CPU A对相关数据做出响应。此时x 存储于cache a和cache b中,x在cache a和cache b中都被设置为S状态(共享)。

修改数据 流程

  • CPU A 计算完成后发指令需要修改x.
  • CPU A 将x设置为 M状态(修改) 并通知缓存了x的CPU B, CPU B将本地cache b中的x设置为 I状态(无效) , 下次判断是无效状态就从主内存中取
  • CPU A 对x进行赋值。

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

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

Store Buffers

引入Store Buffers后,CPU中的结构大体如下图所示:

https://pic1.zhimg.com/v2-dd90b1317d1785749cf432dfa60a90a4_b.jpg


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

CPU操作分为两种:load(读)、store(写),每个CPU都有一个store buffer,当CPU需要执行store操作时,会将store操作先放入store buffer中,不会立即执行store操作,等待合适的时机再执行(store buffer满了等等情况会真正执行store操作),在store后面的load操作不用再等store真正执行完毕才能执行,只要store放入了store buffer中,load就可以执行了。store buffer造成的结果就是事实上store-load操作被重排序了:store操作后面的load先执行,这就是 指令重排 。也就是说:store操作都是有序的,load操作可能无序。

指令重排总结

多核系统中,A处理器修改了位置a,b两处的内容,同步到B处理器的顺序,是不确定的。也即使说,A先修改a,再修改b,但是在B处理器上,可能先看到b被改变,然后才是a的改变。这种情况,在大量通过内存进行通讯实现控制流程的代码逻辑而言,是不可接受的。这个时候,就需要内存屏障来解决问题。


内存屏障

内存屏障是一类指令,使其内存读/写以期望的顺序发生。java用Volatile对标内存屏障。

处理器提供了两个内存屏障指令(Memory Barrier)用于解决上述两个问题: 写内存屏障(Store Memory Barrier) : 在指令后插入Store Barrier, 能让写入缓存中的最新数据更新写入主内存, 让其他线程可见 强制写入主内存, 这种显示调用, CPU就不会因为性能考虑而进行指令重排 读内存屏障(Load Memory Barrier) : 在指令前插入Load Barrier, 可以让高速缓存中的数据失效, 强制从新从主内存读取数据 强制读取主内存内容, 让CPU缓存和主内存保持一致, 避免了缓存导致的一致性问题。

什么是内存模型

这上面的操作其实是介绍的 系统的内存模型(TSO模型), 是x86平台的实现。不同的平台有不同的实现(如SC模型等)。 内存模型就是为了解决多核cpu高效且安全的store和load数据 的方法论。

JMM内存模型

上面我们介绍过x86的内存模型是TSO,java程序运行时,java多线程最底层是被分配到CPU上执行的,所以部署在x86的java程序,底层的操作也是遵循TSO模型的,而JMM是统一的内存模型,与平台无关的,所以要将x86的TSO转换成JMM,要在底层特殊处理。

JMM书面解释: 用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各平台下都能够达到一致的内存访问效果。

JMM的规定

  • 线程从主内存中拷贝共享变量到工作内存
  • 线程的所有操作都在工作内存中进行,而不是直接操作主内存
  • 不同的线程间无法直接操作其他线程的工作内存数据
  • 线程间数据的同步需要通过主内存进行

JMM要解决的问题

原子性

保证一系列操作要么全部完成,要么全部失败 。 JMM解决原子性问题,可以通过synchronized关键字实现。底层通过monitorenter和monitorexit命令实现。

可见性

共享变量的修改对其他线程可见。volatile关键字可以保证对共享变量的修改,能够马上刷新到主内存,其他线程操作共享变量必须重新从主内存获取副本。当然synchronized,final等也能实现可见性。

有序性

保证程序执行的顺序按要求执行。实现禁止指令重排序可以通过volatile添加内存屏障、happen-before原则、synchronized加锁实现单线程处理。

Volatile

终于要讲Volatile了。volatile用于修饰变量,如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。 volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性 。在JVM底层volatile是采用“内存屏障”来实现的。

总结volatile的两大作用

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。保证可见性、不保证原子性。
  • 禁止进行指令重排序。

Volatile变量修饰符如果使用恰当的话,它 比synchronized的使用和执行成本会更低,因为它不会引起线程上下文的切换和调度。


Volatile 实现机制

在x86处理器下通过工具获取JIT编译器生成的汇编指令来看看对Volatile进行写操作CPU会做什么事情。

Java代码: instance = new Singleton();//instance是volatile变量
汇编代码:  0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp);

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  • 它会强制将对缓存的修改操作立即写入主内存;
  • 如果是写操作,它会导致其他CPU中对应的缓存行无效。


Volatile的使用场景

乐观锁场景

public class VolatileTest {
    private volatile int a = 0;

    public static void main(String[] args){
        int expect ;
        do{
            expect = a;
        }while (!compareAndSet(expect, expect+1));
    }
}

如果没有volatile , 线程拿到的expect值很有可能是旧的值,导致while循环可能很久,也就是使得自旋锁自旋时间过长,浪费CPU。

实现高效读锁

private volatile int value;
public int getValue(){ return value;}
public synchronized void doubleValue(){ value = value*value; }

写操作用了synchronized , 读取用volatile来实现。volatile 轻量级,比synchronized 高效。

强烈推荐一个 进阶 JAVA架构师 的博客

Java架构师修炼

支付宝打赏 微信打赏

如果文章对您有帮助,您可以鼓励一下作者