Java中的volatile


Java的关键字 volatile 用于将变量标记为“存储于主内存中”。更确切地说,对 volatile 变量的每次读操作都会直接从计算机的主存中读取,而不是从 cpu 缓存中读取;同样,每次对 volatile 变量的写操作都会直接写入到主存中,而不仅仅写入到 cpu 缓存里。

实际上,从 Java 5 开始关键字 volatile 除了能确保 volatile 变量直接从主存中进行读写,还有以下几个作用。

可见性保证

关键字 volatile 能确保数据变化在线程之间的可见性。

在多线程的应用中多个线程对 non-volatile 变量进行操作,线程在对它们进行操作的时候为了提高性能会将变量从主存复制到 cpu 缓存中。如果你的电脑包含的 cpu 不止一个, 那么每个线程可能会运行于不同的 cpu 上。这意味着,不同线程会将变量复制到不同 cpu 的缓存里。如下图:

在这里插入图片描述
no-volatile 变量不能保证 Java 虚拟机(JVM)何时从主存中将数据读入cpu 缓存,也不能保证何时将数据从 cpu 缓存写入到主存中。这会带来一些问题,我将在下面解释。

想象一个场景,两个或两个以上线程可访问同一个共享对象,这个对象含有一个如下的计数器变量:

public class SharedObject{
  public int counter = 0;
}

再假设有2个线程 Thread1 和 Thread2,只有 Thread1 能增大 counter ,而 Thread1 和 Thread2 都可以在任何时刻读取 counter 的值。
如果 counter 没被声明为 volatile ,将不能保证什么时候 counter 变量的值会从 cpu 缓存回写到主存内。也就是说,变量 counter 在 cpu 缓存中的值可能和主存内的不一致。 如下图:

在这里插入图片描述
这种由于线程还未将变量的值回写到主存而导致其他线程不能看到该变量的最新值的问题,称为可见性问题。一个线程的更新操作对其他线程不可见。

通过将变量 counter 声明为 volatile ,对其进行的所有写操作都会马上回写至主存中。同时,所有 counter 的读操作也将直接在主存中进行。声明方式如下:

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

这样,将变量声明为 volatile 保证了写操作对其他线程的可见性。

Happens-before 保证

自 Java 5 之后,关键字 volatile 不仅仅保证变量写入主存和从主存中的读取。实际上,volatile 保证了以下几点:

如果线程A写 volatile 变量(下文用 volatile 简称 volatile 变量), 然后线程B 读取这个 volatile ,那么在写 volatile 之前对线程A可见的变量也将在线程B 读取这个 volatile 之后可见。
对 volatile 变量的读取和写入指令不能被 JVM 重排序(只要 JVM 识别出程序的行为在重排序后不会改变,它就会对指令进行重排序以提高性能)。操作volatile 之前和之后的指令可以重排序,但是不能将其和这些指令混在一起重排序。任何发生在 volatile 的读写操作之后的指令一定发生在读写操作之后。(具体的可以看本文底部的 “正确使用volatile” 里的说明)
我们来做对以上叙述做进一步的说明:
当线程写入 volatile 时,不单单是将这个 volatile 写入主存中。这个线程在写此
volatile 变量之前改变的所有的变量也将刷新到主存中。当另一个线程读取这个 volatile 变量时,它也能从主存中读取到随 volatile 一起被刷入主存的其他所有变量。

看看这个例子:

Thread A:
  sharedObject.nonVolatile = 123;
  sharedObject.counter = sharedObject.counter + 1; // volatile
Thread B:
  int counter = sharedObject.counter;
  int nonVolatile = sharedObject.nonVolatile;

由于线程A 在写 volatile 的变量 sharedObject.counter 之前写 non-volatile 变量 sharedObject.nonVolatile,sharedObject.counter 和 sharedObject.nonVolatile 会在 写 sharedObject.counter 的时候一起写入到主存中。
由于线程B 开始时先读取 volatile 变量 sharedObject.counter, 那么 sharedObject.counter 和 sharedObject.nonVolatile 会直接从主存读取到供线程B 使用的 cpu 缓存中 。这个时候,线程B 读到的 sharedObject.nonVolatile 就是线程A 写入的新值。

开发人员可以利用这扩展的可见性保证来优化线程间变量的可见性。只将一个或者几个变量声明为 volatile ,而不是把每个变量都声明成 volatile, 比如常用的标记位变量 flag 就可以放心的在处理完相应才操作后置为 true 了 。利用这个原则来简单地重写 Exchanger 类:

public class Exchanger{
  private Object object = null;
  private volatile boolean hasNewObject = false;
  
  public void put(Object newObject){
    while(hasNewObject){
      // 等待 , 不要去覆盖object字段
      System.out.println("等待put");
    }  
    object = newObject;     // 在写 volatile 之前进行的普通写
    hasNewObject =  true; // 写 volatile 
  }

  public Object take(){
    while(! hasNewObject){ // 读 volatile
       // 等待, 不获取旧的 object或null
       System.out.println("等待take");
    }
    Object obj = object;      // 再写 volatile 之前进行的普通读
    hasNewObject = false; // 写 volatile
    return obj;
  }
}

执行场景为线程A 不断调用 put() 方法塞入新对象,线程B 不断调用 take() 方法获取新对象。如果仅有线程A 调用 put()并且仅有线程B 调用 take(), 那么这个 Exchanger 只要使用 volatile 变量就能正常运行了(不需要使用 synchronized 同步代码块)。

然而,如果 JVM 对指令进行重排序后不影响其执行语义,它就会对 Java 指令进行重排序以提高性能。如果 JVM 调整 put() 和
take() 内读写指令的执行顺序,会发生什么? 如果 put() 实际上是按如下顺序执行的会怎样?

while(hasNewObject){
  // 等待 , 不要去覆盖object字段
}
hasNewObject = true; // 写 volatile
object = newObject;

注意,现在上面示例中写 volatile 在新的 object 赋值前就执行了。对 JVM 来说这也许看起来完全合法,因为这两个写操作的值彼此之间没有依赖。
不过, 以上的重排序会损害 volatile 变量 object 的可见性。首先,线程B 可能在线程给变量 object 赋新值之前就看到 hasNewObject 已经是 true 了。其次,现在已经不能保证 object 的新值被回写到主存了(也许是下次线程A 在某处写volatile的时候)。
为了阻止如上情形的发生,关键字 volatile 还提供了 happes before 保证。happens-before 保证对 volatile 变量的读写指令不会被重排序。可以重排序在其之前和之后发生的指令,但是对 volatile 变量的读写指令不能同先于或后于它发生的任何指令一起重排序。
看看如下例子:

sharedObject.nonVolatile1 = 123;
sharedObject.nonVolatile2 = 456;
sharedObject.nonVolatile3 = 789;

sharedObject.volatile = true;  // volatile 变量

int someValue1 = sharedObject.nonVolatile4;
int someValue2 = sharedObject.nonVolatile5;
int someValue3 = sharedObject.nonVolatile6;

JVM 会重排序前面的3个指令,只要保证它们都在 写 volatile 之前发生就可以(它们必须在写 volatile 指令前执行)。

类似地,JVM 也会重排序最后的 3 个指令,只要保证它们都在写 volatile 之后发生就可以。最后的 3 个指令都不能重排序至 写volatile指令之前。
这是 Java volatile happens-before 保证的基本含义。

volatile对于重排序的禁止操作主要是java编译器在生成指令序列时会在适当的位置插入内存屏障来阻止重排序,具体说明可以参考《java并发编程艺术》3.1 和 3.4.3小节。

volatile 并不能满足所有场景

即使关键字 volatile 能保证对它的所有读操作都是直接从主存中读取,所有写操作也都是直接写入主存中,还是有些仅将变量声明为 volatile 不能满足的场景。

在前面讨论的例子中只有线程A 对共享变量 counter 进行写操作,将 counter 声明为 volatile 就足以保证线程B 能看到新写入的值。

实际上,多线程甚至在同时对共享的 volatile 变量进行写操作时,只要新值的写入不依赖它之前的值,就仍然能保持主存中的值是正确的。换句话说,一个线程将一个值写入共享 volatile 变量中,不需要首先读取原来值来计算下一个新值。

只要线程需要先读取 volatile 的值, 然后基于这个值来生成新值,那么这个 volatile 变量就不再能保证其正确的可见性。读取 volatile 和写入新值之间的时间间隙产生了 竞态条件 ,多个线程可能读取到相同的值, 然后生成新值,接着在将值回写到主存中的时候就会覆盖彼此的值了。

多线程增加同一个计数器 counter 就恰好是 volatile 不够用的一个场景。接下来我们来更详细的解释这个例子。

假设线程1 读取值为0的共享变量 counter 到它的 cpu 缓存中,增加值到1,但还没把改变的值回写到主存中。 线程2 接着也能从主存中读取值还是0这个 counter,并将其存入它自己的 cpu 缓存里。接着线程2 也将 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。

references:

转载:https://www.jianshu.com/p/3893fb35240f

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值