Android高级进阶-Java多线程编程之volatile关键字

锁在多线程编程或者说并发编程中极为重要,善用锁有助于避免程序出现意想不到的错误。volatile也可以说是锁机制中的一部分吧,之后会陆续学习分享锁机制的内容。

volatile关键字

volatile关键字用于 保持内存可见性防止指令重排序,什么意思呢?

  • 保持内存可见性:这里需要知道,CPU执行效率远高于内存,为了有更高的执行效率,内存与CPU之间会有一块缓存(CPU Cache)来做第三者。非volatile关键字的变量,在每个线程在使用这个变量时,将变量从内存拷贝到缓存中,并发编程下,多个线程拷贝的变量都是同一个值,在两个线程中单独改变变量的值,不会影响到其他线程中的变量副本,要实现线程同步(内存可见性)就可以使用volatile关键字来修饰。使用volatile关键字修饰后,每次修改变量的值,JVM都会将值刷新至主存中(这个过程加锁了,防止其他线程同时修改),取值时也都重新从主存中重新获取。实际上,这里遵循了 MESI缓存一致性协议 ,每个线程都有一个 总线嗅探机制,一旦使用volatile关键字后,总线嗅探机制就会启动,类似观察者模式一样,主存中的值一旦发生改变,就会清除线程中的变量副本,再次取值/改值时,都重新从主存中获取。举个例子:
public class JMMTest {

    public static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {

        new Thread(){
            @Override
            public void run() {
                System.out.println("11111");
                while (!flag){
                }
                System.out.println("22222");
            }
        }.start();

        Thread.sleep(100);

        new Thread(){
            @Override
            public void run() {
                System.out.println("33333");
                flag = true;
                System.out.println("44444");
            }
        }.start();
    }

}

以上代码,开启两个线程,中间睡眠100ms。其最终结果是:


image.png

为什么呢?明明第二个线程修改了flag变量的值为true,那第一个线程中while(!flag)应该不会进入循环才对,应该最终会打印22222才对。其实这里就是因为两个线程内存不可见性导致,两个线程中的flag都是变量flag变量的一个副本,第二个线程修改flag=true并不影响第一个线程中的flag。其实在IDEA中已经有所提醒了:


image.png

那我们在申明flag变量的地方加上volatile关键字对flag变量进行修饰后再执行结果:
public static volatile boolean flag = false;
image.png

⚠️ 注意: ⚠️ 这个时候,我将上面例子换一个顺序再执行,结果又不尽相同:

public class JMMTest {

    public static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {

        new Thread(){
            @Override
            public void run() {
                System.out.println("33333");
                flag = true;
                System.out.println("44444");
            }
        }.start();

        Thread.sleep(100);

        new Thread(){
            @Override
            public void run() {
                System.out.println("11111");
                while (!flag){
                }
                System.out.println("22222");
            }
        }.start();

    }

}

这个时候到执行结果如下:
image.png

这里就需要抛出一个疑问,不是说两个线程中的变量都是副本么?这里第一个线程改了flag值为true应该和第二个线程没有关系的啊????
解释一下:大家都知道代码执行顺序,当执行到flag=true时,某个时段会将flag刷新回主存中,意味着第二个线程开始执行之前,flag值已经被第一个线程修改并且将值刷新回到了主存中,主存中的flag值变为true,第二个线程执行时拷贝的变量副本就已经是true了。如何验证呢?我们再来修改一下代码:

public static void main(String[] args) throws InterruptedException {

        new Thread(){
            @Override
            public void run() {
                System.out.println("33333");
                flag = true;
                System.out.println("44444");
                while (flag){
                    //此时flag是true,虽然第二个线程1000毫秒后将值重新改回false并刷新回主存,
                    //但是这里的flag在刷新前已经将主存中flag拷贝到了线程工作内存中了,后面的代码将不再执行
                }
                System.out.println("55555");

            }
        }.start();
        Thread.sleep(100);
        new Thread(){
            @Override
            public void run() {
                System.out.println("11111");
                while (!flag){
                }
                System.out.println("22222");

                try {
                    sleep(1000);
                    flag = false;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }.start();

    }

运行后的结果可以预知:不会打印55555:

image.png

volatile 的内存可见性就说这么多了,这里再放一张图,线程工作内存和主存之间的关系:(偷来的图,反正我画的贼难看...)
image.png

  • 防止指令重排:一个对象的赋值过程有四个步骤(指令):new出一个对象存放在栈中、对象引用的赋值(堆中)、对象执行构造方法,对象栈与堆之间的引用建立连接。如下代码:
public class ObjTest {

    public static void main(String[] args) {
        Obj obj = new Obj();
    }
}

class Obj{
    int i = 10;
}

转换字节码后的指令四个过程,过程如:


image.png

在CPU执行指令过程中,第三步和第四步的执行指令顺序可能不一样,在单线程下,第四步指令先执行,后执行第三步指令的情形下,对结果并没有影响,但是在多线程下就可能出现问题。

光说不做不是一枚老程序员的做法,我们验证一下指令重排的效果:


public class VolatileTest {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; ; i++) {
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread threadOne = new Thread() {
                @Override
                public void run() {
                    a = 1;
                    x = b;
                }
            };
            Thread threadTwo = new Thread() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            };

            threadOne.start();
            threadTwo.start();
            threadOne.join();
            threadTwo.join();

//            String result = "第" + i + "次( x=" + x + ",  y="+  + y + ")";
//            System.out.println(result);

            if (x == 0 && y == 0) {
                String result = "第" + i + "次( x=" + x + ", y=" + y + ")";
                System.out.println(result);
                break;
            }
        }
    }
}

如果不发生指令重排的话,正常的执行结果是:x=0,y=1。但是我们实际运行过程中会碰到这种情况,如下图:


image.png

可以发现,在循环第196558次的时候,既然出现了x=0,y=0的情况,发生这种情况的唯一可能就只有threadOne线程中的指令x=b跑到a=1前面,threadTwo中的指令y=a跑到b=1前面才可能发生,因为上面说了,单线程情况下,x=b和a=1谁在前面是不是都没影响,只有在多线程情况下,他们才可能由于指令重排造成意想不到的结果。

上面代码验证了指令重排可能造成的结果,接下来说一个我们最为常用的

如:双重校验锁单例下:

public class TestSingle {

    private static TestSingle instance;

    int i = 0;

    private TestSingle(){
        i = 13;
    }

    public static TestSingle getInstance(){
        if(instance == null){
            synchronized (TestSingle.class){
                if(instance == null){
                    instance = new TestSingle();
                }
            }
        }
        return instance;
    }

}

上面代码,可能发生的情景:
线程执行到new TestSingle时,由于指令重排机制,可能执行的顺序是1-2-3-4或者1-2-4-3。1-2是堆栈的内存分配,不会有指令重排的问题,总的来说就可以分为三个步骤:

  • 1、分配对象内存(给instance分配内存)。
  • 2、调用构造器方法,执行初始化(调用 TestSingle 的构造函数来初始化成员变量)。
  • 3、将对象引用赋值给变量(执行完这步 instance就为非 null 了)。
    这个时候,指令重排可能为:1-2-3或者是 1-3-2,如果是单线程下没有任何问题,但是多线程下就会有不同的结果了。
    1-3-2的结果就有可能是:
    线程A:1-3,但是2尚未得到执行时,线程B来了,进入第一个instance==null判断时,instance不为空,直接返回了instance对象,但是线程A还未执行2调用构造器,执行初始化方法。就会造成线程B获取的instance对象未空或者未初始化完成,i 未赋值为13,默认值为0,就造成了意想不到的结果。

所以,为了防止CPU在多线程下指令重排造成的影响,使用关键字volatile来解决。

好了,Java多线程编程之volatile关键字到此结束,有不同见解的请直接评论区指出,唯有不足才有继续成长的空间!
这里借这片文章再说一下,由于这段时间真的挺忙的,所以很少学习,也很少更新博客公众号,尽量多挤出来时间来学习和记录分享吧。

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

推荐阅读更多精彩内容