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

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关系],简单来说,就是保证一个语句的影响会被另一个语句看到(https://www.jb51.net/article/161649.htm)。

考虑以下的例子,

// 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可以查看详细介绍。

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

(0)

相关推荐

  • 简单易懂讲解happens-before原则

    在接下来的叙述里我首先会说明happens-before规则是干什么用的,然后用一个简单的小程序说明happens-before规则 一.happens-before规则 我们编写的程序都要经过优化后(编译器和处理器会对我们的程序进行优化以提高运行效率)才会被运行,优化分为很多种,其中有一种优化叫做重排序,重排序需要遵守happens-before规则,不能说你想怎么排就怎么排,如果那样岂不是乱了套. happens-before部分规则如下: 1.程序顺序规则:一个线程中的每个操作happen

  • Java内存之happens-before和重排序

    happens-before原则规则: 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作: 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作: volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作: 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C: 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作: 线程中断规则:对线程interrup

  • 深入浅出了解happens-before原则

    看Java内存模型(JMM, Java Memory Model)时,总有一个困惑.关于线程.主存(main memory).工作内存(working memory),我都能找到实际映射的硬件:线程可能对应着一个内核线程,主存对应着内存,而工作内存则涵盖了写缓冲区.缓存(cache).寄存器等一系列为了提高数据存取效率的暂存区域.但是,一提到happens-before原则,就让人有点"丈二和尚摸不着头脑".这个涵盖了整个JMM中可见性原则的规则,究竟如何理解,把我个人一些理解记录下来

  • 深入理解happens-before和as-if-serial语义

    概述 本文大部分整理自<Java并发编程的艺术>,温故而知新,加深对基础的理解程度. 指令序列的重排序 我们在编写代码的时候,通常自上而下编写,那么希望执行的顺序,理论上也是逐步串行执行,但是为了提高性能,编译器和处理器常常会对指令做重排序. 1) 编译器优化的重排序.编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序. 2) 指令级并行的重排序.现代处理器采用了指令级并行技术来将多条指令重叠执行.如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序. 3) 内存系统的

  • 浅谈Java内存模型之happens-before

    happens-before原则非常重要,它是判断数据是否存在竞争.线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题.下面我们就一个简单的例子稍微了解下happens-before : i = 1;       //线程A执行 j = i ;      //线程B执行 j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以确定线程B执行后j = 1 一定成立,如果他们不存在happens-be

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

    volatile变量 volatile是Java的关键词,我们可以用它来修饰变量或者方法. 为什么要使用volatile volatile的典型用法是,当多个线程共享变量,且我们要避免由于内存缓冲变量导致的内存一致性(Memory Consistency Errors)错误时. 考虑以下的生产者消费者例子,在一个时刻我们生产或消费一个单位. public class ProducerConsumer { private String value = ""; private boolea

  • java内存管理关系及内存泄露的原理分析

    目录 java内存管理关系及内存泄露原理 java对象和内存的关系 创建对象 null的作用 内存泄露 检测内存泄露的原理 java内存管理关系及内存泄露原理 这可能是最近写的博客中最接近底层的了.闲言少叙,进入正题. java对象和内存的关系 首先,我们要知道下面几条真理(自己总结的) 一个完整的建立对象流程是 1声明对象,2开辟内存空间,3将对象和内存空间建立联系. 一个对象只能对应一个内存空间,一个内存空间可以对应很多对象 回收一个内存空间 .如果,这个内存空间没有任何一个对象和他有联系.

  • Java虚拟机常见内存溢出错误汇总

    一.引言 从事java开发的小伙伴在平时的开发工作中,应该会遇见各式各样的异常和错误,在实际工作中积累的异常或者错误越多,趟过的坑越多,就会使我们编码更加的健壮,就会本能地避开很多严重的坑.以下介绍几个Java虚拟机常见内存溢出错误.以此警示,避免生产血案. 二.模拟Java虚拟机常见内存溢出错误 1.内存溢出之栈溢出错误 package com.jayway.oom; /** * 栈溢出错误 * 虚拟机参数:-Xms10m -Xmx10m * 抛出异常:Exception in thread

  • phpExcel导出大量数据出现内存溢出错误的解决方法

    phpExcel将读取的单元格信息保存在内存中,我们可以通过 复制代码 代码如下: PHPExcel_Settings::setCacheStorageMethod() 来设置不同的缓存方式,已达到降低内存消耗的目的! 1.将单元格数据序列化后保存在内存中 复制代码 代码如下: PHPExcel_CachedObjectStorageFactory::cache_in_memory_serialized; 2.将单元格序列化后再进行Gzip压缩,然后保存在内存中 复制代码 代码如下: PHPEx

  • redis 使用lettuce 启动内存泄漏错误的解决方案

    redis 使用 lettuce 出现 LEAK: hashedwheelTimer.release() was not called before it's garbage-collected. Enable advanced leak 内存泄漏.其实是内存不够大导致. 找到eclispe 中window->preferences->Java->Installed JRE ,点击右侧的Edit 按钮,在编辑界面中的 "Default VM Arguments "选项

  • java volatile关键字使用方法及注意事项

    java volatile关键字使用方法及注意事项 什么是volatile关键字 volatile 关键字在多线程程序中起着很重要的作用.当多个线程操作同一个变量时,每个线程将拥有对那个变量的本地缓存拷贝,因此,当某一个线程修改了这个变量的值时,实际上修改的是它本地缓存中的变量值,而不是主内存中的变量值,操作这个变量的其他线程并不知道这个变量的值被改变了.为了避免这种情况,我们可以用 valatile 关键字声明这个变量,用 valatile 声明了这个变量之后,变量将不在本地缓存中保存,而在主

  • 详细分析java并发之volatile关键字

    Java面试中经常会涉及关于volatile的问题.本文梳理下volatile关键知识点. volatile字意为"易失性",在Java中用做修饰对象变量.它不是Java特有,在C,C++,C#等编程语言也存在,只是在其它编程语言中使用有所差异,但总体语义一致.比如使用volatile 能阻止编译器对变量的读写优化.简单说,如果一个变量被修饰为volatile,相当于告诉系统说我容易变化,编译器你不要随便优化(重排序,缓存)我. Happens-before 规范上,Java内存模型遵

  • 浅谈Java并发编程基础知识

    进程和线程 在并行程序中进程和线程是两个基本的运行单元,在Java并发编程中,并发主要核心在于线程 1. 进程 一个进程有其专属的运行环境,一个进程通常有一套完整.私有的运行时资源:尤其是每个进程都有其专属的内存空间. 通常情况下,进程等同于运行的程序或者应用,然而很多情况下用户看到的一个应用实际上可能是多个进程协作的.为了达到进程通信的目的,主要的操作系统都实现了Inter Process Communication(IPC)资源,例如pipe和sockets,IPC不仅能支持同一个系统中的进

  • java多线程volatile内存语义解析

    这篇文章主要介绍了java多线程volatile内存语义解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 volatile关键字是java虚拟机提供的最轻量级额的同步机制.由于volatile关键字与java内存模型相关,因此,我们在介绍volatile关键字之前,对java内存模型进行更多的补充(之前的博文也曾介绍过). 1. java内存模型(JMM) JMM是一种规范,主要用于定义共享变量的访问规则,目的是解决多个线程本地内存与共享内存

  • Java开发中的volatile你必须要了解一下

    前言 上一篇文章说了 CAS 原理,其中说到了 Atomic* 类,他们实现原子操作的机制就依靠了 volatile 的内存可见性特性.如果还不了解 CAS 和 Atomic*,建议看一下我们说的 CAS 自旋锁是什么 并发的三个特性 首先说我们如果要使用 volatile 了,那肯定是在多线程并发的环境下.我们常说的并发场景下有三个重要特性:原子性.可见性.有序性.只有在满足了这三个特性,才能保证并发程序正确执行,否则就会出现各种各样的问题. 原子性,上篇文章说到的 CAS 和 Atomic*

随机推荐