本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜寻 郭霖 即可关注,每个工作日都有文章更新。
上周六在公众号分享了一篇对于Java volatile关键字的文章,公布之后有敌人在留言里指出,说这个关键字没啥用啊,Android开发又不像服务器那样有那么高的并发,老分享这种常识干啥?
让我意识到有些敌人对于volatile这个关键字的了解还是有误区的。
另外也有敌人留言说,尽管晓得volatile关键字的作用,然而想不出在Android开发中具体有什么用处。
所以我筹备写篇文章来分析一下这个关键字,顺便答复一下这些敌人的疑难。
因为这篇文章是我用周日一天工夫赶出来的,所以可能不会像平时的文章那样空虚,然而对于上述问题我置信还是能够解释分明的。
对volatile关键字的作用有疑难的同学,可能都不太理解CPU高速缓存这个概念,所以咱们先从这个概念讲起。
CPU高速缓存和可见性问题
当一个程序运行的时候,数据是保留在内存当中的,然而执行程序这个工作却是由CPU实现的。那么当CPU正在执行着工作呢,忽然须要用到某个数据,它就会从内存中去读取这个数据,失去了数据之后再持续向下执行工作。
这是实践上现实的工作形式,然而却存在着一个问题。咱们晓得,CPU的倒退是遵循摩尔定律的,每18个月左右集成电路上晶体管的数量就能够翻一倍,因而CPU的速度只会变得越来越快。
然而光CPU快没有用呀,因为CPU再快还是要从内存去读取数据,而这个过程是十分迟缓的,所以就大大限度了CPU的倒退。
为了解决这个问题,CPU厂商引入了高速缓存性能。内存里存储的数据,CPU高速缓存里也能够存一份,这样当频繁须要去拜访某个数据时就不须要反复从内存中去获取了,CPU高速缓存里有,那么间接拿缓存中的数据即可,这样就能够大大晋升CPU的工作效率。
而当程序要对某个数据进行批改时,也能够先批改高速缓存中的数据,因为这样会十分快,等运算完结之后,再将缓存中的数据写回到内存当中即可。
这种工作形式在单线程的场景下是没问题的,精确来讲,在单核多线程的场景下也是没问题的。但如果到了多核多线程的场景下,可能就会呈现问题。
咱们都晓得,当初不论是手机还是电脑,动不动就宣称是多核的,多核就是多CPU的意思。因为一个CPU在同一时间其实只能解决一个工作,即便咱们开了多个线程,对于CPU而言,它只能先解决这个线程中的一些工作,而后暂停下来转去解决另外一个线程中的工作,以此交替。而多CPU的话,则能够容许在同一时间解决多个工作,这样效率当然就更高了。
然而多CPU又带来了一个新的挑战,那就是在多线程的场景下,CPU高速缓存中的数据可能不精确了。起因也很简略,咱们通过上面这张图来了解一下。
能够看到,这里有两个线程,别离通过两个CPU来执行程序,但它们是共享同一个内存的。当初CPU1从内存中读取数据A,并写入高速缓存,CPU2也从内存中读取数据A,并写入高速缓存。
到目前为止还是没有问题的,然而如果线程2批改了数据A的值,首先CPU2会更新高速缓存中A的值,而后再将它写回到内存当中。这个时候,线程1再拜访数据A,CPU1发现高速缓存当中有A的值啊,那么间接返回缓存中的值不就行了。此时你会发现,线程1和线程2拜访同一个数据A,失去的值却不一样了。
这就是多核多线程场景下遇到的可见性问题,因为当一个线程去批改某个变量的值时,该变量对于另外一个线程并不是立刻可见的。
为了让以上理论知识更具备说服力,这里我编写了一个小Demo来验证上述说法,代码如下所示:
<code class="java">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高速缓存中的值过期,这样其余线程就必须去内存中从新获取最新的值,也就解决了可见性的问题。
咱们能够将方才的代码进行如下批改:
<code class="java">public class Main { volatile static boolean flag; ... }
没错,就是这么简略,在flag变量的后面加上volatile关键字即可。而后从新运行程序,成果如下图所示。
所有如咱们所预期的那样运行了。
指令重排问题
volatile关键字还有另外一个重要的作用,就是禁止指令重排,这又是一个十分乏味的问题。
咱们先来看两段代码:
<code class="java">// 第一段代码 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在执行代码时,其实并不一定会严格依照咱们编写的程序去执行,而是可能会思考一些效率方面的起因,对那些先后顺序无关紧要的代码进行从新排序,这个操作就被称为指令重排。
这么看来,指令重排这个操作没故障啊。的确,但只限在单线程环境下。
很多问题一旦进入了多线程环境,就会变得更加简单,咱们来看如下代码:
<code class="java">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关键字之后,同时也就意味着禁止对该变量进行指令重排。所以咱们只须要这样批改代码就可能保障程序的安全性了。
<code class="java">public class Main { volatile static boolean init; ... }
volatile在Android上的利用
当初咱们曾经理解了volatile关键字的次要作用,然而就像开篇时那位敌人提到的一样,很多人想不进去这个关键字在Android上有什么用处。
其实我感觉任何一个技术点都不应该去生吞活剥,你只有把握了它,该用到时能想到它就能够了,而不是搜索枯肠去想我到底要在哪里应用它。
我在看一些Google库的源码时,其实时不时就能看到这个关键字,只有是波及多线程编程的时候,volatile的出场率还是不低的。
这里我给大家举一个常见的示例吧,在Android上咱们应该都编写过文件下载这个性能。在执行下载工作时,咱们须要开启一个线程,而后从网络上读取流数据,并写入到本地,反复执行这个过程,直到所有数据都读取结束。
那么这个过程我能够用如下繁难代码进行示意:
<code class="java">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的线程是不能够中断的,所以如果想要做勾销下载的性能,个别都是通过标记位来实现的,代码如下所示:
<code class="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关键字:
<code class="java">public class DownloadTask { volatile boolean isCanceled = false; ... }
这样就能够保障你的勾销下载性能始终是平安的了。
好了,对于volatile关键字的作用,以及它在Android开发中具体有哪些用处,置信到这里就解释的差不多了。
原本是想用周日一天工夫写篇小短文的,写着写着如同最初又写出了不少内容,不过只有对大家有帮忙就好。
如果想要学习Kotlin和最新的Android常识,能够参考我的新书 《第一行代码 第3版》,点击此处查看详情。
关注我的技术公众号,每个工作日都有优质技术文章推送。
微信扫一扫下方二维码即可关注: