6.2并发安全前世今生
目录介绍
- 01.并发编程核心问题
- 1.1 核心的理论
- 1.2 理解分工
- 1.3 理解同步
- 1.4 理解互斥
- 02.并发编程bug源头
- 2.1 并发编程难
- 2.2 并发幕后故事
- 2.3 缓存导致可见性问题
- 2.4 线程切换导致原子性问题
- 2.5 编译优化导致有序性问题
- 2.6 并发编程三要素
- 03.并发bug解决方案
- 3.1 并发编程切入点
- 3.2 解决可见性问题
- 3.3 解决原子性问题
- 3.4 解决有序性问题
- 3.5 理解Happens-Before
- 04.并发方案如何选择
- 4.1 以场景纬度选择
- 4.2 以用途纬度选择
01.并发编程核心问题
1.1 核心的理论
- 并发编程可以总结为三个核心问题:分工、同步、互斥。
- 所谓分工指的是如何高效地拆解任务并分配给线程,而同步指的是线程之间如何协作,互斥则是保证同一时刻只允许一个线程访问共享资源。
- Java SDK 并发包很大部分内容都是按照这三个维度组织,例如 Fork/Join 框架就是一种分工模式,CountDownLatch 就是一种典型的同步方式,而可重入锁则是一种互斥手段。
- 这三个核心问题是跨语言的,如果要学习其他语言的并发编程类库,完全可以顺着这三个问题按图索骥。
- Java SDK 并发包其余的一部分则是并发容器和原子类,这些比较容易理解,属于辅助工具,其他语言里基本都能找到对应的。
- 并发编程是否真的难学,难是肯定的。
- 这其中涉及操作系统、CPU、内存等等多方面的知识,如果你缺少某一块,那理解起来自然困难。
- 并发编程并不是 Java 特有的语言特性,它是一个通用且早已成熟的领域。当你理解或学习并发编程的时候,如果能够站在较高层面,系统且有体系地思考问题,那就会容易很多。
- 并发编程全景图
image
1.2 理解分工
- 通过一个例子理解分工
- 所谓分工,类似于现实中一个组织完成一个项目,项目经理要拆分任务,安排合适的成员去完成。
- 并发编程里面的分工
- Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是一种分工方法。
- 并发编程领域还总结了一些设计模式,基本上都是和分工方法相关的,例如生产者 - 消费者、Thread-Per-Message、Worker Thread 模式等都是用来指导你如何分工的。
1.3 理解同步
- 如何理解同步
- 在并发编程领域里的同步,主要指的就是线程间的协作,本质上和现实生活中的协作没区别,不过是一个线程执行完了一个任务,如何通知执行后续任务的线程开工而已。
- 并发编程里面的同步
- Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是分工方法,但同时也能解决线程协作的问题。
- 例如,用 Future 可以发起一个异步调用,当主线程通过 get() 方法取结果时,主线程就会等待,当异步执行的结果返回时,get() 方法就自动返回了。主线程和异步线程之间的协作,Future 工具类已经帮我们解决了。
- 除此之外,Java SDK 里提供的 CountDownLatch、CyclicBarrier、Phaser、Exchanger 也都是用来解决线程协作问题的。
1.4 理解互斥
- 如何理解互斥
- 分工、同步主要强调的是性能,但并发程序里还有一部分是关于正确性的,用专业术语叫“线程安全”。
- 并发程序里,当多个线程同时访问同一个共享变量的时候,结果是不确定的。不确定,则意味着可能正确,也可能错误,事先是不知道的。
- 导致不确定的主要源头是可见性问题、有序性问题和原子性问题,为了解决这三个问题,Java 语言引入了内存模型,内存模型提供了一系列的规则,利用这些规则,可以避免可见性问题、有序性问题,但是还不足以完全解决线程安全问题。
- 解决线程安全问题的核心方案还是互斥。所谓互斥,指的是同一时刻,只允许一个线程访问共享变量。
- 互斥的核心技术
- 实现互斥的核心技术就是锁,Java 语言里 synchronized、SDK 里的各种 Lock 都能解决互斥问题。
- 锁解决了安全性问题,但同时也带来了性能问题,那如何保证安全性的同时又尽量提高性能呢?
- 可以分场景优化,Java SDK 里提供的 ReadWriteLock、StampedLock 就可以优化读多写少场景下锁的性能。
- 还可以使用无锁的数据结构,例如 Java SDK 里提供的原子类都是基于无锁技术实现的。
- 除此之外,还有一些其他的方案,原理是不共享变量或者变量只允许读。这方面,Java 提供了 Thread Local 和 final 关键字,还有一种 Copy-on-write 的模式。
02.并发编程bug源头
2.1 并发编程难
- 编写正确的并发程序是一件极困难的事情
- 并发程序的 Bug 往往会诡异地出现,然后又诡异地消失,很难重现,也很难追踪。
- 要快速而又精准地解决“并发”类的疑难杂症,你就要理解这件事情的本质,追本溯源,深入分析这些 Bug 的源头在哪里。
2.2 并发幕后故事
- 并发程序幕后的故事,来了解一下
- CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。
- CPU 和内存的速度差异可以形象地描述为:CPU 是天上一天,内存是地上一年(假设 CPU 执行一条普通指令需要一天,那么 CPU 读写内存得等待一年的时间)。
- 内存和 I/O 设备的速度差异就更大了,内存是天上一天,I/O 设备是地上十年。
- 程序访问内存/IO性能说明
- 程序里大部分语句都要访问内存,有些还要访问 I/O,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板)。
- 程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。
- 为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
- CPU 增加了缓存,以均衡与内存的速度差异;
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
2.3 缓存导致可见性问题
- 什么叫做可见性
- 一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
- 单核CPU数据一定是可见的
- 在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。
- 因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。
- 线程 A 和线程 B 都是操作同一个 CPU 里面的缓存,所以线程 A 更新了变量 V 的值,那么线程 B 之后再访问变量 V,得到的一定是 V 的最新值(线程 A 写过的值)。
image
- 多核CPU数据不一定是可见的
- 多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。
- 线程 A 操作的是 CPU-1 上的缓存,线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。
image
- 这里罗列一个案例来分析
- 待完善
2.4 线程切换导致原子性问题
- 先说一下背景
- 由于 IO 太慢,早期的操作系统就发明了多进程,即便在单核的 CPU 上我们也可以一边听着歌,一边写 Bug,这个就是多进程的功劳。
- 操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。
- 如何理解线程切换调度
- 早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。
- 现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。
- Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异 Bug 的源头之一。
- 高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。
- 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
- 指令 2:之后,在寄存器中执行 +1 操作;
- 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
- 假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。
image
- 如何通过案例理解原子性
- 潜意识里面觉得 count+=1 这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在 count+=1 之前,也可以发生在 count+=1 之后,但就是不会发生在中间。
- 我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。
2.5 编译优化导致有序性问题
- 如何理解有序性
- 就是有序性。顾名思义,有序性指的是程序按照代码的先后顺序执行。
- 编译优化导致有序性问题
- 编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。
- 不过有时候编译器及解释器的优化可能导致意想不到的 Bug。
2.6 并发编程三要素
- 多线程并发bug的源头说明
- 缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题,其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序性能。
- 但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。
- 线程安全需要保证几个基本特性
- 1、原子性,简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
- 2、可见性,是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,volatile 就是负责保证可见性的。
- 3、有序性,是保证线程内串行语义,避免指令重排等。
03.并发bug解决方案
3.1 并发编程切入点
- 什么是线程安全
- 线程安全就是当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。
- 通俗来讲,如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。
- 或者说:一个类或者程序所提供的接口对于线程来说是原子操作或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用考虑同步的问题 。
- 并发切入点是什么?
- 并发分析的切入点分为两个核心,三大性质。两大核心:JMM内存模型(主内存和工作内存)以及happens-before;三条性质:原子性,可见性,有序性。
3.2 解决可见性问题
3.2.1 彻底理解可见性
- 再次思考下可见性的理解
- 一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
- 可见性问题到底如何解决呢
- 每个线程都有自己的缓存区域,且各个线程缓存是相互隔离。当多个线程去修改变量,把变量缓存到自身线程的缓存空间,可能会让其他线程看不见,这样就导致了不可见。
3.2.2 常见场景和解决措施
- 那些场景会出现并发可见性问题
- 单例模式中的懒汉式获取对象场景
- 多个线程修改一个成员变量场景
- 解决可见性的措施有哪些
- 第一种:使用volatile保护成员变量,在多线程场景下,数据安全。核心原理就是:禁止指令重排和禁用CPU缓存
- 举一个例子说明问题
- 第一种案例:声明一个 volatile 变量 volatile int x = 0,它表达的是:告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入。
3.3 解决原子性问题
3.3.1 彻底理解可见性
- 再次思考一下原子性的理解
- 原子性问题源头是线程切换,一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为“原子性”。理解这个特性有助于你分析并发编程 Bug 出现的原因~
- 原子性问题到底该如何解决呢
- 简单来说:禁止线程切换和同一个时刻只有一个线程执行,可以解决原子性问题。
- “同一时刻只有一个线程执行”这个条件非常重要,称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么就能保证原子性。
3.3.2 常见场景和解决措施
- 那些场景会出现并发原子性问题
- 待完善
- 解决原子性的措施有哪些
- 第一种:简易锁Lock模型,这种是API级别的锁,一版配套使用 lock() 加锁,使用 unlock() 释放锁。
- 第二种:synchronized关键字。Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),且一定是成对出现。
- 这些锁有何共同特点
- 具有互斥性
3.3.3 简易锁模型
- 当谈到互斥,终极解决方案:锁。同时大脑中还会出现以下模型:
image
- 把一段需要互斥执行的代码称为临界区。
- 线程在进入临界区之前,首先尝试加锁 lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;否则呢就等待,直到持有锁的线程解锁;
- 持有锁的线程执行完临界区的代码后,执行解锁 unlock()。
- 思考一下这个问题
- 我们锁的是什么?我们保护的又是什么?
3.3.4 改进后锁模型
- 思考改进后的锁模型场景
- 锁和锁要保护的资源是有对应关系的,比如你用你家的锁保护你家的东西,我用我家的锁保护我家的东西。
- 在并发编程世界里,锁和资源也应该有这个关系,但这个关系在我们上面的模型中是没有体现的,所以我们需要完善一下我们的模型。
- 改进后的模型图
image
- 把临界区要保护的资源标注出来
- 如图中临界区里增加了一个元素:受保护的资源 R;其次,我们要保护资源 R 就得为它创建一把锁 LR;最后,针对这把锁 LR,我们还需在进出临界区时添上加锁操作和解锁操作。
- 在锁 LR 和受保护资源之间,我特地用一条线做了关联,这个关联关系非常重要。
- 很多并发 Bug 的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,这样的 Bug 非常不好诊断,因为潜意识里我们认为已经正确加锁了。
3.3.5 锁和受保护资源关系
- 受保护资源和锁之间的关联关系非常重要,他们的关系是怎样的呢?
- 一个合理的关系是:受保护资源和锁之间的关联关系是 N:1 的关系。
- 现实世界里,可以用多把锁来保护同一个资源,但在并发领域是不行的,并发领域的锁和现实世界的锁不是完全匹配的。
- 不过倒是可以用同一把锁来保护多个资源,这个对应到现实世界就是我们所谓的“包场”。
- 来看一个具体的案例分析一下
class SafeCalc { static long value = 0L; synchronized long get() { return value; } synchronized static void addOne() { value += 1; } }
- 用两个锁保护一个资源。这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class。
- 由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系。
- 临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。
image
3.4 解决有序性问题
3.5 理解Happens-Before
- 如何理解 Happens-Before 呢?
- Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。
- Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。