共计 5488 个字符,预计需要花费 14 分钟才能阅读完成。
本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜寻 郭霖 即可关注,每个工作日都有文章更新。
上周六在公众号分享了一篇对于 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 来验证上述说法,代码如下所示:
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 上的利用
当初咱们曾经理解了 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 版》,点击此处查看详情。
关注我的技术公众号,每个工作日都有优质技术文章推送。
微信扫一扫下方二维码即可关注: