Java并发编程变量可见性避免指令重排使用详解

软件发布|下载排行|最新软件

当前位置:首页IT学院IT技术

Java并发编程变量可见性避免指令重排使用详解

kevinyan   2022-11-30 我要评论

引言

上一篇文章讲的是线程本地存储 ThreadLocal,讲究的是让每个线程持有一份数据副本,大家各自访问各自的,就不用争抢了。

那怎么保证程序里一个线程对共享变量的修改能立马被其他线程看到了?这时候有人会说了,加锁呀,前面不就是因为加锁成本太高才使用的 ThreadLocal的吗?怎么又说回去了?

其实CPU每个核心也都是有缓存的,今天要讲的volatile能保证变量在多线程间的可见性,本文我们会对变量可见性、指令重排、Happens Before 原则以及 Volatile 对这些特性提供的支持和在程序里的使用进行讲解,本文大纲如下:

变量的可见性

一个线程对共享变量的修改,另外一个线程能够立刻看到,称为变量的可见性。

在单核系统中,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。但是多核系统中,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。

比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言不具备可见性。

Java 里可以使用 volatile 关键字修饰成员变量,来保证成员在线程间的可见性。读取 volatile 修饰的变量时,线程将不会从所在CPU的缓存,而是直接从系统的主存中读取变量值。同理,向一个 volatile 修饰的变量写入值的时候,也是直接写入到主存。

下面我们再来看一下,当不使用 volatile 时,多线程使用共享变量时的可见性问题。

Java 变量的可见性问题

Java 的 volatile 关键字能够保证变量更改的跨线程可见,在一个多线程应用程序中,为了提高性能,线程会把变量从主存拷贝到线程所在CPU信息的缓存上再操作。如果程序运行在多核机器上,多个线程可能会运行在不同的CPU 上,也就意味着不同的线程可能会把变量拷贝到不同的 CPU 缓存上。

因为CPU缓存的读写速度远高于主存,所以线程会把数据从主存读到 CPU 缓存,数据的更新也是是先更新CPU 缓存中的副本,再刷回主存,除非有(汇编指令)强制要求否则不会每次更新都把数据刷回主存。

对于非 volatile 修饰的变量,Java 无法保证 JVM 何时会把数据从主存读取到 CPU 缓存,或将数据从 CPU 缓存写入主内存。

这在多线程环境下可能会导致问题,想象一下这样一种情况,有多个线程可以访问一个共享对象,该对象包含一个声明如下的计数器变量。

public class SharedObject {
    public volatile int counter = 0;
}

假设在我们的例子中只有线程1 会更新计数器 counter 的值,线程1 和线程2 都会时不时的读取 counter 的值。 如果 counter 未被声明为 volatile 的,则无法保证变量 counter 的值何时会从 CPU 缓存写回主存。这意味着,CPU 缓存中的计数器变量值可能与主内存中的不同。比如像下图这样:

线程2 访问 counter 的值的结果是 0 ,没有看到变量 counter 最新的值。这是因为 counter 它最新的值还在CPU1 的缓存中,还没有被线程1 写回到主内。

上面这个例子描述的情况,就是所谓“可见性”问题:一个线程的更新对其他线程是不可见的。

Volatile 的可见性保证

Java 的 volatile 关键字旨在解决变量可见性问题。通过将上面例子中的 counter 变量声明为 volatile的,所有对counter 变量的写入都将立即写回主存,所以对 counter 变量的读取都会先将变量从主存读到CPU缓存 (相当于每次都从主存读取)。

把 counter 变量声明成 volatile 只需要在定义中加上 volatile 关键字即可

public class SharedObject {
    public volatile int counter = 0;
}

完整的 volatile 可见性保证

实际上,volatile 的可见性保证超出了 volatile 修饰的变量本身。它的可见性保证规则如下:

  • 如果线程 A 写入一个 volatile 变量,而线程 B 随后读取了同一个 volatile 变量,那么线程 A 在写入 volatile 变量之前,对线程 A 可见(更新可见)的所有变量,在线程 B 读取 volatile 变量之后也将对线程 B 可见。
  • 如果线程 A 读取一个 volatile 变量,那么在读取 volatile 变量时,线程 A 可见的所有变量也将从主存中重新读取。

我们通过例程解释一下这两个规则。

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

udpate() 方法写入三个变量,其中只有变量 days 是 volatile 的。 完整的 volatile 可见性保证意味着,当一个新值被写入到变量 days 时,该线程可见的所有变量也会被写入主内。这意味着,当一个新值被写入变量 days 时,years 和 months 的值也会被写入主存。

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

而对于 volatile 变量的读取来说,在上面例程的 totalDays 方法中,当读取 days 变量的值的时候,除了会从主存中重新读取变量 days 的值外,其他两个未被 volatile 修饰的变量 years 和 months 也会被从主存中重新读取到CPU缓存。通过上述读取顺序,可以确保看到 days、months 和 years 的最新值。

指令重排

在指定的语义保持不变的情况下,出于性能原因,JVM 和 CPU 可能会对程序中的指令进行重新排序。比如说,下面这几个指令:

int a = 1;
int b = 2;
a++;
b++;

这些指令可以重新排序为以下序列

int a = 1;
a++;
int b = 2;
b++;

然而,对于存在被声明为 volatile 的变量的程序而言,我们传统理解的指令重排会导致严重的问题,还以上面使用过的例程来描述一下这个问题。

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

一旦当 update() 方法将新值写入 days 变量时,新写入的 years 和 months 的值也将被写入主存。但是,如果 JVM 重排指令,把程序变成下面这样会怎样呢?

public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

重排后变成了先对 days 进行赋值,根于完整可见性的第一条规则,当写入 days 变量时,months 和 years 变量的值也会被写入主存。但是指令重排后,变量 days 的赋值这一次是在新值写入 months 和 years 之前发生的。因此,它们的新值不会正确地对其他线程可见。

显然,重新排序的指令的语义已经改变,不过 Java 内部会有解决方案防止此类问题的发生。

volatile 的 Happens Before 保证

为了解决上面例子里指令重排导致的问题,除了可见性保证之外,Java 的 volatile 关键字还提供了“happens-before”保证。

  • 如果原来位于写 volatile 变量之前的非 volatile 变量的读写,在指令重排时,不允许这些指令出现在 volatile 变量的写入指令之后。但是原来在 volatile 变量写入之后的对其他变量的读写指令,在重排时,是允许出现在写 volatile 变量之前的--即从后变前允许,从前变后不行。
  • 如果原来位于读 volatile 变量之后的对非 volatile 变量的读写,在指令重排时,不允许出现在读 volatile 变量之前。

上面的 Happens-Before 保证确保了 volatile 在程序发生指令重排时也能提供正确的可见性保证。

volatile 不能保证原子性

虽然 volatile 关键字保证了对 volatile 修饰的变量的所有读取都直接从主存中读取,对 volatile 变量的所有写入都会写入到主存中,但 volatile 不能保证原子性。

在前面共享计数器的例子中,我们设置了一个前提--只有线程1 会更新计数器 counter 的值,线程1 和线程2 会时不时的读取 counter 的值。在这个前提下,把 counter 变量声明成 volatile 的足以确保线程 2 始终能看到线程1最新写入的值。

事实上,当写入变量的新值不依赖先前的值(比如累加)的时候,多个线程都向同一个 volatile 变量写入时,是能保证向主存中写入的是正确的值的。但是,如果需要首先读取 volatile 变量的值,并基于该值为 volatile 变量生成一个新值,那么 volatile 就不能保证变量正确的可见性了。读取 volatile 变量和写入新值之间的这个短短的时间间隔,在多线程并发写入的情况下也是会产生 Data Racing 的。

想象一下,如果线程 1 将值为 0 的 counter 变量读取到运行它的 CPU 的缓存中,将其递增到 1,在线程1把 counter 的值写回主存之前,线程 2 可能正好也从主内存中把 counter 变量读到了运行它的 CPU 缓存中,读取到的 counter 变量的值也是 0,然后线程 2 也对 counter 变量进行递增的操作。

线程 1 和线程 2 现在实际上已经不同步了。理论上 counter 变量从 0 经过两次递增应该变成 2,但实际上每个线程在其 CPU 缓存中的 counter 变量的值为 1,即使线程最终将 counter 变量的值写回主存,它的值也是不对的。

那么,如何做到线程安全呢?有两种方案:

  • volatile + synchronized
  • 使用原子类替代 volatile

原子类后面到 J.U.C 相关的章节的时候再去学习。

什么时候适合使用 volatile

如果 volatile 修饰符使用恰当的话,它比 synchronized 的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。但是要注意 volatile 是无法替代 synchronized ,因为 volatile 无法保证操作的原子性。

通常来说,使用 volatile 必须具备以下 2 个条件:

  • 对变量的写操作不依赖于当前值
  • volatile 变量没有包含在具有其他变量的表达式中

示例:双重锁实现线程安全的单例模式

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

volatile 的原理

使用 volatile 关键字时,程序对应的汇编代码在对应位置会多出一个 lock 前缀指令。lock 前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供 3 个功能:

  • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  • 它会强制将对缓存的修改操作立即写入主存;
  • 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。

注意 volatile 的性能问题

读取和写入 volatile 变量都会直接访问主存,读写主存比访问 CPU 缓存更慢得多,不过使用 volatile 变量还可以防止指令重排,这是一种正常的性能增强技术。因此,我们只应该在确实需要变量的可见性和防止指令重排时,再使用 volatile 变量。

Copyright 2022 版权所有 软件发布 访问手机版

声明:所有软件和文章来自软件开发商或者作者 如有异议 请与本站联系 联系我们