Translation "Java Concurrent Programming: Volatile"

Author: Jacob Jankov

Original: tutorials.jenkov.com/java-concur…

Translation: Pan Shenlian's personal website If you have a better translated version, welcome ❤️ to submit an issue or contribution~

Updated: 2022-02-24

Java's volatilekeywords are used to mark Java variables as "stored in main memory". More precisely, every read to a volatilevariable will be read from the computer's main memory, not from the CPU cache, and every write to a volatilevariable will be written to main memory, not just a write in the CPU cache.

In fact, since Java 5, volatilekeywords have not only been used to ensure that volatilevariables read and write to main memory. I will explain this below.

Java volatile tutorial video

If you like videos, I have a video version of this Java volatiletutorial : Java volatile Tutorial Video

1.jpg

Variable visibility issues

Java's volatilekeywords guarantee the "visibility" of shared variables in multithreading. This might sound a little abstract, so let me elaborate.

In a multithreaded application, if multiple threads volatileoperate on the same variable without the declared keyword, for performance reasons, each thread can copy the variable from main memory to the CPU cache as it processes the variable. If your computer has multiple CPUs, each thread may run on a different CPU. This means that each thread can copy variables on the CPU caches of different CPUs. This is explained here:

2.png

对于无声明volatile关键词的变量而言,无法保证Java虚拟机(JVM)何时将数据从主内存读取到CPU缓存,或者将数据从CPU缓存写入主内存。这就可能会导致几个问题,我将在以下部分内容解释这些问题。

想象一个场景,多个线程访问一个共享对象,该对象包含一个声明如下的计数器(counter)变量:

public class SharedObject {

    public int counter = 0;

}
复制代码

假设只有线程1会增加计数器(counter)变量的值,但是线程1和线程2会不时的读取这个计数器变量。

如果计数器(counter)变量没有声明volatile关键词,则无法保证计数器变量的值何时从CPU缓存写回主内存。这就意味着,每个CPU缓存上的计数器变量值和主内存中的变量值可能不一致。这种情况如下所示:

3.png

一个线程的写操作还没有写回主内存(每个线程都有本地缓存,即CPU缓存,一般写入成功会从cpu缓存刷新至主内存),其他线程看不到变量的最新值,这就是“可见性”问题,即一个线程的更新对其他线程是不可见的。

Java volatile 可见性保证

Java的volatile关键字就是为了解决变量的可见性问题。通过对计数器(counter)变量声明volatile关键字,所有线程对该变量的写入都会被立即同步到主内存中,并且,所有线程对该变量的读取都会直接从主内存读取。

以下是计数器(counter)变量声明了关键字volatile的用法:

public class SharedObject {

    public volatile int counter = 0;

}
复制代码

因此,声明了volatile关键字的变量,保证了其他线程对该变量的写入可见性。

在以上给出的场景中,一个线程(T1)修改了计数器变量,而另一个线程(T2)读取计数器变量(但是没有进行修改),这种场景下如果给计数器(counter)变量声明volatile关键字,就能够保证计数器(counter)变量的写入对线程(T2)是可见的。

但是如果线程(T1)和线程(T2)都对计数器(counter)变量进行了修改,那么给计数器(counter)变量声明volatile关键字是无法保证可见性的,稍后讨论。

volatile 全局可见性保证

实际上,Java的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关键字声明的变量,被写入时会直接从本地线程缓存刷新到主内存。

volatile的全局可见性保证,指的是当一个值被写入days时,所有对当前写入线程可见的变量也都会被写入到主内存。意思就是当一个值被写入days变量时,year变量和months变量也会被写入到主内存。

在读yearsmonthsdays的值时,你可以这样做:

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变量时,也会从主内存读取month变量和years变量的值。因此你可以通过以上的读取顺序,来保证读取到三个变量days,monthsyears最新的值。

指令重排序的挑战

为了提高性能,一般允许 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变量的最新值也会被写入到主内存当中。但是,如果Java虚拟机对指令进行重排,例如这样:

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关键字还提供了Happens-Before规则。Happens-Before规则保证:

  • 如果其他变量的读写操作原先就发生在volatile变量的写操作之前,那么其他变量的读写指令不能被重排序到volatile变量的写指令之后;
    • volatile变量写入之前,发生的其他变量的读写,Happens-Before 于volatile变量的写入。

注意:例如在volatile变量写入之后的其他变量读写,仍然可能被重排到volatile变量写入之前。只不过不能反着来,允许后面的读写重排到前面,但不允许前面的读写重排到后面。

  • 如果其他变量的读写操作原先就发生在volatile变量读操作之后,那么其他变量的读写指令不能被重排序到volatile变量的读指令之前;

注意:例如在volatile变量读之前的其他变量读取,可能被重排到volatile变量的读之后。只不过不能反着来,允许前面的读取重排到后面,但不允许后面的读取重排到前面。

上述的Happens-Before规则,确保了volatile关键字的可见性保证会被强制要求。

仅声明 volatile 不足以保证线程安全

即使volatile关键字保证直接从主内存读取volatile变量,并且所有对volatile变量的写入都直接写入主内存,在某些情况下仅仅声明变量volatile是不足以保证线程安全的。

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

事实上,如果写入变量的新值不需要依赖之前的值,那多个线程可以同时对一个volatile共享变量进行写入操作,并且在主内存中仍然存储正确的值。换而言之,如果一个线程仅对一个volatile共享变量进行写入操作,那并不需要先读取出这个变量的值,再通过计算得到下一个值。

一旦线程需要首先读取出volatile变量的值,再基于该值为volatile共享变量生成新值,那volatile变量就不再足以保证正确的可见性。在读取volatile变量和写入新值之间的短暂时间会产生资源竞争,存在多个线程同时来读取volatile变量并得到相同的值,且都为变量赋予新值,然后将值都写回主内存中,从而会覆盖掉彼此的值。

多个线程递增同个计数器(counter)变量的情况,导致volatile变量不够保证线程安全性。 以下部分更详细地解释了这种情况:

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

4.png

线程1和线程2现在几乎不同步。共享计数器(counter)变量的实际值应该是2,但每个线程在其CPU缓存中的变量值为1,在主内存中该值仍然为0。真是一团糟!即使线程最终将其共享计数器变量的值写回主内存,该值也将是错误的。

volatile 何时是线程安全的

正如我前面提到的,如果两个线程都在读取和写入共享变量,那么使用volatile关键字是不足以保证线程安全的。一般这种情况下,您需要使用synchronized来保证变量的读取和写入是原子性的。读取或写入volatile变量不会阻塞其他线程读取或写入。为此,您必须在关键部分周围使用synchronized关键字。

作为synchronized块的替代方案,您可以选择使用java.util.concurrent并发包中的原子数据类型。 例如,AtomicLongAtomicReference或其它之一。

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

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

volatile 的性能注意事项

Reading and writing volatilevariables will be directly read and written from main memory, which is more expensive than reading and writing from CPU cache, but accessing volatilevariables can prevent instruction reordering, which is a normal performance enhancement technique. So unless you really need to enforce the visibility of variables, use variables less otherwise volatile.

(End of this article)

Original: tutorials.jenkov.com/java-concur…

Translation: Pan Shenlian's personal website If you have a better translated version, welcome ❤️ to submit an issue or contribution~

Guess you like

Origin juejin.im/post/7077840479148048392