Java 并发
这是一个总结性质的文章, 并且主要以Java内存模型为基础. 注意, 其中某些说法我使用了自己的理解, 我觉得这样理解更加有助于理想整个逻辑.
为什么需要多线程(多线程的好处)
物理上
- 多核CPU的出现, 为了充分利用硬件, 我们需要更强的并行性
逻辑上
- 串行模型建模简单, 编程简单. 其反面情况是, 为了处理更多的请求, 我们需要使用IO多工/异步IO等方式来编程, 其思维难度较大
- 某些程序本身需要并发的支持, 如字处理软件的自动保存功能, 如果将其实现为同步操作, 其响应性会很差
为什么需要避免多线程(多线程的坏处)
- 安全性: 为了编写及测试在多线程环境下正确工作的代码, 程序员需要花费大量的时间与精力, 比起串行代码, 其思维难度大了许多
- 活跃性: 除了考虑安全性(坏的事情不会发生), 我们还需要考虑活跃性(好的事情一定会发生). 比起串行代码, 我们还需要考虑死锁/饥饿等
- 性能: 多线程不是免费的! 并不是用了多线程我们程序的性能就会高, 就会随着CPU数量的增加而免费增加. 线程的调度与同步都需要很大的开销
如何进行多线程编程
在了解了多线程的好处与坏处之后, 我们发现, 很多情况下, 多线程是无法避免的, 因此, 我们需要一个系统的方法来指导我们进行多线程编程, 使得我们可以充分发挥多线程的优势, 同时又可以避免它的种种问题.
显然, 无论什么情况下, 把事情做对最重要, 然后才是做快, 做好. 于是, 我们首先来处理, 如何保证你的多线程程序是安全的! 在了解了如何写出安全的多线程程序后, 下面的问题很自然的就是, 如何处理数据与算法, 毕竟, 这是我们程序的两端. 我们需要使用线程安全的数据结构, 同时, 我们要能够将我们所要处理的问题分解/建模成一个可以使用并行方式来解决的模型.
保证线程安全性
想要保证线程安全性, 首先需要多线程情况下为什么会不安全. 显然, 这是因为, 多个线程会共享数据, 这就引入了潜在的问题. 这里, 先定义两个名词:
- Race Condition: 当两个及以上个线程同时访问一个对象, 且其中至少一个为写
- Data Race: 未经同步的Race Condition, 会造成Data Race, 出现Data Race的Java代码, 其结果是未定义的
显然, 我们需要避免Data Race.
Java内存模型
在解决这个问题之前, 我们先来介绍Java的内存模型. 什么是内存模型? 简单而言, 就是多个线程访问内存时的读写操作的语义, 即, 线程A/B同时执行一系列操作, 从一个全局角度(或者说从内存角度, 因为内存是一个唯一事实)看来, 其效果的总体顺序是怎么样的(有序性)?
将上面的内容更加细化一下:
- 可见性: 线程A写变量x, 之后线程B去读变量x, 其结果是怎么样的? 答案是, 有可能读到新值, 有可能过一段时间之后读到, 也可能永远读不到!
- 原子性: 线程A/B同时执行一个复合操作(典型的如
x++), 其结果怎样? 比如, 一个经典的例子, 线程A/B同时执行x++100次, 则x最后的值是多少. 由于x++不是一个原子操作, 为了保证结果的正确性, 我们需要线程A/B执行x++时有序执行, 而不是交错执行.
(事实上, 我也不太清楚应该如何以一个清晰的框架来描述有序性/原子性/可见性三者之前的关系, 姑且现在这样理解)
这实际上是一个一致性问题. 理想情况下, 我们希望每个线程都可以读到变量x最新的值, 不论这个值是谁写入的, 同时, 所以线程的操作应该是按照其执行时间全局有序的. 但是, 由于内存比CPU慢100倍, 这种强同步的开销太大! 通常, 每个CPU都会有自己的私有缓存(寄存器/write buffer等), 这些私有缓存内的东西不会立即写入内存, 也就不会被其他CPU所看到. 因此, 我们常常会做一定的妥协. 不同的硬件所做的妥协有不同, 而JVM屏蔽了这些细节, 为程序员提供了一个单一的内存模型. 说明如下:
- 所以线程共享一个主存
- 每个线程有自己的私有缓存, 所以数据会先从主存中读取到私有缓存中, 然后进行操作
上面这两点的直接后果是, 线程A更新了变量x后, 线程B 可能 永远看不到. 线程A/B可能同时更新了自己私有的变量x, 那么主存中的x最后会是什么?
为了保证线程A在其私有缓存更新了x后, B能够看到, 我们需要进行 同步, 简单地可以理解为将线程A的私有变量的值同步到线程B中, 实际上是A先将x写回主存, B再从主存中读取x, 更新自己过时的私有缓存(这是一个可见性问题);为了保证线程AB在执行一个非原子操作时, 两者不会重叠, 即原子性(两个操作A/B, 要么A要B前, 要么B在A前, 不会A执行一半后B在其之前执行), 我们需要 同步.
我们可以有一个词来描述来描述整个内存模型: Happen-before, 即一个有序性模型. 它定义了一个偏序关系(严格有序的开销太大).
- 在同一个线程内, 所有操作是有序的, 即代码序上先出现的操作happend-before后出现的操作
- 在不同线程间, 同步定义了一个集合点, 使得线程A/B在集合点之前操作的结果, 对集合点之后所有的操作可见
- 对内置锁的解锁操作happen-before对同一个内置锁的加锁操作
- 对volatile的写操作happen-before对它的读操作
- happen-before具有传递性
显然, happen-before很好的定义了之前所提到的两个方面: 原子性与可见性:
- 如果操作A happen-before B, 则A发生于B前面, A/B不会重叠
- 如果操作A happen-before B, 则A的结果对B可见
我会在下面的说明中, 介绍如何进行同步.
保证安全性-同步多个线程对某个变量的访问
根据Race Condition的定义, 我们可以通过某种协调机制, 来保证多个线程在访问同一个对象时, 会通过某种顺序来访问, 而不是并行地访问. 这种机制我们称之为同步.
同步解决了两个问题,原子性及可见性. Java提供了多种同步机制: 内置的(volatile及synchronized), 以库形式提供的(原子变量, Lock等).
- volatile: 最基础的同步原语, 它提供了可见性, 即, 对它的写操作是立即可见的. 线程A写了一个volatile后, 线程B可以立即读到它. 但是, 它不提供原子性, 即, 线程A/B如果同时对
volatile int x执行x++操作100次, 则最终的结果仍然不保证是200. - 内置锁: 同时提供了可见性与原子性, 即由同一个变量保护的同步块内的
x++操作, 相互之间立即可见, 并且先后有序. - 原子变量: 细粒度的同步机制, 一种更好的volatile变量, 聊了可见性外, 还提供了原子性的支持
- Lock: 以库形式提供的锁, 提供了比内置锁更加丰富的功能
- 其他: Java新的并发包中还有很多其他的同步机制, 在此不一一说明.
下面, 对原子性与可见性分别加以叙述.
原子性
- 所以基本类型, 读写操作都是原子的(x++实际上等价于x = x + 1, 是一次读, 一次写, 因此不是原子的)
- long与double, 读写不是原子的
- volatile long/double, 读写是原子的
- 需要更加复杂的原子性, 请使用同步!
可见性: 对象的可见性可以分成两个方面, 一是初始发布, 二是安全发布后的对象的更新的可见性
- 不安全发布:
- 如果一个对象x, 不安全发布的话, 另外一个线程可能只能看到x的引用更新了, 但其fields可能还是旧的, 也可能部分fields新, 部分fields旧, 因为你没有恰当的同步, 从而无法保证可见性
- this逸出: 如果在构建器中, this逸出了, 则其他线程可能会访问到未完全构建的对象
- 安全发布
- 在static构建器中发布对象
- 使用volatile及AtomicReference发布的对象
- 保存到正确构建对象的final域中的对象
- 保存到由lock保护的域中的对象
- 不可变对象的发布
- 不可变对象: 所有域为final/创建后不能修改/this未逸出
- 不需要同步, 其他线程直接可以看到
- 事实不可变对象的发布
- 事实不可变对象: 不要求所有域为final
- 安全发布后, 访问不需要同步
保证安全性-不在多线程间共享变量
这个思想很简单, 但是是解决问题的最好方法. 它还可以细分为三个维度, 我将从最简单到最复杂的顺序来介绍它们
- stack变量: 将所有的变量在线程栈上分配, 栈是局部于线程的, 因此, 不会有其他线程来访问它
- thread-local变量: 比起stack变量, thread-local变量稍微复杂了一些, 但是它可以避免线程间共享数据. 同时, 它又是一种不太好的设计, 因为它在某种意义上是一个”全局”变量. 考虑这样一个例子, 我们有一个线程安全的StringFormat对象, 在format字符串时, 它依赖一个内部Buffer, 显然, 在调用format方法时, Buffer不是一个stack变量, 它会在多线程间共享, 为了保证线程安全性, 我们需要同步所有调用, 即加锁. 然而同步是需要开销的, 我们希望避免它, 因此, 我们可以将Buffer声明为ThreadLocal, 如此, 每个线程会有自己的Buffer, 而互不影响.
- 人工保证不在多线程间共享变量: 这是一种最为反人类的实现方式, 就是人工保证某个变量只会在特定线程中被访问. 典型实现是GUI系统, 如QT, 所有控件都只会在主线程中被访问
保证安全性-使用不可变对象
Java中的不可变对象定义比较简单, 它保证在多线程下访问是安全的, 发布时不需要同步, 之后访问也不需要同步.
多线程中的数据结构
数据结构是程序的核心之一, 构建线程安全的数据结构, 使得我们可以对抽象编程, 从而避免很多线程安全性的问题.
如何设计线程安全的数据结构是一个相对比较复杂的问题, 在这里我不准备细说, 大致有两点:
- 如何通过已有的线程安全对象构建新的线程安全对象:委托/加锁
- 如何组合非线程安全的对象构建新的线程安全的对象:实例封闭/加锁
多线程中的算法
在旧的编程思想下, 大家解决一个问题会使用串行的思想来解决它, 即, 先做什么, 后做什么. 这种思想更好, 能解决很多问题. 然而, 在多线程环境下,
TODO

