volatile的底层原理与实现

volatile的底层原理

volatile的两个作用:

  • 可见性
  • 防止指令重排序

计算机的组成

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

volatile的底层原理与实现_第1张图片

存储器的层次结构

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

volatile的底层原理与实现_第2张图片

为什么会有存储器分级策略?从理论上来说,我们希望存储器速度快、体积小、空间大、能耗低、散热好、断电数据不丢失,成本低。但是在现实中,这些条件是无法同时满足的,比如存储器的体积越小,那么存储空间就会受到制约,电子元件的密度越大,产生的热量越集中,散热就会越差。所以在现实中我们会对上面的一些需求进行权衡与取舍,根据数据的使用频率使用不同的存储器:高频使用的数据,读写越快越好,因此用最贵的材料,放到离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,那么一个周期是十亿分之一秒,也就是1纳秒。

CPU对高速缓存的读取速度差不多是对内存的100倍,相差两个数量级。

缓存行

缓存行(CacheLine)是位于CPU和内存中间的高速缓存。高速缓存一般分为三级,分别为L1、L2、L3,在计算机中的分布如下图:

volatile的底层原理与实现_第3张图片

说明:

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

怎么查看自己电脑高速缓存的大小呢?使用cpu-z工具查看,下载地址:https://www.cpuid.com。

volatile的底层原理与实现_第4张图片

volatile的底层原理与实现_第5张图片

细心的你肯定发现了上面图中一级缓存又细分为指令缓存和数据缓存,为什么L1要一分为二,L2和L3不要区分呢?

一级缓存可分为一级指令缓存和一级数据缓存,一级指令缓存用于暂时存储并向CPU递送各类运算指令;一级数据缓存用于暂时存储并向CPU递送运算所需数据,这就是一级缓存的作用。CPU执行指令的速度非常快,所以要先预读一些指令到一级指令缓存中,如果数据和指令都放在L1中,一旦数据覆盖了指令,那么计算机将无法正确执行了,因此L1需要划分为两个区域,而L2和L3不需要参与指令的预读,所以不需要划分。

超线程

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

超线程技术在CPU内部的实现如下图:

volatile的底层原理与实现_第6张图片

所谓超线程就是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类换成下面的,其余保持不变:

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;
}

运行结果如下:

12710ms
例21270ms

运行结果原因分析:

  • 例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

运行结果分析:假如不存在指令重排序,上面代码的执行顺序只会有以下几种情况:

// 情况1
a = 1; // t1
x = b; // t1
b = 1; // t2
y = a; // t2

// 情况2
b = 1; // t2
y = a; // t2
a = 1; // t1
x = b; // t1

// 情况3
a = 1; // t1
x = b; // t2
b = 1; // t2
y = a; // t1

// 情况4
x = b; // t2
a = 1; // t1
y = a; // t1
b = 1; // t2

不管出现哪种情况,x和y不可能同时为0,只有出现指令重排序,x和y才会同时为0,程序才会退出。

DCL为什么要加volatile

下面是双重检查同步锁(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()创建一个对象,编译后的字节码包含如下5条指令:

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

既然jvm中将创建一个对象分为了5条指令,那么这5条指令就有可能发生指令重排序,假如invokespecialastore_1发生了指令重排序,线程1先在内存中分配内存空间并为变量赋予默认值(对象的默认值为null),然后直接将内存中的对象地址返回给变量o(并未调用构造方法),此时线程2开始执行,发现变量o不为空,直接使用对象o的对象变量(此变量默认为空,在构造方法中初始化)的一些属性,就会抛出空指针异常。

如果对这个变量使用volatile修饰,那么jvm就会使用内存屏障来防止指令重排序。

两个疑惑:

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

什么样的指令可以重排序?

对cpu来说,基本上任何指令都可以实现重排序,因为这样可以提高性能,除了一些lock或禁止重排序的指令外。

对jvm来说,jvm规范中提到了happens-before原则,也就是不在下面8条原则中的指令都可以重排序:

  • 程序次序原则:在一个线程内,代码按照编写时的次序执行(jvm会对指令重排序,但是会保证最终一致性)。
  • 锁定原则:如果一个锁是锁定状态,要先unlock后才能lock。
  • volatile变量规则:对变量的写操作要先于对变量的读操作。
  • 传递规则:A先于B,B先于C,那么A先于C。
  • 线程启动规则:线程的start()方法先于run()方法运行。
  • 线程中断规则:线程收到了中断信号,那么之前一定有interrupt()。
  • 线程终结规则:线程的任务执行单元要发生在线程死亡之前。
  • 对象的终结规则:线程的初始化先于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的读写请求都会被阻塞,直到锁释放。

你可能感兴趣的:(多线程与高并发,1024程序员节,volatile,多线程,cpu,缓存)