欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

volatile与happens-before的关系与内存一致性错误

程序员文章站 2023-11-27 08:10:28
volatile变量 volatile是java的关键词,我们可以用它来修饰变量或者方法。 为什么要使用volatile volatile的典型用法是,当多个线程共享...

volatile变量

volatile是java的关键词,我们可以用它来修饰变量或者方法。

为什么要使用volatile
volatile的典型用法是,当多个线程共享变量,且我们要避免由于内存缓冲变量导致的内存一致性(memory consistency errors)错误时。

考虑以下的生产者消费者例子,在一个时刻我们生产或消费一个单位。

public class producerconsumer {
 private string value = "";
 private boolean hasvalue = false;
 public void produce(string value) {
 while (hasvalue) {
 try {
 thread.sleep(500);
 } catch (interruptedexception e) {
 e.printstacktrace();
 }
 }
 system.out.println("producing " + value + " as the next consumable");
 this.value = value;
 hasvalue = true;
 }
 public string consume() {
 while (!hasvalue) {
 try {
 thread.sleep(500);
 } catch (interruptedexception e) {
 e.printstacktrace();
 }
 }
 string value = this.value;
 hasvalue = false;
 system.out.println("consumed " + value);
 return value;
 }
}

在这个例子中,produce方法产生一个新的值,并保存在value变量中,并且将hasvalue标志位置为true。while循环检查hasvalue是否为true,为true则标志产生的数据还没有被消费,如果为true,则休眠当前线程。当hasvalue置为false的时候,休眠循环才会停止,也就是将数据被consume方法消费后。如果没有可用的数据,cosume方法会休眠。当produce方法产生一个新的数据后,consume会结束休眠,消费该数据,并清除hasvalue标志位。

现在设想两个线程使用该类的同一个对象——一个用来产生数据(write线程),另一个用来消耗数据(read线程)。实例代码如下,

public class producerconsumertest {
 @test
 public void testproduceconsume() throws interruptedexception {
 producerconsumer producerconsumer = new producerconsumer();
 list<string> values = arrays.aslist("1", "2", "3", "4", "5", "6", "7", "8",
 "9", "10", "11", "12", "13");
 thread writerthread = new thread(() -> values.stream()
 .foreach(producerconsumer::produce));
 thread readerthread = new thread(() -> {
 for (int i = 0; i > values.size(); i++) {
 producerconsumer.consume();
 }
 });
 writerthread.start();
 readerthread.start();
 writerthread.join();
 readerthread.join();
 }
}

在大多数情况下,该例子会输出预期的结果,但是也有很大的可能进入死锁状态!

为什么会发生该现象?

首先我们介绍一点计算机架构的知识。

我们知道计算机包括了cpu和内存单元(还有其他组件)。程序指令和变量处在的内存成为主内存;在程序执行期间,为了更好的性能,cpu可能会在其内部内存(也就是cpu缓冲)中存放变量的拷贝。由于现在计算机包括了不止一个cpu,所以同时也包括了多个cpu缓冲。

在多线程环境中,多个线程有可能在同一个时间运行,每个在不同的cpu(由底层os决定),并且他们可能从主内存中复制变量到对应的cpu缓冲中。当线程访问这些变量时,其访问的是这些缓冲的变量,并不是位于主内存的实际变量。

现在我们假设上个例子中的两个线程运行在两个不同的cpu上,并且hasvalue变量被缓冲在其中一个cpu上(或者两个)。考虑以下的执行序列:

1.writer线程产生一个数据,并将hasvalue设置为true。然而,这个改变只是体现在cpu缓冲上,而不是主内存。
2.reader线程准备消耗一个数据,但是其cpu缓冲的hasvalue为false。所以即使writer线程产生了一个数据,reader线程也不能消耗该数据。
3.由于reader线程无法消费新产生的数据,writer线程也不能继续产生新的数据(由于hasvalue为true),因此writer会休眠。
4.然后就出现了死锁!

当hasvalue值在所有的缓冲中都同步(基于底层os),该情形就会改变。

解决方案?volatile如何适用该例子?

如果我们将hasvalue设置为volatile,那么我们可以保证这种类型的死锁不会出现。

private volatile boolean hasvalue = false;
将一个变量设置为volatile后,线程就会直接从主内存中读取该变量的值,并且该变量的写入会立即刷新到主内存中。如果一个线程缓冲了该变量,那么每次读和写操作都会和主内存同步。

这个修改后,考虑上面那个可能会导致死锁的步骤:

1.writer产生了一个新的数据,并将hasvalue设置为true。该更新会直接反映在主内存中(即使该线程使用了缓存)。
2.reader线程尝试消费一个变量,并检查hasvalue的值。该变量的每次读都会直接从主内存获得,所以它能获得到writer线程导致的改变。
3.reader线程消费该变量并清楚hasvalue标志位。该变量会刷新到主内存中(如果被缓存,则缓存的变量也会刷新)。
4.由于reader线程每次都操作的主内存,所以writer线程能看到reader导致的改变。其会继续产生新的数据。

volatile与happens-before关系

访问volatile变量在语句间建立了happens-before关系。当写入一个volatile变量时,它与之后的该变量的读操作建立了happens-before关系。那么什么是happens-before关系呢?可以参考笔者之前的博客[java并发编程番外篇(二)happens-before关系],简单来说,就是保证一个语句的影响会被另一个语句看到()。

考虑以下的例子,

// definition: some variables
private int first = 1;
private int second = 2;
private int third = 3;
private volatile boolean hasvalue = false;
// first snippet: a sequence of write operations being executed by thread 1
first = 5;
second = 6;
third = 7;
hasvalue = true;
// second snippet: a sequence of read operations being executed by thread 2
system.out.println("flag is set to : " + hasvalue);
system.out.println("first: " + first); // will print 5
system.out.println("second: " + second); // will print 6
system.out.println("third: " + third); // will print 7

我们假设两面的两个片段运行在两个线程——线程1和线程2. 当线程1修改hasvalue值后,不仅仅hasvalue的值会直接写入到主内存,前面的三个写操作也会写入主内存(和之前的其他写操作)。因此,当线程2访问这三个变量时,它会看到线程1对这些变量进行的修改,即使他们会缓存(这些缓存也会被更新)。

这也正是在第一个例子中,我们没有将value变量设置为volatile的原因。这是由于访问hasvalue之前其他变量的写操作,和读hashvalue之后其他变量的读操作,会自动和主内存同步。

这是另外一个有趣的序列。jvm以它的程序优化著名。有时候,在不影响输出的情况下,jvm会对指令进行重排序来获得更好的性能。作为例子,它可能将该序列的代码,

first = 5;
second = 6;
third = 7;

重排序为,

first = 5;
second = 6;
third = 7;
然而,当一个语句涉及到访问volatile变量,那么jvm就不会将一个volatile写操作之前的语句放到volatile写操作之后。也就是说,它不会将以下的代码序列,
first = 5; // write before volatile write
second = 6; // write before volatile write
third = 7; // write before volatile write
hasvalue = true;

修改成,

first = 5;
second = 6;
hasvalue = true;
third = 7; // order changed to appear after volatile write! this will never happen!
即使从代码正确性的角度来看,这两者是相同的。注意到jvm仍然允许重排序前三条语句,只要他们位于volatile写之前。

类似,jvm不会将位于volatile读之后的代码重排序到volatile读之前。也就是说该代码,

system.out.println("flag is set to : " + hasvalue); // volatile read
system.out.println("first: " + first); // read after volatile read
system.out.println("second: " + second); // read after volatile read
system.out.println("third: " + third); // read after volatile read

并不会修改为,


http://system.out.println("first: " + first); // read before volatile read! will never happen!
system.out.println("fiag is set to : " + hasvalue); // volatile read
system.out.println("second: " + second); 
system.out.println("third: " + third);

然而,jvm可以将后三条语句重排序,只要他们在volatile读之后。

volatile带来的性能开销

volatile强制进行主内存访问,而主内存访问通常比cpu缓存访问慢。同时也阻止了jvm进行的一些程序优化,更进一步降低了性能。

能否使用volatile来保证多线程的数据一致性?

答案是不能。当多个线程访问同一个变量时,将该变量标志为volatile并不足以保证一致性,考虑下面的unsafecounter类,

public class unsafecounter {
 private volatile int counter;
 public void inc() {
 counter++;
 }
 public void dec() {
 counter--;
 }
 public int get() {
 return counter;
 }
}

测试代码,

public class unsafecounter {
 private volatile int counter;
 public void inc() {
 counter++;
 }
 public void dec() {
 counter--;
 }
 public int get() {
 return counter;
 }
}

代码很容易读懂。我们在一个线程中增加计数器的值,然后在另一个线程中减少计数器的值。运行这个测试,我们预期的计数器的结果是0,但是这并不能保证。大多数情况下都是0,然而,一些情况下,可能是-2,-1,1,2,甚至[-5,5]的任何数字。

为什么会发生这种情况呢?这是由于counter变量的增加和减少操作都不是原子操作——他们不是一次执行完毕的。他们都包括了多个步骤,而且两个步骤序列有交叠。你可以认为自增这样操作:

1.读取counter数值
2.增加1
3.将数值写入到counter中

同样的,自减操作:

1.读取counter数值
2.减少1
3.将数值写入到counter中

现在,我们考虑以下的执行序列:

1.第一个线程从内存中读取counter的值。其被初始化为0. 然后该线程将其自增.
2.第二个线程同时也从内存中读取counter的值,并且该值也为0. 然后该线程对其执行自减操作。
3.第一个进程将数值写入到内存中,即,counter的值为1.
4.第二个线程将数值写入到内存中,即,counter的值为-1.
5.第一个线程的更新被丢失。

怎么阻止该现象呢?

1. 使用同步:

public class synchronizedcounter {
 private int counter;
 public synchronized void inc() {
 counter++;
 }
 public synchronized void dec() {
 counter--;
 }
 public synchronized int get() {
 return counter;
 }
}

2. 或者使用atomicinteger:

public class atomiccounter {
 private atomicinteger atomicinteger = new atomicinteger();
 public void inc() {
 atomicinteger.incrementandget();
 }
 public void dec() {
 atomicinteger.decrementandget();
 }
 public int get() {
 return atomicinteger.intvalue();
 }

我的选择是使用atomicinteger,因为同步方法只允许一个线程访问inc/dec/get方法,这带来了额外的性能开销。

使用同步方法时,我们并没有将counter设置为volatile变量。这是因为,使用synchronized关键词就建立了happens-before关系。进入一个同步方法(代码块),在该语句之前的代码和方法(代码块)中的代码建立了happens-before关系。浅谈java内存模型之happens-before可以查看详细介绍。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。