当前位置: 首页 > 图灵资讯 > 技术篇> volatile的底层原理与实现

volatile的底层原理与实现

来源:图灵教育
时间:2023-04-24 10:21:30

volatile的底层原理

volatile的两个功能:

  • 可见性
  • 防止指令重新排序
计算机的组成

下图是一个典型的计算机结构图,主要包括CPU、存储器(内存)、IO(输入输出设备)。

volatile的底层原理与实现_volatile

存储器的层次结构

下图是计算机中存储器的层次结构。离CPU越近,访问速度越快,成本越高。最快的存储器是CPU内部的寄存器。

volatile的底层原理与实现_volatile_02

为什么会有存储分级策略?理论上,我们希望存储器速度快,体积小,空间大,能耗低,散热好,断电数据不丢失,成本低。然而,在现实中,这些条件不能同时满足。例如,存储器的体积越小,存储空间就会受到限制。电子元件的密度越大,产生的热量越集中,散热就越差。因此,在现实中,我们将权衡和选择上述需求,并根据数据的使用频率使用不同的存储器:高频数据,读写越快越好,所以使用最昂贵的材料,放在离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在计算机中的分布如下图所示:

volatile的底层原理与实现_cpu_03

说明:

  1. L1、L2位于核内,核独享。
  2. L3位于CPU内,多个核共享。

如何检查计算机高速缓存的大小?使用cpu-z工具查看,下载地址:https://www.cpuid.com。

volatile的底层原理与实现_1024程序员节_04

volatile的底层原理与实现_1024程序员节_05

小心点,你一定发现上图中的一级缓存分为指令缓存和数据缓存。为什么L1要分为两部分,L2和L3不区分?

一级缓存可分为一级指令缓存和一级数据缓存。一级指令缓存用于临时存储和向CPU发送各种操作指令;一级数据缓存用于临时存储和向CPU发送操作所需的数据,这是一级缓存的功能。CPU执行指令非常快,所以预读一些指令到一级指令缓存,如果数据和指令在L1中,一旦数据覆盖指令,计算机将无法正确执行,所以L1需要分为两个区域,L2和L3不需要参与指令预读,所以不需要划分。

超线程

超线程是英特尔同时运行两个线程的核技术。

CPU内超线程技术的实现如下图所示:

volatile的底层原理与实现_缓存_06

所谓超线程,就是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].xarr[1].x它很可能位于同一缓存行中,所以在多个核中都有高速缓存arr[0].xarr[1].x当线程t1对的副本arr[0].x修改过程中,其他核中的缓存线会失效,导致其他线程需要从内存中读取每次数据的操作,高速缓存没有得到充分利用,影响性能。
  • 例2:变量x前后填充7个long变量,即前后填充56个字节,x本身共64个字节,缓存行大小为64个字节,保证arr[0].xarr[1].x当线程t1和线程t2分别对同一缓存行进行时,它们肯定不会在同一缓存行中arr[0].xarr[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;    }}

那么为什么instancevolatile必须修改变量?

让我们来看看一个对象的创建过程和使用Object o = new Object()编译后的字节码包含以下五个指令来创建对象:

new #2 <java/lang/Object> 内存空间在内存中分配,默认值dupinvokespecial用于变量 #1 <java/lang/Object.<init>> 调用结构方法,赋予变量初始值astore_1 # 在内存中建立对象与变量o的引用关系return

由于jvm将创建一个对象分为五个指令,如果这五个指令重新排序,则可能会发生这五个指令invokespecialastore_1当指令重新排序时,线程1首先在内存中分配内存空间,并赋予变量默认值(对象默认值为null),然后直接将内存中的对象地址返回到变量o(未调用结构方法)。此时,线程2开始执行,发现变量o不是空的,直接使用对象o的对象变量(默认为空的变量,在结构方法中初始化)的一些属性将抛出空指针异常。

如果使用volatile来修改这个变量,jvm将使用内存屏障来防止指令重新排序。

两个疑惑:

  1. 什么样的指令可以重新排序?
  2. 如何防止指令重新排序内存屏障?
什么样的指令可以重新排序?

对于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的读写请求将被阻塞,直到锁释放。