Volatile原理深度剖析

熟悉Java并发编程的程序员应该对于volatile,synchronized关键都不陌生,这两个关键字是并发编程的基础,在之前笔者看过很多关于volatile关键字的解析博客,讲述的也比较详细,但是感觉不是很全面,今天总结了一下自己之前所学的知识,想比较全面的写一篇关于volatile关键字原理的文章,分享给大家。

转发请标明原文链接:原文链接

Volatile实现原理

对于volatile的解释,我相信更直白的说就是对于一个被volatile关键字修饰的变量,在并发情况下Java内存模型(JMM)保证每个线程对该变量的可见性,保证他们读取的数据是一致的,因此volatile实现了数据的可见性,有序性,但不保证原子性(下文会详细解释)。但是怎样保证可见性的呢?在jvm底层对于volatile修饰的共享变量进行写操作的时候主要实现了两个步骤:

  1. 将当前处理器缓存行(下文会有详细说明)的数据写回到系统内存。
  2. 将其他处理器中缓存了该数据的缓存行设置为无效。

Java程序执行时会编译为字节码通过加载器加载到JVM中,JVM执行字节码最终将其转变为汇编代码相关的CPU指令,因此对于使用该关键字修饰的变量,将其转变为汇编指令后比其他普通的变量多一行以Lock为前缀的指令,因此在对变量执行写操作的时候JVM会向处理器发送一条Lock#指令将缓存行中的数据更新到主存相应的地址中,但是此时的数据就会和其他处理器缓存行中的数据不一致,此时为了保证数据的一致性,就会实现缓存一致性协议,每一个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改过,就会将该缓存行设置为无效转态,当处理器需要读取该数据的时候,就会重新从主存中读取到缓存行。

系统的梳理一下volatile的实现原理:

  1. 当volatile修饰的变量进行写操作的时候,JVM就会向CPU发送LOCK#前缀指令,此时当前处理器的缓存行就会被锁定,通过缓存一致性机制确保修改的原子性,然后更新对应的主存地址的数据。
  2. 处理器会使用嗅探技术保证在当前处理器缓存行,主存和其他处理器缓存行的数据的在总线上保持一致。在JVM通过LOCK前缀指令更新了当前处理器的数据之后,其他处理器就会嗅探到数据不一致,从而使当前缓存行失效,当需要用到该数据时直接去内存中读取,保证读取到的数据时修改后的值。

可见性
并发的不可缺少的条件,对于volatile来说,它会使当前处理器缓存行的数据更新到内存,然后强制使其他处理器上存储该数据的缓存行失效,保证了数据的可见性与并发条件下的数据一致性。

有序性
对于有序性,volatile通过内存屏障来维护,硬件层的内存屏障主要分为两种Load Barrier,Store Barrier,即读屏障和写屏障。对于Java内存屏障来说,它分为四种,即这两种屏障的排列组合。

  1. LoadLoad:即load2操作对于数据的读取必须在load1操作读取完毕之后。
  2. LoadStroe:即Store2写操作必须在load1操作读取完毕之后进行。
  3. StoreStore:Store2操作写入之前必须保证Store1操作写入对于其他处理器可见。
  4. StoreLoad:即Load2操作读取数据之前必须保证Store1操作写入的数据对于其他处理器可见。

    因此使用内存屏障,禁止在内存屏障中的指令重排序,即保证了指令的有序性。

原子性
volatile关键字修改的变量并不具有原子性,首先看一段代码:

package cn.just.thread.concurrent;

public class TestVolatile {
    public volatile int a=0;
    public static void main(String[] args) {
        TestVolatile test=new TestVolatile();
        for(int i=0;i<1000;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j=0;j<100;j++){
                        test.a++;
                    }
                }
            }).start();
        }
        System.out.println(test.a);

    }
}

如果变量具有原子性,那么结果会是100000,但是实际运行结果却不如我们所愿,总是小于100000,这也说明了volatile并不能保证原子性。但是细心的读者会发现,上面我们讲述了volatile会使用缓存一致性机制对缓存行进行锁定,即保证了缓存行的一致性,那么有为何在缓存行中变量不具有原子性呢?我们来从两个方面来说明。对于a+1操作,这条在代码层面上的指令在底层执行时并不具有原子性,换句话说就是原子性必须保证读取,修改,赋值这整个操作的原子性,但实际上并没有,因此上面代码中a变量即使使用volatile关键字修饰,但也不能起到原子性的作用。

另一方面,我们来假设一个场景,两个线程需要对a=1的值进行修改,线程1拿到缓存行中a的值,但是还没得及修改,线程2也已经取到另一个处理器中缓存行中的值,并且刷新到主存当中,a=2,此时其他处理器中缓存行的数据失效,这时线程1也将修改的a的值a=2写到缓存中,并且写会主存,因此其他缓存行失效,这时就出现了线程安全,数据不一致的问题,两个线程都对a进行加1修改操作,但是a=2,它的值只相加了1。这样看来,volatile关键字修改的变量即使在缓存行中也不具有原子性了。

下面来讲解一下上面涉及到知识点。

缓存行

从操作系统的层面,为了降低IO速度,处理器不直接和内存进行通信,而是先将内存中的数据读到内存缓存中,然后再进行操作,内存缓存分为多个等级,靠近CPU的缓存区越小,处理速度也越快,后面靠近主存的越大,处理速度也越慢。
这里写图片描述

如上图所示,靠近CPU的L1缓存区要比靠近主存的L3缓存区处理的速度要快,但是它的大小也比较小一点。缓存区中最小的处理单位是缓存行,缓存行的大小是2的整数次幂,在32-256字节之间,一般情况为64字节。在多线程访问缓存行的时候处理不当就会引发内存的伪共享以及性能的下降。下面来说一下伪共享。

伪共享

上面说到一个缓存行的大小一般为64个字节大小,这里我们来做一个假设,当缓存行中有两个变量a,b,当一个线程对a变量进行写操作的时候,会将另一个处理器中缓存行中b设置为无效,另一个线程在对b进行写操作的时候,会将其他处理器缓存行中的a变量设置为无效,这样就产生了写冲突,也叫伪共享。

一个缓存行的大小为64个字节,当它不足64个字节大小的时候,就会产生免费缓存,即缓存行会将缓存的当前数据相邻的数据也加入到缓存行中。这时,如果相邻的数据之间并没有什么关系,以一个链表为例,如果一个线程需要访问head节点的时候,就会锁定当前缓存行,不允许其他线程进行修改,明明可以在并发环境下生成者可以在tail中添加数据,消费者可以读取数据,但是此时就不能并行,因此性能也会降低不少。同样的,有两个线程读取同一个缓存行中不相关的数据时,当一个线程更新其中一个变量的数据之后就会使另一个变量失效,下一个线程使用时就需要去主存中读取,这样也是给性能带来了影响。那么,如何解决伪共享问题以及缓存行带来性能的问题呢?有一个更好的办法就是缓存行填充

缓存行填充

引入缓存行填充的目的是为了解决伪共享,它的原理就是使用追加字节的方式填充64个字节,从而不会发生免费缓存,因此在不会发生伪共享或者因为访问不同的数据从而带来的性能的影响。下面看具体怎么实现,比如下面的代码:

public volatile long a;

我们将a用volatile关键字修饰。这时a被加入到缓存行中占用8个字节的大小,如果不适用缓存行填充就会发生免费缓存,引发上面所讲述的问题,这时我们可以使用追加字节的方式来填充到64字节,因此对于a可以进行如下封装:

public class CacheFill{
    public volatile long a;
    object a1,a2,a3,a4,a5,a6,a7,a8,a9,pa,pb,pc,pd;
}

我们通过填充字段来达到64个字节,a占用8个字节,a1到pd字段占用13*4=52个字节,对象头占用4个字节,因此8+52+4=64字节。

在JDK内部的Disruptor框架中就使用了缓存行填充。不过也并不是使用volatile关键字修饰的变量都需要使用缓存行填充,下面两种情况下可以例外:

  1. 缓存行非64字节宽的处理器。
  2. 共享变量不会频繁的被读写。

以上就是对于volatile关键字原理详解,如有问题可以留言讨论,欢迎指教!!!

猜你喜欢

转载自blog.csdn.net/qq_37142346/article/details/80870309