什么是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方法使用了新值的情况下才有可能。 如果没有新值可用,那么消耗方法将请求当前线程休眠。 当Produce方法产生一个新值时,它将终止其睡眠循环,使用它并清除value标志。
现在想象一下,有两个线程正在使用此类的对象–一个正在尝试产生值(写线程),另一个正在使用它们(读线程)。 以下测试说明了这种方法:
public class ProducerConsumerTest {@Testpublic 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标志已缓存在其中一个(或两个)上。 现在考虑以下执行顺序:
- writerThread产生一个值,并将hasValue更改为true。 但是,此更新仅反映在缓存中,而不反映在主存储器中。
- readerThread尝试使用一个值,但是hasValue标志的缓存副本设置为false。 因此,即使writerThread产生了一个值,它也无法使用它,因为线程无法脱离睡眠循环( hasValue为false)。
- 由于readerThread没有使用新生成的值, writerThread不能继续进行,因为该标志没有被清除,因此它将停留在其休眠循环中。
- 而且我们手中有一个僵局!
仅当hasValue标志跨所有缓存同步时,这种情况才会改变,这完全取决于基础操作系统。
volatile如何适合此示例?
如果仅将hasValue标志标记为volatile ,则可以确保不会发生这种类型的死锁:
private volatile boolean hasValue = false;
将变量标记为volatile将强制每个线程直接从主内存读取该变量的值。 而且,每次对volatile变量的写操作都会立即刷新到主存储器中。 如果线程决定缓存该变量,则它将在每次读/写时与主内存同步。
进行此更改后,请考虑导致死锁的先前执行步骤:
- 作家线程 产生一个值,并将hasValue更改为true。 这次更新将直接反映到主内存中(即使已缓存)。
- 读取器线程正在尝试使用一个值,并检查hasValue的值。 这次,每次读取将强制直接从主内存中获取值,因此它将获取写入线程所做的更改。
- 阅读器线程使用生成的值,并清除标志的值。 这个新值将进入主内存(如果已缓存,则缓存的副本也将被更新)。
- 编写器线程将接受此更改,因为每个读取现在都在访问主内存。 它将继续产生新的价值。
瞧! 我们都很高兴^ _ ^!
这是所有易失性行为,迫使线程直接从内存中读取/写入变量吗?
实际上,它还具有其他含义。 访问易失性变量会在程序语句之间建立先发生后关系。
什么是
两个程序语句之间的先发生后关系是一种保证,可以确保一个语句写的任何内存对另一条语句可见。
它与
当我们写入一个volatile变量时,它会在以后每次读取该相同变量时创建一个事前发生的关系。 因此,直到对该易失性变量进行写操作之前完成的所有内存写操作,对于该易失性变量的读取之后的所有语句,随后都将可见。
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 {@Testpublic 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]范围内的任何整数值。
为什么会这样? 发生这种情况是因为计数器的递增和递减操作都不是原子的-它们不会一次全部发生。 它们都由多个步骤组成,并且步骤顺序相互重叠。 因此,您可以考虑以下增量操作:
- 读取计数器的值。
- 添加一个。
- 写回计数器的新值。
递减操作如下:
- 读取计数器的值。
- 从中减去一个。
- 写回计数器的新值。
现在,让我们考虑以下执行步骤:
- 第一个线程已从内存中读取计数器的值。 最初将其设置为零。 然后向其中添加一个。
- 第二个线程还从内存中读取了计数器的值,并看到将其设置为零。 然后从中减去一个。
- 现在,第一个线程将counter的新值写回内存,将其更改为1。
- 现在,第二个线程将计数器的新值写回内存,即-1。
- 第一线程的更新丢失。
我们如何防止这种情况?
通过使用同步:
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关键字还可以建立语句之间的事前发生关系。 输入同步的方法/块将在它之前出现的语句与该方法/块内部的语句之间建立先发生后关系。 有关建立事前关系的完整列表,请转到此处 。
就暂时而言,这就是我要说的。
- 所有示例都已上传到我的github存储库中 。
翻译自: https://www.javacodegeeks.com/2015/11/java-multi-threading-volatile-variables-happens-before-relationship-and-memory-consistency.html