多线程——Volatile 关键字详解

多线程——Volatile 关键字

一、概念

在前一篇文章中,我们详细介绍了 Java 内存模型,我们可以发现 Java 内存模型都是围绕着在并发过程中如何处理原子性、可见性和有序性三个特征来建立的,简单回顾一下为什么会产生这三个特性:

  • 原子性:比如一段代码运行在服务器,有一段代码会被所有线程访问,每一个线程都会修改里面的共享变量值,为了保证一个线程在修改的时候,其他的线程不会进来捣乱,就引入了原子性的概念,保证原子性的手段最常用的就是锁机制
  • 可见性:我们知道内存分为工作内存和主内存,每个线程都有其独占的工作内存,所有线程共享主内存;可见性就是当一个线程修改了共享内存的值时,其他线程都能够立即得知这个修改
  • 有序性:硬件层面由于为了压榨速度超快的 CPU,引入了 CPU 的乱序执行操作优化,映射到 Java 中就是指令重排序;然后有些地方指令重排序会产生问题,所以需要限制指令重排序,这就是有序性

其中原子性主要由锁机制来保证,锁机制同样也能保证可见性和有序性,这些我们后面会详细讲。本篇文章中我们主要来讲保证可见性和有序性的一种轻量级的手段—— Volatile 关键字


关键字 Volatile 可以说是 JVM 提供的最轻量级的同步机制,但是开发中基本不使用 Volatile,一来并发情况下最需要保证原子性, Volatile 并不能保证原子性;二来 Synchronized 比较全面而且效率也并不会太低于 Volatile,所以普遍都是使用 Synchronized 作为同步手段

不过理解了 Volatile,就会对内存模型和多线程操作的其他特性就会有更深的理解

二、Volatile保证可见性

当一个变量被 volatile 修饰之后,就能够保证此变量对所有的线程的可见性——当一个线程修改了这个变量的值,新值对于其他线程都是立即得知的

普通变量并不能保证这一点,普通变量的值在线程间传递时据需要通过主内存来完成;比如线程 A 修改了一个普通变量的值,然后向主内存进行回写,另外一条线程 B 只有在线程 A 完成了回写之后再对主内存进行读取操作,新变量值才会对线程 B 可见

Volatile 变量对于所有线程都是立即可见的,对 Volatile 变量的所有写操作都能立即反映到其他线程之中,但是基于 volatile 变量的运算在并发情况下并不是线程安全的(不能保证原子性);这句话是什么意思呢?来分析下面的示例代码:

public class VolatileTest {
    
    
    public static volatile int race = 0;

    public static void increase(){
    
    
        race++;
    }

    private static final int THREAD_COUNT = 20;

    public static void main(String[] args) {
    
    
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int i = 0; i < THREAD_COUNT; i++){
    
    
            threads[i] = new Thread(new Runnable() {
    
    
                @Override
                public void run() {
    
    
                    //每个线程都会执行 1000 次的自增操作
                    for (int i = 0; i < 1000; i++){
    
    
                        increase();
                    }
                }
            });
            threads[i].start();
        }


        //等待所有的累加线程都结束
        while (Thread.activeCount() > 1)
            Thread.yield();

        //输出最终结果,如果线程安全的话结果应该是 20000
        System.out.println(race);
    }
}

如果并发安全的话,结果应该是线程数 * 线程自增操作 = 20000,但是运行起来结果永远都小于 20000,这是因为只有一行代码的 increase() 方法在 Class文件中是由四条字节码指令构成的,由于线程随时都会停止,导致这四条字节码指令并不会一次性全部执行完毕,此时实际执行流程可能如下:

  1. 线程 A 读取 race = 100,执行 increase() 方法,但是四条指令只执行了 1 条
  2. CPU 中断线程 A ,执行线程 B
  3. 线程 B 读取 race 值,由于 A 并没有修改 race = 100,线程 B 完整的执行自增操作,race = 101
  4. 线程 B 执行完毕,CPU 继续执行线程 A
  5. 线程 A 继续执行 increase() 方法剩下的三条指令,使用过期数据 race = 100 完成自增操作,race = 101
  6. 线程 A 将 race 值同步辉主内存,本来 A、B 执行了两次自增操作,但是 race 只增加了 1,数据就错误了

因此由于 Volatile 之能保证可见性,如果需要并发安全仍然需要加锁(Synchronized、JUC下的锁或原子类)来保证原子性

但是 Volatile 仍然有它的用处,比如最经典的双检锁单例(DCL)就使用了 Volatile 关键字

public class Singleton {
    
    
    //使用 volatile 修饰
    private static volatile Singleton INSTANCE;

    private Singleton() {
    
    
    }

    public static Singleton getInstance() {
    
    
        //假设线程 A 判断为 null,向下执行
        if (INSTANCE == null) {
    
    
            //线程 A 被打断,线程 B 在上面判断为 null,线程 B 拿到锁向下执行
            synchronized (Singleton.class) {
    
    
                //线程 B 先来到此处,完成初始化工作并释放锁
                //线程 A 在线程 B 释放锁之后才会拿到锁来到此处,判断是否为 null
                //由于线程 B 实例化过了,并且由于使用 Volatile 修饰,线程 A 已经能够看到 INSTANCE 非空,就不会再次执行初始化工作
                if(INSTANCE == null) {
    
    
                    try {
    
    
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
    
    
                        e.printStackTrace();
                    }
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }

    public static void main(String[] args) {
    
    
        Singleton.getInstance();
    }
}

三、Volatile禁止指令重排序

使用 Volatile 的第二个语义是禁止指令重排序优化,普通的变量仅会保证该方法的执行过程中所有依赖赋值结果的地方都能够获取到正确的结果,而不能保证变量赋值操作的顺序和代码中的顺序一致

譬如如下三个指令的功能:

  • 指令 1:把地址 A 中的值加 10
  • 指令 2:把地址 A 中的值乘以 2
  • 指令 3:把地址 B 中的值减去 3

这时指令 1 和指令 2 是有依赖的,它们之间的顺序并不能重排——(A + 10) * 2 与 A * 2 + 10 的结果明显是不一样的,但是指令 3 是可以重排到指令1、2之前或者之间,因为它操作的是 B 地址的空间,和 A 地址的空间并没有依赖关系

如果分析程序的指令,可以发现 Volatile 修饰的变量,复制后多执行了一个 “lock addl $0x0,(%esp)” 操作,这个操作的作用相当于一个内存屏障,指令重排序时不能把后面的指令重排序到内存屏障之前的位置,当一个处理器使用 “lock addl $0x0,(%esp)” 指令把修改同步回主内存时,意味着所有之前需要保证依赖关系的操作都已经执行完成,另外的所有处理器就能够在正确的数据上继续执行剩余的指令

四、总结

Volatile 可以保证可见性和有序性,是一种很轻量级的同步手段,但是由于其不能保证原子性,换言之也就是无法在并发情况下保证线程安全,所以我们很少手动使用 Volatile 关键字

不过 Volatile 的轻量性使其在保证并发安全的机制的底层实现中被很广泛的使用,比如 AQS 就是使用 Volatile + CAS 操作实现的,而 AQS 的又是一种很重要的机制,所以掌握 Volatile 是很有必要的

参考:《深入理解Java虚拟机》
关联文章:多线程—Java内存模型与线程

猜你喜欢

转载自blog.csdn.net/qq_42583242/article/details/108176528