likes
comments
collection
share

一篇文章吃透volatile常见面试问题,可见性、JMM、指令重排等。**volatile**的主要作用是确保多线程环境

作者站长头像
站长
· 阅读数 25

介绍

volatile是Java语言提供的一种轻量级的同步机制,主要用于确保多线程环境下对变量的可见性。当一个线程修改了一个volatile变量的值,这个修改对其他线程是立即可见的,也就是说,如果一个线程修改了一个volatile修饰的变量的值,那么其他线程在读取这个变量的值时会立刻看到最新修改的值,而不会使用之前的缓存值。这一点对于保证多线程间的数据一致性非常重要。

然而,volatile并不能保证对变量的操作是原子的。如果多个线程同时对一个volatile变量进行读写操作,可能会导致竞态条件和不一致的结果(在原理篇会举例说明)。因此,要保证原子性操作,通常需要使用其他同步机制,如互斥锁或原子操作。

此外,volatile还可以禁止编译器优化,即禁止指令重排序。编译器在编译代码时会进行优化,可能会删除或重新排序一些看似无用的变量读写操作,以提高代码执行效率。但在多线程环境下,这种优化可能会导致问题,因为一个线程修改了变量的值,但其他线程可能不会立即看到这个变化。使用volatile告诉编译器不要对变量进行这种优化,确保变量的读写操作按照代码的顺序执行。

下面我们就根据上述三个特性分别来介绍volatile的具体原理。

原理

可见性

可见性问题的根本原因是:相对CPU运算来说,去主内存拿数据这个指令太慢了,此时CPU就提供了L1,L2,L3的三级缓存,每次去主内存拿完数据后,就会存储到CPU缓存中,后续cpu运算就从cpu缓存中获取数据,以便效率提升运算效率。但此时如果多核cpu参与运算同一逻辑,每个cpu的缓存都是独立,各个cpu无法看对其他cpu的运算结果,最后就会导致运算不符合预期。这里我贴了一张百度来的cpu三级缓存架构图:

一篇文章吃透volatile常见面试问题,可见性、JMM、指令重排等。**volatile**的主要作用是确保多线程环境

上述就是一种cpu的三级缓存架构图,而市面上不仅仅这一种架构方式。jvm为了实现一次编译导出运行,它自己实现了一套管理线程的内存模型(jmm),屏蔽了底层的差异,如下图:

一篇文章吃透volatile常见面试问题,可见性、JMM、指令重排等。**volatile**的主要作用是确保多线程环境 注意jmm不是jvm运行时数据区,很多文章都会把这两个概念搞混。jmm与jvm内存数据取是两个不同的层次的概念。jmm描述的是一组内存处理规则,通过这组规则控制各个变量在主内存区域和线程私有内存区域的访问方式。除此之外jmm和上面的cpu缓存架构图很相像,很多人会以为主内存对应的就是cpu层面的主内存,工作内存对应的是cpu缓冲;这种理解是错误的,jmm是一种抽象的概念,并不实际存在,不管是工作内存还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,既有可能存储到主内存中也有可能存储在cpu缓存或寄存器中,因此jmm和计算机硬件内存架构是一个相互交叉的关系。

下面先看一下,加了volatile字节码层面会有什么变化。代码:

public class Test {
    int i;
    volatile int j;
}

执行javac Test.java,javap -v Test.class,观察结果:

  int i;
    descriptor: I
    flags: (0x0000)

  volatile int j;
    descriptor: I
    flags: (0x0040) ACC_VOLATILE

字节码层面仅仅是增加了ACC_VOLATILE标识。下面我们来看一段有不可见性的程序:

private static boolean flag = true;

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
        while (flag) {
        }
        System.out.println("线程结束");
    });

    t.start();
    Thread.sleep(100);
    flag = false;
    System.out.println("flag更改为false");
}

上述代码是开启一个线程,线程判断flag的值,如果一直是true就会一直循环;此时主线程把该标识更改为false理论上是会跳出循环执行线程结束的;可是当使用open jdk8的时结果只会打印flag更改为false,不会跳出循环;当然有jdk版本,由于内部做了优化会跳出循环并打印线程结束的,例如zulu-11等。如果在jdk8想跳出循环,就需要给flag属性增加volatile标识,大家可以自己尝试一下会直接跳出循环的。

经过前面可见性的介绍以及代码示例,可以知道:

如果属性被volatile修饰,对当前属性的操作,不允许直接使用线程工作内存,必须在主内存中操作。

  • volatile属性被写:当写一个volatile变量,JMM会将当前线程工作内存中的缓存及时刷新到主内存中;
  • volatile属性被读:当读一个volatile变量,JMM会将对应线程工作内存中的缓存设置为无效,去主内存中重新读取共享变量。

伪共享

在cpu读存数据的时候,每次都会读取一行(64字节),如果存在这样的场景,有多个线程操作不同的成员变量,但是相同的缓存行,此时就会发生伪共享问题就发生了。百度来了一个示例图:

一篇文章吃透volatile常见面试问题,可见性、JMM、指令重排等。**volatile**的主要作用是确保多线程环境

可以看到core1使用变量X,core2使用变量Y,这两个变量在同一缓存行中,虽然实际上他们没有竞争关系,但由于共同占用一行缓存行,他们会互相抢占,在平时开发的过程中可以在前面插入一些占位变量来解决伪共享问题。

原子性

volatile并不能保证对变量的操作是原子的,这里只需要把一段代码转为汇编语言大家看一下就明白了。代码如下:

private volatile int j = 0;

public void test(){
    j ++;
}

volatile修饰的属性i进行++操作,使用javac ,javap -v 进行查看汇编指令为:

public void test(); 
    // 方法描述符:无参数,返回值为 
    void descriptor: ()V 
    // 访问标志:公共方法 
    flags: (0x0001) ACC_PUBLIC 
    Code: 
    // 操作数栈所需的最大深度为 3,局部变量表大小为 1,参数数量为 1 
        stack=3, locals=1, args_size=1 
            0: aload_0 
            // 将当前对象引用加载到操作数栈 
            1: dup 
          ***  // 复制当前对象引用 ***
            2: getfield #7 
            // 从当前对象获取指定字段(可能是名为 'j' 的整数字段) 
            5: iconst_1 
            // 将整数 1 压入操作数栈 
            6: iadd 
       ***     // 将操作数栈顶的两个整数相加 ***
            7: putfield #7 // 将相加结果存储回对象的指定字段 
            10: return // 方法返回

通过汇编可以看到,在第2处,在7处存放值,中间还有其他操作,这中间的过程不是加锁的,所以是没办法保证原子性的。

禁止编译器优化

在java程序运行的过程中会存在两种指令级别的优化。一种是cpu级别的优化,CPU在执行指令时,为了提升执行效率,会在不影响最终结果的前提下(满足一些要求),对指令进行重排。另一种是编译器级别的优化,它也是为了提升系统运行效率而进行的优化。代码如下:

static int a,b,x,y;

public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < Integer.MAX_VALUE; i++) {
        a = 0;
        b = 0;
        x = 0;
        y = 0;

        Thread t1 = new Thread(() -> {
            a = 1;
            x = b;
        });
        Thread t2 = new Thread(() -> {
            b = 1;
            y = a;
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        if(x == 0 && y == 0){
            System.out.println("第" + i + "次,x = "+ x + ",y = " + y);
        }
    }
}

阅读上述代码发现,按照正常逻辑x和y不可能同时为0,除非程序自己把执行顺序颠倒了,大家可以在open jdk8版本下执行试一下,结果是会打印出来x和y同时等于0的。在单线程中这种指令重排是没问题的,可是在多线程下就会出现不符合预期的结果。此时我们可以使用volatile修饰属性,它会禁止指令重排,在属性进行读写时会增加Load BarrierStore Barrier读写屏障;而这种读写屏障在不同硬件上的实现是不同的,jvm会统一帮忙适配的,具体内存屏障指令为:

  • 在每个volatile写操作的前面插入一个StoreStore屏障;
  • 在每个volatile写操作的后面插入一个StoreLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadStore屏障;

总结

volatile的主要作用是确保多线程环境下对变量的可见性,防止编译器进行优化,但它不保证原子性;在使用volatile的时候要注意伪共享问题;JMMjvm运行时内存数据区是两个层次的概念不要搞混;JMM的内存模型和cpu缓存架构模型不是一一对应的。

转载自:https://juejin.cn/post/7405158045628432384
评论
请登录