引子:一个秒杀系统的困局
假设你正在开发一个电商秒杀系统,库存100件商品,瞬间涌入1000个并发请求。看似简单的需求,却隐藏着并发编程最本质的困难。
场景A:单线程实现(简单但性能差)
/**
* 单线程秒杀服务
* 优势:简单、可预测、无并发问题
* 劣势:性能差,无法处理高并发
*/
public class SingleThreadSeckillService {
private int stock = 100; // 库存
public synchronized boolean seckill(String userId) {
// 检查库存
if (stock <= 0) {
System.out.println("库存不足,秒杀失败");
return false;
}
// 模拟业务处理(数据库操作、支付调用等)
try {
Thread.sleep(10); // 10ms的业务处理时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 扣减库存
stock--;
System.out.println("用户 " + userId + " 秒杀成功,剩余库存:" + stock);
return true;
}
}
// 性能测试
public class SingleThreadTest {
public static void main(String[] args) {
SingleThreadSeckillService service = new SingleThreadSeckillService();
long start = System.currentTimeMillis();
// 1000个用户顺序秒杀
for (int i = 0; i < 1000; i++) {
service.seckill("User-" + i);
}
long end = System.currentTimeMillis();
System.out.println("总耗时:" + (end - start) + "ms");
}
}
/*
执行结果:
总耗时:10,000ms(10秒)
分析:每个请求10ms,1000个请求串行执行,总计10秒
问题:在真实秒杀场景中,10秒是不可接受的响应时间
*/
性能瓶颈分析:
单线程处理能力:
├─ 单个请求耗时:10ms
├─ QPS(每秒请求数):1000ms / 10ms = 100 QPS
├─ 1000个请求耗时:1000 × 10ms = 10秒
└─ 结论:无法满足秒杀场景的高并发需求(需要>10000 QPS)
场景B:多线程实现(性能好但问题多)
/**
* 多线程秒杀服务(错误示范)
* 优势:性能大幅提升
* 劣势:存在严重的并发Bug
*/
public class MultiThreadSeckillService {
private int stock = 100; // 库存(共享变量)
public boolean seckill(String userId) {
// 检查库存(问题1:可见性问题)
if (stock <= 0) {
System.out.println("库存不足,秒杀失败");
return false;
}
// 模拟业务处理
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 扣减库存(问题2:原子性问题)
stock--; // 这不是原子操作!
System.out.println("用户 " + userId + " 秒杀成功,剩余库存:" + stock);
return true;
}
}
// 多线程性能测试
public class MultiThreadTest {
public static void main(String[] args) throws InterruptedException {
MultiThreadSeckillService service = new MultiThreadSeckillService();
CountDownLatch latch = new CountDownLatch(1000);
long start = System.currentTimeMillis();
// 1000个线程并发秒杀
for (int i = 0; i < 1000; i++) {
final int userId = i;
new Thread(() -> {
service.seckill("User-" + userId);
latch.countDown();
}).start();
}
latch.await(); // 等待所有线程完成
long end = System.currentTimeMillis();
System.out.println("总耗时:" + (end - start) + "ms");
}
}
/*
执行结果(多次运行结果不一致):
总耗时:50ms(相比单线程提升200倍!)
但是...
问题1:库存超卖
- 期望:100件商品,只有前100个用户秒杀成功
- 实际:可能有150个用户秒杀成功(库存变成-50)
问题2:最终库存不一致
- 第一次运行:最终库存 = -45
- 第二次运行:最终库存 = -52
- 第三次运行:最终库存 = -38
每次结果都不同!
问题3:可见性问题
- 有些线程读到的stock是过期值
- 线程A扣减库存后,线程B可能看不到最新值
*/
并发Bug剖析:
// 问题的本质:stock--不是原子操作
// stock-- 实际上分为三步:
// 1. 读取stock的值(READ)
// 2. 计算stock - 1(COMPUTE)
// 3. 写回stock(WRITE)
// 并发场景下的交错执行:
时间线:
t1: 线程A读取stock = 100
t2: 线程B读取stock = 100 (A还没写回)
t3: 线程A计算 100 - 1 = 99
t4: 线程B计算 100 - 1 = 99 (B也读到了100)
t5: 线程A写回 stock = 99
t6: 线程B写回 stock = 99 (覆盖了A的结果!)
结果:两个线程都秒杀成功,但库存只减少了1(应该减少2)
场景C:正确的多线程实现
/**
* 多线程秒杀服务(正确版本)
* 使用synchronized保证线程安全
*/
public class SafeMultiThreadSeckillService {
private int stock = 100;
public synchronized boolean seckill(String userId) {
// 检查库存
if (stock <= 0) {
System.out.println("库存不足,秒杀失败");
return false;
}
// 模拟业务处理
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 扣减库存
stock--;
System.out.println("用户 " + userId + " 秒杀成功,剩余库存:" + stock);
return true;
}
}
/*
执行结果:
总耗时:1,000ms(1秒)
库存最终值:0(正确!)
成功用户数:100(正确!)
性能对比:
├─ 单线程:10,000ms
├─ 多线程(错误):50ms(但结果错误)
└─ 多线程(正确):1,000ms(结果正确,性能提升10倍)
结论:
虽然synchronized引入了同步开销,但相比单线程仍有10倍性能提升
关键是:正确性 > 性能
*/
三种方案的对比
| 对比维度 | 单线程 | 多线程(错误) | 多线程(正确) |
|---|---|---|---|
| 耗时 | 10,000ms | 50ms | 1,000ms |
| QPS | 100 | 20,000 | 1,000 |
| 正确性 | ✅ 正确 | ❌ 库存超卖 | ✅ 正确 |
| 可预测性 | ✅ 结果一致 | ❌ 每次不同 | ✅ 结果一致 |
| 代码复杂度 | 简单 | 简单(但有隐藏Bug) | 中等 |
| 调试难度 | 容易 | 极难(Bug难复现) | 中等 |
| 适用场景 | 低并发 | ❌ 不可用 | 高并发 |
核心洞察:
并发编程的本质困难:
├─ 性能与正确性的权衡
├─ 简单性与复杂性的权衡
└─ Bug的隐蔽性和难以复现
并发编程的黄金法则:
正确性 > 性能 > 简单性
宁愿慢一点,也不能出错
一、并发的本质困难
1.1 第一性原理拆解
并发编程的三大核心问题:
为什么并发如此困难?
└─ 共享内存的多线程模型带来三大问题
问题1:可见性(Visibility)
└─ 一个线程对共享变量的修改,其他线程能否立即看到?
问题2:原子性(Atomicity)
└─ 一个操作是否不可分割?能否被其他线程打断?
问题3:有序性(Ordering)
└─ 程序执行的顺序是否与代码顺序一致?
1.2 可见性问题:CPU缓存的困扰
1.2.1 问题根源:多级缓存架构
现代计算机的内存架构:
CPU Core 1 CPU Core 2
↓ ↓
L1 Cache (32KB) L1 Cache (32KB) ← 每个核心独立
↓ ↓
L2 Cache (256KB) L2 Cache (256KB) ← 每个核心独立
↓ ↓
L3 Cache (8MB) ← 所有核心共享
↓
主内存 (RAM)
性能差异(访问延迟):
├─ L1 Cache: 1ns
├─ L2 Cache: 3ns
├─ L3 Cache: 12ns
├─ 主内存: 100ns
└─ 相差100倍!
设计目标:用缓存隐藏内存延迟
副作用:带来可见性问题
1.2.2 可见性问题的实际案例
/**
* 可见性问题演示
* 场景:线程A修改flag,线程B循环检查flag
* 问题:B可能永远看不到A的修改
*/
public class VisibilityProblem {
private static boolean flag = false; // 共享变量
public static void main(String[] args) throws InterruptedException {
// 线程A:1秒后修改flag
Thread threadA = new Thread(() -> {
try {
Thread.sleep(1000);
flag = true; // 修改flag
System.out.println("线程A:flag已设置为true");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
// 线程B:循环检查flag
Thread threadB = new Thread(() -> {
while (!flag) {
// 空循环,等待flag变为true
}
System.out.println("线程B:检测到flag = true,退出循环");
});
threadB.start();
Thread.sleep(100); // 确保B先启动
threadA.start();
threadA.join();
threadB.join();
}
}
/*
执行结果:
线程A:flag已设置为true
(然后程序卡住,线程B永远不会退出)
问题分析:
1. 线程B读取flag并缓存到CPU寄存器
2. 循环中一直读取寄存器的值(false)
3. 线程A修改的flag在主内存,B看不到
4. 即使A写入主内存,B的缓存也不会自动失效
解决方案1:使用volatile
private static volatile boolean flag = false;
解决方案2:使用synchronized
synchronized(lock) { flag = true; }
*/
可见性问题的本质:
单线程视角(程序员期望):
写变量 → 立即生效 → 其他线程可见
多线程实际情况:
线程A写变量 → 写入L1缓存 → 刷新到主内存(延迟)
线程B读变量 ← 读L1缓存(可能是旧值) ← 主内存
时间差问题:
├─ A写入缓存:t1
├─ A刷新到主内存:t2(t2 > t1)
├─ B的缓存失效:t3(t3 > t2)
└─ B读取新值:t4(t4 > t3)
期望:t1 = t2 = t3 = t4(立即可见)
实际:t1 < t2 < t3 < t4(存在延迟)
1.3 原子性问题:指令交错执行
1.3.1 问题根源:CPU的指令级并行
高级语言的一行代码 ≠ CPU的一条指令
示例:stock--;
Java代码(1行):
stock--;
字节码(3条指令):
1. getfield stock // 读取stock的值
2. iconst_1 // 常量1入栈
3. isub // 减法运算
4. putfield stock // 写回stock
CPU指令(更多):
1. MOV EAX, [stock] // 从内存读取stock到寄存器EAX
2. DEC EAX // EAX减1
3. MOV [stock], EAX // 从寄存器EAX写回内存
关键问题:这些指令不是原子的!
线程切换可能发生在任何两条指令之间
1.3.2 原子性问题的实际案例
/**
* 原子性问题演示
* 场景:1000个线程并发累加counter
* 期望:最终结果10000
* 实际:结果小于10000(每次不同)
*/
public class AtomicityProblem {
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[1000];
for (int i = 0; i < 1000; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10; j++) {
counter++; // 非原子操作
}
});
}
// 启动所有线程
for (Thread thread : threads) {
thread.start();
}
// 等待所有线程完成
for (Thread thread : threads) {
thread.join();
}
System.out.println("最终结果:" + counter);
System.out.println("期望结果:10000");
System.out.println("丢失更新:" + (10000 - counter));
}
}
/*
执行结果(多次运行):
第1次:最终结果:9876 丢失更新:124
第2次:最终结果:9923 丢失更新:77
第3次:最终结果:9851 丢失更新:149
问题分析:
counter++ 分解为三步:
1. 读取counter(READ)
2. 加1(ADD)
3. 写回counter(WRITE)
并发场景下的交错执行:
时间 线程A 线程B
t1 READ(0)
t2 READ(0)
t3 ADD(0+1=1)
t4 ADD(0+1=1)
t5 WRITE(1)
t6 WRITE(1) ← 覆盖了A的结果!
结果:两个线程都执行了counter++,但counter只增加了1
*/
原子性问题的可视化:
预期的原子执行(理想情况):
线程A: [READ → ADD → WRITE]
线程B: [READ → ADD → WRITE]
实际的交错执行(问题场景):
线程A: [READ → ADD → WRITE]
线程B: [READ → ADD → WRITE]
↑
这里读到了过期值
丢失更新的根本原因:
READ和WRITE之间有时间窗口,其他线程可能在此期间修改变量
1.4 有序性问题:指令重排序
1.4.1 问题根源:编译器和CPU的优化
为什么会发生指令重排序?
层级1:编译器重排序
├─ Java源码 → 字节码
├─ 编译器会调整指令顺序以优化性能
└─ 前提:不改变单线程的执行结果
层级2:CPU指令重排序
├─ CPU乱序执行(Out-of-Order Execution)
├─ CPU流水线优化
└─ 前提:不违反as-if-serial语义
层级3:内存系统重排序
├─ 写缓冲区(Store Buffer)
├─ 失效队列(Invalidate Queue)
└─ 前提:单处理器内存模型一致
问题:单线程正确 ≠ 多线程正确
编译器和CPU只保证单线程语义,不考虑多线程
1.4.2 有序性问题的经典案例:DCL单例
/**
* 双重检查锁定(DCL)单例模式
* 问题:存在指令重排序导致的线程安全问题
*/
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 问题根源!
}
}
}
return instance;
}
}
/*
问题分析:
instance = new Singleton() 分解为三步:
1. memory = allocate() // 分配内存
2. ctorInstance(memory) // 初始化对象
3. instance = memory // 设置instance指向内存地址
指令重排序后可能变成:
1. memory = allocate() // 分配内存
3. instance = memory // 设置instance指向内存地址(提前!)
2. ctorInstance(memory) // 初始化对象
并发问题:
时间 线程A 线程B
t1 分配内存
t2 instance = memory(未初始化)
t3 if (instance == null) → false
t4 return instance ← 返回未初始化的对象!
t5 初始化对象
结果:线程B拿到了一个未初始化的对象,使用时会出错
解决方案1:使用volatile禁止重排序
private static volatile Singleton instance;
解决方案2:使用静态内部类(推荐)
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
*/
有序性问题的本质:
单线程视角(as-if-serial):
代码顺序:A → B → C
执行顺序:A → B → C(或 A → C → B,只要结果一致)
保证:最终结果与代码顺序执行一致
多线程视角:
线程1:A1 → B1
线程2:A2 → B2
可能的交错执行:
├─ A1 → A2 → B1 → B2
├─ A1 → B1 → A2 → B2
├─ A2 → A1 → B2 → B1
└─ ...(多种可能)
指令重排序导致的问题:
线程1:A1 → C1 → B1 (B1和C1重排序)
线程2:A2 → B2
交错执行:A1 → C1 → A2 → B2 → B1
如果B2依赖C1的副作用,就会出错
二、Java内存模型(JMM)
2.1 为什么需要内存模型?
核心问题:如何在保证性能的同时,提供一致的并发语义?
矛盾的需求:
├─ 硬件:追求性能,使用缓存、乱序执行、预测执行
├─ 编译器:追求优化,进行指令重排序
└─ 程序员:追求正确性,期望代码按顺序执行
Java内存模型(JMM)的角色:
在硬件/编译器的优化和程序员的正确性期望之间,建立一个契约
├─ 定义:什么样的程序行为是合法的
├─ 约束:编译器和CPU可以做哪些优化
└─ 保证:在遵守规则的前提下,程序员能得到什么保证
2.2 JMM的抽象结构
Java内存模型的抽象:
线程A 线程B
↓ ↓
工作内存A 工作内存B
├─ 本地变量 ├─ 本地变量
├─ 共享变量副本 ├─ 共享变量副本
└─ 操作缓存 └─ 操作缓存
↓ ↓
主内存(共享变量)
├─ 实例字段
├─ 静态字段
└─ 数组元素
关键理解:
1. 所有共享变量存储在主内存
2. 每个线程有自己的工作内存(抽象概念,映射到寄存器、缓存、写缓冲区)
3. 线程对变量的操作必须在工作内存中进行
4. 不同线程之间无法直接访问对方的工作内存
5. 线程间通信必须通过主内存完成
与硬件的映射关系:
工作内存 ≈ CPU寄存器 + L1/L2/L3缓存 + 写缓冲区
主内存 ≈ 物理内存(RAM)
2.3 happens-before规则:JMM的核心
happens-before的定义:
如果操作A happens-before 操作B,那么:
1. A的执行结果对B可见
2. A的执行顺序在B之前
关键理解:
├─ happens-before不是"时间上的先后"(物理时序)
├─ 而是"可见性和有序性的保证"(逻辑时序)
└─ 即使A在时间上晚于B执行,只要满足happens-before规则,程序行为仍然正确
2.3.1 happens-before的八大规则
/**
* 规则1:程序顺序规则(Program Order Rule)
* 一个线程内,代码的执行顺序,书写在前面的操作 happens-before 书写在后面的操作
*/
int a = 1; // A
int b = 2; // B
// A happens-before B
// 保证:B能看到A对a的写入
/**
* 规则2:监视器锁规则(Monitor Lock Rule)
* 对一个锁的解锁 happens-before 随后对这个锁的加锁
*/
synchronized (lock) {
// A:释放锁之前的所有操作
x = 1;
} // 解锁
synchronized (lock) {
// B:加锁之后的所有操作
int y = x; // 能看到 x = 1
}
/**
* 规则3:volatile变量规则(Volatile Variable Rule)
* 对一个volatile变量的写 happens-before 任意后续对这个volatile变量的读
*/
volatile boolean flag = false;
// 线程A
x = 1; // 普通写
flag = true; // volatile写
// 线程B
if (flag) { // volatile读
int y = x; // 能看到 x = 1
}
/**
* 规则4:传递性规则(Transitivity)
* 如果 A happens-before B,B happens-before C,那么 A happens-before C
*/
x = 1; // A
flag = true; // B(volatile写)
if (flag) { // C(volatile读)
int y = x; // A happens-before C,能看到 x = 1
}
/**
* 规则5:线程启动规则(Thread Start Rule)
* Thread.start() happens-before 该线程的每一个操作
*/
int x = 0;
Thread t = new Thread(() -> {
int y = x; // 能看到主线程对x的修改
});
x = 1;
t.start(); // start() happens-before 线程内的操作
/**
* 规则6:线程终止规则(Thread Termination Rule)
* 线程的所有操作 happens-before 其他线程检测到该线程终止
* (通过Thread.join()或Thread.isAlive())
*/
Thread t = new Thread(() -> {
x = 1; // 线程内操作
});
t.start();
t.join(); // 等待线程终止
int y = x; // 能看到 x = 1
/**
* 规则7:线程中断规则(Thread Interruption Rule)
* 对线程interrupt()的调用 happens-before 被中断线程检测到中断事件
*/
Thread t = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 能检测到中断
}
});
t.start();
t.interrupt(); // happens-before 线程内检测到中断
/**
* 规则8:对象终结规则(Finalizer Rule)
* 一个对象的初始化完成 happens-before 它的finalize()方法的开始
*/
// 保证对象构造完成后才能执行finalize()
2.4 volatile的语义和实现
2.4.1 volatile的两大语义
/**
* volatile的语义
* 1. 可见性:对volatile变量的写,立即刷新到主内存;读,从主内存读取
* 2. 有序性:禁止指令重排序
*/
public class VolatileSemantics {
private int a = 0;
private volatile boolean flag = false;
// 线程A
public void writer() {
a = 1; // 1
flag = true; // 2(volatile写)
}
// 线程B
public void reader() {
if (flag) { // 3(volatile读)
int i = a; // 4
// i一定等于1
}
}
}
/*
happens-before分析:
1. 程序顺序规则:1 happens-before 2
2. volatile规则:2 happens-before 3
3. 程序顺序规则:3 happens-before 4
4. 传递性:1 happens-before 4
结论:操作4能看到操作1的结果
禁止重排序的规则:
volatile写之前的操作 → 不能重排序到volatile写之后
volatile读之后的操作 → 不能重排序到volatile读之前
*/
2.4.2 volatile的底层实现:内存屏障
volatile的实现机制:内存屏障(Memory Barrier)
内存屏障的四种类型:
1. LoadLoad屏障: Load1; LoadLoad; Load2
确保Load1数据的装载先于Load2及后续装载指令
2. StoreStore屏障: Store1; StoreStore; Store2
确保Store1数据对其他处理器可见(刷新到主内存)先于Store2及后续存储指令
3. LoadStore屏障: Load1; LoadStore; Store2
确保Load1数据装载先于Store2及后续存储指令刷新到主内存
4. StoreLoad屏障: Store1; StoreLoad; Load2
确保Store1数据对其他处理器可见先于Load2及后续装载指令
最昂贵的屏障,会使该屏障之前的所有内存访问指令完成
volatile写插入的内存屏障:
StoreStore屏障
volatile写操作
StoreLoad屏障
volatile读插入的内存屏障:
volatile读操作
LoadLoad屏障
LoadStore屏障
示例:
int a = 1; // 普通写
StoreStore屏障 // 防止上面的普通写与下面的volatile写重排序
volatile写 flag = true
StoreLoad屏障 // 防止上面的volatile写与下面的操作重排序
volatile读 flag
LoadLoad屏障 // 防止下面的普通读与上面的volatile读重排序
LoadStore屏障 // 防止下面的普通写与上面的volatile读重排序
int b = a; // 普通读
2.4.3 volatile的使用场景
/**
* 场景1:状态标志
* 最典型的用法
*/
public class StatusFlag {
private volatile boolean shutdownRequested = false;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// 做业务逻辑
}
}
}
/**
* 场景2:DCL单例模式
* 防止指令重排序导致的问题
*/
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
/**
* 场景3:独立观察(发布-订阅)
* 一个线程写,多个线程读
*/
public class CurrentTemperature {
private volatile int temperature;
public void update(int newTemp) {
temperature = newTemp; // 写线程
}
public int read() {
return temperature; // 读线程
}
}
/**
* 注意:volatile不能保证复合操作的原子性
*/
public class VolatileCounter {
private volatile int count = 0;
public void increment() {
count++; // 非原子操作!仍然有线程安全问题
}
// 正确做法1:使用synchronized
public synchronized void incrementSafe1() {
count++;
}
// 正确做法2:使用AtomicInteger
private AtomicInteger count2 = new AtomicInteger(0);
public void incrementSafe2() {
count2.incrementAndGet();
}
}
2.5 final的内存语义
/**
* final的内存语义
* 1. 构造函数内对final字段的写入,与把this引用赋值给其他变量,不能重排序
* 2. 初次读对象引用,与初次读该对象包含的final字段,不能重排序
*/
public class FinalFieldExample {
final int x;
int y;
static FinalFieldExample obj;
public FinalFieldExample() {
x = 1; // final字段的写入
y = 2; // 普通字段的写入
}
// 线程A
public static void writerThread() {
obj = new FinalFieldExample();
}
// 线程B
public static void readerThread() {
FinalFieldExample local = obj; // 读对象引用
if (local != null) {
int a = local.x; // 一定能看到 x = 1
int b = local.y; // 可能看到 y = 0(普通字段)
}
}
}
/*
final的保证:
1. 在构造函数内对final字段的写入,不会与构造函数外的对象引用赋值重排序
2. 一旦在构造函数内初始化final字段,其他线程就能看到final字段的值
3. 普通字段没有这个保证
错误示例(如果x不是final):
可能的重排序:
obj = new FinalFieldExample(); // 对象引用赋值
x = 1; // 字段初始化
如果发生重排序,线程B可能看到 obj != null 但 x = 0
*/
三、并发Bug剖析
3.1 经典案例1:双重检查锁定问题
我们在2.4.2节已经分析过DCL单例的问题,这里进行更深入的剖析。
/**
* 问题版本:没有volatile
*/
public class Singleton {
private static Singleton instance; // 问题:没有volatile
public static Singleton getInstance() {
if (instance == null) { // 1. 第一次检查
synchronized (Singleton.class) { // 2. 加锁
if (instance == null) { // 3. 第二次检查
instance = new Singleton(); // 4. 创建对象
}
}
}
return instance;
}
}
/*
深度剖析:为什么会出问题?
1. 对象创建的三个步骤:
memory = allocate() // 1. 分配内存
ctorInstance(memory) // 2. 初始化对象
instance = memory // 3. 设置instance指向内存
2. 可能的重排序:
memory = allocate() // 1. 分配内存
instance = memory // 3. 设置instance(提前!)
ctorInstance(memory) // 2. 初始化对象(延后!)
3. 并发问题:
时间 线程A 线程B
t1 if (instance == null) → true
t2 synchronized (Singleton.class)
t3 if (instance == null) → true
t4 memory = allocate()
t5 instance = memory(未初始化)
t6 if (instance == null) → false
t7 return instance ← 未初始化!
t8 ctorInstance(memory)
4. 问题本质:
线程B拿到的instance不为null,但指向的对象未完成初始化
使用这个对象会导致不可预测的错误
5. 解决方案的对比:
*/
// 方案1:使用volatile(推荐)
public class Singleton {
private static volatile Singleton instance; // 加volatile
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
// volatile禁止重排序,保证初始化完成后才赋值
}
}
}
return instance;
}
}
// 方案2:静态内部类(最推荐)
public class Singleton {
private Singleton() {}
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
/*
优势:
1. 懒加载:只有调用getInstance()时才加载SingletonHolder
2. 线程安全:类加载机制保证初始化只执行一次
3. 无需同步:没有synchronized开销
4. 无需volatile:没有指令重排序问题
*/
// 方案3:枚举(最简洁)
public enum Singleton {
INSTANCE;
public void doSomething() {
// 业务逻辑
}
}
/*
优势:
1. 线程安全:枚举的实例创建是线程安全的
2. 防止反射攻击:枚举不能通过反射创建新实例
3. 防止反序列化攻击:枚举的反序列化会返回同一实例
4. 代码最简洁
*/
3.2 经典案例2:SimpleDateFormat的线程安全问题
/**
* 问题:SimpleDateFormat不是线程安全的
*/
public class DateFormatProblem {
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
try {
String dateStr = "2024-01-15 12:30:00";
Date date = sdf.parse(dateStr);
System.out.println(date);
} catch (ParseException e) {
e.printStackTrace();
}
});
}
executor.shutdown();
}
}
/*
执行结果(多次运行可能出现不同问题):
1. java.lang.NumberFormatException
2. 解析出错误的日期
3. 偶尔正常
问题根源:
SimpleDateFormat内部使用Calendar对象,calendar字段是共享的
多线程并发调用parse()方法,会同时修改calendar,导致数据混乱
源码分析:
public Date parse(String source) throws ParseException {
// calendar是成员变量,多线程共享
calendar.clear(); // 清空日历
calendar.set(...); // 设置日期
// 问题:线程A执行到一半,线程B清空了calendar
}
解决方案对比:
*/
// 方案1:每次创建新实例(浪费资源)
public class Solution1 {
public Date parse(String dateStr) throws ParseException {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return sdf.parse(dateStr);
}
}
// 方案2:使用synchronized(性能差)
public class Solution2 {
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public synchronized Date parse(String dateStr) throws ParseException {
return sdf.parse(dateStr);
}
}
// 方案3:使用ThreadLocal(推荐)
public class Solution3 {
private static final ThreadLocal<SimpleDateFormat> threadLocal = ThreadLocal.withInitial(
() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
);
public Date parse(String dateStr) throws ParseException {
return threadLocal.get().parse(dateStr);
}
}
// 方案4:使用DateTimeFormatter(JDK 8+,最推荐)
public class Solution4 {
private static final DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public LocalDateTime parse(String dateStr) {
return LocalDateTime.parse(dateStr, formatter);
}
}
/*
优势:
1. 线程安全:DateTimeFormatter是不可变类
2. 性能好:无需同步,无需创建新实例
3. API更友好:新的时间API更好用
*/
3.3 如何排查并发Bug
并发Bug的特点:
├─ 难以复现:可能运行1000次才出现1次
├─ 难以调试:加了日志或断点,Bug可能消失(Heisenbug)
├─ 难以定位:错误现场可能离问题根源很远
└─ 危害严重:数据不一致、死锁、内存泄漏
排查方法:
1. 静态代码分析
├─ 工具:FindBugs、PMD、SonarQube
├─ 检查:共享变量未同步、双重检查锁定错误
└─ 局限:只能发现明显的问题
2. 动态分析工具
├─ JProfiler:监控线程状态、死锁检测
├─ VisualVM:线程dump分析
├─ Arthas:阿里开源的Java诊断工具
└─ ThreadSanitizer:Google的动态数据竞争检测器
3. 日志分析
├─ 线程ID:记录每个操作的线程ID
├─ 时间戳:精确到微秒
├─ 操作序列:记录关键操作的顺序
└─ 变量状态:记录共享变量的变化
4. 压力测试
├─ 高并发:使用多线程模拟高并发场景
├─ 长时间运行:运行数小时甚至数天
├─ 随机化:引入随机延迟,增加交错执行的概率
└─ 重复运行:同样的测试运行1000次
5. 代码审查
├─ 关注点:共享变量、锁的范围、wait/notify
├─ 问题点:双重检查锁定、SimpleDateFormat
└─ 最佳实践:不可变对象、线程本地存储
四、总结与思考
4.1 并发编程的核心原则
1. 优先使用不可变对象
├─ 不可变对象天然线程安全
├─ String、Integer等包装类都是不可变的
└─ 自定义类:所有字段final + 不提供setter
2. 减少共享变量
├─ 局部变量优于成员变量
├─ ThreadLocal实现线程隔离
└─ 栈封闭:数据只在方法内使用
3. 同步必不可少时,选择合适的工具
├─ synchronized:简单场景
├─ Lock:需要灵活性(超时、可中断、条件变量)
├─ Atomic类:简单的原子操作
└─ 并发集合:ConcurrentHashMap、BlockingQueue
4. 最小化锁的范围
├─ 锁的代码越少越好
├─ 避免在锁内调用外部方法
└─ 避免嵌套锁
5. 性能与正确性的权衡
├─ 正确性 > 性能
├─ 先保证正确,再优化性能
└─ 不要过早优化
4.2 何时使用并发,何时避免并发
适合使用并发的场景:
├─ CPU密集型任务:多核并行计算
├─ IO密集型任务:网络请求、文件读写
├─ 响应时间要求高:秒杀、实时系统
└─ 吞吐量要求高:服务器处理大量请求
不适合使用并发的场景:
├─ 简单任务:单线程足够快
├─ 任务间有强依赖:无法并行
├─ 共享状态多:同步开销大于收益
└─ 开发时间紧:并发Bug难以排查
并发的代价:
├─ 开发成本:代码复杂度提升
├─ 调试成本:Bug难以复现和定位
├─ 性能开销:上下文切换、同步开销
└─ 心智负担:需要理解并发模型
4.3 并发编程的学习路径
阶段1:理解并发的本质问题(本文)
├─ 可见性、原子性、有序性
├─ Java内存模型(JMM)
├─ happens-before规则
└─ volatile、final的语义
阶段2:掌握同步机制(下一篇)
├─ synchronized原理
├─ Lock接口与ReentrantLock
├─ AQS(AbstractQueuedSynchronizer)
└─ 死锁问题
阶段3:并发工具类(第三篇)
├─ Atomic原子类
├─ CAS原理
├─ CountDownLatch、CyclicBarrier、Semaphore
└─ 无锁编程
阶段4:线程池与异步编程(第四篇)
├─ ThreadPoolExecutor
├─ ForkJoinPool
├─ CompletableFuture
└─ 异步编程模式
阶段5:并发集合(第五篇)
├─ ConcurrentHashMap
├─ BlockingQueue
├─ CopyOnWriteArrayList
└─ 高性能并发集合
阶段6:设计模式与最佳实践(第六篇)
├─ 不可变对象模式
├─ ThreadLocal
├─ 两阶段终止
└─ 并发编程最佳实践
五、下一篇预告
在下一篇文章《线程安全与同步机制:从synchronized到Lock》中,我们将深入探讨:
什么是线程安全?
- 线程安全的三种实现方式
- 不可变对象的威力
synchronized深度解析
- 对象头与Monitor
- 锁升级过程(偏向锁、轻量级锁、重量级锁)
- wait/notify机制
Lock接口与ReentrantLock
- 为什么需要Lock?
- 公平锁 vs 非公平锁
- Condition条件队列
AQS源码剖析
- AQS的核心思想
- 同步队列(CLH锁队列)
- 手写一个基于AQS的同步器
死锁问题
- 死锁的四个必要条件
- 死锁预防策略
- 死锁检测工具
敬请期待!
本文要点回顾:
核心问题:
├─ 可见性:CPU缓存导致变量修改不可见
├─ 原子性:指令交错执行导致复合操作被打断
└─ 有序性:编译器和CPU重排序导致执行顺序与代码顺序不一致
Java内存模型(JMM):
├─ 抽象结构:主内存 + 工作内存
├─ happens-before规则:8大规则保证可见性和有序性
├─ volatile:可见性 + 禁止重排序
└─ final:构造函数内的写入对其他线程可见
经典Bug:
├─ 双重检查锁定:指令重排序导致返回未初始化对象
├─ SimpleDateFormat:共享Calendar导致线程不安全
└─ 排查方法:静态分析、动态工具、压力测试
核心原则:
├─ 正确性 > 性能
├─ 优先使用不可变对象
├─ 减少共享变量
└─ 选择合适的同步工具
系列文章:
- ✅ Java并发第一性原理:为什么并发如此困难?(本文)
- ⏳ 线程安全与同步机制:从synchronized到Lock
- ⏳ 并发工具类与原子操作:无锁编程的艺术
- ⏳ 线程池与异步编程:从Thread到CompletableFuture
- ⏳ 并发集合:从HashMap到ConcurrentHashMap
- ⏳ 并发设计模式与最佳实践
参考资料:
- 《Java并发编程实战》- Brian Goetz
- 《Java并发编程的艺术》- 方腾飞、魏鹏、程晓明
- Java Language Specification - Chapter 17: Threads and Locks
- JSR-133: Java Memory Model and Thread Specification
关于作者: 专注于Java技术栈的深度研究,致力于用第一性原理思维拆解复杂技术问题。本系列文章采用渐进式复杂度模型,从"为什么"出发,系统化学习Java并发编程。
如果这篇文章对你有帮助,欢迎关注本系列后续文章!