java 多线程变量可见性_Java多线程:易变变量,事前关联和内存一致性

java 多线程变量可见性

什么是volatile变量?

volatile是Java中的关键字。 您不能将其用作变量或方法名称。 期。

我们什么时候应该使用它?

哈哈,对不起,没办法。

当我们在多线程环境中与多个线程共享变量时,通常使用volatile关键字,并且我们希望避免由于这些变量在CPU高速缓存中的缓存而导致任何内存不一致错误

考虑下面的生产者/消费者示例,其中我们一次生产/消费一件商品:

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,则请求当前线程进入睡眠状态。 仅当hasValue标志已更改为false时,此睡眠循环才会停止,这仅在consumer方法使用了新值时才有可能。 如果没有新值可用,那么消耗方法将请求当前线程Hibernate。 当Produce方法产生一个新值时,它将终止其睡眠循环,使用它并清除value标志。

现在想象一下,有两个线程正在使用此类的对象–一个正在尝试产生值(写线程),另一个正在使用它们(读线程)。 以下测试说明了这种方法:

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中运行(尽管这完全取决于底层操作系统),并且每个线程都可以从main复制变量。内存放入相应的CPU缓存中。 当线程访问这些变量时,它们随后将访问这些缓存的副本,而不是主内存中的实际副本。

现在,假设测试中的两个线程在两个不同的CPU上运行,并且hasValue标志已缓存在其中一个(或两个)上。 现在考虑以下执行顺序:

  1. writerThread产生一个值,并将hasValue更改为true。 但是,此更新仅反映在缓存中,而不反映在主存储器中。
  2. readerThread尝试使用一个值,但是hasValue标志的缓存副本设置为false。 因此,即使writerThread产生了一个值,它也无法使用它,因为线程无法脱离睡眠循环( hasValue为false)。
  3. 由于readerThread没有使用新生成的值, writerThread不能继续进行,因为该标志没有被清除,因此它将停留在其Hibernate循环中。
  4. 而且我们手中有一个僵局!

仅当hasValue标志跨所有缓存同步时,这种情况才会改变,这完全取决于基础操作系统。

volatile如何适合此示例?

如果仅将hasValue标志标记为volatile ,则可以确保不会发生这种类型的死锁:

private volatile boolean hasValue = false;

将变量标记为volatile将迫使每个线程直接从主内存中读取该变量的值。 而且,每次对volatile变量的写操作都会立即刷新到主存储器中。 如果线程决定缓存该变量,则它将在每次读/写时与主内存同步。

进行此更改之后,请考虑导致死锁的先前执行步骤:

  1. 作家线程   产生一个值,并将hasValue更改为true。 这次更新将直接反映到主内存中(即使已缓存)。
  2. 读取器线程正在尝试使用一个值,并检查hasValue的值 这次,每次读取都将强制直接从主内存中获取值,因此它将获取写入线程所做的更改。
  3. 阅读器线程使用生成的值,并清除标志的值。 这个新值将进入主内存(如果已缓存,则缓存的副本也将被更新)。
  4. 编写器线程将接受此更改,因为每个读取现在都在访问主内存。 它将继续产生新的价值。

瞧! 我们都很高兴^ _ ^!

这是否所有的易失性行为都迫使线程直接从内存中读取/写入变量?

实际上,它还具有其他含义。 访问易失性变量在程序语句之间建立先发生后关系。

什么是

两个程序语句之间的先发生后关系是一种保证,可确保一个语句写的任何内存对另一条语句可见。

它与

当我们写入一个易失性变量时,它会以后每次读取该相同变量时创建一个事前发生的关系。 因此,在对该易失性变量进行写操作之前执行的所有内存写操作,对于该易失性变量的读取之后的所有语句,随后都将可见。

Err..Ok ....我明白了,但也许是一个很好的例子。

好的,对模糊的定义表示抱歉。 考虑以下示例:

// 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)执行。当第一个线程更改hasValue时 ,它不仅会将此更改刷新到主内存,还将导致前三个写操作(以及其他任何写操作)先前的写入)也要刷新到主存储器中! 结果,当第二个线程访问这三个变量时,它将看到线程1进行的所有写操作,即使它们之前都已被缓存(这些缓存的副本也将被更新)!

这就是为什么我们在第一个示例中也不必用volatile标记变量的原因。 由于我们在访问hasValue之前已写入该变量,并在读取hasValue之后对其进行了读取,因此该变量会自动与主内存同步。

这还有另一个有趣的结果。 JVM以其程序优化而闻名。 有时,它在不更改程序输出的情况下重新排列程序语句以提高性能。 例如,它可以更改以下语句序列:

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

到这个:

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

但是,当语句涉及访问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在它们之间对前三个写入进行重新排序。

同样,JVM也不会更改在读取易失性变量后出现在访问之前的语句的顺序。 这意味着:

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

JVM绝不会将其转换为:

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可以肯定它们中最后三个读取的顺序,只要它们在可变读取之后一直出现。

我认为必须为易失性变量付出性能损失。

您说对了,因为易失性变量会强制访问主内存,并且访问主内存总是比访问CPU缓存慢。 它还会阻止JVM对某些程序进行优化,从而进一步降低性能。

我们是否可以始终使用易变变量来维护线程之间的数据一致性?

不幸的是没有。 当多个线程读写同一变量时,将其标记为volatile不足以保持一致性。 考虑以下UnsafeCounter类:

public class UnsafeCounter {
  private volatile int counter;

  public void inc() {
    counter++;
  }

  public void dec() {
    counter--;
  }

  public int get() {
    return counter;
  }
}

和以下测试:

public class UnsafeCounterTest {

  @Test
  public void testUnsafeCounter() throws InterruptedException {
    UnsafeCounter unsafeCounter = new UnsafeCounter();
    Thread first = new Thread(() -> {
      for (int i = 0; i < 5; i++) { 
        unsafeCounter.inc();
      }
    });
    Thread second = new Thread(() -> {
      for (int i = 0; i < 5; i++) {
        unsafeCounter.dec();
      }
    });

    first.start();
    second.start();
    first.join();
    second.join();

    System.out.println("Current counter value: " + unsafeCounter.get());
  }
}

该代码是不言自明的。 我们在一个线程中递增计数器,而在另一个线程中递减计数器相同次数。 运行此测试后,我们希望计数器保持0,但这不能保证。 在大多数情况下,它将为0,在某些情况下,它将为-1,-2、1、2,即[-5、5]范围内的任何整数值。

为什么会这样? 发生这种情况是因为计数器的递增和递减操作都不是原子的-它们不会一次全部发生。 它们都由多个步骤组成,并且步骤顺序相互重叠。 因此,您可以考虑以下增量操作:

  1. 读取计数器的值。
  2. 添加一个。
  3. 写回计数器的新值。

递减操作如下:

  1. 读取计数器的值。
  2. 从中减去一个。
  3. 写回计数器的新值。

现在,让我们考虑以下执行步骤:

  1. 第一个线程已从内存中读取计数器的值。 最初它设置为零。 然后向其中添加一个。
  2. 第二个线程还从内存中读取了该计数器的值,并看到将其设置为零。 然后从中减去一个。
  3. 现在,第一个线程将counter的新值写回内存,将其更改为1。
  4. 现在,第二个线程将计数器的新值写回内存,即-1。
  5. 第一线程的更新丢失。

我们如何防止这种情况?

通过使用同步:

public class SynchronizedCounter {
  private int counter;

  public synchronized void inc() {
    counter++;
  }

  public synchronized void dec() {
    counter--;
  }

  public synchronized int get() {
    return counter;
  }
}

或使用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方法,从而大大降低了性能。

意思是不是……..?

对。 使用synced关键字还可以建立语句之间的事前发生关系。 输入同步的方法/块将在它之前出现的语句与该方法/块内部的语句之间建立先发生后关系。 有关建立事前关系的完整列表,请转到此处

就暂时而言,这就是我要说的。

翻译自: https://www.javacodegeeks.com/2015/11/java-multi-threading-volatile-variables-happens-before-relationship-and-memory-consistency.html

java 多线程变量可见性

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值