Jvm volatile解决可见性

452 阅读5分钟

这是我参与11月更文挑战的第7天,活动详情查看:2021最后一次更文挑战

i++问题

class A {
static int i;
public void inc() {
    i++;
}
}

上面inc方法在多线程环境下是非线程安全的,多线程执行1w次,最终i的值会小于10w,主要有两个原因导致这个问题:

  1. 可见性
  2. 原子性

可见性

可见性问题是线程1对变量的修改对其它线程不可见,原因是CPU高速缓存。

CPU高速缓存

常见的存储介质从机械硬盘,固态硬盘,内存,CPU核心的高速缓存,寄存器,按照顺序离CPU越近其速度越快,造价越来越高,容量越来越小。

由于CPU的运算性能与内存的运算性能有着指数级别的差异,而寄存器容量小,所以出现了高速缓存。每个CPU核心具备自己独立的高速缓存。当CPU通过内存寻址获取地址对应的数值时(比如汇编命令 move ax,[0x66]:0xcc),如果该地址对应的值已经在高速缓存会直接从高速缓存获取。同理,当修改某个内存地址对应的值时,如果该变量已经在高速缓存中,就会直接修改高速缓存中的值。在我一开始的理解中,高速缓存就是一个Map[内存地址,内存地址对应的值],然而当书中提到高速缓存以缓存行为单位,我傻眼了。Map中的value对应的是64kb的缓存行,而不是内存地址对应的值,我翻阅各种资料,难以找到为什么设计缓存行,而不是结构更为合理的Map结构?在这里,我能想到的就是字节对其。缓存行的大小是固定搞得64kb(不同处理器可能不同)。固定的字节大小有个优势就是可以直接寻址。比如数组,每个元素的大小固定,通过下标可以直接寻找到对应的元素。

好了,在我的认知中有了缓存行的概念,并且高速缓存是以缓存行为单位进行读写。那么下一个问题就是缓存值怎么获取?cpu get一次获取了整个缓存行,而我需要的仅仅是缓存行其中的一个元素。所以还必须存储内存地址对应数值的字节数以及哪个缓存行以及缓存行的起始字节。当然,这仅仅的是我的猜想。

回到问题中,由于CPU的高速缓存,导致了i++的可见性问题使得线程1看不到线程2的修改,如何解决呢?

CPU指令: LOCK

为了解决CPU高速缓存的问题,Intel CPU提供了LOCK指令。当修改某个内存地址对应的数值的汇编指令(如lock addl $0x0,($esp))加上了lock前缀后,CPU会做以下两件事:

  1. 将内存地址对应的值写回到主内存中。
  2. 使用缓存一致性协议让其它CPU高速缓存中的该内存地址对应的缓存行失效。

回写主内存

当CPU执行到这条修改内存地址对应的值的汇编指令时,作为使用CPU的我们,是希望其它CPU能够立刻看到修改的。可以试想一下,如果不是这种机制会发生什么?我的i+1执行,但是其它线程拿到的i还是旧值,然后进行i+1,会导致脏数据。由此,我们希望回写主内存也类似与Java中读写锁的机制,当CPU0在回写该缓存行时,其它CPU无法读取以及写入该缓存行,这个操作也称作缓存锁定。CPU有两个锁的方式达到该效果:

  1. 缓存锁: 缓存行锁会使其它CPU仅仅不能get/update该缓存行。
  2. 总线锁: 总线锁会使其它所有CPU都无法get/update缓存行。当缓存行不存在时,只能使用总线锁了。

这是lock指令带来的第一个作用,即缓存锁定,既然有加锁,那么什么时候解锁呢,这就要说道lock的下一个作用。

缓存一致性协议

如果让你做缓存同步你会怎么做?

这是我的答案:

image.png

这个方案就是MESI方案:

  • M: modify,修改。当修改某个内存地址上对应的值时,主内存与缓存行中的数据就会不一致,此时就需要同步数据
  • E: exclude,独占。数据只存在当前CPU的缓存行中。
  • S: share,共享。某个数据被存在于多个CPU的缓存行中。
  • I: invalid,作废。使某个缓存行的数据作废掉。

缓存锁定后,给其他CPU发送invalid cache line指令,等待其它CPU返回invalid cache line的ack返回后,解除缓存锁定。

方案可行,就是性能存在问题,CPU运算速度非常快,如果用来等待ACK,性能会直接拉拉垮。Intel 工程师想到一个好办法,CPU上专门搞一个东西件来做MESI的事情,这个东西就是store buffer,当接收到带有LOCK前缀的指令后,转交给store buffer(其数据结构是一个队列),store buffer然后发送invalid给其他CPU,然后等待ack处理。

volatile

既然CPU的LOCK指令可以解决可见性问题,那么Java中怎么才能用到这个指令呢?答案就是voaltile,Java中对类变量使用volatile关键字修饰,JVM会对带有volatile关键字修饰的变量进行写入代码在编译成汇编指令的前面加上LOCK指令,从而保证变量的可见性。