本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜索 郭霖 即可关注,每个工作日都有文章更新。
上周六在公众号分享了一篇关于 Java volatile 关键字的文章,发布之后有朋友在留言里指出,说这个关键字没啥用啊,Android 开发又不像服务器那样有那么高的并发,老分享这种知识干啥?
让我意识到有些朋友对于 volatile 这个关键字的理解还是有误区的。
另外也有朋友留言说,虽然知道 volatile 关键字的作用,但是想不出在 Android 开发中具体有什么用途。
所以我准备写篇文章来剖析一下这个关键字,顺便回答一下这些朋友的疑问。
由于这篇文章是我用周日一天时间赶出来的,所以可能不会像平时的文章那样充实,但是对于上述问题我相信还是可以解释清楚的。
对 volatile 关键字的作用有疑问的同学,可能都不太了解 CPU 高速缓存这个概念,所以我们先从这个概念讲起。
当一个程序运行的时候,数据是保存在内存当中的,但是执行程序这个工作却是由 CPU 完成的。那么当 CPU 正在执行着任务呢,突然需要用到某个数据,它就会从内存中去读取这个数据,得到了数据之后再继续向下执行任务。
这是理论上理想的工作方式,但是却存在着一个问题。我们知道,CPU 的发展是遵循摩尔定律的,每 18 个月左右集成电路上晶体管的数量就可以翻一倍,因此 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 上有什么用途。
其实我觉得任何一个技术点都不应该去生搬硬套,你只要掌握了它,该用到时能想到它就可以了,而不是绞尽脑汁去想我到底要在哪里使用它。
我在看一些 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 版》,点击此处查看详情。