volatile的底层原理
volatile的两个功能:
- 可见性
- 防止指令重新排序
下图是一个典型的计算机结构图,主要包括CPU、存储器(内存)、IO(输入输出设备)。
存储器的层次结构下图是计算机中存储器的层次结构。离CPU越近,访问速度越快,成本越高。最快的存储器是CPU内部的寄存器。
为什么会有存储分级策略?理论上,我们希望存储器速度快,体积小,空间大,能耗低,散热好,断电数据不丢失,成本低。然而,在现实中,这些条件不能同时满足。例如,存储器的体积越小,存储空间就会受到限制。电子元件的密度越大,产生的热量越集中,散热就越差。因此,在现实中,我们将权衡和选择上述需求,并根据数据的使用频率使用不同的存储器:高频数据,读写越快越好,所以使用最昂贵的材料,放在离CPU最近的位置;数据使用频率越低,离CPU越远,材料越便宜。
CPU对每个存储器的访问速度和容量的比较如下表所示:
存储器
速度(单位:时钟周期)
容量
寄存器
1
<1KB
L1
2~4
几十KB
L2
10~20
几百KB
L3
20~60
几M
内存
200~300
G
磁盘
2000~200000
T/P
一个时钟周期是多久?这与CPU的频率有关。假设CPU的频率是1GHZ,那么一个周期是10亿分之一秒,也就是1纳秒。
缓存行CPU对高速缓存的读取速度几乎是内存的100倍,相差两个数量级。
缓存行(CacheLine)它是位于CPU和内存之间的高速缓存。高速缓存一般分为L1三级、L2、L3在计算机中的分布如下图所示:
说明:
- L1、L2位于核内,核独享。
- L3位于CPU内,多个核共享。
如何检查计算机高速缓存的大小?使用cpu-z工具查看,下载地址:https://www.cpuid.com。
小心点,你一定发现上图中的一级缓存分为指令缓存和数据缓存。为什么L1要分为两部分,L2和L3不区分?
一级缓存可分为一级指令缓存和一级数据缓存。一级指令缓存用于临时存储和向CPU发送各种操作指令;一级数据缓存用于临时存储和向CPU发送操作所需的数据,这是一级缓存的功能。CPU执行指令非常快,所以预读一些指令到一级指令缓存,如果数据和指令在L1中,一旦数据覆盖指令,计算机将无法正确执行,所以L1需要分为两个区域,L2和L3不需要参与指令预读,所以不需要划分。
超线程超线程是英特尔同时运行两个线程的核技术。
CPU内超线程技术的实现如下图所示:
一致性协议缓存所谓超线程,就是CPU核中有两组寄存器和控制器,分别分配给两个线程,避免了寄存器和控制器之间的上下文切换,同时运行两个线程,但运算器仍然共享,需要上下文切换。
在多核CPU中,由于高速缓存,多核高速缓存中会有一个数据副本。当一个核数据修改时,另一个核数据会出现不一致性问题,缓存一致性协议旨在解决CPU高速缓存中的数据不一致性问题。
MSI等缓存一致性协议有很多种,MESI,MOSI,Synapse,Firefly、DragonProtocol等。
以下是英特尔芯片提供的缓存一致性协议MESI,MESI是以下四种状态首字母大写的组合:
状态
描述
说明
M(modify)
修改
目前,CPU刚刚修改了数据状态。目前,CPU有最新数据,其他CPU有无效数据,与主存数据不一致
E(exclusive)
独占
只有当前CPU中有数据,其他CPU中没有改变数据。当前CPU的数据与主存储的数据一致
S(shared)
共享
目前,CPU和其他CPU都有共同的数据,并且与主存中的数据一致
I(invalid)
失效
当前CPU中的数据无效,数据应从主存储中获取,其他CPU中可能有数据或无数据;当前CPU中的数据与主存储中的数据不一致。
MESI的主要原理是:当前CPU修改数据后,当前CPU的数据状态为M(修改),其他CPU中的数据状态为I(故障),以便其他CPU在操作数据时首先从内存中读取,以确保数据的一致性。
局部原理和缓存行时间局部性:CPU读取数据的顺序是寄存器->L1->L2->L3->内存,当从内存中读取数据时,将数据一次放入L3、L2、在L1中,CPU下次再读取此数据时直接从L1中获取,无需读取内存。CPU认为程序在短时间内多次操作相同的数据,因此将数据存储在高速缓存中。
空间局部性:CPU认为从内存中读取数据,下次访问可能是旁边的数据,所以预读,一次性预读大小一般为64Byte,64字节大小一般称为缓冲线,即CPU读取缓冲线大小。
以下是一个证明缓冲行存在的例子:
例1:
package com.morris.concurrent.volatiledemo;public class CacheLinePadding { private static class T { public volatile long x = 0L; } public static T[] arr = new T[2]; static { arr[0] = new T(); arr[1] = new T(); } public static void main(String[] args) throws Exception { Thread t1 = new Thread(() -> { for (long i = 0; i < 1000_0000L; i++) { arr[0].x = i; } }); Thread t2 = new Thread(() -> { for (long i = 0; i < 1000_0000L; i++) { arr[1].x = i; } }); final long start = System.nanoTime(); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println((System.nanoTime() - start) / 1_000_000); }}
例2:将例1中的T类换成下面的T类,其余保持不变:
private static class T { public volatile long p1, p2, p3, p4, p5, p6, p7; public volatile long x = 0L; public volatile long p8, p9, p10, p11, p12, p13, p14;}
运行结果如下:
例1:2710ms例2:1270ms
运行结果原因分析:
- 例1:
arr[0].x
与arr[1].x
它很可能位于同一缓存行中,所以在多个核中都有高速缓存arr[0].x
和arr[1].x
当线程t1对的副本arr[0].x
修改过程中,其他核中的缓存线会失效,导致其他线程需要从内存中读取每次数据的操作,高速缓存没有得到充分利用,影响性能。 - 例2:变量x前后填充7个long变量,即前后填充56个字节,x本身共64个字节,缓存行大小为64个字节,保证
arr[0].x
与arr[1].x
当线程t1和线程t2分别对同一缓存行进行时,它们肯定不会在同一缓存行中arr[0].x
与arr[1].x
写作时,不需要使用缓存一致性协议来保证数据的一致性,充分利用高速缓存,从而提高性能。
提供jdk@sun.misc.Contended
注释实现缓存行对齐,无需手动填充变量,运行时需要设置JVM启动参数-XX:-RestrictContended
,使用方法如下:
private static class T { @sun.misc.Contended public volatile long x = 0L;}
可见性
使用上述缓存一致性协议实现volitale的可见性底层。
防止指令重新排序以下代码可以证明jvm将重新排序指令:
package com.morris.concurrent.volatiledemo;public class DisOrder { private static int x = 0, y = 0; private static int a = 0, b = 0; public static void main(String[] args) throws InterruptedException { int i = 0; for (; ; ) { i++; x = 0; y = 0; a = 0; b = 0; Thread one = new Thread(() -> { a = 1; x = b; }); Thread other = new Thread(() -> { b = 1; y = a; }); one.start(); other.start(); one.join(); other.join(); if (x == 0 && y == 0) { System.err.println("第" + i + "次 (" + x + "," + y + ")"); break; } } }}
运行结果如下:
第35440次 (0,0)
操作结果分析:如果没有指令重新排序,上述代码的执行顺序只有以下情况:
// 情况1a = 1; // t1x = b; // t1b = 1; // t2y = a; // t2// 情况2b = 1; // t2y = a; // t2a = 1; // t1x = b; // t1// 情况3a = 1; // t1x = b; // t2b = 1; // t2y = a; // t1// 情况4x = b; // t2a = 1; // t1y = a; // t1b = 1; // t2
无论发生什么情况,x和y都不能同时为0,只有当指令重新排序时,x和y才会同时为0,程序才会退出。
为什么DCL添加volatilele?以下是双重检查同步锁(Double Check Lock)+volatile实现懒汉单例。
package com.morris.concurrent.volatiledemo;public final class Singleton { private static volatile Singleton instance; private Singleton() { } public static Singleton getInstance() { if(null != instance) { synchronized (instance) { if(null != instance) { instance = new Singleton(); } } } return instance; }}
那么为什么instance
volatile必须修改变量?
让我们来看看一个对象的创建过程和使用Object o = new Object()
编译后的字节码包含以下五个指令来创建对象:
new #2 <java/lang/Object> 内存空间在内存中分配,默认值dupinvokespecial用于变量 #1 <java/lang/Object.<init>> 调用结构方法,赋予变量初始值astore_1 # 在内存中建立对象与变量o的引用关系return
由于jvm将创建一个对象分为五个指令,如果这五个指令重新排序,则可能会发生这五个指令invokespecial
与astore_1
当指令重新排序时,线程1首先在内存中分配内存空间,并赋予变量默认值(对象默认值为null),然后直接将内存中的对象地址返回到变量o(未调用结构方法)。此时,线程2开始执行,发现变量o不是空的,直接使用对象o的对象变量(默认为空的变量,在结构方法中初始化)的一些属性将抛出空指针异常。
如果使用volatile来修改这个变量,jvm将使用内存屏障来防止指令重新排序。
两个疑惑:
- 什么样的指令可以重新排序?
- 如何防止指令重新排序内存屏障?
对于cpu来说,除了一些lock或禁止重排序的指令外,基本上任何指令都可以重排序,因为它可以提高性能。
对jvm而言,jvm规范中提到了happens-before原则,即不在以下八条原则中的指令可以重新排序:
- 程序顺序原则:在一个线程中,代码按编写时的顺序执行(jvm将重新排序指令,但最终一致性将得到保证)。
- 锁定原则:如果锁处于锁定状态,则需要unlock才能lock。
- volatile变量规则:在读取变量之前,对变量进行写作。
- 传输规则:A先于B,B先于C,A先于C。
- 线程启动规则:线程的start()方法先于run()方法。
- 线程中断规则:如果线程收到中断信号,以前必须有interupt()。
- 线程终结规则:线程任务执行单元在线程死亡前发生。
- 对象的终结规则:线程的初始化在finalize()方法之前。
内存屏障包括以下四个指令:
- LoadLoad屏障:对于这样的句子
Load1; LoadLoad; Load2
,在访问Load2和后续读取操作中要读取的数据之前,确保读取Load1要读取的数据。 - StoreStore屏障:对于这样的句子
Store1; StoreStore; Store2
,在Store2和后续写入操作执行之前,确保Store1的写入操作可见于其他处理器。 - LoadStore屏障:对于这样的句子
Load1; LoadStore; Store2
,在执行Store2和后续写入操作之前,确保读取Load1要读取的数据。 - Storeload屏障:对于这样的句子
Store1; StoreLoad; Load2
,在Load2和所有后续读取操作执行之前,确保所有处理器都能看到Store1的写入。
hotspot层面是如何实现的?
bytecodeinterpreter.cpp
int field_offset = cache->f2_as_index();if (cache->is_volatile()) { if (support_IRIW_for_not_multiple_copy_atomic_cpu) { OrderAccess::fence(); }
orderaccess_linux_x86.inline.hpp
inline void OrderAccess::fence() { if (os::is_MP()) { // always use locked addl since mfence is sometimes expensive#ifdef AMD64 __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");#else __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");#endif }}
Lock前缀首先锁定总线和缓存,然后执行以下指令,最后释放高速缓存中的所有数据。当Lock锁定总线时,其他CPU的读写请求将被阻塞,直到锁释放。