volatile关键字原理解读

115 阅读6分钟

文章目录


1. 引入

1.1 问题

共享变量在多线程操作场景下之所以会出现线程安全问题,主要原因就是每个线程对于变量的操作结果对其他线程是不可见。当一个线程已经修改了变量,而另一个线程使用的仍然是旧的变量,那么就会出现线程安全问题。解决线程安全问题由两种思路:

  • 使用ThreadLocal对每个线程维护一个ThreadLocalMap类型的ThreadLocals变量,ThreadLocals中的Entry对象将共享变量的副本作为value
  • 多线程之间操作变量的结果彼此即时可见,确保任何一个线程使用变量时都是当前的最新值

下面的volatile关键字解决线程安全问题就是使用了第二种思路,它依赖于读写屏障来保证共享变量的可见性,以及禁止指令的重排序。

从源码实现理解ThreadLocal和InheritableThreadLocal

1.2 指令重排序

为了提高性能,编译器和处理器可能会对指令做重排序。但指令重排序的前提是:重排序后的指令执行不能改变结果。重排序可以分为三种:

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

同样JMM中也存在指令重排序优化,这种优化在单线程中是不会存在问题的,但如果这种优化出现在多线程环境中,就可能会出现多线程安全的问题。

1.3 读写屏障

操作系统中的读写屏障保证可见性和有序性,具体如下:

  • 可见性:写屏障保证该屏障之前对于共享变量的改动都会同步到主存当中;读屏障保证该屏障之后对于共享变量的读取,加载的都是主存中最新数据
  • 有序性:写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后;读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

了解了操作系统中的指令重排序和读写屏障,以及Java内存模型的工作原理,那么理解volatile关键字的底层实现也就很容易了。


2. volatile

2.1 概念

为了确保在多线程环境下的共享变量可以被准确和一致的更新,线程应该确保通过排它锁单独获得这个变量。

volatile相对于sychronized来说,它是一种更加轻量级的选择,不会引发线程上下文的切换和调度。volatile关键字在进行写操作时,底层实现中通过Lock前缀的指令使得处理器做如下两件事:

  • 将当前处理器缓存行中的数据写回到系统内存

  • 回写内存的操作会使得在其他线程的工作内存中缓存了该内存地址的数据无效

    其他缓存了该共享变量的线程会根据缓存一致性协议,不断的检查自己工作内存中缓存的数据和主内存中的数据是否一致。如果该共享变量没有发生变化,则可以继续使用缓存中的数据,否则使用时需要从主内存中重新来获取最新的数据。

volatile通过上述的两步重要操作来确保共享变量的可见性。

2.2 底层实现

下面我们通过一个简单的例子来看一下,Volatile在字节码指令层面是如何实现上述的效果的。Demo如下:

/**
 * @Author dyliang
 * @Date 2020/9/5 16:56
 * @Version 1.0
 */
public class Test {
    public static volatile int number = 1;

    public static void main(String[] args) {

        new Thread(()->{
            System.out.println("number before t1 read : " + number);
            number += 1;

        }, "t1").start();

        new Thread(()->{
            System.out.println("number before t2 read : " + number);
            number += 5;
            System.out.println("number after t2 write : " + number);
        }, "t2").start();
    }
}

编译运行程序,控制台输出:

number before t1 read : 1
number before t2 read : 1
number after t2 write : 7
    
也可能是:

number before t1 read : 1
number before t2 read : 2
number after t2 write : 7

但是无论哪一种,Thread-1对共享变量number的修改,对于Thread-2是可见的。即时Thread-2一开始输出1,但是执行加操作时使用的是number的最新值2。我们看一下Thread-1线程对应的字节码指令,如下所示:

0 getstatic #9 <java/lang/System.out>
3 new #10 <java/lang/StringBuilder>
6 dup
7 invokespecial #11 <java/lang/StringBuilder.<init>>
10 ldc #12 <number before t2 read : >
12 invokevirtual #13 <java/lang/StringBuilder.append>
15 getstatic #14 <Volatile/Test.number>
18 invokevirtual #15 <java/lang/StringBuilder.append>
21 invokevirtual #16 <java/lang/StringBuilder.toString>
24 invokevirtual #17 <java/io/PrintStream.println>
27 getstatic #14 <Volatile/Test.number>
30 iconst_5
31 iadd
32 putstatic #14 <Volatile/Test.number>
35 getstatic #9 <java/lang/System.out>
38 new #10 <java/lang/StringBuilder>
41 dup
42 invokespecial #11 <java/lang/StringBuilder.<init>>
45 ldc #18 <number after t2 write : >
47 invokevirtual #13 <java/lang/StringBuilder.append>
50 getstatic #14 <Volatile/Test.number>
53 invokevirtual #15 <java/lang/StringBuilder.append>
56 invokevirtual #16 <java/lang/StringBuilder.toString>
59 invokevirtual #17 <java/io/PrintStream.println>
62 return

其中比较重要的指令是getstatic #14 读取时会带读屏障,putstatic #14写入时会带写屏障,这样就保证了volatile的两个重要特性。


image-20200905172447040

3. 单例模式中的double-checking

理解设计模式中的单例模式

示意代码如下:

class Singleton5{
    private volatile static Singleton5 instance;

    public static Singleton5 getInstance(){
        if (instance == null){
            synchronized (Singleton5.class){
                if (instance == null){
                    instance = new Singleton5();
                }
            }
        }
        return instance;
    }
}

public class SingletonDemo5 {
    public static void main(String[] args) {
        Singleton5 instance1 = Singleton5.getInstance();
        Singleton5 instance2 = Singleton5.getInstance();
        System.out.println(instance1 == instance2);
        System.out.println("instance1 = " + instance1);
        System.out.println("instance2 = " + instance2);

    }
}

控制台输出:

true
instance1 = Singleton.Singleton5@7f31245a
instance2 = Singleton.Singleton5@7f31245a

它所对应的字节码指令为:

 0 getstatic #2 <Singleton/Singleton5.instance>  
 3 ifnonnull 37 (+34)
 6 ldc #3 <Singleton/Singleton5>
 8 dup
 9 astore_0
10 monitorenter
11 getstatic #2 <Singleton/Singleton5.instance>
14 ifnonnull 27 (+13)
17 new #3 <Singleton/Singleton5>
20 dup
21 invokespecial #4 <Singleton/Singleton5.<init>>
24 putstatic #2 <Singleton/Singleton5.instance>
27 aload_0
28 monitorexit
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit
35 aload_1
36 athrow
37 getstatic #2 <Singleton/Singleton5.instance>
40 areturn

同样的在0 getstatic #2读取instance时加入了读屏障;10 monitorenter是synchronized保证了原子性和可见性;24 putstatic #2 <Singleton/Singleton5.instance>加入了instance变量的写屏障;最终synchronized释放锁时,通过28 monitorexit来保证原子性和可见性。