并发编程的源头
1.1. 并发编程的全景图:三个核心问题
1. 分工 —— 提高并发性能的关键
- 含义:合理分配任务给多个线程,就像项目经理分配工作。
- 目标:提升程序执行效率。
- 实现工具和模式:
-
- Java SDK 并发包中的工具:
-
-
Executor
Fork/Join
Future
-
-
- 并发设计模式:
-
-
- 生产者-消费者模式
- Thread-Per-Message 模式
- Worker Thread 模式
-
- 学习建议:
-
- 类比现实场景,例如“厨师做菜 - 服务员上菜”说明生产者-消费者模型。
2. 同步 —— 实现线程间协作
- 含义:一个线程完成任务后,通知其他线程继续。
- 目标:线程之间有序协作,避免混乱。
- 常见技术:
-
- 异步调用与
Future
的配合(通过get()
实现等待与通知) - 协作工具类:
- 异步调用与
-
-
CountDownLatch
CyclicBarrier
Phaser
Exchanger
-
- 底层机制:
-
- 管程(Monitor):线程协作的理论基础。
- 常见协作场景举例:
-
- 生产者 - 消费者模型中的“等待”和“唤醒”。
3. 互斥 —— 保证线程安全
- 含义:多个线程访问共享资源时保证操作的正确性。
- 三大线程安全问题:
-
- 可见性
- 有序性
- 原子性
- 解决方案:
-
- Java 内存模型(JMM):解决可见性、有序性问题。
- 互斥(锁):解决原子性问题。
-
-
- 常见锁工具:
-
-
-
-
synchronized
ReentrantLock
ReadWriteLock
StampedLock
-
-
-
- 无锁方案:
-
-
- 原子类(如
AtomicInteger
等) - Copy-On-Write(写时复制)
ThreadLocal
、final
变量等。
- 原子类(如
-
- 注意问题:
-
- 性能开销
- 死锁风险
- 理论基础需补充:
-
- CPU 缓存一致性
- 操作系统原语
- 原子操作底层原理(如 CAS)
1.2. JVM( Java Virtual Machine )
JVM是一个虚构出来的计算机,一种规范。通过在实际的计算机上仿真模拟各类计算机功能实现
JVM 其实就类似于一台小电脑运行在 windows 或者 linux 这些操作系统环境下即可。它直接和操作系统进行交互,与硬件不直接交互,而操作系统可以帮我们完成和硬件进行交互的工作。
运行过程:
- Java 文件经过编译后变成 .class 字节码文件字
- 节码文件通过类加载器被搬运到 JVM 虚拟机中
- 虚拟机主要的 5 大块:方法区,堆都为线程共享区域,有线程安全问题,栈和本地方法栈和计数器都是独享区域,不存在线程安全问题,而 JVM 的调优主要就是围绕堆,栈两大块进行
JVM 执行机制(以方法调用为例)
假设你调用一个 Java 方法,JVM 的运行机制如下:
- 类加载器 找到对应
.class
文件并加载进 JVM。 - 方法区 存储类的结构(字段、方法、常量池等)。
- 堆 创建对象实例。
- 栈帧(Stack Frame) 被压入线程的 Java 栈,记录该方法执行过程。
- 程序计数器 保存当前线程所执行的字节码指令地址。
- 执行引擎 解析字节码或使用 JIT 编译后直接执行本地代码。
- 如需调用 C/C++ 方法,则通过 JNI 接口进入本地方法栈。
- 方法执行完后,栈帧被销毁,返回结果。
- 如果对象生命周期结束,GC 检测并清理其内存。
2. 可见性、原子性和有序性问题:并发编程Bug的源头
并发问题三要素:
特性 | 问题来源 | 表现 |
可见性 | CPU 缓存 | 值更新对其他线程不可见 |
原子性 | 线程切换 | 操作中断导致数据错误 |
有序性 | 编译优化 | 执行顺序颠倒引发异常 |
写并发程序的建议:
- 理解底层机制:并发问题源于系统性能优化,了解 CPU、内存、编译器行为至关重要;
- 掌握解决方案:如使用
volatile
、synchronized
、并发包等; - 调试有方法:抓住“可见性 / 原子性 / 有序性”三个点去分析并发 Bug。
2.1. 并发Bug背后的根本原因
并发问题的本质:硬件与软件设计之间的不一致性和优化带来的副作用。
三大差异导致的核心矛盾:
组件 | 相对速度 |
CPU | 快如“天上一天” |
内存 | 慢如“地上一年” |
I/O | 更慢如“地上十年” |
为缓解三者矛盾,系统层面做了三件事:
- CPU → 加缓存
- OS → 引入进程/线程分时复用
- 编译器 → 指令重排序优化
它们提升了性能,但同时也带来了并发 Bug 的根源
2.2. 并发Bug的三大源头
1. 缓存导致的可见性问题
单核 vs 多核缓存模型:
- 单核 CPU:线程共享缓存,写入立刻对其他线程可见。
- 多核 CPU:每个核有自己的缓存,写入后不会自动同步到其他缓存,产生“脏读”现象。
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
可见性关键词:
- 缓存副本
- 不一致性
- 写后不立即同步
volatile
可用于解决部分可见性问题
2. 线程切换带来的原子性问题
操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。
在一个时间片内,如果一个进程进行一个 IO 操作,例如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让 CPU 的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。
这里的进程在等待 IO 时之所以会释放 CPU 使用权,是为了让 CPU 在这段等待时间里可以做别的事情,这样一来 CPU 的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样 IO 的使用率也上来了。
早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。
count += 1
背后的 CPU 操作:
- 从内存读取 count 到寄存器;
- 在寄存器中加一;
- 写回内存或CPU cache。
如果在线程 A 执行完步骤 1 后被切换,线程 B 也执行这三步,会导致 最终结果丢失更新。
我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。
CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。
原子性关键词:
- 线程上下文切换
- 指令级非原子
- Java 提供
synchronized
/AtomicLong
等保证原子性
3. 编译优化带来的有序性问题
编译器重排序:
- 出于性能考虑,编译器会调整代码顺序(只要最终结果不变)
- 但在并发场景下,顺序错乱可能导致 Bug
经典案例:双重检查锁单例
public class Singleton {static Singleton instance;static Singleton getInstance(){if (instance == null) {synchronized(Singleton.class) {if (instance == null)instance = new Singleton();}}return instance;}
}
问题出在 new
操作执行顺序的优化:
- 正确顺序:
- 分配内存;
- 初始化对象;
- 将地址赋值给
instance
;
- 实际优化后可能:
- 分配内存;
- 将地址赋值给
instance
; - 初始化对象;
- 如果线程 B 在步骤 2 后读取 instance,它会以为对象已初始化,从而导致 空指针异常。
有序性关键词:
- 指令重排
volatile
可部分禁止重排序- Java 内存模型(JMM)