java内存屏障详解,
在现代计算机中,CPU往往是多核的,而且由于每个CPU核都有自己的缓存,会造成内存中数据读写不一致,表现为指令无序和不可见。因此,为了统一物理世界的计算机架构,java提出了JMM内存模型,抽象出了Load Load、StoreStore、LoadStore、StoreLoad四种内存屏障指令,以应对不同的CPU系统。本文首先介绍了多核CPU系统下并发编程需要克服的问题,然后介绍了Java下内存屏障各自的含义,并举例说明了相应的场景。
顺序和可视性在执行程序时,为了提高性能,编译器和处理器通常会对指令进行重新排序,即:
编译器优化的重新排序。编译器可以重新安排语句的执行顺序,而不改变单线程程序的语义。指令级并行的重新排序。现代处理器采用指令级并行技术,以重叠的方式执行多条指令。如果没有数据依赖性,处理器可以改变对应于语句的机器指令的执行顺序。(同时由于处理器中缓存的存在,数据不一致。从读写的角度来看,也有类似无序重排的效果,即记忆系统的重新排序。)存储系统的重新排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看起来是无序的。也就是说,即使指令按顺序执行,没有重新排序,由于缓存的存在,仍然会出现数据不一致的情况。我们可以把这种普通的阅读```普通的写作理解为延迟阅读和延迟写作,所以即使前读后写,因为延迟,前写后读还是会发生。
为了解决上述重排带来的问题,提出了as-if-serial原理,即无论如何重排,程序执行的结果在单线程内保持不变。为了遵守as-if-serial原则,我们需要一个特殊的指令来防止特定的重排并保持结果的一致性。这个指令就是内存屏障。
记忆障碍有两个影响:
防止指令重排序:插入内存屏障指令后,无论前后的指令是什么,都不能用内存屏障指令进行重排序,以保证前后指令的顺序执行,即保证顺序。全局可见性:插入的内存屏障保证了内存操作的读写结果将被立即写入内存,并对其他CPU内核可见,保证了可见性,解决了普通读写的延迟问题。比如插入读屏障后,可以删除缓存,后续的读操作可以立即读取内存中最新的数据(至少当时看起来是最新的)。插入写屏障后,缓存中的数据可以立即刷新到内存中,使其对其他CPU内核可见。因此,在CPU的物理世界中,通常存在三种内存障碍:
Lfence: load fence,即立即使CPU缓存失效,从内存中读取数据并加载到缓存中。Sfence: write fence,即立即刷新将缓存中的数据刷入内存。Mfence: memory fence,即读写屏障,保证读写都是序列化的,保证所有数据都写入内存并清空缓存。JMM的四种读写屏障在物理世界中有不同的CPU屏障指令和效果。为了达到跨平台的效果,Java在JMM内存模型中提出了读操作加载和写操作存储的四种组合,以覆盖读写的所有情况,即:读加载、读写加载存储、读写存储存储、读写存储加载。
负载障碍:对于这样的语句,Load1负载负载;2、在Load2要读取的数据和后续读取操作被访问之前,确保Load1要读取的数据被完全读取。StoreStore barrier:对于这种语句,Store1StoreStore在执行Store2和后续写操作之前,确保Store1的写操作对其他处理器可见。LoadStore barrier:对于这样的语句,Load1LoadStoreStore2,确保Load1要读取的数据在Store2和后续写操作被刷出之前被读出。StoreLoad barrier:对于这种语句,Store1储存量;在执行Load2和所有后续读取操作之前,确保Store1的写入对所有处理器可见。它的开销是四个障碍中最大的。在大多数处理器实现中,这种屏障是通用屏障,它具有其他三种存储器屏障的功能。那么如何理解这四个屏障指令呢?或者为什么需要这四个屏障指令,如果没有会怎么样。在讨论这个问题的时候,我们可以把重点放在可见性上,因为如果顺序不能保证,结果肯定是有问题的,也就是as-if-serial原理不能保证。那么,为什么所有指令顺序执行会出现数据不一致的问题呢?
这是CPU中的缓存系统导致的重新排序。由于普通的读写都是面向每个CPU核的内部缓存,读取可能会读取旧值,写入的数据不会立即刷入主存,使得其他CPU核可见。也就是因为CPU中缓存的存在,读写都是“延迟”的。由于读写的时效性无法保证,也出现了“可视性”导致的指令重排问题。
load v.s. storestore两个屏障指令比较好理解,就是CPU物理世界中的读屏障lfence和写屏障sfence,保证load1负载负载;load2中load1读取的数据保证在load2读取之前完成,store1保证;StoreStorestore2中的Store1必须在store2读入数据之前读入数据,并且对其他处理器可见。
请注意,这里的读取数据是指读取那些“最新”的数据,这些数据在其他处理器中是外界可见的,也就是读取内存中的数据,而不是自己缓存中的数据。写数据也是,写数据的标准对其他处理器是可见的,也就是store1的数据会先为store2写入内存,这样其他处理器才能正确获得store1和store2的写入顺序。
但无论是读障碍还是写障碍,它们只保证最新数据的读写顺序,不保证立即执行,即读写可能延迟,如store1StoreStore在store2中,store1和store2的数据可能无法及时刷入内存,但如果刷入,store1必须在store2之前刷入。
同时,每个barrier指令只负责自己的场景,即LoadLoad前后负责读取数据,有先后顺序,但不保证写的顺序,比如load1负载负载;1,store1,因为读写都是延迟的,而LoadLoad并不负责相关的写操作,所以这种情况下,需要有另外两个barrier指令LoadStore StoreLoad来帮助。通过自由组合上述四个指令,我们可以确保as-if-serial原理得以实现。
//初始状态A和B都是0
int a=0,int b=0;
//CPU 0执行foo()
void foo(void) {
a=1;
//StoreStore;
b=1;
}
//CPU 1执行bar()
无效栏(无效){
而(b==0)继续;
//load load;
断言(a==1);
}在上面的代码中,如果没有内存屏障,在CPU0中,b=1可能会先写入主存,导致在CPU1中读取A时,A的值仍然是0。当插入StoreStore屏障时,确保当b=1时,A也是1。然而,如果CPU1中未插入任何负载Load,它仍会导致A读取旧值(a=0)。因此,还需要一个负载读取屏障,以确保A读取最新值a=1。
LoadStoreLoadStore相对于Load和StoreStore来说有点难理解,它在load1LoadStoreStore1,确保load1在store1抓取的数据对其他处理器可见之前读取了最新的数据。这种情况很难用代码来表达。让我们想象一下,在缓存系统中,比如缓存备用模式,当读取数据时,顺序是先查找缓存。如果缓存中没有数据,则继续读取数据库。读取数据库后,将其加载到缓存中。写入数据时,首先更新数据库,然后使缓存失效。这里有一个小概率,当读操作完成读取数据库并加载到缓存中时,后续的写操作刚好更新了数据库并使缓存失效,然后读操作的数据被加载到缓存中,导致缓存中出现脏数据。
类似地,将这里的数据库改为主存,将缓存改为CPU中的缓存是load1LoadStore一号店的情况。记住,如前所述,普通阅读和普通写作都有延迟。即使指令执行的顺序是连续的,但执行完成的顺序并不能保证顺序。所以需要一个名为LoadStore的屏障指令,保证读操作在确认数据已经加载到缓存后,再执行后续的写操作,防止出现乱序。
//初始状态A和B都是0
int a=0,int b=0;
//CPU 0执行foo()
void foo(void) {
断言(a==0);
//LoadStore;
b=1;
}
//CPU 1执行bar()
无效栏(无效){
而(b==0)继续;
//load load;
断言(a==1);
}这里我们假设变量A和B都在同一个缓存线上(缓存线是cpu缓存中最小的读写单位,一个缓存线通常是64字节,可以容纳16个int类型)。在CPU0中,当执行assert(a==0)时,主存储器中的A=0和b=0作为缓存行加载到缓存中。如果没有LoadStore barrier,在缓存线(a=0,b=0)加载到缓存之前会有一个b=1的写操作,然后读操作时的缓存线(a=0,b=0)加载到缓存中,导致CPU1中b的读操作。
StoreLoad和LoadStore类型,它们需要确保store1储存量;只有当load1中store1刷入的数据对其他处理器可见时才能读取。同样,如果不添加屏障指令,因为普通读取和普通写入延迟,你会遇到写入指令还没有使缓存失效,而读取操作已经读取了缓存中的数据,导致脏读。StoreLoad可以说是具备了其他三个指令的全部功能。首先,需要确保存储指令已完成,并且对其他处理器可见,然后读取。写屏障和读屏障都被使用,所以StoreLoad是四个屏障指令中最昂贵的。
为什么LoadStore没有StoreLoad重?因为只需要保证在执行写操作之前完成加载操作,也就是允许延迟写操作,而不需要了解刷出的数据对其他处理器是可见的。在某种程度上,在物理实现领域,LoadLoad类似于LoadStore,但是因为JMM是一个需要跨平台的抽象,所以定义了四个功能完整的指令。
//初始状态A和B都是0
int a=0,int b=0;
boolean isFinished=false
//CPU 0执行foo()
void foo(void) {
a=1;
//存储负载
if(a==1 b==1) {
isFinished=true
}
}
//CPU 1执行bar()
无效栏(无效){
b=1;
//存储负载
if(a==1 b==1) {
isFinished=true
}
}上面代码的意图是在执行了a=1和b=1这两个操作后,将Iscompleted设置为true。显然,如果遵循as-if-serial原则,无论是先执行CPU0的代码还是先执行CPU1的代码,总会有一个最终使Iscompleted为真。但是,如果没有StoreLoad barrier,即使a=1和b=1在后续读取操作之前完成,由于不可见,CPU0读取的B的值仍然是0,CPU1读取的A的值仍然是1,导致isFinished仍然为false。
我什么时候需要插入记忆屏障?因为内存屏障的指令成本高于普通指令(涉及总线锁或缓存锁),所以并不是所有指令都需要在中间插入内存屏障。java规范中给出了插入每个屏障的时间。
以及相应的代码示例:
物理世界中的内存壁垒在物理实现中,除了cache,CPU中还有StoreBuffer和InvalidQueue来加速cache处理的实现。其总体示意图如下:
对于写操作,为了优化写性能,会先写入storebuffer队列,然后向其他CPU发送Invalidate消息。收到ack后,它会将数据刷入缓存行。同样,为了加快invalid的ack回复速度,cpu会将所有的无效消息保存在无效队列中,但在必要时会通过清除无效队列来更新缓存行。
为了避免单个CPU核的读写顺序问题,读取时会先从storebuffer中获取数据,以保证单个核内部数据一致。
由于storebuffer和invalidateQueue的存在,读写都有延迟,无法直接写入缓存或者从缓存中读取最新值。所以CPU提供了一个读屏障和一个写屏障,其中读屏障会清空InvalidateQueue中的消息,使对应的缓存行直接无效,从而保证所有最新的值都被读取。写屏障是直接刷新存储缓冲区中的数据,直接将所有数据写入缓存。
以上是CPU Cache工作的简化示意图。真正的CPU比这个复杂多了。由于JMM要解决跨平台兼容性,四种屏障指令被映射到物理世界:
从图中可以看出,在x86系统中,除了StoreLoad,其他三条指令都是no-op,为什么?原因是x86 CPU中没有InvalidateQueue,所有读操作都是及时的,所以不需要读屏障,也就是LoadLoad是no-op .而且因为storebuffer的关系,对于StoreStore的写入,其自身写入storebuffer是有序的。LoadStore是因为读操作是及时的,而写操作写入storebuffer是延迟的,所以保证了加载操作必须先于存储。所以,唯一需要解决的就是StoreLoad。所以x86里面有很多mfence或者cpuid或者locked insn的方式。因为locked最成熟,性能更好,所以java中StoreLoad的实现使用了locked指令。
Java规范解释了为什么StoreLoad在JDK是用locked实现的。它的原文是:
在x86上,任何带锁前缀的指令都可以用作StoreLoad屏障。(linux内核中使用的形式是无操作锁;添加$0,0(%%esp)。)支持“SSE2”扩展的版本(Pentium4和更高版本)支持mfence指令,这似乎更可取,除非无论如何都需要像CAS这样的带锁前缀的指令。cpuid指令也能工作,但速度较慢。翻译过来,早期的intel处理器只支持locked和cpuid,后来的Pentium 4开始支持mfence。虽然mfence比locked有更好的性能,但出于简单性和向前兼容性的考虑,仍然选择了locked。至于cpuid,性能不如locked指令。
对应于no-op的其他三个壁垒的使用是没有成本的吗?其实是有的。JDK将在这三类屏障中插入禁止操作指令。当编译器和CPU看到无操作指令时,它们将不再优化指令(以确保顺序)。所以有价格,但是价格比StoreLoad低很多。这也从另一个角度解释了我前面说的:StoreLoad命令是最贵的,同时具有其他三个屏障的效果。
版权归作者所有:原创作品来自博主小二上九8,转载请联系作者取得转载授权,否则将追究法律责任。
郑重声明:本文由网友发布,不代表盛行IT的观点,版权归原作者所有,仅为传播更多信息之目的,如有侵权请联系,我们将第一时间修改或删除,多谢。