Java 常见问题

  • ensuring variable visibility in multi-threaded Java applications

在了解 volatile 关键字的作用之前需要先了解一下 Java 虚拟机的内存模型

并发的三个特性

volatile 实现了可见性和有序性(happen before)。在多线程环境中,需要保证这两个特性可以使用 volatile 关键字。

The Java volatile keyword is used to mark a Java variable as “being stored in main memory”. More precisely that means, that every read of a volatile variable will be read from the computer’s main memory, and not from the CPU cache, and that every write to a volatile variable will be written to main memory, and not just to the CPU cache.

将一个变量声明为 volatile 就指示 JVM 这个变量是共享且不稳定的,每次使用它都到主存中读取(保证让所有其他线程可见

多线程的程序在操作 non-volatile 变量时,每一个 thread 都会将 variable 从 main memory 拷贝到 CPU cache,提高性能。如果电脑拥有多个CPU,线程在不同的 CPU 上跑,那意味着每一个线程都拷贝了一份变量到 CPU cache 上。

non-volatile 变量就造成了一个问题,JVM 在从主存读到 CPU cache,或者从 CPU cache 写入 到主存时,可能产生问题----可见性问题

thread1 更新的变量内容只在 CPU cache 中,那么 thread2 就无法看到这个变量的修改,因为 thread2 也有一份 CPU cache 中的变量。

volatile 实现原理

如何保证可见性

为了达到 CPU 的处理速度,CPU 并不直接和内存进行通信,而是将内存数据读取到 CPU 内部的缓存中,但是这个操作没有确定的写回内存的时机。

但如果变量被 volatile 标注,一旦 CPU 修改了该变量,会执行:

  • CPU 强制将缓存中的变化写回到内存
  • 写回内存的操作使得 CPU 缓存地址失效,CPU 如果要再次使用这个变量,需要到内存中读取

对于一个语句 i = i+1; 当执行这个语句的时候,某个核上运行的线程会将 i 的值拷贝一个副本到 CPU 缓存中,执行完成后,写回到主存。多线程的环境,每一个线程都会运行在独立的核上对应一个工作内存,每一个线程都有一个私有缓存区。对于 i+1 的问题,假设 i 的初始值是 0,两个线程同时执行这条语句,每个线程:

  • 从主存读取 i 的值到工作内存(CPU 缓冲区)
  • 计算 i+1
  • 将结果写回主存

假设两个线程各自执行 10000 次,但实际并不是,i 的值小于 20000,因为缓存不一致。只有使用了 volatile 关键字:

  • 每次对变量的修改,写回到主存中
  • 工作内存写回到主存导致其他线程的缓存失效,主要再从主存中读取

当变量加上 volatile 之后,还是没有变成 20000,因为 volatile 并没有保证原子性。

如何保证有序性

Java 虚拟机的内存模型

happens-before 原则中有一条 volatile 变量规则,对一个变量的写操作先行发生于后面对这个变量的读操作。

class Singleton {
    private volatile static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstane() {
    if (instance == null) { // step 1
        synchronized (Singleton.class) {
            if (instance == null) { // step 2
                instance == new Singleton(); // step 3
            }
        }
    }
}

如果 instance 不使用 volatile 修饰,假设有两个线程调用 getInstance() 方法,线程 1 执行步骤 step1,发现为 null,同步锁住 Singleton 类,判断 instance 是否为 null,依然是空,执行 step3,实例化 Singleton。在实例化的过程中,线程 2 走到 step 1,发现 instance 不为空,但 instance 还没有完成初始化。

初始化分为三个步骤:

memory = allocate(); // 分配对象的内存空间
ctorInstance(memory); // 初始化对象
instance = memeory; // 设置 instance 指向对象的内存空间

步骤 2,3 依赖 1,而步骤 2 和 3 之间没有依赖关系,所以这两条语句可能发生指令重排序,可能步骤 3 在 步骤 2 之前执行。这种情况下,步骤 3 执行了,但是步骤2 还没执行,也就是说 instance 实例化没有初始化完毕,这个时候线程 2 判断 instance 不为 null,直接返回了 instance 实例,但这个时候 instance 其实是一个不完全的对象,使用的时候就有问题。

使用 volatile 关键字,对一个 volatile 修饰的变量写 happens-before 在任意后续对该变量的读。上述初始化的过程中,步骤 2 和 3 都是对 instance 的写,所以一定会发生于后面对 instance 的读,也就是不会出现返回不完全初始化的 instance。

JVM 底层通过「内存屏障」来实现。内存屏障,也叫做内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。

The Java volatile Visibility Guarantee

使用 volatile 关键字定义的变量,将显示的说该变量的修改会直接写回到 main memory (main memory 在大部分的文章中会被写成主存,实际翻译并没有问题,但是这里的主存实际上是相对于 CPU memory cache 而言的)中,并且所有读的操作会直接从 main memory 中读取。

Full volatile Visibility Guarantee

可见性保证:

  • 如果 Thread A 写入一个 volatile 变量,接着 Thread B 读相同的 volatile 变量,那么所有在写入到 volatile 变量之前 Thread A 可见的变量,在 Thread B 读取 volatile 变量后也会对 Thread B 可见
  • 如果 Thread A 读取了一个 volatile 变量,那么当读取 volatile 变量后对 Thread A可见的变量,将会从 main memory 中重新读取。

Instruction Reordering Challenges

int a = 1;
int b = 2;

a++;
b++;

重排序后,可能是

int a = 1;
a++;

int b = 2;
b++;

The Java volatile Happens-Before Guarantee

To address the instruction reordering challenge, the Java volatile keyword gives a “happens-before” guarantee, in addition to the visibility guarantee. The happens-before guarantee guarantees that:

  • Reads from and writes to other variables cannot be reordered to occur after a write to a volatile variable, if the reads / writes originally occurred before the write to the volatile variable. The reads / writes before a write to a volatile variable are guaranteed to “happen before” the write to the volatile variable. Notice that it is still possible for e.g. reads / writes of other variables located after a write to a volatile to be reordered to occur before that write to the volatile. Just not the other way around. From after to before is allowed, but from before to after is not allowed.

  • Reads from and writes to other variables cannot be reordered to occur before a read of a volatile variable, if the reads / writes originally occurred after the read of the volatile variable. Notice that it is possible for reads of other variables that occur before the read of a volatile variable can be reordered to occur after the read of the volatile. Just not the other way around. From before to after is allowed, but from after to before is not allowed.

  • 对一个volatile变量的写操作先行发生于后面对这个变量的读取操作

如果volatile变量修饰符使用得当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

reference