JAVA拾遗 - volatile关键字和原子性的探讨

机房又只有我一个人...无聊到点开CSDN写一篇文章吧~记录下最近的学习

之前在学习JAVA的过程中有点模糊的地方,最近一个一个拔掉钉子,还是满开心的。

在看到多线程后就发现有个非常不能理解的东西,比如说这篇文章即将讲到的volatile关键字

本篇博客部分翻译自http://tutorials.jenkov.com/java-concurrency/volatile.html

什么是volatile

The Java volatile keyword guarantees visibility of changes to variables across threads.

volatile关键字的目的是为了标记一个Java变量,使得其能够存储于主存中。更加具体的说,是每次都会直接从电脑的主内存中读取这个变量,而不是从CPU的高速缓存里面。同样的,每次写入都会写入到主存中,而不是cache里面。

事实上,从Java5开始,volatile关键字的作用就不是保证变量只会从主存里面读写了,接下来就来阐述这一概念。

Java volatile的可视性

voliatile关键字保证了在进程中变量的变化的可视性。

在多线程的应用里,如果线程操作了一个没有被volatile关键字标记的变量,那么每个线程都会在使用到这个变量时从主存里拷贝这个变量到CPU的cache里面(为了性能!)。如果你的电脑有多于一个CPU,那么每个线程都会在不同的CPU上面运行,这意味着每个线程都会把这个变量拷贝到不同的CPU cache里面,正如下图所示:

未加volatile关键字的变量在线程中的轨迹

一个不带有volatile关键字的变量在JVM从主存里面读取数据到CPU cache或者从cache里面写入数据到主存时是没有保证的,这会导致一些问题,在接下来的章节中我们就来讨论这些问题。

想象这样一个场景,当一到两个线程允许去共享一个包含了一个计数变量的对象,这个计数变量如下所定义

public class SharedObject {

    public int counter = 0; //无关键字

}

然后,这线程一增加了counter变量的值,但是,但是同时线程一和线程二都有可能随时读取这个counter变量。

如果这个counter变量未曾使用volatile声明,那么我们就无法保证这个变量在两个线程中所位于的CPU的cache和主存中的值是否保持一致了。示意图如下:
Cache和主存中的counter变量值不同了!

那么部分的线程就不能看到这个变量最新的样子,因为这个变量还没有被线程写回到主存中,这就是可视性的问题,这个线程更新的变量对于其他线程是不可视的。

在声明了counter变量的volatile关键字后,所有写入到counter变量的值会被立即写回到主存中。同时,所有读取这个变量的线程会直接从主存里面读取这个变量,下面的代码就是声明带volatile关键字的变量的方法

public class SharedObject {

    public volatile int counter = 0;

}

如此声明这个变量就保证了这个变量对于其他写这个变量的线程的可视性。

Java volatile 对于happens-before的保证

什么是happens-before?

多线程有两个基本的问题,就是原子性和可见性,而happens-before规则就是用来解决可见性(我还是比较喜欢称之为可视性)的。

在时间上,动作A发生在动作B之前,能不能**保证**B可以看见A?如果可以保证的话,那么就可以说hb(A,B)

JVM保证了一下的几条法则:
* 如果A和B是同一个线程的,那么hb(A, B)
* 如果A是对锁的unlock,而B是对同一个锁的lock,那么hb(A, B)
* 如果A是对volatile变量的写操作,B是对同一个变量的读操作,那么hb(A, B)
* 传递性:如果hb(A, C) 且 hb(B, C),那么hb(A, C)

如果有两个线程

thread1                 thread2
----------------------------------
x = 1    (A)
M.unlock (B)
x = 2    (C)
                       M.lock (D)
                       y = x  (E)

那么执行到E的时候,E能不能保证看到C步呢?
由法则1,hb(D,E)
由法则2,hb(B,D) 由法则1, hb(A,B) 综上可以推出,hb(A, E),但是推不出hb(C, E) 所以,E不一定能看见C,但是E一定能看见A

所以执行E的时候,有可能thread2看到的x的值还是1

次序法则见附录:A

从Java 5开始volatile关键字就不只保证了只从主存中读取和写入变量,volatile关键字保证了:
* 如果线程A写入到一个volatile的变量,随后线程B读取了这个volatile变量,那么所有的变量在A写入到volatile变量前都具有可见性,同时所有的变量在线程B读取这个volatile变量后同样对于B有可见性。
* 对于volatile变量的读取与写入命令不能被JVM重新规划排序(其他的变量可能因为性能原因在被JVM探测到不会在程序中改变后而重新规划排序)。之后与之前的命令可以被重新规划,所有在读取或者写入volatile变量的后的命令都会被保证安排到这次读取与写入之后

当一个线程写入到一个volatile变量后,不仅仅是这个volatile变量自己会被写入到主存中,同时所有的在这次写入之前的被这个线程改变的变量都会被flush到主存中。当线程读取一个volatile变量时,这个线程也会从主存中读取随着这个volatile变量flush到主存的所有其他的变量。

如下所示

Thread A:
    sharedObject.nonVolatile = 123;
    sharedObject.counter     = sharedObject.counter + 1;

Thread B:
    int counter     = sharedObject.counter;
    int nonVolatile = sharedObject.nonVolatile;

当线程A写了一个未标记的变量 shareObject.nonVolatile后与写入volatile变量counter前,这两个变量都会随着counter的写入而写入到主存中。

当线程B开始读取volatile变量counter时,counter和nonVolatile都会从主存中被读取到CPU cache里面从而被线程B使用,同时B读取nonVolatile时会看到这个被A改变的变量。

开发者可能会利用这个额外的可视性原则在线程之间去优化变量的可视性,除去声明所有的变量为volatile,只需要声明少量的变量为volatile就行了,以下为实例:

public class Exchanger {

    private Object   object       = null;
    private volatile hasNewObject = false;

    public void put(Object newObject) {
        while(hasNewObject) {
            //wait - do not overwrite existing new object
        }
        object = newObject;
        hasNewObject = true; //volatile write
    }

    public Object take(){
        while(!hasNewObject){ //volatile read
            //wait - don't take old object (or null)
        }
        Object obj = object;
        hasNewObject = false; //volatile write
        return obj;
    }
}

线程A在放入对象时会调用put()方法,线程B在获取对象时会调用take()方法,这个Exchanger类能够在不使用synchronized锁的前提下,只使用volatile关键字变量来使得只有在线程A调用了put()后线程B调用take()。

前面说了,JVM会根据性能调优的缘故去调换操作的顺序,如果JVM调换了put和take方法内部的变量读取与写入的顺序,那么put方法可能会怎样执行呢:

while(hasNewObject) {
    //wait - do not overwrite existing new object
}
hasNewObject = true; //天啊,换到前面去了volatile write
object = newObject;

可以发现JVM会先对hasNewObject改值,再去新建一个变量Object,这对于JVM来说是没有问题的,因为这两个变量的值并没有相关性。

这样,重新排列命令会对object变量的可视性造成毁灭打击。第一是线程B可能在线程A新建object之前就发现了hasNewObject变成了TRUE;然后是现在对于object来说,不在会有把它flush到主存的保证了。

为了预防这个情况的出现,volatile有了个“happens before 保证”,这个保证了JVM不再回去重排读取与写入volatile变量的命令的顺序,在对volatile变量读取写入的命令之前的命令可以被重排,但是volatile变量的读写操作不能被重排到前面或者与之后交换位置。

如下:

sharedObject.nonVolatile1 = 123;
sharedObject.nonVolatile2 = 456;
sharedObject.nonVolatile3 = 789;

sharedObject.volatile     = true; //a volatile variable

int someValue1 = sharedObject.nonVolatile4;
int someValue2 = sharedObject.nonVolatile5;
int someValue3 = sharedObject.nonVolatile6;

JVM会根据性能调优去更改前面三个赋值的顺序,但是这些赋值命令必须在volatile变量的写入之前完成。

相似的,JVM会重排后三个操作的顺序,但是后三个操作都不会重排到volatile变量写入完成之前。

volatile变量变成了一个关卡!

Volatile可不够你用

尽管volatile关键字保证了所有对于volatile变量的读取都会直接调用主存,而所有对于volatile的写入都会直接写到主存,但这里面依然有些情况单单声明volatile变量是不够的。

在我们之前提到的情况里,当只有Thread 1写入到共享的counter变量,声明counter变量为volatile足够保证线程B看到的counter变量总是最新的。

但是事实上,多线程会写入到同一个共享的volatile变量了,如果对于这个volatile变量的写入不是基于他目前的值,换句话说,如果线程写入到一个共享的volatile变量不会首先去读取它的值去弄清楚它接下来的值。

当一个线程需要去第一次读取一个volatile变量,同时生成一个基于这个变量值的变量时(i = i + 1),这个volatile关键字就不够来保证其的可视性了。在这短短的读取、写入volatile变量的间隔中,当多个线程可能回去读取这同一个值的时候,就会建立一个竞争的环境,在一个线程为这个变量生成一个新的值,和写入这个值到内存中这个过程中,可能就会把其他人的值给复写了。

在多线程计数的情况下,volatile关键字变量是完全不够的,接下来的例子会讲一些细节的东西

想象如果线程1读取了共享的counter变量value 0到他的CPU cache中,增加其到1但还没来得及写回到主存中,线程2也可以从主存中读取同一个counter变量(此时这个变量还是0),线程2也可以令这个counter从0变成1,也还没来得及写回主存。
如图所示,counter变量是线程不安全的
线程1和线程2现在出现了不同步的现象,counter的真实值应该是2,但是每一个线程的counter都变是1(存于CPU cache中),而主存中的变量甚至还是0。是不是很恐怖!尽管每个线程都会直接把他们的counter值写回到主存中,但是这个counter的值依然是错误的。

那什么时候该用volatile

在之前提到,当两个线程同时读取与写入到一个变量时,使用volatile关键字是不够的,你还是需要给它上个锁来保证这个变量的原子性。读取与写入一个volatile变量不会暂停一个线程的读取与写入,所以你需要利用synchronized关键字来保证准确的行动。

为了替代synchronized的暂停现象,你也可以利用那些原子性的数据形式,你可以在 java.util.concurrent package(http://tutorials.jenkov.com/java-util-concurrent/index.html)里找到这些数据类型。

在只利用一个线程读取和写入volatile变量,而其他线程只读取变量时,那么这个读取的线程就保证能够看到这个volatile值的最终值,换句话说,如果你不用volatile关键字,这可不能保证哦~

volatile关键字只能对32位和64位的变量使用

volatile的性能

读取与写入一个volatile变量会从主存里面直接获取。而对注册你的操作是更低效于Cpu的cache的,同样使用volatile关键字会减少JVM自带的调整命令顺序调优性能这一黑科技。所以你应该在你真正需要这个关键字的时候再去使用它!

附录:

附录A:次序法则
1, 程序次序法则,如果A一定在B之前发生,则happen before
2, 监视器法则,对一个监视器的解锁一定发生在后续对同一监视器加锁之前 
3, Volatie变量法则:写volatile变量一定发生在后续对它的读之前  
4, 线程启动法则:Thread.start一定是发生在线程中的动作  
5, 线程终结法则:线程中的任何动作一定发生在括号中的动作之前(其他线程检测到这个线程已经终止,从Thread.join调用成功返回,Thread.isAlive()返回false)  
6, 中断法则:一个线程调用另一个线程的interrupt一定发生在另一线程发现中断。  
7, 终结法则:一个对象的构造函数结束一定发生在对象的finalizer之前  
8, 传递性:A发生在B之前,B发生在C之前,A一定发生在C之前。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值