Java volatile变量 原理与应用

本文讨论volatile的原理和应用场景,涉及多线程内存模型、指令重排(代码执行次序)、Happens-before原则

问题

多线程程序中,多个线程读写一个共享变量时,如果只是普通变量(比如 int i; 不采用任何同步机制),该变量的值是不确定的。(示例代码 Counter, Status)。

volatile的作用

java程序中可以定义volatile变量,主要用于多线程情况下的共享变量,使其具有轻量级的同步特征。volatile变量可以保证两点:

  1. 变量的可见性

    volatile保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个volatile变量的值,这个新值对其他线程来说是立即可见的。

    直觉上,一个变量本来就是一个值啊,所有线程访问的不都是这一个值吗?其实,为了提升程序执行效率,java会给每个线程分配一块高速缓存作为工作内存,各线程使用自己的共享变量的副本进行操作,根据情况同步主内存中该变量的值(下面会进一步讨论内存模型)。所以实际情况下,一个变量在各线程的工作内存,以及在主内存中的值,很大可能是不一样的。

    而volatile变量被任一线程写入值,将确保随后所有对该变量的读取将得到写入后的最新值。注意,多线程是系统调度执行的,如果线程1读取 volatile变量a,然后阻塞,切换到线程2写入a的值,那么线程1不会得到线程2写入的a值。如果再次读取a,将得到b写入的最新值。

  2. 禁止进行指令重排(保证部分代码的执行次序)

    如果一段代码中间有对volatile变量的读写操作,那么该操作前面的语句肯定在该操作之前执行完毕,该操作之后的语句肯定在该操作之后开始执行。在任何线程看来,这一点都会得到保障。

    看到这里可能会有疑问,代码按照书写的次序执行难道不是理所当然的吗?实际上还真不是。java编译器和底层指令会自动进行代码优化,其中一种可能是调整代码执行的次序。这里就会涉及Happens-before原则,下面再进一步说明。

    如果定义为volatile变量,volatile会禁止指令重排。涉及volatile变量的操作,其前面的指令不能调整到其后面执行,其后面的指令也不会在其之前执行,且其前面指令的结果对其后面的指令可见。

volatile变量不具有的作用

  1. volatile变量不保证原子性

    原子性意思是某个操作是不可被中断的,要么执行,要么不执行。比如银行转账操作,转出账户扣钱和转入账户加钱必须整体是一个原子操作,不能这边扣了那边还没加。

    Java中如果需要原子性,要用synchronized或lock等同步机制来实现。volatile变量相关的操作并不具有额外的原子性。比如

    x = 10;        //语句1。写入x
    y = x;         //语句2。读取x;写入y
    x++;           //语句3。读取x;加1;写入x
    x = x + 1;     //语句4。读取x;加1;写入x
    

    上面只有语句1是原子操作,其它都不是。非原子操作不会因volatile变量而变成原子操作。volatile变量不具备以下效果

    synchronized {y = x;}         //语句2
    synchronized {x++;}           //语句3
    synchronized {x = x + 1;}     //语句4
    

    不过有个例外是long, double类型的变量,根据 Java语言规范 17.7 ,一个64bit的long或double变量的读写有可能分成2个步骤完成,一次处理32bit,因此并不是原子操作。但是定义为 volatile long 或 volatile double 后,Java将保证该变量的读写(写常量)是原子性的。这也意味着,在多线程程序中使用共享的long或double变量,要定义为volatile才能确保安全

volatile应用场景

根据上述volatile变量的特性,只有很少的场景适合应用volatile变量来实现期望的功能。正确使用volatile必须满足两个条件:

  1. 对变量的写操作不依赖当前值。

    比如上面的语句2、3、4情况,多线程下不能保证x或y的值是稳定的。而写入常量是可以的,比如语句1的情况。

  2. 该变量没有包含在具有其它变量的不变式中。

    比如 start <= end,多线程下该条件不能得到保障。而与常量进行条件运算是可以的,比如 while(c==true)

总的来说还是因为volatile不具有原子性。所以,volatile不适合用来实现比如 计数器(示例代码 CounterWithVolatile) 或 范围限定。

volatile最合适的应用场景是状态标记变量。也就是 通过对一个 volatile变量赋值不同的常数来标识不同的状态(示例代码 StatusWithVolatile)。

另外,有些文章提到使用volatile实现 多线程情况下双重检查单实例,其实是可能存在问题的。多线程情况下的单实例用 static 变量来实现更简洁。这里就不具体讨论了。有兴趣的话可以参考 Java单例模式中双重检查锁的问题。

Java内存模型

  1. 共享变量副本

    前面提到 Java会给每个线程分配一块高速缓存作为工作内存,各线程使用自己的共享变量的副本进行操作,根据情况同步主内存中该变量的值(由JMM Java内存模型 控制)。Java内存模型的抽象示意图如下(来自深入理解Java内存模型(一)——基础):

    Java volatile变量 原理与应用_第1张图片
    img
  2. 指令重排

    为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

    1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
    2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
    3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

    从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

    img

    上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。也就是说,实际的指令执行次序并不确保是代码编写的次序。

    上述两种特性在多线程的情况下会导致数据和指令执行过程的混乱,为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。对开发人员来说,就是提供了一些同步机制来确保一些操作和数据按照预定的计划执行。

关于Java内存模型的更详细信息请参考:

深入理解Java内存模型(一)——基础

《深入理解Java内存模型》读书总结

Happens-before原则

从JDK5开始,java使用新的JSR -133内存模型。JSR-133提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

有一些事先规定好的Happens-before/先于...发生原则,以方便开发人员理解在哪些情况下,可见性得到保证。下面引用自 java语言规范(Java Language Specification)中 Happens-before 的说明(这里只讨论第一条规则,因为其表述方式很容易引起误解):

Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second.

If we have two actions x and y, we write hb(x, y) to indicate that x happens-before y.

  • If x and y are actions of the same thread and x comes before y in program order, then hb(x, y).

意思说:同一个线程中操作x和操作y的执行次序会依照代码中的次序。

What?!听起来完全像是废话嘛!但接下来又有一句话:

It should be noted that the presence of a happens-before relationship between two actions does not necessarily imply that they have to take place in that order in an implementation. If the reordering produces results consistent with a legal execution, it is not illegal.

意思说:两个操作具有Happens-before关系,并不确保实际上也是先后执行,即使改变执行次序,但只要确保得到完全一致的结果,就没有问题。

刚才说操作x和操作y按照代码次序,现在又说不一定,只要结果一样就行,到底是有次序还是没有次序呢?

答案是:实际的指令执行并不一定是按代码次序,但是也不是随意乱调整(否则程序就没法写了),Java保证【同一个线程中】操作x和操作y的执行次序【可以认为】是依照代码中的次序。

看一个例子:

a=1;
b=new Integer(2);
c=a+10;
d=3;

最后结果是 a=1,b=2,c=11,d=3。但考虑这四个操作的执行次序的话,除了a必须在c之前执行,其它语句可以任意次序执行,比如 d=3; 被调整到a之前执行,结果完全相同。这正是编译和硬件进行指令优化时可能发生的事情。所以实际上,操作可能改变次序但确保【结果与原始次序相同】。所以,如果前后操作有依赖关系,肯定不会改变次序,但前后不相关的代码,是可能被调整次序的,但是对结果完全没有影响。

但是,最重要的其实是规范中强调了【同一个线程】,如果涉及其它线程是不能引用这条规范的,所以,在【另一个线程】看来,操作次序的改变就是真的改变了。比如另一个线程中 if(d==3){e=b.intValue();} 可能抛出空指针异常。(假设经过指令优化, d=3; 被调整到b之前执行)。

多线程访问的情况下,如何确保操作次序的正确性呢?

这里就需要添加一些同步机制了。像上面的例子中 if(d==3){e=b.intValue();} 如果要正确执行,就要确保b语句在d语句之前。可以将上面的语句包含在一个同步块(synchronized)中,也可以将 b,c,d 之中任何一个定义为 volatile 变量,因为volatile会禁止指令重排,其前面的指令不能调整到其后面执行,其后面的指令也不会在其之前执行,且其前面指令的结果对其后面的指令可见,从而b和d的次序得到保证。

从应用的角度看,保证操作次序也是一种状态量,状态b的值要准确反应b已经初始化完成。

小结

用一句话简单来说:volatile保证其前面的指令先执行完成(禁止指令重排),且其结果对后面的指令可见(通过同步缓存保证变量对后续指令的可见性)

参考

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

深入理解Java内存模型(一)——基础

《深入理解Java内存模型》读书总结

聊聊并发(一)——深入分析Volatile的实现原理

奇怪的并发现象探究——JMM的指令重排、内存级指令重排

Java语言规范

你可能感兴趣的:(Java volatile变量 原理与应用)