Java 并发

qqiangwu
qqiangwu
Feb 25, 2017 · 8 min read

这是一个总结性质的文章, 并且主要以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在集合点之前操作的结果, 对集合点之后所有的操作可见
  1. 对内置锁的解锁操作happen-before对同一个内置锁的加锁操作
  2. 对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, 读写是原子的
  • 需要更加复杂的原子性, 请使用同步!

可见性: 对象的可见性可以分成两个方面, 一是初始发布, 二是安全发布后的对象的更新的可见性

  • 不安全发布:
  1. 如果一个对象x, 不安全发布的话, 另外一个线程可能只能看到x的引用更新了, 但其fields可能还是旧的, 也可能部分fields新, 部分fields旧, 因为你没有恰当的同步, 从而无法保证可见性
  2. this逸出: 如果在构建器中, this逸出了, 则其他线程可能会访问到未完全构建的对象
  • 安全发布
  1. 在static构建器中发布对象
  2. 使用volatile及AtomicReference发布的对象
  3. 保存到正确构建对象的final域中的对象
  4. 保存到由lock保护的域中的对象
  • 不可变对象的发布
  1. 不可变对象: 所有域为final/创建后不能修改/this未逸出
  2. 不需要同步, 其他线程直接可以看到
  • 事实不可变对象的发布
  1. 事实不可变对象: 不要求所有域为final
  2. 安全发布后, 访问不需要同步

保证安全性-不在多线程间共享变量

这个思想很简单, 但是是解决问题的最好方法. 它还可以细分为三个维度, 我将从最简单到最复杂的顺序来介绍它们

  • stack变量: 将所有的变量在线程栈上分配, 栈是局部于线程的, 因此, 不会有其他线程来访问它
  • thread-local变量: 比起stack变量, thread-local变量稍微复杂了一些, 但是它可以避免线程间共享数据. 同时, 它又是一种不太好的设计, 因为它在某种意义上是一个”全局”变量. 考虑这样一个例子, 我们有一个线程安全的StringFormat对象, 在format字符串时, 它依赖一个内部Buffer, 显然, 在调用format方法时, Buffer不是一个stack变量, 它会在多线程间共享, 为了保证线程安全性, 我们需要同步所有调用, 即加锁. 然而同步是需要开销的, 我们希望避免它, 因此, 我们可以将Buffer声明为ThreadLocal, 如此, 每个线程会有自己的Buffer, 而互不影响.
  • 人工保证不在多线程间共享变量: 这是一种最为反人类的实现方式, 就是人工保证某个变量只会在特定线程中被访问. 典型实现是GUI系统, 如QT, 所有控件都只会在主线程中被访问

保证安全性-使用不可变对象

Java中的不可变对象定义比较简单, 它保证在多线程下访问是安全的, 发布时不需要同步, 之后访问也不需要同步.

多线程中的数据结构

数据结构是程序的核心之一, 构建线程安全的数据结构, 使得我们可以对抽象编程, 从而避免很多线程安全性的问题.

如何设计线程安全的数据结构是一个相对比较复杂的问题, 在这里我不准备细说, 大致有两点:

  • 如何通过已有的线程安全对象构建新的线程安全对象:委托/加锁
  • 如何组合非线程安全的对象构建新的线程安全的对象:实例封闭/加锁

多线程中的算法

在旧的编程思想下, 大家解决一个问题会使用串行的思想来解决它, 即, 先做什么, 后做什么. 这种思想更好, 能解决很多问题. 然而, 在多线程环境下,

TODO

DS Adventure

I wrote both code and poetry

qqiangwu

Written by

qqiangwu

https://github.com/qqiangwu

DS Adventure

I wrote both code and poetry

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade