15

volatile关键字在Android中到底有什么用?

 3 years ago
source link: https://blog.csdn.net/guolin_blog/article/details/109009649
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜索 郭霖 即可关注,每个工作日都有文章更新。

上周六在公众号分享了一篇关于Java volatile关键字的文章,发布之后有朋友在留言里指出,说这个关键字没啥用啊,Android开发又不像服务器那样有那么高的并发,老分享这种知识干啥?

让我意识到有些朋友对于volatile这个关键字的理解还是有误区的。

另外也有朋友留言说,虽然知道volatile关键字的作用,但是想不出在Android开发中具体有什么用途。

所以我准备写篇文章来剖析一下这个关键字,顺便回答一下这些朋友的疑问。

由于这篇文章是我用周日一天时间赶出来的,所以可能不会像平时的文章那样充实,但是对于上述问题我相信还是可以解释清楚的。

对volatile关键字的作用有疑问的同学,可能都不太了解CPU高速缓存这个概念,所以我们先从这个概念讲起。

CPU高速缓存和可见性问题

当一个程序运行的时候,数据是保存在内存当中的,但是执行程序这个工作却是由CPU完成的。那么当CPU正在执行着任务呢,突然需要用到某个数据,它就会从内存中去读取这个数据,得到了数据之后再继续向下执行任务。

这是理论上理想的工作方式,但是却存在着一个问题。我们知道,CPU的发展是遵循摩尔定律的,每18个月左右集成电路上晶体管的数量就可以翻一倍,因此CPU的速度只会变得越来越快。

ZFnmmue.jpg!mobile

但是光CPU快没有用呀,因为CPU再快还是要从内存去读取数据,而这个过程是非常缓慢的,所以就大大限制了CPU的发展。

Bry6j2a.jpg!mobile

为了解决这个问题,CPU厂商引入了高速缓存功能。内存里存储的数据,CPU高速缓存里也可以存一份,这样当频繁需要去访问某个数据时就不需要重复从内存中去获取了,CPU高速缓存里有,那么直接拿缓存中的数据即可,这样就可以大大提升CPU的工作效率。

而当程序要对某个数据进行修改时,也可以先修改高速缓存中的数据,因为这样会非常快,等运算结束之后,再将缓存中的数据写回到内存当中即可。

这种工作方式在单线程的场景下是没问题的,准确来讲,在单核多线程的场景下也是没问题的。但如果到了多核多线程的场景下,可能就会出现问题。

我们都知道,现在不管是手机还是电脑,动不动就声称是多核的,多核就是多CPU的意思。因为一个CPU在同一时间其实只能处理一个任务,即使我们开了多个线程,对于CPU而言,它只能先处理这个线程中的一些任务,然后暂停下来转去处理另外一个线程中的任务,以此交替。而多CPU的话,则可以允许在同一时间处理多个任务,这样效率当然就更高了。

但是多CPU又带来了一个新的挑战,那就是在多线程的场景下,CPU高速缓存中的数据可能不准确了。原因也很简单,我们通过下面这张图来理解一下。

jQFZvum.png!mobile

可以看到,这里有两个线程,分别通过两个CPU来执行程序,但它们是共享同一个内存的。现在CPU1从内存中读取数据A,并写入高速缓存,CPU2也从内存中读取数据A,并写入高速缓存。

到目前为止还是没有问题的,但是如果线程2修改了数据A的值,首先CPU2会更新高速缓存中A的值,然后再将它写回到内存当中。这个时候,线程1再访问数据A,CPU1发现高速缓存当中有A的值啊,那么直接返回缓存中的值不就行了。此时你会发现,线程1和线程2访问同一个数据A,得到的值却不一样了。

rYFRFfz.png!mobile

这就是多核多线程场景下遇到的可见性问题,因为当一个线程去修改某个变量的值时,该变量对于另外一个线程并不是立即可见的。

为了让以上理论知识更具有说服力,这里我编写了一个小Demo来验证上述说法,代码如下所示:

public class Main {
 

    static boolean flag;

    public static void main(String... args) {
 
        new Thread1().start();
        new Thread2().start();
    }

    static class Thread1 extends Thread {
 
        @Override
        public void run() {
 
            while (true) {
 
                if (flag) {
 
                    flag = false;
                    System.out.println("Thread1 set flag to false");
                }
            }
        }
    }

    static class Thread2 extends Thread {
 
        @Override
        public void run() {
 
            while (true) {
 
                if (!flag) {
 
                    flag = true;
                    System.out.println("Thread2 set flag to true");
                }
            }
        }
    }

}

这段代码真的非常简单,我们开启了两个线程来对同一个变量flag进行修改。Thread1使用一个while(true)循环,发现flag是true时就把它改为false。Thread2也使用一个while(true)循环,发现flag是false时就把它改为true。

理论上来说,这两个线程同时运行,那么就应该一直交替打印,你改我的值,我再给你改回去。

实际上真的会是这样吗?我们来运行一下就知道了。

JRnY3mU.gif!mobile

可以看到,打印过程只持续了一小会就停止打印了,但是程序却没有结束,依然显示在运行中。

这怎么可能呢?理论上来说,flag要么为true,要么为false。true的时候Thread1应该打印,false的时候Thread2应该打印,两边都不打印是为什么呢?

我们用刚才所学的知识就可以解释这个原本解释不了的问题,因为Thread1和Thread2的CPU高速缓存中各有一份flag值,其中Thread1中缓存的flag值是false,Thread2中缓存的flag值是true,所以两边就都不会打印了。

这样我们就通过一个实际的例子演示了刚才所说的可见性问题。那么该如何解决呢?

答案很明显,volatile。

volatile这个关键字的其中一个重要作用就是解决可见性问题,即保证当一个线程修改了某个变量之后,该变量对于另外一个线程是立即可见的。

至于volatile的工作原理,太底层方面的内容我也说不上来,大概原理就是当一个变量被声明成volatile之后,任何一个线程对它进行修改,都会让所有其他CPU高速缓存中的值过期,这样其他线程就必须去内存中重新获取最新的值,也就解决了可见性的问题。

我们可以将刚才的代码进行如下修改:

public class Main {
 

    volatile static boolean flag;
    ...

}

没错,就是这么简单,在flag变量的前面加上volatile关键字即可。然后重新运行程序,效果如下图所示。

FfYZjyA.gif!mobile

一切如我们所预期的那样运行了。

指令重排问题

volatile关键字还有另外一个重要的作用,就是禁止指令重排,这又是一个非常有趣的问题。

我们先来看两段代码:

// 第一段代码
int a = 10;
int b = 5;
a = 20;
System.out.println(a + b);

// 第二段代码
int a = 10;
a = 20;
int b = 5;
System.out.println(a + b);

第一段代码,我们声明了一个a变量等于10,又声明了一个b变量等于5,然后将a变量的值改成了20,最后打印a + b的值。

第二段代码,我们声明了一个a变量等于10,然后将a变量的值改成了20,又声明了一个b变量等于5,最后打印a + b的值。

这两段代码有区别吗?

不用瞎猜了,这两段代码没有任何区别,声明变量b和修改变量a之间的顺序是随意的,它们之间谁也不碍着谁。

也正是因为这个原因,CPU在执行代码时,其实并不一定会严格按照我们编写的顺序去执行,而是可能会考虑一些效率方面的原因,对那些先后顺序无关紧要的代码进行重新排序,这个操作就被称为指令重排。

这么看来,指令重排这个操作没毛病啊。确实,但只限在单线程环境下。

很多问题一旦进入了多线程环境,就会变得更加复杂,我们来看如下代码:

public class Main {
 

    static boolean init;
    static String value;

    static class Thread1 extends Thread {
 
        @Override
        public void run() {
 
            value = "hello world";
            init = true;
        }
    }

    static class Thread2 extends Thread {
 
        @Override
        public void run() {
 
            while (!init) {
 
                // 等待初始化完成
            }
            value.toUpperCase();
        }
    }

}

这段代码的思路仍然很简单,Thread1用于对value数据进行初始化,初始化完成之后会将init设置成true。Thread2则会先通过while循环等待初始化完成,完成之后再对value数据进行操作。

那么这段代码可以正常工作吗?未必,因为根据刚才的指令重排理论,Thread1中value和init这两个变量之间是没有先后顺序的。如果CPU将这两条指令进行了重排,那么就可能出现初始化已完成,但是value还没有赋值的情况。这样Thread2的while循环就会跳出,然后在操作value的时候出现空指针异常。

所以说,指令重排功能一旦进入了多线程环境,也是可能会出现问题的。

而至于解决方案嘛,当然还是volatile了。

对某个变量声明了volatile关键字之后,同时也就意味着禁止对该变量进行指令重排。所以我们只需要这样修改代码就能够保证程序的安全性了。

public class Main {
 

    volatile static boolean init;
    ...

}

volatile在Android上的应用

现在我们已经了解了volatile关键字的主要作用,但是就像开篇时那位朋友提到的一样,很多人想不出来这个关键字在Android上有什么用途。

其实我觉得任何一个技术点都不应该去生搬硬套,你只要掌握了它,该用到时能想到它就可以了,而不是绞尽脑汁去想我到底要在哪里使用它。

我在看一些Google库的源码时,其实时不时就能看到这个关键字,只要是涉及多线程编程的时候,volatile的出场率还是不低的。

这里我给大家举一个常见的示例吧,在Android上我们应该都编写过文件下载这个功能。在执行下载任务时,我们需要开启一个线程,然后从网络上读取流数据,并写入到本地,重复执行这个过程,直到所有数据都读取完毕。

那么这个过程我可以用如下简易代码进行表示:

public class DownloadTask {
 

    public void download() {
 
        new Thread(new Runnable() {
 
            @Override
            public void run() {
 
                while (true) {
 
                    byte[] bytes = readBytesFromNetwork(); // 从网络上读取数据
                    if (bytes.length == 0) {
 
                        break; // 下载完毕,跳出循环
                    }
                    writeBytesToDisk(bytes); // 将数据写入到本地
                }
            }
        }).start();
    }

}

到此为止没什么问题。

不过现在又来了一个新的需求,要求允许用户取消下载。我们都知道,Java的线程是不可以中断的,所以如果想要做取消下载的功能,一般都是通过标记位来实现的,代码如下所示:

public class DownloadTask {
 

    boolean isCanceled = false;

    public void download() {
 
        new Thread(new Runnable() {
 
            @Override
            public void run() {
 
                while (!isCanceled) {
 
                    byte[] bytes = readBytesFromNetwork();
                    if (bytes.length == 0) {
 
                        break;
                    }
                    writeBytesToDisk(bytes);
                }
            }
        }).start();
    }

    public void cancel() {
 
        isCanceled = true;
    }

}

这里我们增加了一个isCanceled变量和一个cancel()方法,调用cancel()方法时将isCanceled变量设置为true,表示下载已取消。

然后在download()方法当中,如果发现isCanceled变量为true,就跳出循环不再继续执行下载任务,这样也就实现了取消下载的功能。

这种写法能够正常工作吗?根据我的实际测试,确实基本上都是可以正常工作的。

但是这种写法真的安全吗?不,因为你会发现download()方法和cancel()方法是运行在两个线程当中的,因此cancel()方法对于isCanceled变量的修改,未必对download()方法就立即可见。

所以,存在着这样一种可能,就是我们明明已经将isCanceled变量设置成了true,但是download()方法所使用的CPU高速缓存中记录的isCanceled变量还是false,从而导致下载无法被取消的情况出现。

因此,最安全的写法就是对isCanceled变量声明volatile关键字:

public class DownloadTask {
 

    volatile boolean isCanceled = false;
    ...

}

这样就可以保证你的取消下载功能始终是安全的了。

好了,关于volatile关键字的作用,以及它在Android开发中具体有哪些用途,相信到这里就解释的差不多了。

本来是想用周日一天时间写篇小短文的,写着写着好像最后又写出了不少内容,不过只要对大家有帮助就好。

如果想要学习Kotlin和最新的Android知识,可以参考我的新书 《第一行代码 第3版》点击此处查看详情

关注我的技术公众号,每个工作日都有优质技术文章推送。

微信扫一扫下方二维码即可关注:

MJZnmej.jpg!mobile

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK