Java volatile关键字的实现原理

前言

看书籍《Java并发编程的艺术》一书2.1节的时候,看到一句话“但在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区
域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁
定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

百思不得其解,随翻阅大量资料,记录一些自己对这句话的理解。

1. 总线锁定与高速缓存的一致性

我们知道为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。

首先,要明白的是,这是两个操作系统层面的概念。随着多核时代的到来,并发操作已经成了很正常的现象,所以操作系统必须要有一些机制和原语,以保证某些基本操作的原子性。比如处理器需要保证读一个字节或写一个字节是原子的,那么它是如何实现的呢?
有两种机制:总线锁定和缓存一致性。

为什么要有总线锁定与缓存一致性

所有CPU有自己的内部缓存(L1、L2),根据一些规则将内存中的数据读取到内部缓存中来,以加快频繁读取的速度。我们假设在一台PC上只有一个CPU和一份内部缓存,那么所有进程和线程看到的数都是缓存里的数,不会存在问题;但现在服务器通常是多 CPU。更普遍的是,每块CPU里有多个内核,而每个内核都维护了自己的缓存,那么这时候多线程并发就会存在缓存不一致性,这会导致严重问题。这也就是Java中常说的 cpu缓存带来的可见性问题

举个简单的例子。以 i++为例,i的初始值是0.那么在开始每块缓存都存储了i的值0,当第一块内核做i++的时候,其缓存中的值变成了1,即使马上回写到主内存,那么在回写之后第二块内核缓存中的i值依然是0,其执行i++,回写到内存就会覆盖第一块内核的操作,使得最终的结果是1,而不是预期中的2.

问题的解决方法
1. 总线锁定

操作系统提供了总线锁定的机制。CPU总线是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信,包括高速缓存、内存、北桥,其控制总线向各个部件发送控制信号、通过地址总线发送地址信号指定其要访问的部件、通过数据总线双向传输。
在CPU1要做 i++操作的时候,其在总线上发出一个LOCK#信号,其他处理器就不能操作缓存了该共享变量内存地址的缓存,当然也不能进行被锁定了的那部分内存操作,也就是阻塞了其他CPU,使该处理器可以独享此共享内存

在《IA-32架构软件开发人员手册》中的原话是:当这个LOCK#输出信号发出的时候,来自其它处理器或总线代理的总线控制请求将被阻塞。

[外链图片转存失败(img-CVV0DaVG-1567149058916)(en-resource://database/2857:0)]

但我们只需要对此共享变量的操作是原子就可以了,而总线锁定把CPU和内存的通信给锁住了,使得在锁定期间,其他处理器不能操作其他内存地址的数据,从而开销很大。于是就有了缓存锁与缓存一致性机制的优化方案。

2. 缓存一致性机制

缓存一致性机制简单来说,是当某块CPU对缓存中的数据进行操作了之后,就通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取。如下图所示:

[外链图片转存失败(img-9ueXUEir-1567149058917)(en-resource://database/2855:0)]

缓存一致性机制的具体实现(MESI协议)

MESI 协议是以缓存行(缓存的基本数据单位,在Intel的CPU上一般是64字节)的几个状态来命名的(全名是Modified、Exclusive、 Share or Invalid)。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于M、E、S和I这四种状态之一,各种状态含义如下:

M:被修改的。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。同时其状态相对于内存中的值来说,是已经被修改的,且没有更新到内存中。

E:独占的。处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改,即与内存中一致。

S:共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。

I:无效的。本CPU中的这份缓存已经无效。

MESI协议约定的缓存上对应的监听

  1. 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。
  2. 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
  3. 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。

cpu的具体处理过程
当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。

当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下会性能开销是相对较大的。在写入完成后,修改其缓存状态为M。

所以如果一个变量在某段时间只被一个线程频繁地修改,则使用其内部缓存就完全可以办到,不涉及到总线事务,如果缓存一会被这个CPU独占、一会被那个CPU 独占,这时才会不断产生RFO指令影响到并发性能。

这里说的缓存频繁被独占并不是指线程越多越容易触发,而是这里的CPU协调机制,这有点类似于有时多线程并不一定提高效率,原因是线程挂起、调度的开销比执行任务的开销还要大,这里的多CPU也是一样,如果在CPU间调度不合理,也会形成RFO指令的开销比任务开销还要大。当然,这不是编程者需要考虑的事,操作系统会有相应的内存地址的相关判断,这不在本文的讨论范围之内。

最后就是并非所有情况都会使用缓存一致性的,如被操作的数据不能被缓存在CPU内部或操作数据跨越多个缓存行(状态无法标识)a,则处理器会调用总线锁定;另外当CPU不支持缓存锁定时,自然也只能用总线锁定了,比如说奔腾486以及更老的CPU。

2. Java volatile实现原理解析

上面介绍了cpu的总线锁定与缓存锁定,现在回到文章标题。
首先看一下在在X86处理器下查看对volatile进行写操作时,CPU会做什么事情。

instance = new Singleton(); // instance是volatile变量

汇编代码如下

0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl 
$0×0,(%esp);

所以可以知道,Java的volatile就是使用的LOCK#信号来进行volatile语义的实现,保证了可见性.

查阅《IA-32架构软件开发者手册》发现,Lock前缀的指令在多核处理器下会引发了两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存。

  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
    这两条语义的实现,就是利用总线锁和缓存一致性协议(MESI)协议来实现.

volatile字段写和读和具体过程

1. 底层cpu使用的是总线锁

使用总线锁的几种情况:1. 数据无法缓存在cpu的高速缓存中;2. 数据跨多个缓存行,无法使用mesi标识位标识;

如果底层cpu使用的是总线锁,那就很简单了,可以类型Java的独占锁,cpu1处理完后(get-add-set 总线锁保证了原子性),cpu2的请求才会被响应,自然也就保证了可见性. (类似Java的独占锁)但这种方式效率很低.现代cpu大部分会采用高速缓存锁.

2. 底层cpu使用的是高速缓存锁

一般如果被访问的内存区域是在处理器内部进行高速缓存的,那么通常不发出LOCK#信号.也就是会使用缓存锁.

假设有两个处于s状态的缓存行A和B, 缓存行A所在的cpu要进行更新操作,就会把A置为独占状态,而B监听到A发出的独占请求后,就把自己的置为I状态;(这样就高速缓存一致性机制就阻止两个或多个缓存了同一内存区域的处理器同时修改该区域的数据。)而当B所在的cpu想要获取数据的时候,因为自己的缓存行已经是I状态了,那么b所在的cpu就会等待缓存锁更新完成,从内存中读取数据(也会发出一个读取请求,那么A所在地的cpu更新完成后,A就会从m状态变成s状态),并进行缓存,将缓存置为s状态。(有点类似读写锁。)

3. 总结

如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,CPU就会立即将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。那如何解决呢?

所以,在多处理器下,为了保证各个处理器的缓存是一致的。cpu有两种解决方案,一种是总线锁;一种缓存锁,具体就是MESI协议保证其他cpu读都能读到正确的值;

扩展:为什么volatile i,i++不是原子性的?

这个问题也可以类比Java中的类库,比如线程安全的Vector。单独的get或者set操作都是线程安全的,而组合起来就不是线程安全的了。Java中也叫竞态条件.

i++,操作可以分解为三步,get add set。

在现代cpu中,为了效率,这些单个操作都必须是原子的操作,而不能以总线加锁的方式自动处理。
又因为频繁使用的内存区域经常被缓存在处理器的L1、L2 高速缓存里,所以通常情况下处理器并不需要激活总线锁就可在它的内部实现原子的操作。

第一步:cpu1和cpu2都是读到缓存中,i的值为1.
第二步:cpu1和cpu2分别在自己的缓存进行计算,结果都为2.并都写道高速缓存,等待写到内存(因为volatile的作用,所以都会立马进行写内存操作)。假设cpu1先进行写内存动作(写共享内存肯定会加锁,就意味这cpu2阻塞了),一样的通过mesi协议来控制缓存的标识为。
第三部:问题来了,假设cpu1完成了写操作;这个时候cpu2已经完成了计算,并不需要在发出读请求获取最新值,cpu2拿到写内存的锁后,就直接进行写操作了,于是i的值仍然是2,而不是我们期望的3;(类比Java也是一样的,第二个线程均只是阻塞在Vector的的set()方法上)。

这里的操作就有点类似,首先两个线程同时获取读锁(状态都为s),进行i++,同时获取写锁,最后i的值依然是2

注意:(总线锁也是一样的:因为读取、计算这两步同时完成后,均会在等待写内存上)

最后,一句话就是,写内存,读内存是原子的,保证可见性的,但组合起来就不是原子性的了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值