博主刚学并发时看了大量的概念,什么各种关键字的内存语义,happens-before 原则,JMM,看完之后依然云里雾里,无法分清主次和联系,希望这篇文章能给初学者启蒙。
为什么要使用并发编程?
并发编程可以提高资源的利用率,发挥多核 CPU 的优势,可以在监听事件的同时进行后台数据处理等。
并发编程需要处理的问题---互斥与同步
假设有两个同时运行的程序,他们都用着各自的资源,且互相不需要通信,那这两个线程就像运行在两台电脑上一样,永远无关。但是我们所说的并发编程通常不是这样的,它们在同一台电脑上运行,且不可避免的需要共享一些资源(如内存区,系统或文件等),其中有一些资源同时只能供一个线程使用 (比如打印机),那么当我们编写程序时,就需要注意这个问题,不能让两个线程同时使用打印机,要不打印出来的就是一串由两种内容穿插而成的乱码了,这就引出了一个需要解决的问题——互斥。
比如这是一段要调用打印机的代码
void Printer(String content) {
//调用打印机 打印content
......
}
我们要做到的,就是保证这段代码同时只有一个线程在执行,这种代码块可以称之为临界区。
但是,这还是不够的,线程之间的关系有时并不是简单的互斥,而是需要交换信息,比如线程 A 生产出了一本书的 1,4,5 章节,而线程 B 生产出了一本书的 2,3,6 章节,它们需要互相通信协作完成整本书的有序打印,那就需要解决比互斥更为复杂的情况了——线程的同步。
同步可以说是一种更为复杂的互斥,而互斥是一种特殊的同步。
保证程序同步面临的难题---缓存与重排序
首先,假设计算机没有缓存,也没有对指令进行重排序(你现在不需要知道啥是缓存和重排序,反正现在假设它没有),我们先在这种理想状态下来解决一个同步问题:
- 先让一个线程打印第一页,再让另一个线程打印第二页
/*
* 这段代码表示先打印完第一页,再打印第二页
* 打印完第一页,ThreadA 将 page 置为 1,
* ThreadB 检测到如果 page 为 1,打印第二页
*/
class alternatePrinter {
int page = 0;
class ThreadA implements Runnable {
void run() {
Printer("打印第一页"); //1
page = 1; //2
}
}
class ThreadB implements Runnable {
void run() {
while(true) {
if (page == 1) {
Printer("打印第二页");
break;
}
}
}
}
}
如果没有缓存和重排序,这样写完全可以保证只有先打印出第一页才会打印第二页。但是,计算机和编译器在编译时和运行时会打乱程序的指令,以配合计算机底层的一些结构使程序运行的更快。那你可能惊呆了,我的程序要不是按我写的顺序执行,那岂不是全乱套了吗。但其实它是在保证了单线程下不改变任何运行结果的情况下进行的重排序,对有数据依赖的指令是不会乱动的(参考 as-if-serial 语义),但是对于单线程的执行不改变运行结果,并不意味着对多线程的执行不改变运行结果,想一下,上图程序中的 1 和 2(看注释),如果改变了执行顺序(2 在 1 前执行,这样不会改变单线程中的运行结果),就会出现先打印出第二页,再打印第一页的错误情况。
不仅是重排序,缓存的存在也会让我们的多线程程序执行出让人意外的结果,比如下面这段代码:
public class TestMain {
int a = 0;
class ThreadA implements Runnable {
public void run() {
for (int i = 0; i < 3; i++)
a = a + 1;
}
}
class ThreadB implements Runnable {
public void run() {
for (int i = 0; i < 3; i++)
a = a + 1;
}
}
}
这段代码当线程 A 和 B 都执行完后,a 的值可能是 3 到 6 中的任意值,因为在 java 内存模型中,每个线程都有它自己的本地内存(缓存),和一个主内存,就像计组原理中的主内存和每个 cpu 各自的一级缓存,二级缓存关系是一样的。
这样,在多线程的情况下,读入和写入的可能都是自己的本地内存中变量的副本,线程A 对 a 值的操作可能对线程B 是不可见的,如果在某一时刻,对于变量 a,本地内存A 的副本为 0,本地内存B 的副本也为 0,这两个线程分别对 a 进行加 1 操作,然后又把它们同步到主内存中,那么主内存中的 a 的值就变成了 1,而我们想要的是 2。
解决缓存与重排序问题---happens-before原则及它的底层实现
这篇文章对 JMM happens-before原则 内存屏障的解释都不够完整,文章旨在捋清它们之间的关系,如果想进一步学习,博主推荐《java并发编程的艺术》
计算机对程序的优化(重排序与缓存)使我们对多线程同步的控制变的无法进行,但是如果我们完全摒弃它们来实现对多线程程序的控制,就有点 得之桑榆,失之东隅 的感觉了,好在编程语言的设计者想到了一个折衷的办法,发明了 JMM(java内存模型),这套规范规定了 java 中一系列与同步相关的关键字(synchronize,volatile,lock相关)与它们所遵循的原则(happens-before原则)和这些原则的具体实现方法(插入内存屏障),下面就展开说一下这些内容,先来一张图:
然后解释一下 happens-before 原则的定义:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。(JMM 对程序员的的承诺)
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与 happens-before 关系来执行的结果一致,那么这种重排序并不非法(JMM 对编译器和处理器重排序的约束原则)
再举一个例子:
- 关键字:volatile
- 它所遵循的 happens-before 原则:对一个 voaltile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 底层如何实现这个原则:
在每个 volatile 写操作的前面插入一个 StoreStore 屏障
在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
在每个 volatile 读操作的后面插入一个 LoadLoad 屏障