理解Java并发原理或者其他语言的并发(没错,这篇文章是“跨语言”的!!!还这么短,你说牛逼不牛逼)只需要记住理解两个东西:
1.CPU访问存储的方式——多级存储;
2.CPU执行指令的方式——乱序
首先回忆我们大学的一门课程——《计算机组成原理》也许你的记忆里只有:“呃,你要说xx进制转换成xx进制吗?”。没关系我帮你回忆一下:
-
有一节课讲多级存储,说计算机最快的存储是CPU里面的Cache,其次是内存,最后是硬盘,最次的是外部存储(比如光盘之类的)。
-
还有一节课讲的是CPU流水线,乱序执行、分支预测,说CPU考虑性能问题会把几个没有数据关联的指令打乱顺序执行。
怎么样?有印象了吗?(什么?没读过大学?那我觉得你有必要读一下大学的课程——即便你不想混文凭)。
多级存储
我们来看一个“无聊的”Java例子(例子没有任何意义,会枯燥一些,耐着性质你读懂了可以超脱了)
程序定义了一个线程,线程会不停的判断stop标志位,如果为真则循环累加i。然后我们在主线程里面修改stop为true。期望线程在进行2秒之后停止。
如果运行这个程序我们得到的结果是——程序永远不会停止。主线程里面修改的变量在testThread里面并没有发生改变。
解释这个程序就用到了“多级存储”,在x86架构的CPU中对数据的的访问都是经过寄存器,如果数据在内存中CPU会先加载到寄存器然后在读取;写入的时候CPU只写入到寄存器,在“适当的时候”数据会被回写到内存中。画个图:
操作系统把我们程序中的主进程和testThread调度到不同的CPU,testThread(CPU1)访问stop的时候数据被复制到Cache中然后读取;主进程(CPU2)访问stop的时候数据被复制到Cache中然后读取,赋值的时候会写入到Cache中。所以CPU2修改的值并不会立马被CPU1看到,这取决于:
-
CPU2是不是写回到内存中;
-
CPU1的Cache是不是被“淘汰”重新从内存中加载数据;
第一条比较容易满足,因为Cache必定会回写到内存中(只不过不是实时写入);第二条看起来比较困难,唯一的解决办法是我们访问stop变量的时候每次都从内存加载而不是通过Cache。在Java中实现这个功能的关键字是volatile
。
public static volatile boolean stop = false;
这样程序就可以“正常”执行了。需要注意,volatile
只保证“好吧,我不用Cache”,无法保证原子性(比如赋值操作被拆分为多个CPU指令,那么其他进程可能看到的是一个“中间结果”)。所以volatile
其实是一种低效、不安全的并发处理方式。(不使用Cache效率低,无法保证原子性所以不安全)。
流水线,乱序执行、分支预测
代码比上一个更加枯燥,忍耐一下:
我定义了4个变量,两个线程,然后分别启动两个线程,等待线程执行完之后输出x,y的值。同志们可以猜猜结果是多少。(注释后面的标号代表语句编号)
没错,根本没有“正确”答案。我这里有四种答案:
-
结果:x=0, y=1;执行顺序:1, 2, 3, 4
-
结果:x=1, y=0;执行顺序:3, 4, 1, 2
-
结果:x=1, y=1;执行顺序:1, 3, 2, 4
-
结果:x=0, y=0;执行顺序:2, 4, 1, 3
(前面三种执行结果你多执行几次都会出现,后面的理论是存在。但是我没有执行出来,单颗CPU更容易出现这样的结果)
这就是并发的本质,你的代码不会按照你写顺序执行。前三个很容解释,两个线程可能会被“交替”执行,让人困惑的是第四个结果,解释这个就必须用到“流水线,乱序执行、分支预测”。
CPU内部有多个执行单元(如果是多个CPU那就更多执行单元了),为了提高吞吐量,它会采用流水线同时执行多条指令;为了优化程序执行的效率适应流水线,CPU会分析指令的依赖关系把可以并行执行的指令并行执行。
在one线程中,a=1和y=b是没有任何依赖关系的,所以可能y=b会被先执行,a=1则后执行。同样的道理other线程中也是如此。
总结
没错,存储访问引起的不一致性+CPU为了提高效率引入的并行机制就是并发程序设计的困难,这两个问题结合在一起就是“Memory barrier”(内存屏障、内存栅栏),这不是Java独有的,在任何编程语言中都会存在这个问题,除非你的CPU不是多级存储、没有流水线(这还是CPU吗?)。
写这篇文章的目的是希望用“基础”知识来解释并发编程的问题,而不是像“某些”文章一样一上来就摆各种名词,各种JVM内存模型,各种Java规范。我觉得后者只能让人更困惑,有时候“基础”的力量非常强大。希望这篇文章对大家有帮助。