小学妹学Java-Volatile关键字有彩蛋哦

4次阅读

共计 4793 个字符,预计需要花费 12 分钟才能阅读完成。

Java 的 volatile 关键字用于标记一个变量“应当存储在主存”。更确切地说,每次读取 volatile 变量,都应该从主存读取,而不是从 CPU 缓存读取。每次写入一个 volatile 变量,应该写到主存中,而不是仅仅写到 CPU 缓存。

实际上,从 Java 5 开始,volatile 关键字除了保证 volatile 变量从主存读写外,还提供了更多的保障。我将在后面的章节中进行说明。

变量可见性问题

Java 的 volatile 关键字能保证变量修改后,对各个线程是可见的。这个听起来有些抽象,下面就详细说明。

在一个多线程的应用中,线程在操作非 volatile 变量时,出于性能考虑,每个线程可能会将变量从主存拷贝到 CPU 缓存中。如果你的计算机有多个 CPU,每个线程可能会在不同的 CPU 中运行。这意味着,每个线程都有可能会把变量拷贝到各自 CPU 的缓存中,如下图所示:

对于非 volatile 变量,JVM 并不保证会从主存中读取数据到 CPU 缓存,或者将 CPU 缓存中的数据写到主存中。这会引起一些问题,在后面的章节中,我会来解释这些问题。

试想一下,如果有两个以上的线程访问一个共享对象,这个共享对象包含一个 counter 变量,下面是代码示例:

public class SharedObject {public int counter = 0;}

如果只有线程 1 修改了(自增)counter 变量,而线程 1 和线程 2 两个线程都会在某些时刻读取 counter 变量。

如果 counter 变量没有声明成 volatile,则 counter 的值不保证会从 CPU 缓存写回到主存中。也就是说,CPU 缓存和主存中的 counter 变量值并不一致,如下图所示:

这就是“可见性”问题,线程看不到变量最新的值,因为其他线程还没有将变量值从 CPU 缓存写回到主存。一个线程中的修改对另外的线程是不可见的。

volatile 可见性保证

Java 的 volatile 关键字就是设计用来解决变量可见性问题。将 counter 变量声明为 volatile,则在写入 counter 变量时,也会同时将变量值写入到主存中。同样的,在读取 counter 变量值时,也会直接从主存中读取。

下面的代码演示了如果将 counter 声明为 volatile:

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

将一个变量声明为 volatile,可以保证变量写入时对其他线程的可见。

在上面的场景中,一个线程(T1)修改了 counter,另一个线程(T2)读取了 counter(但没有修改它),将 counter 变量声明为 volatile,就能保证写入 counter 变量后,对 T2 是可见的。

然而,如果 T1 和 T2 都修改了 counter 的值,只是将 counter 声明为 volatile 还远远不够,后面会有更多的说明。

完整的 volatile 可见性保证

实际上,volatile 的可见性保证并不是只对于 volatile 变量本身那么简单。可见性保证遵循以下规则:

如果线程 A 写入一个 volatile 变量,线程 B 随后读取了同样的 volatile 变量,则线程 A 在写入 volatile 变量之前的所有可见的变量值,在线程 B 读取 volatile 变量后也同样是可见的。

如果线程 A 读取一个 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;
    }
}

update() 方法写入 3 个变量,其中只有 days 变量是 volatile。

完整的 volatile 可见性保证意味着,在写入 days 变量时,线程中所有可见变量也会写入到主存。也就是说,写入 days 变量时,years 和 months 也会同时被写入到主存。

下面的代码读取了 years、months、days 变量:

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;
    }
}

请注意 totalDays() 方法开始读取 days 变量值到 total 变量。在读取 days 变量值时,months 和 years 的值也会同时从主存读取。因此,按上面所示的顺序读取时,可以保证读取到 days、months、years 变量的最新值。

指令重排问题

出于性能考虑,JVM 和 CPU 是允许对程序中的指令进行重排的,只要保证(重排后的)指令语义一致即可。如下代码为例:

int a = 1;
int b = 2;

a++;
b++;

这些指令可以按以下顺序重排,而不改变程序的语义:

int a = 1;
a++;

int b = 2;
b++;

然而,指令重排面临的一个问题就是对 volatile 变量的处理。还是以前面提到的 MyClass 类来说明:

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 被修改时,months、years 的值也会写入到主存,但这时进行写入,months、years 并不是新的值(译者注:即在 months、years 被赋新值之前,就触发了这两个变量值写入主存的操作,自然这两个变量在主存中的值就不是新值)。新的值自然对其他线程是不可见的。指令重排导致了程序语义的改变。

Java 对此有一个解决方法,我们会在下一章节中说明。

Java volatile Happens-Before 保证

为了解决指令重排的问题,Java 的 volatile 关键字在可见性之外,又提供了 happends-before 保证。happens-before 原则如下:

如果有读写操作发生在写入 volatile 变量之前,读写其他变量的指令不能重排到写入 volatile 变量之后。写入一个 volatile 变量之前的读写操作,对 volatile 变量是有 happens-before 保证的。注意,如果是写入 volatile 之后,有读写其他变量的操作,那么这些操作指令是有可能被重排到写入 volatile 操作指令之前的。但反之则不成立。即可以把位于写入 volatile 操作指令之后的其他指令移到写入 volatile 操作指令之前,而不能把位于写入 volatile 操作指令之前的其他指令移到写入 volatile 操作指令之后。

如果有读写操作发生在读取 volatile 变量之后,读写其他变量的指令不能重排到读取 volatile 变量之前。注意,如果是读取 volatile 之前,有读取其他变量的操作,那么这些操作指令是有可能被重排到读取 volatile 操作指令之后的。但反之则不成立。即可以把位于读取 volatile 操作指令之前的指令移到读取 volatile 操作指令之后,而不能把位于读取 volatile 操作指令之后的指令移到读取 volatile 操作指令之前。

以上的 happens-before 原则为 volatile 关键字的可见性提供了强制保证。

volatile 并不总是可行的

虽然 volatile 关键字能保证 volatile 变量的所有读取都是直接从主存读取,所有写入都是直接写入到主存中,但在一些情形下,仅仅是将变量声明为 volatile 还是远远不够的。

就像前面示例所说的,线程 1 写入共享变量 counter 的值,将 counter 声明为 volatile 已经足够保证线程 2 总是能获取到最新的值。

事实上,多个线程都能写入共享的 volatile 变量,主存中也能存储正确的变量值,然而这有一个前提,变量新值的写入不能依赖于变量的旧值。换句话说,就是一个线程写入一个共享 volatile 变量值时,不需要先读取变量值,然后以此来计算出新的值。

如果线程需要先读取一个 volatile 变量的值,以此来计算出一个新的值,那么 volatile 变量就不足够保证正确的可见性。(线程间)读写 volatile 变量的时间间隔很短,这将导致一个竞态条件,多个线程同时读取了 volatile 变量相同的值,然后以此计算出了新的值,这时各个线程往主存中写回值,则会互相覆盖。

多个线程对 counter 变量进行自增操作就是这样的情形,下面的章节会详细说明这种情形。

设想一下,如果线程 1 将共享变量 counter 的值 0 读取到它的 CPU 缓存,然后自增为 1,而还没有将新值写回到主存。线程 2 这时从主存中读取的 counter 值依然是 0,依然放到它自身的 CPU 缓存中,然后同样将 counter 值自增为 1,同样也还没有将新值写回到主存。如下图所示:


从实际的情况来看,线程 1 和线程 2 现在就是不同步的。共享变量 counter 正确的值应该是 2,但各个线程中 CPU 缓存的值都是 1,而主存中的值依然是 0。这是很混乱的。即使线程最终将共享变量 counter 的值写回到主存,那值也明显是错的。

何时使用 volatile

正如我前面所说,如果两个线程同时读写一个共享变量,仅仅使用 volatile 关键字是不够的。你应该使用 synchronized 来保证读写变量是原子的。(一个线程)读写 volatile 变量时,不会阻塞(其他)线程进行读写。你必须在关键的地方使用 synchronized 关键字来解决这个问题。

除了 synchronized 方法,你还可以使用 java.util.concurrent 包提供的许多原子数据类型来解决这个问题。比如,AtomicLong 或 AtomicReference,或是其他的类。

如果只有一个线程对 volatile 进行读写,而其他线程只是读取变量,这时,对于只是读取变量的线程来说,volatile 就已经可以保证读取到的是变量的最新值。如果没有把变量声明为 volatile,这就无法保证。

volatile 关键字对 32 位和 64 位的变量都有效。

volatile 的性能考量

读写 volatile 变量会导致变量从主存读写。从主存读写比从 CPU 缓存读写更加“昂贵”。访问一个 volatile 变量同样会禁止指令重排,而指令重排是一种提升性能的技术。因此,你应当只在需要保证变量可见性的情况下,才使用 volatile 变量。

总结

在多线程环境下,正确使用 volatile 关键字可以比直接使用 synchronized 更加高效而且代码简洁,但是使用 volatile 关键字也更容易出错。所以,关注公众号:”程序零世界“教你 volatile 的使用场景,获取更多干货文章,

正文完
 0