java多线程之volatile关键字

[TOC]

volatile

英 [ˈvɒlətaɪl]美 [ˈvɑlətl]
adj.易变的,不稳定的;(液体或油)易挥发的;爆炸性的;快活的,轻快的

摘要:

volatile有2个特性:可见性、有序性,但是不具备原子性,所以用volatile修饰变量不能保证线程安全。

1 JMM模型

Java内存模型是理解volatile的前提。

简单来说,Java程序的执行单元是线程,而每个线程创建时JVM都会为其创建一个工作内存(也有人叫栈空间、线程栈),用于存储线程私有的数据。当线程操作数据时,实际是把内存(通常叫“主内存”用于区分工作内存)中的数据拷贝一份副本到自己的内存空间。线程在各自的私有工作内存空间完成对副本的操作,最后分别写入主内存。不同线程的私有副本互相“看不到”对方,线程间的数据不具备可见性。可以用下面的程序来验证,不同线程之间数据不具有可见性。
可以参考这篇博客:JMM和底层实现原理

JMM.png

/*
*例1:不可见性导致死循环
*/
public class CantSeeTest {
    int value = 0;
    public static void main(String[] args) throws InterruptedException {
        CantSeeTest cst = new CantSeeTest();
        new Thread(()->{
            while(cst.value==0){

            }
        }).start();
        Thread.sleep(1000);//确保子线程已经start
        cst.value = 1;
        System.out.println("cst.value已经被主线程修改为1.");
    }
}

程序的执行结果是:主线程已经把value值改为1,但是匿名线程仍然死循环,因为读不到最新的value值。因为匿名线程看不到主线程修改的value值。

2 指令重排序

重排序是指编译器处理器为了优化程序性能而对指令序列进行重新排序的一种手段,所以重排序分为两类:编译期重排序和运行期重排序。

2.1 编译期重排序

编译期重排序的典型就是通过调整指令顺序,在不改变程序语义的前提下,尽可能减少寄存器的读取、存储次数,充分复用寄存器的存储值。例如:
  第一条指令计算一个值赋给变量A并存放在寄存器中,第二条指令与A无关但需要占用寄存器(假设它将占用A所在的那个寄存器),第三条指令使用A的值且与第二条指令无关。那么如果按照顺序一致性模型,A在第一条指令执行过后被放入寄存器,在第二条指令执行时A不再存在,第三条指令执行时A重新被读入寄存器,而这个过程中,A的值没有发生变化。通常编译器都会交换第二和第三条指令的位置,这样第一条指令结束时A存在于寄存器中,接下来可以直接从寄存器中读取A的值,降低了重复读取的开销。

2.2 运行期重排序

现代CPU几乎都采用流水线机制加快指令的处理速度,一般来说,一条指令需要若干个CPU时钟周期处理,而通过流水线并行执行,可以在同等的时钟周期内执行若干条指令,具体做法简单地说就是把指令分为不同的执行周期,例如读取、寻址、解析、执行等步骤,并放在不同的元件中处理,同时在执行单元EU中,功能单元被分为不同的元件,例如加法元件、乘法元件、加载元件、存储元件等,可以进一步实现不同的计算并行执行。流水线架构决定了指令应该被并行执行,而不是在顺序化模型中所认为的那样。重排序有利于充分使用流水线,进而达到超标量的效果。

2.3 重排序原则

在单线程环境下,重排序后的指令执行的最终效果应当与其在顺序执行下的效果一致as-if-serial,否则这种优化便没有意义。重排序必须遵守的规则,就是大名鼎鼎的Happens-Before原则,他规定了以下情况下指令不能进行重排序:

  • 程序顺序原则:一个线程内,代码执行的过程必须保证语义的串行性( as-if-serial,看起来是串行的;另外如果程序内数据存在依赖,也不允许进行重排序 );
  • 监视器锁规则:解锁unlock必然发生在加锁lock前。
  • 传递性规则:如果操作A先于操作B,操作B先于操作C,那么操作A必先于操作C。
  • volatile规则:一个共享变量的写操作,必须先于读操作,这是volatile可见性语义的要求。
  • 线程的start规则:线程的start操作先于线程内其他任何操作。
  • 线程的join规则:如果线程ThreadA中执行了ThreadB.join()方法,那么ThreadB的所有操作先于ThreadA中ThreadB.join()返回后的操作。

2.4 重排序带来的问题

虽然Happens-Before对指令重排序做了限制,但是在多线程环境下,仍然会有很多不符合预期的情况出现。上代码:

/*
 *例2:有bug的双重检查锁(double-checked-locking)单例模式
 */
public class LazySingleton {
    private static LazySingleton instance;

    private LazySingleton() {
    }
    public LazySingleton getInstance() {
        if (null == instance) {//.................................(1)
            synchronized (LazySingleton.class) {
                if(null == instance){
                    instance = new LazySingleton();//............(2)
                }
            }
        }
        return instance;
    }
}

关于双重检查锁单例模式这里不做解释,感兴趣可以参考设计模式:(一)单例模式。我们只关注instance = new LazySingleton();这句话,可以粗略认为这条语句分3步骤执行:

  • 1.memory = allocate() 分配内存空间
  • 2.ctorInstance(memory) 初始化对象
  • 3.instance = memory 将句柄指向分配的内存空间
    重排序后可能发生1-3-2的顺序执行,假设A线程按照1-3-2顺序执行,且刚刚完成3,没有到2;此时B线程运行到判断语句(1),判断为false,得到了一个没有初始化的句柄,从而引起错误。

3 volatile

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

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

3.1 volatile的作用

从上面例1和例2可以看出JMM模型和指令重排序规则本身并不能确保程序的执行会得到我们的期望的结果。只要稍加修改,在value和instance变量的类型前加上volatile关键,程序运行就能得到正确的结果。可见volatile关键字保证了被修饰的变量具有可见性禁止重排序。它的读写内存语义是:

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存;
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,直接将从主内存中读取变量;

3.2 volatile语义的实现

为了实现volatile的内存语义,JMM会分别限制编译期重排序和执行期两种类型的重排序。

  1. 针对编译期指定的volatile重排序规则表。


    volatile_compile_rule.png
  • 当第二个操作为volatile写操作时,不管第一个操作是什么,都不能进行重排序。这个规则确保volatile写之前的所有操作都不会被重排序到volatile写之后;
  • 当第一个操作为volatile读操作时,不管第二个操作是什么,都不能进行重排序。这个规则确保volatile读之后的所有操作都不会被重排序到volatile读之前;
  • 当第一个操作是volatile写操作时,第二个操作是volatile读操作,不能进行重排序。
    2.通过内存屏障禁止运行期的特定类型重排序
  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障(禁止前面的写与volatile写重排序)。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障(禁止volatile写与后面可能有的读和写重排序)。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障(禁止volatile读与后面的读操作重排序)。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障(禁止volatile读与后面的写操作重排序)。


    volatile_runtime_rule.png

3.3 volatile读写原子性

从volatile语义实现方式来看,单步骤的读写都是原子性的,但是不能保证读写、写读、自增、自减等复合操作的原子性。而一些组合操作看起来又极具迷惑性,例如i++,实际包含了先读在写,在多线程环境下volatile无法保证这个操作的原子性。考虑下面代码:

/*
 *例3:volatile没有原子性
 */
public class NonAtomicTest {
    volatile int value = 0;
    public static void main(String[] args) {
        NonAtomicTest nat = new NonAtomicTest();
        for(int i =0;i<10;i++){
            new Thread(()->{
                for(int j=0;j<1000;j++){
                    nat.value++;
                }
            },""+i).start();
        }
        while(Thread.activeCount()>2){
            Thread.yield();
        }
        System.out.println("当前value值:"+nat.value);
    }
}

其输出结果小于10000。

3.4 CAS

CAS(compare and swap)是cpu指令,依赖于硬件。CAS操作涉及三个操作数:内存值V,期望值E,要更新的值U。如果V=E(视为本线程读取V以后,没有其他线程修改过V的值),则将V设置为U,返回true,否则返回flase。如果V用volatile修饰,在每次修改以后其他线程可见,那么用do{E = get_V }while(CAS(V,E,U))的方式,每次从内存读取V值,在while中不断尝试CAS操作,一直到成功为止即可实现原子性。如果觉得这个伪代码太敷衍,可以看下这个:

/*
*例4:sun.misc.Unsafe.class
*/
public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

以上代码是sun.misc.Unsafe类中的方法。其中var1代表一个对象的引用,var2是这个对象中的一个属性的偏移地址,通过var1和var2就能够得到对象属性的数值,即var5。每次调用compareAndSwapInt方法时,会通过var1和var2获取最新的属性值,如果和var5不相等,则返回false,发生自旋,再次循环一遍,一直到成功。juc中的AtomicXXX类都是通过调用Unsafe的方法实现的原子操作。

3.5 ABA问题

虽然CAS加自旋锁能够解决原子问题,但是也存在一些缺点,比如高并发下效率低(都在自旋等待);能够保证读写或写读的原子性,但是不能保证代码块的原子性;最重要的是有ABA问题:线程t1将共享变量的值从A变为B,再从B变为A。同时有线程t2要将值从A变为C。当t2做CAS检查的时候会发现共享变量的值没有改变,但是实质上它已经发生了改变,可能会造成数据的缺失。ABA问题导致的原因,是CAS过程中只简单进行了“值”的校验,有些情况下,“值”相同不会引入错误的业务逻辑,有些情况下,“值”虽然相同,却已经不是原来的数据了。例如:
有一个栈,初始值是A-B-C-A-D,有2个并发线程t1、t2同时读取栈顶的值都得到A,t2连续3次出栈后t1得到cpu时间片,进行CAS操作,发现期望值和当前值都是A,CAS操作成功,将A改为X,但是栈已经只剩下2个元素,不是期望的数据。

ABA.png

解决ABA问题:
只需要在每次CAS成功后增加一个版本号或者时间戳即可,参考AtomicStampedReference类。

4 volatile使用场景

  • 1.状态标识
    用于指示发生了一个重要的一次性事件,例如完成初始化或任务结束。状态标志并不依赖于程序内任何其他状态,且通常只有一种状态转换。例如把例1中value改为bool类型。
  • 2.一次性安全发布(one-time safe publication)
    在缺乏同步的情况下,可能会导致某个线程获得一个未完全初始化的实例。(这就是双重检查锁定问题的根源,例2)
  • 3.低开销的读-写锁策略
    当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
    利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性
public class Counter {
    private volatile int value;
    //利用volatile保证读取操作的可见性, 读取时无需加锁
    public int getValue() { return value; }
    // 使用 synchronized 加锁
    public synchronized int increment() { 
        return value++;
    }
}
  • 4.独立观察(independent observation)
    使用 volatile 定期 “发布” 观察结果供程序内部使用。假设有一种环境传感器能够感觉环境温度,一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前温度的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。即一个线程写,多个线程读。

参考资料:
https://blog.csdn.net/qq_35362055/article/details/78981792
https://www.iteye.com/blog/zhaodengfeng1989-2419692
https://download.oracle.com/otndocs/jcp/memory_model-1.0-pfd-spec-oth-JSpec/
https://www.jianshu.com/p/9e467de97216

volatile.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343