Java内存模型与线程04:对于volatile型变量的特殊规则

一、写在前面的话

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,但是它并不容易完全被正确、完整的理解,以至于许多程序员都习惯不去使用它,遇到需要处理多线程数据竞争问题的时候一般使用synchronized来进行同步。了解volatile变量的语义对于后面了解多线程操作的其他特性很有意义。

二、关键字volatile

当一个变量被定义为volatile之后,它具备两种特性。

第一是保证此变量对所有线程的“可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递需要通过主内存来完成,例如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成之后再从主内存进行读取操作,新变量值才会对B线程可见。


关于volatile变量的可见性,经常会被开发人员误解,试看下面这条常见的描述:“volatile变量对所有线程是立即可见的,对volatile变量所有的读写操作都能立刻反应到其他线程之中,换句话说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是安全的”。这句话的论据部分并没有错,但是论据并不能得出“基于volatile变量的运算在并发下是安全的”这个结论。volatile变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的,我们可以通过一段简单的代码来说明原因,请看下面的例子。

package com.js.article04;
/**
 * volatile变量自增运算测试
 * @author jiangshuai
 *
 */
public class VolatileTest {
	public static volatile int race = 0;
	public static void increase(){
		race++;
	}
	public static final int THREAD_COUNT = 20;
	public static void main(String[] args){
		Thread[] threads = new Thread[THREAD_COUNT];
		for(int i=0;i<THREAD_COUNT;i++){
			threads[i] = new Thread(new Runnable() {
				
				@Override
				public void run() {
					for(int i = 0; i<10000;i++){
						increase();
					}
				}
			});
			threads[i].start();
		}
//		等待所有累加线程都结束
		while(Thread.activeCount()>1)
			Thread.yield();
		System.out.println(race);
	}
}

这段代码发起了20个线程,每个线程对race变量进行10000次自增操作,如果这段代码能够正确并发的话,最后输出的结果应该是200000,。但是运行代码之后,并不会获得期望的结果,而且会发现每次运行程序,输出的结果都不一样,都是一个小于200000的数字,这是为什么呢?


问题就出在自增运算“race++”之中,我们用Javap反编译这段代码后会得到代码清单,如下所示:

Compiled from "VolatileTest.java"
public class VolatileTest {
  public static volatile int race;

  public static final int THREAD_COUNT;

  public VolatileTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":
()V
       4: return

  public static void increase();
    Code:
       0: getstatic     #2                  // Field race:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field race:I
       8: return

  public static void main(java.lang.String[]);
    Code:
       0: bipush        20
       2: anewarray     #4                  // class java/lang/Thread
       5: astore_1
       6: iconst_0
       7: istore_2
       8: iload_2
       9: bipush        20
      11: if_icmpge     43
      14: aload_1
      15: iload_2
      16: new           #4                  // class java/lang/Thread
      19: dup
      20: new           #5                  // class VolatileTest$1
      23: dup
      24: invokespecial #6                  // Method VolatileTest$1."<init>":()
V
      27: invokespecial #7                  // Method java/lang/Thread."<init>":
(Ljava/lang/Runnable;)V
      30: aastore
      31: aload_1
      32: iload_2
      33: aaload
      34: invokevirtual #8                  // Method java/lang/Thread.start:()V

      37: iinc          2, 1
      40: goto          8
      43: invokestatic  #9                  // Method java/lang/Thread.activeCou
nt:()I
      46: iconst_1
      47: if_icmple     56
      50: invokestatic  #10                 // Method java/lang/Thread.yield:()V

      53: goto          43
      56: getstatic     #11                 // Field java/lang/System.out:Ljava/
io/PrintStream;
      59: getstatic     #2                  // Field race:I
      62: invokevirtual #12                 // Method java/io/PrintStream.printl
n:(I)V
      65: return

  static {};
    Code:
       0: iconst_0
       1: putstatic     #2                  // Field race:I
       4: return
}





发现只有一行代码的increase()方法在Class文件中是由4条字节码指令构成的(return指令不是由race++产生的,这条指令可以不计算),从字节码层面上很容易就分析出并发失败的原因了:当getstatic指令把race的值取到操作栈顶时,volatile关键字保证race的值此时是正确的,但是在执行iconst_1、
iadd这些指令的时候,其他线程可能已经把race的值加大了,而在操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的race值同步会主内存之中。


客观的说,在此使用字节码来分析并发问题,仍然是不严谨的,因为即使编译出来只有一条字节码指令,也并不意味执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器将要运行许多代码才能实现它的语义,如果是编译执行,一条字节码指令也可能转化为若干条本地机器码指令,此处使用-XX:+PrintAssembly参数输出反汇编来分析会更加严谨一些,但考虑到字节码已经能说明问题,所以此处使用字节码来分析。


由于volatile变量只能保证可见性,在不符合以下两条规则的场景中,我们仍然需要通过加锁(使用synchronized或者java.util.concurrent中的原子类)来保证原子性。

1、运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。

2、 变量不需要与其他的状态变量共同参与不变约束。

而在如下所示的这类场景就很适合使用volatile变量来控制并发,当shutdown()方法被调用时,能保证所有线程中执行的doWork()方法都立即停下来。

package com.js.article04;

public class VolatileTest2 {
	volatile boolean shutdownRequested;
	public void shutdown(){
		shutdownRequested = true;
	}
	public void doWork(){
		while(!shutdownRequested){
			//do stuff
		}
	}
}

使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是Java内存模型中描述的所谓“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)。



    原文作者:java内存模型
    原文地址: https://blog.csdn.net/Dove_Knowledge/article/details/72841375
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞