本文同步发表于个人微信公众号,扫一扫文章底部的二维码或在微信搜索 郭霖 便可关注,每一个工做日都有文章更新。
上周六在公众号分享了一篇关于Java volatile关键字的文章,发布以后有朋友在留言里指出,说这个关键字没啥用啊,Android开发又不像服务器那样有那么高的并发,老分享这种知识干啥?java
让我意识到有些朋友对于volatile这个关键字的理解仍是有误区的。编程
另外也有朋友留言说,虽然知道volatile关键字的做用,可是想不出在Android开发中具体有什么用途。缓存
因此我准备写篇文章来剖析一下这个关键字,顺便回答一下这些朋友的疑问。安全
因为这篇文章是我用周日一天时间赶出来的,因此可能不会像平时的文章那样充实,可是对于上述问题我相信仍是能够解释清楚的。服务器
对volatile关键字的做用有疑问的同窗,可能都不太了解CPU高速缓存这个概念,因此咱们先从这个概念讲起。
微信
当一个程序运行的时候,数据是保存在内存当中的,可是执行程序这个工做倒是由CPU完成的。那么当CPU正在执行着任务呢,忽然须要用到某个数据,它就会从内存中去读取这个数据,获得了数据以后再继续向下执行任务。网络
这是理论上理想的工做方式,可是却存在着一个问题。咱们知道,CPU的发展是遵循摩尔定律的,每18个月左右集成电路上晶体管的数量就能够翻一倍,所以CPU的速度只会变得愈来愈快。多线程
可是光CPU快没有用呀,由于CPU再快仍是要从内存去读取数据,而这个过程是很是缓慢的,因此就大大限制了CPU的发展。并发
为了解决这个问题,CPU厂商引入了高速缓存功能。内存里存储的数据,CPU高速缓存里也能够存一份,这样当频繁须要去访问某个数据时就不须要重复从内存中去获取了,CPU高速缓存里有,那么直接拿缓存中的数据便可,这样就能够大大提高CPU的工做效率。ide
而当程序要对某个数据进行修改时,也能够先修改高速缓存中的数据,由于这样会很是快,等运算结束以后,再将缓存中的数据写回到内存当中便可。
这种工做方式在单线程的场景下是没问题的,准确来说,在单核多线程的场景下也是没问题的。但若是到了多核多线程的场景下,可能就会出现问题。
咱们都知道,如今不论是手机仍是电脑,动不动就声称是多核的,多核就是CPU中有多个运算单元的意思。由于一个运算单元在同一时间其实只能处理一个任务,即便咱们开了多个线程,对于单核CPU而言,它只能先处理这个线程中的一些任务,而后暂停下来转去处理另一个线程中的任务,以此交替。而多核CPU的话,则能够容许在同一时间处理多个任务,这样效率固然就更高了。
可是多核CPU又带来了一个新的挑战,那就是在多线程的场景下,CPU高速缓存中的数据可能不许确了。缘由也很简单,咱们经过下面这张图来理解一下。
能够看到,这里有两个线程,分别经过两个CPU的运算单元来执行程序,但它们是共享同一个内存的。如今CPU1从内存中读取数据A,并写入高速缓存,CPU2也从内存中读取数据A,并写入高速缓存。
到目前为止仍是没有问题的,可是若是线程2修改了数据A的值,首先CPU2会更新高速缓存中A的值,而后再将它写回到内存当中。这个时候,线程1再访问数据A,CPU1发现高速缓存当中有A的值啊,那么直接返回缓存中的值不就好了。此时你会发现,线程1和线程2访问同一个数据A,获得的值却不同了。
这就是多核多线程场景下遇到的可见性问题,由于当一个线程去修改某个变量的值时,该变量对于另一个线程并非当即可见的。
为了让以上理论知识更具备说服力,这里我编写了一个小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。
理论上来讲,这两个线程同时运行,那么就应该一直交替打印,你改个人值,我再给你改回去。
实际上真的会是这样吗?咱们来运行一下就知道了。
能够看到,打印过程只持续了一小会就中止打印了,可是程序却没有结束,依然显示在运行中。
这怎么可能呢?理论上来讲,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关键字便可。而后从新运行程序,效果以下图所示。
一切如咱们所预期的那样运行了。
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上有什么用途。
其实我以为任何一个技术点都不该该去生搬硬套,你只要掌握了它,该用到时能想到它就能够了,而不是绞尽脑汁去想我到底要在哪里使用它。
我在看一些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版》,点击此处查看详情。
关注个人技术公众号,每一个工做日都有优质技术文章推送。微信扫一扫下方二维码便可关注: