Java并发编程——为什么要用volatile关键字

日常编程中出现 volatile 关键字的频率并不高,大家可能对 volatile 关键字比较陌生,再深入一点也许是听闻 volatile 只能保证可见性而不能保证原子性,无法有效保证线程安全,于是更加避免使用 volatile ,简简单单加上synchronize关键字就完事了。本文稍微深入探讨 volatile 关键字,分析其作用及对应的使用场景。

并发编程的几个概念简述

首先简单介绍几个与并发编程相关的概念:

  • 可见性

    可见性是指变量在线程之间是否可见,JVM 中默认情况下线程之间不具备可见性。

  • 原子性

    对于 a = 0 操作是属于原子操作,但 a = a + 1 则不是原子操作,因为这里涉及到要先读取原来 a 的值,然后再为 a 加 1 ,当涉及多线程同时执行该语句时,会出现值不稳定的情况,所以非原子操作在并发场景下是不安全的。

  • 有序性

    java 内存模型中允许编译器和处理器进行指令重排优化,重排过程中不会影响单个线程的指令执行顺序,但会影响多线程环境中的运行正确性

  • 指令重排

    在多核 CPU 的情况下,为了充分利用时间片,提高指令执行效率,处理器会根据一定规则对指令进行重排序,由于规则的限定,指令重排后理论上最终运行结果不变。

volatile 的主要作用

volatile 的主要作用是实现可见性禁止指令重排

  1. 实现可见性

    在 JVM 内存模型中内存分为主内存和工作内存,各线程有独自的工作内存,对于要操作的数据会从主内存拷贝一份到工作内存中,默认情况下工作内存是相互独立的,也就是线程之间不可见,而 volatile 最重要的作用之一就是使变量实现可见性。

  2. 禁止指令重排

    虽然指令重排理论上不会影响执行结果的正确性,但指令重排只能保证底层的机器语言重排序后结果正确,而对于Java高级语言,所以在没有干预的情况下并不能确保每条语句在编译对应的指令重排后与期望的执行效果一致。

对于以下示例,由于 ready 没有指定 volatile ,当变量 ready 线程间不可见时,可能导致线程中读不到 ready 的新值,无法停止循环;如果指令重排序,可能在线程执行前变量 ready 已赋值为 true ,导致线程内容不打印。

public class NoVisibility {
    private static boolean ready;
    private static int number;
    private static class ReaderThread extends Thread {
        @Override
        public void run() {
            while(!ready) {
                Thread.yield();
            }
            System.out.println("1");
        }
    }
    public static void main(String[] args) {
        new ReaderThread().start();
        ready = true;
    }
}

为什么volatile不能保证线程安全?

想要线程安全必须保证原子性,可见性,有序性,而 volatile 只能保证可见性和有序性。

volatile 字段主要是让线程从主内存中获取值从而保证可见性,但是CPU中还有一层高速缓存——寄存器,对于非原子性操作,在底层指令运算中还是会出现数据缓存导致运算结果不正确的情况,从而无法保证线程安全。
简单来说,volatile 在多 cpu 环境下不能保证其它 cpu 的缓存同步刷新,因此无法保证原子性。

为什么不直接用synchronized

synchronized 可保证原子性、可见性、有序性,能有效保证线程安全,但是有个缺点是性能开销较大,而 volatile 是轻量级的线程安全实现方案,在某些特定场合下也能保证线程安全。由于 synchronized 的便捷性,也容易导致 synchronized 的滥用。

双重检查锁

因为 volatile 不能简易的实现线程安全,需要有较深入的了解才能正确使用,所以 volatile也显得更为复杂,使用频率也较低,而 volatile 的一个典型使用例子是双重检查锁模式

双重检查锁通常用于单例模式或延迟赋值的场景,其代码通常如下

public class Singleton {
    private volatile static Singleton uniqueSingleton; // 1. 为变量添加volatile修饰符

    private Singleton() {
    }

    public Singleton getInstance() {
        if (null == uniqueSingleton) { //2. 第一重检查
            synchronized (Singleton.class) { // 3. synchronized加锁
                if (null == uniqueSingleton) { // 4. 第二重检查
                    uniqueSingleton = new Singleton();
                }
            }
        }
        return uniqueSingleton;
    }
}

以下是对这段代码的一些疑问及解答:

Q: 为什么不在 getInstance 方法直接加 synchronized ?

A: 只有在第一次初始化时才需要加锁,如果在getInstance方法上加锁则每次获取实例时都会对整段代码块加锁,影响性能

Q: 为什么需要双重检查?

A: 如果多线程同时通过了第一次检查,其中一个线程需要通过了第二次检查才进行实例化对象,其余线程在后续等待获取到锁后则判断到变量非空,跳过赋值操作。

Q: 为什么 uniqueSingleton 需要添加volatile关键字?

A: 对于 uniqueSingleton = new Singleton();语句,实际上可以分解成以下三个步骤:

  1. 分配内存空间
  2. 初始化对象
  3. 将对象指向刚分配的内存空间

但是有些编译器为了性能的原因,可能会将第二步和第三步进行重排序,顺序就成了:

  1. 分配内存空间
  2. 将对象指向刚分配的内存空间
  3. 初始化对象

现在考虑重排序后,两个线程发生了以下调用:

Time Thread A Thread B
T1 检查到uniqueSingleton为空
T2 获取锁
T3 再次检查到uniqueSingleton为空
T4 为uniqueSingleton分配内存空间
T5 将uniqueSingleton指向内存空间
T6 检查到uniqueSingleton不为空
T7 访问uniqueSingleton(此时对象还未完成初始化)
T8 初始化uniqueSingleton

在这里添加volatile关键字主要是避免在对象未完整完成对象创建就已经被其他线程读取,造成空指针异常。

总结

  1. volatile 的主要作用是实现可见性和禁止指令重排。
  2. 线程安全需要满足可见性、有序性、原子性。
  3. volatile 可以保证可见性和有序性,但是无法保证原子性,所以是线程不安全的。(非原子操作可能会导致数据缓存在CPU的cache中,产生数据不一致)
  4. synchronized 关键字虽然可以保证可见性、有序性、原子性,而且用法简单,但是性能开销大。
  5. 双重检查锁模式是 volatile 的典型使用场景,双重检查锁通常用于实现单例模式或延迟赋值。

参考

Java中Volatile关键字详解

java volatile关键字解惑

为什么双重检查锁模式需要 volatile ?

Java中的双重检查锁(double checked locking)

posted @ 2020-07-08 08:28  leapMie  阅读(1092)  评论(0编辑  收藏  举报