java volatile关键字只需知道这点就行

Volatile 这个关键字可能很多朋友都听说过,但是可能不敢用,毕竟这个关键字非常不好控制,干脆不用为好。Volatile在一般的多线程编程里面算是比较尴尬的关键字了。本人也不想说(抄)太多的底层原理,相信很多人也不愿意看,只想知道怎么用,但是大概简单的了解也是必须的,这使得我们很容易的理解,并正确的使用。

一.Java内存模型

在java中,线程之间的共享变量是存储在主内存中的,每个线程都有一个属于自己的私有的本地内存,其中存放着主内存中所有线程共享的变量的值的拷贝。内存模型图如下

图片.png

现在假设本地内存A和本地内存B存着主内存中的共享变量x的副本。假设初始化这三个内存中的x值都是0。现在线程A和线程B同时执行 x=x+1;那么我们希望两个线程执行完之后x的值变为2。但是事实会是这样吗?
可能存在下面一种情况:初始时,两个线程分别读取x的值存入各自的本地内存当中,然后线程A进行加1操作,然后把x的最新值1写入到内存。此时线程B的本地内存中还是0,读取x=0后进行加1操作之后,x的值为1,然后线程B把x的值写入内存。最终的结果x=1;这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。

1.原子性

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子,请分析以下哪些操作是原子性操作:

x = 10;         //语句1
y = x;         //语句2
x++;           //语句3
x = x + 1;     //语句4

咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。

  • 语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
  • 语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入本地内存,虽然读取x的值以及 将x的值写入本地内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
  • 同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。

所以上面4个语句只有语句1的操作具备原子性。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

2.可见性

对于可见性,Java提供了volatile关键字来保证可见性。

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主内存,当有其他线程需要读取时,它必须去主内存中读取新值。

而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3.有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2

从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢? 看下面的代码

public class ReorderExample {
    int a = 0;
    boolean flag = false;
    
    public void writer(){
        a = 1;  //1
        flag = true; //2
    }

    public void reader(){
        if (flag) {
            int i = a;
            //other
        }
    }
}

线程A先执行writer方法,1,2语句重排序了,先执行了语句2,被阻塞了,语句1还没执行,这时候线程B执行reader()方法,看到的a=0;
所以在这里多线程的语义被重排序破坏了。

二.深入剖析volatile关键字

1.声明为 volatile 变量有以下保证:
  • 其他线程对volatile变量的修改,可以即时反应到当前线程中
  • 确保当前线程对volatile变量的修改,能即时写回主内存中,并别其他线程可见
  • 使用 volatile 声明的变量,编译器会保证其有序性

看以下代码:

public class VolatileTest extends Thread{
    private  boolean stop = false;
    
    public void stopMe(){
        stop = true;
    }

    @Override
    public void run() {
        int i = 0;
        while (!stop) {
            i++;
        }
        System.out.println("Thread Stop");
    }
    
    public static void main(String[] args) throws InterruptedException {
        VolatileTest  test = new VolatileTest();
        test.start();
        Thread.sleep(1000);
        test.stopMe();
        Thread.sleep(1000);
    }
}

这是很典型的一段代码,上面的线程会被停止吗?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程,这是有隐藏bug的代码。

在前面已经解释过,每个线程在运行过程中都有自己的本地内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的本地内存当中。
  那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
  但是用volatile修饰之后就变得不一样了:
  第一:使用volatile关键字会强制将修改的值立即写入主存;
  第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的本地内存中缓存变量stop的缓存行无效;
  第三:由于线程1的本地内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
  那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的本地内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主内存地址被更新之后,然后去对应的主内存读取最新的值。
  那么线程1读取到的就是最新的正确的值。这个线程就能确保一定能停下来。

再来看重排序的问题,看回这段代码:

public class ReorderExample {
    int a = 0;
    boolean flag = false;
    
    public void writer(){
        a = 1;  //1
        flag = true; //2
    }

    public void reader(){
        if (flag) {
            int i = a;
            //other
        }
    }
}

线程A先执行writer方法,1,2语句重排序了,先执行了语句2,被阻塞了,语句1还没执行,这时候线程B执行reader()方法,看到的a=0;所以在这里多线程的语义被重排序破坏了。

但是当用volatile声明flag变量的时候:

  • 线程A写一个volatile变量的时候,会把写之前对共享变量所做的修改写到主内存中,并且对其他线程可见,并通知线程B去主内存中读数据。那么就不会出现上面那种由于重排序破坏了多线程的语义。并且volatile会保证有序性。
2.volatile 能保证原子性吗:

看以下一个例子:

public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        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);
    }
}

大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字
  可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。
  这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。
在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

假如某个时刻变量inc的值为10,

线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的本地内存中缓存变量inc的缓存行无效,所以线程2会直接去主内存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入本地内存,最后写入主内存。然后线程1接着进行加1操作,由于之前已经读取了inc的值,注意此时在线程1的工作本地中inc的值仍然为10(但是已经被标识无效,下一次读的时候就会去主内存中读),所以线程1对inc进行加1操作后inc的值为11,然后将11写入本地内存,最后写入主内存。
  那么两个线程分别进行了一次自增操作后,inc只增加了1。

三.使用volatile关键字的场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
  1)对变量的写操作不依赖于当前值
  2)该变量没有包含在具有其他变量的不变式中

  • 状态标记量
public class VolatileTest extends Thread{
    private volatile  boolean stop = false;
    
    public void stopMe(){
        stop = true;
    }

    @Override
    public void run() {
        int i = 0;
        while (!stop) {
            i++;
        }
        System.out.println("Thread Stop");
    }
    
    public static void main(String[] args) throws InterruptedException {
        VolatileTest  test = new VolatileTest();
        test.start();
        Thread.sleep(1000);
        test.stopMe();
        Thread.sleep(1000);
    }
}
  • 防止重排序对多线程语义破坏
public class ReorderExample {
    int a = 0;
    boolean volat flag = false;
    
    public void writer(){
        a = 1;  //1
        flag = true; //2
    }

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

推荐阅读更多精彩内容