前言
以前在学习 C++ 关键字 volatile 的时候,看过阿里数据库大牛何登成关于 volatile 的文章《C/C++ volatile关键词深度剖析》,看的云里雾里。主要是当时没理解什么是可见性、原子性和有序性;没有理解什么是内存模型及一些规范。相信很多初学者和我一样,在学习 Java 并发编程书籍的时候都直奔 volatile 、synchronized、wait/notify 的主题去了,其实这是不对的。比如在看《Java 高并发程序设计》这本书的时候请一定不要跳过第 1/2 章。最近看了极客时间上并发编程专栏的文章后,有种醍醐灌顶的感觉,一下让我想通了很多在学习 C++ 时不理解的知识点。加上整理,将自己理解的一些知识点做个串联并记录下来,为并发编程做准备。文章可能有点长,但内容比较简单,请耐心看完。
为什么需要并发编程
在 CPU 性能低、内存小、硬盘贵的年代,别说多线程就是单线程能正常跑完都谢天谢地了,所以那时候大部分程序都是串行的。然而随着硬件的发展,CPU 的性能越来越高、核数越来越多、内存越来越大、磁盘也越来越便宜。为了在程序中充分利用计算机硬件,特别是那些宝贵的资源(如 CPU ),单线程/单进程已经不能满足要求了,便开始渐渐出现了并发程序。并发程序一方面可以充分利用计算机资源,另一方面可以更快的响应用户,两全其美,何乐而不为。但新的世界大门打开,它有光,也必有黑暗。学习并发编程,我们可以在新的世界里遨游;理解并发编程,我们可以避开新世界里的黑暗。
并发中的多线程与多进程
在讨论并发编程的时候我们更多的是讨论同一份代码的程序并发,而不是不同代码程序之间的并发。前者是程序员需要解决的问题,后者是操作系统需要解决的问题。并发编程常用的有几种方式:多进程和多线程以及他们的组合。在用 C++ 编程的时候还会考虑到多进程,而在 Java 编程的时候完全只考虑多线程这种方式了。这样的选择也是有道理的,毕竟操作系统对线程的切换比进程的切换消耗的资源少、速度快。进程与线程的区别每个被面试过的程序员都应该滚瓜烂熟了,这里不在赘述,本段要强调的是后续的讨论都是基于单进程的多线程并发编程。
程序能进行并发的前提
IO 密集型和 CPU 密集型这两个名词相信大家都不陌生,前者是指程序执行过程中 IO操作(磁盘读写、网络读写)会多一些,比如 mysql 进行数据读写;后者是指执行过程中 CPU 使用会多一些,比如 matlab 做矩阵乘法。可以说,所有程序在执行过程中不是在使用 CPU 就是在进行 IO 操作,当然还有休眠的时候。正是因为此,才有了并发的可能性。想象一下,如果某台机器上的所有程序都只使用 CPU 或者只进行 IO,如何并发?比如 while(true){} 这样的程序!
我们知道 CPU 是计算机的大脑,理论上在进行任何操作的时候都需要 CPU ,那为啥 CPU 密集型和 IO 密集型能分开讨论呢?这不得不提一下 DMA(Direct Memory Access)技术,它让 IO 操作只在开始和结束的时候需要使用下 CPU ,其他时间 CPU 可以干其他事情。它让 CPU 和 IO 并行工作成了可能。所以程序能进行并发的前提有如下两点:
- CPU 执行指令与 IO 操作可以并行
- 绝大多数程序既要使用 CPU 又要进行 IO
并发编程的难点
并发编程难主要有以下几点原因:
- 理论多:不像语言中的其他语法部分,并发编程不仅要了解语言所涉及到的关键知识,还涉及到底层的操作系统。在学习很多计算机语言的时候,光并发编程部分就能写一本厚厚的书籍了,所以说理论多。
- 接触少:再过几个月,笔者就工作三年了,学习编程也有十年了。在这么多的时间里,笔者也就在学习的过程中写过几个并发编程的例子,工作中都没实际运用过。并发编程大部分情况下并不常有,特别是高并发,一般只有那些中间件系统会比较常见。
- 易出错:由于并发编程中的可见性、原子性和有序性问题导致并发程序常出现一些诡异的 BUG。这些 BUG 往往复现难、定位难。索性很多程序放弃了使用并发,就我在腾讯在职期间维护的比较大的系统都是单进程单线程的。性能不够,机器来凑。
核心知识点
都说打蛇打七寸,学习并发编程也要把握住其关键技术。但学习并发编程关键点的前提是弄懂这些关键技术都是为了解决什么问题以及怎么解决的,这样才能学的更快、记得更牢。多线程并发编程关键技术点都是围绕可见性、原子性和有序性建立的。掌握了可见性、原子性和有序性的定义和触发问题的场景,在定位并发 BUG 时便有迹可循,在学习并发编程时也游刃有余。
1、什么是可见性
可见性(Visibility):一个线程对共享变量的修改,另外一个线程能立刻看到,我们称之为可见性。对串行程序来说可见性问题是不存在,但并发程序就不一定了,其中一种可能如下:
CPU 执行指令的速度是内存读写速度的数十倍甚至上百倍,如果 CPU 每执行一条读写指令都去内存中读写数据的话,那 CPU 的性能就被大大浪费了。学过操作系统的都知道,为了解决这个问题硬件工程师在 CPU 和内存之间加入了高速缓存,CPU 执行读写指令时将数据读入缓存,并将执行结果存入缓存,当运算结束后再在某个时候将结果同步到内存。这样做好处多多,但会引入新的问题:缓存一致性。
如上图所示,如果线程 1 更改了变量并缓存,线程 2 并不能马上看到结果,这就产生了可见性问题。可见性问题是一个综合问题,CPU 缓存只是导致可见性的一种可能之一,如编译器优化、指令重排都有可能造成可见性问题。
2、什么是原子性
原子性(Atomicity):一个或者多个操作在 CPU 执行的过程中不被中断的特性,我们称之为原子性。原子这个词程序员并不陌生,因为数据库中也有原子的概念。从化学角度来看,原子是不可再被分割的基本微粒。回到计算机的世界,很多不了解底层程序员以为高级语言的每条语句都是一个原子操作,其实不然,比如简单的赋值操作在 C++ 中以上代码至少需要三条 CPU 指令。
- 指令1:首先,需要把变量 i 从内存加载到 CPU 的寄存器。
- 指令2:之后,在寄存器中执行 i=1 操作;
- 指令3:最后,将结果写入内存。
如果是单线程串行即使一个语句分成多条指令执行,也不会存在原子性问题。而在并发程序就不一定了。Java 的 long 类型是 8 字节的,在 32 系统上,该类型变量的读写就需要两次操作内存(即两条虚拟机指令),而 Java 虚拟机规范又允许两次虚拟机指令操作是非原子的。这样机会出现以下场景:
- 线程 1 刚读取 i 的前 4 字节,准备读取后 4 字节;
- 线程 2 对 i 后 4 字节进行了修改;
- 线程 1 读取 i 后 4 字节并将值展示。
这样 i 读出来是一个拼接后错误值,出现原子性问题。具体见《深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)》12.3.4-对于long和double型变量的特殊规则这一章节。
3、什么是有序性
有序性(Ordering):即程序执行的顺序性。我们总是以为代码是从前往后依次执行的,在单线程情况下确实是这样。但在并发程序中可能就会出现乱序,从而导致有序性问题。一句话总结为:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。举个栗子,如下代码:
<pre style="-webkit-tap-highlight-color: transparent; box-sizing: border-box; font-family: Consolas, Menlo, Courier, monospace; font-size: 16px; white-space: pre-wrap; position: relative; line-height: 1.5; color: rgb(153, 153, 153); margin: 1em 0px; padding: 12px 10px; background: rgb(244, 245, 246); border: 1px solid rgb(232, 232, 232); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">class OrderingExample {
int x = 0;
boolean flag = false;
public void writer() {
x = 42; //宇宙的终极答案
flag = true;
}
public void reader() {
if (flag == true) {
//x = ?
}
}
}
</pre>
在单线程中 x 肯定等于 42,而在并发程序中,x 不一定等于 42,也有可能是 0。由于编译器优化、指令重排等可能导致以上代码在被执行时是这样:
<pre style="-webkit-tap-highlight-color: transparent; box-sizing: border-box; font-family: Consolas, Menlo, Courier, monospace; font-size: 16px; white-space: pre-wrap; position: relative; line-height: 1.5; color: rgb(153, 153, 153); margin: 1em 0px; padding: 12px 10px; background: rgb(244, 245, 246); border: 1px solid rgb(232, 232, 232); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">class OrderingExample {
int x = 0;
boolean flag = false;
public void writer() {
flag = true;
x = 42; //宇宙的终极答案
}
public void reader() {
if (flag == true) {
x = ?
}
}
}
</pre>
当线程 1 刚执行到 writer 中的 flag = true ,准备接着执行 x = 42 时,cpu 切换到线程 2 执行reader 中的 if(flag==true),并进入 x = ?代码,此时 x = 0,出现有序性问题。
内存模型
由于可见性、原子性和有序性导致并发程序在读写内存中共享变量存在种种问题,那该如何解决呢?这就是内存模型需要做的事:内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。它解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的可见性、原子性和有序性。
内存模型只是一种模型也即一种规范,每种语言有着自己的具体的实现细节,Java 有 Java 的内存模型 JMM,C++ 有着 C++ 的内存模型(依赖操作系统的内存模型)。这里需要注意的是内存模型与对象模型的区别,在学习 C++ 的时候有本比较出名的书《深入理解 C++ 对象模型》它讲的是 C++ 对象在内存中如何布局的,跟 C++ 内存模型完全是两码事,Java 中同理。同时还要区分内存模型与内存结构的区别,很多人容易混淆这两个概念,不信百度下内存模型,很多讲的都是堆栈之类的。而堆栈、静态区对应的应该是内存结构。
操作系统与 JVM
本来不想加这段的,因为前传只想写一些与语言无关的知识点。但为了提醒下 Java 程序员,加上了这段。我们都知道 C++ 程序是不可移植的,主要因为它的编译与操作系统息息相关。而 Java 不一样,它能做到真正的 write once,run everywhere,主要是因为它有 Java 虚拟机(Java Vitual Machine,JVM)。JVM 在操作系统上做了一层抽象,屏蔽了操作系统层面的细节。对于 Java 程序来说,JVM 就是它的操作系统,所以操作系统中的很多概念都直接搬到了 JVM 中,比如进程/线程、IO 操作等,大多时候很多书籍都不对其进行区分,因为这些 api 大部分情况下都是 JVM 调用 native 方法实现的。但有些概念却有所不同,比如虚拟机指令、虚拟机程序计数器、主内存与工作内存、JMM 等,可能因为这些实现与操作系统不大一样。这里做个对照,在 Java 虚拟机中:虚拟机指令对应 CPU 指令;主内存对应物理内存;工作内存对应 CPU 缓存。
总结
本篇文章讲的并发编程内容与语言无关,更多的是学习并发编程的一些前置概念知识,是我个人的一些理解。希望对你有所帮助,错误的地方还请多多指教。
欢迎工作一到五年的Java程序员朋友们加入Java架构交流:810589193
本群提供免费的学习指导 架构资料 以及免费的解答
不懂得问题都可以在本群提出来 之后还会有职业生涯规划以及面试指导