转载

Concurrency(七: volatile关键字)

Java中 volatile 关键字用于标记Java变量“始终存储在主存中”.这意味着每次都是从主存中读取 volatile 修饰的变量,且每次对 volatile 修饰的变量的更改都会写回到主存中,而不是cpu缓存.

事实上,自java5之后, volitle 关键字保证的不只是始终在主存中读取和修改 volatile 修饰的变量.

变量可见性问题

volatile 保证了线程间对共享变量的修改是可见的.

在多线程应用中,对于没有 volatile 修饰的变量,每个线程在执行过程中都会从主存中拷贝一份变量的副本到cpu缓存中.假设计算机中拥有多个cpu,每个线程可能在不同的cpu上执行,即每个线程很可能将变量加载到不同的cpu缓存中.如图所示:

Concurrency(七: volatile关键字)

没有 volatile 修饰将不能保证JVM何时从主存中读取变量或何时将变量写回主存.这将导致若干问题的发生.

假设两个或更多的线程访问一个包含有counter变量的共享对象,声明如下:

public class SharedObject{
    public int counter = 0;
}
复制代码

想象一下,当只有线程1对counter进行累加计算,但线程1和线程2在往后的时间会不定时的从主存中加载变量counter.

如果没有将变量counter修饰为 volatile 将不能保证对变量counter的修改会在何时写回主存.这意味着不同cpu缓存中counter变量的值可能与主存中不一样.如图所示:

Concurrency(七: volatile关键字)

问题在于线程1对counter变量的修改对于线程2不可能见.这种一个线程对共享变量的修改对于另一个线程不可见的问题,我们称之为"可见性"问题.

volatile对于可见性的保障

Java中 volatile 关键字用于解决可见性问题.若将counter修饰为 volatile ,那么所有对于counter的修改会被立即写回到主存中,且限制counter只能从主存中读取.

public class SharedObject{
    public volatile int counter = 0;
}
复制代码

将变量修饰为 volatile 保证了变量修改对其他线程的可见性.

上文中提及线程1对counter的修改,线程2对counter的读取能够通过 volatile 来保证线程1对counter变量的修改对于线程2可见.

然而,如果线程1和2同时累加counter变量,此时仅仅将变量修饰为 volatile 是不够的.详情在下文会提及.

volatile对可见性的充分保障

事实上, volatile 对于可见性保障不仅仅局限于 volatile 修饰的变量本身.可见性保障内容如下所示:

  • 如果线程1修改 volatile 修饰的变量,紧接着线程2读取同样 volatile 修饰的变量,那么线程1对修改 volatile 变量之前其他变量的修改都会对线程2可见.
  • 如果线程1读取一个 volatile 修饰的变量,那么 volatile 修饰变量之后用到的其他变量都会强制从主存中读取以保证所有变量对于线程1可见.

代码实例:

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是 volatile 修饰的.

volatile 对可见性的充分保障意味着当线程更新days的值时,会连同days之前的yeas 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,紧接着连同参与计算的months和years也一起从主存中读取.因此你可以保障上面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 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;
    }
}
复制代码

一旦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的修改也会被写回主存,但这次对days的修改是在months和years之前,因此对于months和years的最新修改不会对其他线程可见.重排序后的语义已经发生改变.

volatile关于Happens-Before的保障

针对指令重排序的挑战, volatile 给出了"happens-before"保障,用于补充可见性保障.happens-before保障的内容如下所示:

  • 若对于其他变量的读写原顺序是在写volatile修饰变量之前进行的,不能被重排序为之后进行.保证了写volatile变量之前对其他变量的读写操作正常的发生.相反,允许对于其他变量的读写原顺序是在写volatile修饰变量之后的,被重排序为之前进行.
  • 若对于其他变量的读写原顺序是在读volatile变量之后的,不能被重排序为之前进行.保证了读volatile变量之后对其他变量的读写操作正常的发生.相反,允许对于其他变量的读写原顺序是在读volatile修饰变量之前的,被重排序为之后进行.

happens-before保证了 volatile 可见性保障的强制执行.

volatile并不总是足够的

尽管 volatile 保障了 volatile 修饰的变量总是从主存中读取和写回主存,但还是有些情况即使将变量修饰为 volatile 也不能满足.

之前的情况是线程1对于 volatile 变量的修改总是对于线程2可见.

在多线程下,如果产生的新值并不依赖主存中的旧值(不需要使用旧值来推导出新值),那么即使两个线程同时更新主存中 volatile 修饰的变量值也不会有问题.

当一个线程产生的新值需要依赖旧值时,那么仅仅用 volatile 修饰共享变量来保障可见性是不够的.当一个线程在读取主存中 volatile 修饰的共享变量之前,此时有两个线程同时从主存中加载相同的 volatile 修饰的变量,同时进行更新且写回主存时会产生竞态条件,此时两个线程对旧值的更新会互相覆盖.那么之后线程从主存中读取的数值可能是错误的.

这种情况下,当两个线程同时累加相同的counter变量时,用 volatile 修饰变量已经不能满足了.如下所示:

Concurrency(七: volatile关键字)

当线程1从主存加载counter到cpu缓存中,此时counter为0,对counter进行累加之后counter变为1,此时线程1还没有将counter写回主存,线程2同样将主存中counter加载到cpu缓存中进行累加操作.此时线程2也还没有将counter写回主存.

实际上线程1和线程2是同时进行的.而主存中counter变量的预期结果应该为2,但如图所示两个线程在各自缓存中的值为1,而在主存中的值为0.即使两个线程将各自缓存中的值写回主存也是错误的.

volatile什么情况才是足够的?

两个线程同时对一个共享变量进行读写操作时,使用 volatile 修饰已经不能满足情况.你需要使用 synchronized 来保障变量读写操作的原子性.使用 volatile 并不能同步线程的读写操作.这种情况下只能使用 synchronized 关键字来修饰临界区代码.

除了 synchronized ,你还可以选择 java.util.concurrent 包中提供的原子数据类型.如 AtomicLongAtomicRefrerence 等.

在其他情况下,如果只有一个线程对 volatile 修饰的变量进行读写操作,其他线程只进行读操作,那么 volatile 是足够保障可见性的,若没有 volatile 修饰,那就不能保障了.

volatile 关键字在32bit和64上的变量可用.

volatile实践建议

对于 volatile 修饰变量的读写能够被强制在主存中进行(从主存中读取,写回主存).直接在主存中读写的性能消耗远大于在cpu缓存中读写. volatile 能够在特定情况有效防止指令重排序.所以应该谨慎使用 volatile ,只有在真正需要保障变量可见性的情况下使用.

原文  https://juejin.im/post/5ca0f1f36fb9a05e11131112
正文到此结束
Loading...