Java并发编程:volatile关键字的使用

volatile也是实现多线程并发编程常用的关键字,但是在使用频率上面不如加锁机制。实际上volatile和加锁是Java语言内在的两种同步机制,只是volatile关键字修饰的变量同步性稍差一些。

Java语言规范第三版中对volatile的定义如下:

Java语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排它锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个变量被声明为volatile,Java内存模型确保所有线程看到这个变量的值是一致的。

volatile在使用过程中可以看做是“轻量级的synchronized”,加锁可以保证原子性和可见性,但是volatile只能保证可见性,摘自《Java并发编程实践》。因为volatile在处理并发问题上仅是synchronized的一部分,再者由于volatile关键字与Java的内存模型(JMM)有关,因此在多线程开发中想要真正的理解volatile并不是一件容易的事情。在理解volatile关键字之前先简单介绍一下Java内存模型相关的概念和知识。

Java内存模型JMM

Java内存模型JMM(Java Memory Model)主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。在Java语言中,并发采用的是共享内存模型。线程之间共享程序的公共状态,在本文中将这些共享的公共状态成为共享变量,这里的共享变量跟Java编程中的变量不是同一个概念,它包含了实例字段、静态字段和构成数组对象的元素,但不包含局部变量和方法参数,因为后者是线程私有的,不会共享,当然不存在数据竞争问题也不受内存模型的影响。(如果局部变量是一个reference引用类型,它引用的对象在Java堆中可被各个线程共享,但是reference引用本身在Java栈的局部变量表中,是线程私有的)。

Java线程之间的通信由Java内存模型控制,JMM决定一个线程的共享变量的写入何时对另一个线程可见。从抽象角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存被称作工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。工作内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

Java内存抽象示意图如下:

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

Java内存模型围绕着并发编程中的原子性、可见性和有序性这3个特征建立的,下面依次讨论一下这3个特征。

原子性

原子性(Atomicity)即一个操作不能被打断,要么不执行,要么全部执行完毕,类似数据库中事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。

例如,对long类型的运算,很多系统就需要分成多条指令分别对高位和低位进行操作才能完成。还比如,我们经常使用的整数 i++ 的操作,其实需要分成三个步骤:(1)读取整数 i 的值;(2)对 i 进行加一操作;(3)将结果写回内存。这个过程在多线程下就可能出现如下现象:

很多人都知道i++是非原子操作的,对于这种操作想要保证原子性,最常见的方式就是加锁,如Java中的Synchronized或 Lock都可以实现。除了锁以外,还有一种方式就是CAS(Compare And Swap),即修改数据之前先比较与之前读取到的值是否一致,如果一致,则进行修改,如果不一致则重新执行,这也是乐观锁的实现原理。不过CAS在某些场景下不一定有效,比如另一线程先修改了某个值,然后再改回原来值,这种情况下,CAS是无法判断的。

可见性

可见性是指当多个线程访问同一个共享变量是,若其中某个线程更改了变量的值,其它线程可立刻看到(感知到)该变量这种修改(变化)。

如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

有序性

在执行程序时为了提高性能,编译器和处理器常常会对指令重排序,Java内存模型中,对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

下面就来具体介绍下happens-before原则(先行发生原则):

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。

这8条原则摘自《深入理解Java虚拟机》,在《Java并发编程实践》也给出了8条类似规则。

volatile关键字介绍

前面介绍了那么多基本上都是为volatile做铺垫。 一旦一个共享变量被volatile修饰之后,就具备了两层语义:
  • 编译器和处理器会注意到该变量是共享的,因此不会讲将变量上的操作跟其它内存操作一起重排序;
  • volatile变量不会被缓存在不可见的地方,因此在读取volatile变量值的时候总会返回最新写入的值。

上面两层语义也表明了volatile可以保证可见性。理解volatile变量的有效方法,将它的行为想象成SynchronizedInteger类似行为,并将volatile的读操作和写操作分别对应get方法和set方法。但是在使用volatile时并不会执行加锁操作,因此不会阻塞线程,所以volatile是比加锁更轻量级的同步机制。

public class SynchronizedInteger {
	
	private int value;

	public synchronized int getValue() {
		return value;
	}

	public synchronized void setValue(int value) {
		this.value = value;
	}

}

volatile可以保证原子性吗?

文章开始部分就简单说明了,加锁可以保证原子性和可见性,但是volatile只能保证可见性,这里我们可以通过一个示例简单说明一下。

public class MainTest {
	public volatile int inc = 0;
    
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final MainTest test = new MainTest();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

如果volatile修饰的变量在多线程下是安全的,这里结果大家可以猜测一下应该是多少,实际上每次运行的结果值都是一个小于10000的值,原因就出在inc++上面,因为该操作不具有原子性。实际执行过程中i++包含了三步,读取变量原始值、对变量进行加1操作、将加1后的值重新赋值。

如果想要线程安全的,实际上简单的将increase方法加上一个锁即可实现。这样每次输出的值都是10000。

public class MainTest {
	public int inc = 0;
    
    public synchronized void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final MainTest test = new MainTest();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

volatile可以保证有序性吗?

先看一个简单的示例。

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;                   //1
        flag = true;               //2
    }

    public void reader() {
        if (flag) {                //3
            int i =  a;           //4
            ……
        }
    }
}

在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量之间重排序。在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行:

旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。

此在旧的内存模型中,volatile的写-读没有锁的释放-获所具有的内存语义。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

volatile使用举例

用于状态标记位

volatile boolean shutdown;
...

public void shutdown() { 
    shutdown = true; 
}

public void doWork() { 
    while (!shutdown) { 
        // do something
    }
}

上面的示例也是volatile的一种典型用法:检查某个标记位以判断是否退出循环。

单例模式双检查机制

在单例模式中有一种实现机制是使用双检查机制,但是由于Java内存模型的原因并不能保证每次都能成功,但是使用volatile之后该实现方式则可行。具体可以参看 Java单例模式中双重检查锁的问题 或者Java 中的双重检查(Double-Check)

public class Singleton {
    private volatile Singleton instance = null;
    public Singleton getInstance() {
        if (instance == null) {
            synchronized(this) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

使用volatile发布不可变对象

首先看一下《Java并发编程实践》中如何定义不可变对象的,当满足以下条件时,对象才是不可变的:

  • 对象创建完之后其状态就不能修改;
  • 对象的所有与都是 final 类型;
  • 对象时正确创建的(创建期间没有 this 的逸出)。
public class OneValueCache {
	private final BigInteger lastNumber;
	private final BigInteger[] lastFactors;

	/**
	 * 如果在构造函数中没有使用 Arrays.copyOf()方法,那么域内不可变对象 lastFactors却能被域外代码改变 那么
	 * OneValueCache 就不是不可变的。
	 */
	public OneValueCache(BigInteger i, BigInteger[] factors) {
		lastNumber = i;
		lastFactors = Arrays.copyOf(factors, factors.length);
	}

	public BigInteger[] getFactors(BigInteger i) {
		if (lastNumber == null || !lastNumber.equals(i))
			return null;
		else
			return Arrays.copyOf(lastFactors, lastFactors.length);
	}
}

如果在使用过程中将OneValueCache声明为 volatile,这样当一个线程将cache设置为引用一个新的OneValueCache时,其他线程就会立即看到新缓存的数据。

小结

正如前面介绍的那样,volatile变量虽然很方便,但是存在局限性。volatile通常被用作某个操作完成、发生中断或者状态的标志。正如文章开始部分所讲,加锁机制可以确保可见性又可以确保原子性,但是volatile变量只能确保可见性。通常来说volatile必须具备以下2个条件才行:

  • 对变量的写操作不依赖于当前值;
  • 该变量没有包含在具有其他变量的不变式中。

参考资料

Java并发编程实践

Java并发编程:volatile关键字解析

浅析java内存模型--JMM(Java Memory Model)

Java 并发编程:核心理论

谁给解释下java内存模型读volatile域时的语义?

基于锁的原子操作

正确使用 Volatile 变量

评论

您确定要删除吗?删除之后不可恢复