关于关键字Volatile的理解

Java Volatile Keyword

在这篇文章中,我们将关注Java语言中的基本但经常被误解的概念 - volatile关键字。

1.概述

在Java中,每个线程都有一个独立的内存空间,称为工作内存; 这保存了用于执行操作的不同变量的值。在执行操作之后,线程将变量的更新值复制到主存储器,并且从那里其他线程可以读取最新值。

Java volatile关键字作用将Java变量标记为“存储在主存储器中”。更确切地说,这意味着,每次读取一个volatile变量都将从计算机的主内存中读取,而不是从CPU缓存中读取,并且每次写入volatile变量都将写入主内存,而不仅仅是CPU缓存。

简单地说,volatile关键字标记一个变量,在多个线程访问它的情况下,总是转到主内存,读取和写入。

实际上,自Java 5以来,volatile关键字保证的不仅仅是易失性变量被写入主内存并从主内存中读取。我将在以下部分解释。

可变可见性问题

Java volatile关键字保证可以跨线程查看变量的变化。这可能听起来有点抽象,所以让我详细说明。

在线程操作非易失性变量的多线程应用程序中,出于性能原因,每个线程可以在处理它们时将变量从主内存复制到CPU缓存中。如果您的计算机包含多个CPU,则每个线程可以在不同的CPU上运行。这意味着,每个线程可以将变量复制到不同CPU的CPU缓存中。这在这里说明:

关于关键字Volatile的理解_第1张图片
线程可以保存CPU缓存中主存储器的变量副本。

对于non-volatile变量,无法保证Java虚拟机(JVM)何时将数据从主内存读入CPU缓存,或将数据从CPU缓存写入主内存。这可能会导致几个问题,我将在以下部分中解释。

想象一下两个或多个线程可以访问共享对象的情况,该共享对象包含一个声明如下的计数器变量:

public class SharedObject {
    public int counter = 0;
}

想象一下,只有线程1递增counter变量,但线程1和线程2都可能counter不时读取变量。

如果counter变量未声明为volatile,则无法保证何时将counter变量的值从CPU缓存写回主内存。这意味着counter变量在CPU缓存中的变量值可能与主存储器中的变量值不同。这种情况如下所示:

关于关键字Volatile的理解_第2张图片
线程1和主内存使用的CPU缓存包含计数器变量的不同值。

这里的问题是,其他线程没有看到counter变量的最新值,原因是它还没有被另一个线程写回主内存,称为“可见性”问题。其他线程看不到一个线程的更新。

2.易失性和线程同步

对于所有多线程应用程序,我们需要确保一致的行为规则:

  • 相互排斥 - 一次只有一个线程执行一个关键部分
  • 可见性 - 一个线程对共享数据所做的更改对其他线程可见,以维护数据一致性

同步方法和块提供上述两种属性,但代价是应用程序的性能。

Volatile是一个非常有用的原语,因为它可以帮助确保数据变化的可见性方面,当然,不提供互斥。因此,它在我们可以使用多个线程并行执行代码块但我们需要确保可见性属性的地方很有用。

Java易失性可见性保证

Java volatile关键字旨在解决可变可见性问题。通过声明counter变量为volatile,对counter变量的所有写操作都将立即写回内存。此外,counter变量的所有读取都将直接从主存储器中读取。

以下是 变量volatile声明的counter样子:

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

声明volatile变量可以保证对该变量的其他写入线程的可见性。

在上面给出的场景中,一个线程(T1)修改计数器,另一个线程(T2)读取计数器(但从不修改它),声明该counter变量volatile后足以保证counter变量的写入对T2是可见的。

但是,如果T1和T2都在增加counter变量,那么声明 counter变量volatile就不够了。稍后会详细介绍。

完全不稳定的可见性保证

实际上,Java volatile的可见性保证超出了volatile 变量本身。能见度保证如下情形:

如果线程A写入volatile变量并且线程B随后读取相同的volatile变量,线程A在写入之前的所有volatile变量,当线程B读取volatile变量后也将对线程B可见。

如果线程A读取volatile变量,则读取变量时线程A可见的所有变量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;
    }
}

该udpate()方法写入三个变量,其中只有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与上述读取序列。

指令重新排序挑战

只要指令的语义含义保持不变,Java 虚拟机和CPU就可以出于性能原因重新排序程序中的指令。例如,请查看以下说明:

int a = 1;
int b = 2;

a++;
b++;

这些指令可以按以下顺序重新排序,而不会丢失程序的语义含义:`

int a = 1;
a++;

int b = 2;
b++;

然而,当其中一个变量是volatile变量时,指令重新排序时将面临挑战。让我们看看MyClass这个Java 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的值同样被写入到主内存。
但是,如果Java VM重新排序指令,如下所示:

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

当我们去修改days变量时months的值和years值仍写入到主内存,但这次发生的是days修改是发生在写months years之前。新的值(months,years)不能正确被其他线程可见.重新排序的指令的语义含义已经改变。
Java有一个解决这个问题的方法,我们将在下一节中看到。

3.发生在保证之前

从Java 5开始,volatile关键字还提供了额外的功能,可确保包括非易失性变量在内的所有变量的值与Volatile写操作一起写入主存储器。

这称为Happens-Before,因为它为所有变量提供了对另一个读取线程的可见性。此外,JVM不会重新排序volatile变量的读写指令。

Java volatile Happens-Before Guarantee

为了解决指令重新排序挑战,volatile除了可见性保证之外,Java 关键字还提供“先发生”保证。事先发生的保证保证:
1.如果读取/写入最初发生在对volatile变量写入之前,则无法重新排序对其他变量的读和写操作。

对volatile变量的写入之前的读/写将会保证在写入”volatile"之前“先发生”。
请注意,在写入“volatile"变量之前,可能会对其他变量的读/写进行重新排序,以使其在写入“volatile"后发生。只是不是另一种方式。从以后到以前是允许的, 但从以前到以后是不允许的。

2.如果读取/写入最初发生在对volatile变量读取之后,则无法重新排序对其他变量的读和写操作。

请注意, 在读取“volatile"变量之前, 可能会对其他变量的读取进行重新排序, 以使其在读取“volatile"后发生。只是不是另一种方式。从之前到以后是允许的, 但从以后到以前是不允许的。

上述情况-在保证确保volatile关键字的可见性保证被强制执行之前。

volatile is Not Always Enough

即使volatile关键字保证volatile变量直接从主存储器读取变量的所有读取,并且所有对volatile变量的写入都直接写入主存储器,仍然存在声明变量不足的情况volatile

在前面解释的情况中,只有线程1写入共享counter变量,声明该counter变量volatile足以确保线程2始终看到最新的写入值。

实际上,volatile如果写入变量的新值不依赖于其先前的值,则多个线程甚至可以写入共享变量,并且仍然具有存储在主存储器中的正确值。换句话说,如果将值写入共享volatile变量的线程首先不需要读取其值来计算其下一个值。

一旦线程需要首先读取volatile变量的值,并且基于该值为共享volatile变量生成新值,volatile变量就不再足以保证正确的可见性。读取volatile 变量和写入新值之间的短时间间隔会产生竞争条件 ,其中多个线程可能读取volatile变量的相同值,为变量生成新值,并在将值写回时主存 - 覆盖彼此的值。

多个线程递增相同计数器的情况恰好是 volatile变量不够的情况。以下部分更详细地解释了这种情况。

想象一下,如果线程1将counter值为0 的共享变量读入其CPU高速缓存,则将其增加到1并且不将更改的值写回主存储器。然后,线程2可以counter从主存储器读取相同的变量,其中变量的值仍为0,进入其自己的CPU高速缓存。然后,线程2也可以将计数器递增到1,也不将其写回主存储器。这种情况如下图所示:

关于关键字Volatile的理解_第3张图片
两个线程已将共享计数器变量读入其本地CPU高速缓存并递增。

线程1和线程2现在几乎不同步。共享counter变量的实际值应为2,但每个线程的CPU缓存中的变量值为1,而主存中的值仍为0.这是一个混乱!即使线程最终将共享counter变量的值写回主存储器,该值也将是错误的。

什么时候挥发够了?

正如我前面提到的,如果两个线程都在读取和写入共享变量,那么使用 volatile关键字是不够的。 在这种情况下,您需要使用synchronized来保证变量的读取和写入是原子的。读取或写入volatile变量不会阻止线程读取或写入。为此,您必须synchronized 在关键部分代码

作为synchronized块的替代方法,您还可以使用java.util.concurrent包中提供的原子数据类型。例如,AtomicLong或者 AtomicReference来避免竞争条件。

  • 标记为synchronized的逻辑变为同步块,在任何给定时间只允许一个线程执行。

如果只有一个线程读取和写入volatile变量的值,而其他线程只读取变量,那么读取线程将保证看到写入volatile变量的最新值。如果不使变量变为volatile,则无法保证。

volatile关键字保证适用于32位和64位变量。

挥发性的性能考虑因素

读取和写入volatile变量会导致变量被读取或写入主存储器。读取和写入主内存比访问CPU缓存更昂贵。访问volatile变量也会阻止指令重新排序,这是一种正常的性能增强技术。因此,在真正需要强制实施变量可见性时,应该只使用volatile变量。

你可能感兴趣的:(关于关键字Volatile的理解)