Java 并发:JMM、锁与线程池原理
本文从并发 Bug 的三大根源出发,逐层深入分析 Java 内存模型、锁机制、AQS、线程池、并发容器与 ThreadLocal 的核心原理,结合 JDK 源码给出生产实践建议。
目录
| 章节 | 说明 |
|---|---|
| 并发 Bug 的三大根源 | 可见性、原子性、有序性 |
| Java 内存模型(JMM) | Happens-Before 规则、volatile、final |
| synchronized 原理 | 偏向锁 → 轻量级锁 → 重量级锁 |
| Lock 与 AQS | ReentrantLock、ReadWriteLock、StampedLock |
| AQS 内部实现 | CLH 队列、state 字段、acquire/release 源码、Condition |
| 线程池 | ThreadPoolExecutor 参数详解、最佳实践 |
| 并发容器 | ConcurrentHashMap、CopyOnWriteArrayList、阻塞队列 |
| 原子类与 CAS | AtomicLong、LongAdder、ABA 问题 |
| ThreadLocal | 实现原理、内存泄漏防范 |
| 并发设计模式 | 不可变、COW、生产者-消费者 |
| 常见问题排查 | 死锁、活锁、饥饿 |
并发 Bug 的三大根源
并发程序的诡异 Bug,根源无外乎三个:缓存导致的可见性问题、线程切换带来的原子性问题、编译优化带来的有序性问题。
可见性:CPU 缓存的代价
多核 CPU 每个核心有自己的缓存,线程 A 在 CPU-1 上修改了变量,线程 B 在 CPU-2 上读取,可能读到旧值。
// 典型案例:两个线程各自累加 10000 次,结果不是 20000
public class VisibilityDemo {
private long count = 0;
private void add10K() {
int idx = 0;
while (idx++ < 10000) {
count += 1; // 非原子操作,且存在可见性问题
}
}
}
原子性:线程切换的代价
count += 1 在 CPU 层面是三条指令:读内存 → 加 1 → 写内存。操作系统可以在任意指令之间发生线程切换,导致两个线程同时基于旧值计算,写入结果互相覆盖。
核心认知:CPU 保证原子性的粒度是 CPU 指令,而非高级语言的一条语句。
有序性:编译优化的代价
编译器和处理器会对指令重排序以提升性能。经典案例是双重检查单例(DCL):
// 有问题的 DCL 单例——instance 未加 volatile
public class Singleton {
static Singleton instance;
static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null)
instance = new Singleton(); // 可能被重排序!
}
}
return instance;
}
}
new Singleton() 的正常步骤:①分配内存 → ②初始化对象 → ③将地址赋给 instance。
重排序后可能变为:①分配内存 → ②将地址赋给 instance → ③初始化对象。
若线程 B 在步骤 ② 之后读到 instance 不为 null,就会拿到一个未初始化的对象。
正确写法:给 instance 加 volatile。
Java 内存模型(JMM)
JMM 的本质是:规范 JVM 如何提供按需禁用缓存和编译优化的方法,具体手段是 volatile、synchronized、final 三个关键字,以及六条 Happens-Before 规则。
Happens-Before 规则
Happens-Before 的含义不是"时间上先于",而是"前者的结果对后者可见"。
| 规则 | 说明 |
|---|---|
| 程序顺序规则 | 同一线程内,前面的操作 HB 后面的操作 |
| volatile 变量规则 | volatile 写 HB 后续的 volatile 读 |
| 传递性规则 | A HB B,B HB C,则 A HB C |
| 管程中锁的规则 | 解锁 HB 后续对同一锁的加锁 |
| 线程 start() 规则 | 主线程 start() HB 子线程的所有操作 |
| 线程 join() 规则 | 子线程所有操作 HB 主线程 join() 返回 |
volatile 的两层语义
- 禁用 CPU 缓存:读写必须直接操作内存(不使用寄存器缓存)
- 禁止指令重排序(Java 1.5 增强):volatile 写操作之前的所有操作,对 volatile 读之后的所有操作可见
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42; // happens-before v=true(程序顺序规则)
v = true; // volatile 写
}
public void reader() {
if (v == true) {
// x 一定是 42(volatile 规则 + 传递性)
System.out.println(x);
}
}
}
final 的内存语义
final 字段在构造函数中完成初始化后,其他线程可以看到正确的值。但要避免"逸出"——在构造函数中将 this 赋给全局变量:
// 错误:this 逸出,其他线程可能看到未初始化的 final 字段
public class FinalFieldExample {
final int x;
static FinalFieldExample instance;
public FinalFieldExample() {
x = 3;
instance = this; // 逸出!x 可能尚未初始化
}
}
synchronized 原理
字节码层面
synchronized 代码块编译后包含 monitorenter 和 monitorexit 指令;synchronized 方法则在访问标记中设置 ACC_SYNCHRONIZED。
每个锁对象维护锁计数器和持有线程指针,支持可重入(同一线程多次加锁,计数器递增)。
锁升级路径
HotSpot 的锁实现分三层,按竞争程度自动升级(不可降级):
flowchart LR
A["无锁<br/>01"] -->|首次加锁| B["偏向锁<br/>101"]
B -->|其他线程竞争| C["轻量级锁<br/>00"]
C -->|竞争激烈| D["重量级锁<br/>10"]
style B fill:#cfc,stroke:#060
style C fill:#ff9,stroke:#960
style D fill:#fcc,stroke:#c00
| 锁类型 | 适用场景 | 实现机制 | 代价 |
|---|---|---|---|
| 偏向锁 | 只有一个线程反复获取 | CAS 将线程地址写入对象头 mark word | 极低(第一次 CAS,后续直接返回) |
| 轻量级锁 | 多线程在不同时间段获取 | CAS 将 mark word 替换为锁记录指针 | 低(自旋等待) |
| 重量级锁 | 多线程同时竞争 | 操作系统 mutex,阻塞/唤醒线程 | 高(系统调用,用户态→内核态) |
自适应自旋:重量级锁在阻塞前会先自旋,根据历史成功率动态调整自旋次数。
正确使用 synchronized
// 保护静态变量:锁 Class 对象
class SafeCounter {
static long value = 0L;
// 两个方法必须用同一把锁,否则可见性无法保证
synchronized static long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
⚠️ 常见错误:用 synchronized(new Object()) 每次都创建新锁对象,完全无效。
Lock 与 AQS
为什么需要 Lock
synchronized 无法解决"不可抢占"问题:线程申请锁失败后直接阻塞,无法主动释放已持有的锁。Lock 提供了三种解决方案:
// 1. 支持中断(破坏不可抢占)
void lockInterruptibly() throws InterruptedException;
// 2. 支持超时
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 3. 非阻塞获取
boolean tryLock();
ReentrantLock 使用规范
import java.util.concurrent.locks.ReentrantLock;
class BankAccount {
private final ReentrantLock lock = new ReentrantLock();
private int balance;
public void transfer(BankAccount target, int amount) {
lock.lock();
try {
target.lock.lock();
try {
this.balance -= amount;
target.balance += amount;
} finally {
target.lock.unlock(); // finally 中释放,防止死锁
}
} finally {
lock.unlock();
}
}
}
ReentrantLock 可见性保证
ReentrantLock 内部有一个 volatile int state 字段。加锁时读写 state,解锁时也读写 state。根据 volatile 的 Happens-Before 规则,解锁操作 HB 后续加锁操作,从而保证可见性。
公平锁 vs 非公平锁
// 非公平锁(默认):性能更好,但可能饥饿
ReentrantLock unfairLock = new ReentrantLock();
// 公平锁:按等待时间顺序唤醒,吞吐量较低
ReentrantLock fairLock = new ReentrantLock(true);
ReadWriteLock:读多写少场景
import java.util.concurrent.locks.ReentrantReadWriteLock;
class Cache {
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Map<String, Object> map = new HashMap<>();
public Object get(String key) {
rwl.readLock().lock();
try {
return map.get(key);
} finally {
rwl.readLock().unlock();
}
}
public void put(String key, Object value) {
rwl.writeLock().lock();
try {
map.put(key, value);
} finally {
rwl.writeLock().unlock();
}
}
}
StampedLock:乐观读
StampedLock 在 ReadWriteLock 基础上增加了乐观读,读操作不加锁,只在最后校验是否有写操作发生:
import java.util.concurrent.locks.StampedLock;
class Point {
private final StampedLock sl = new StampedLock();
private double x, y;
public double distanceFromOrigin() {
// 乐观读:不加锁
long stamp = sl.tryOptimisticRead();
double curX = x, curY = y;
// 校验期间是否有写操作
if (!sl.validate(stamp)) {
// 升级为悲观读锁
stamp = sl.readLock();
try {
curX = x;
curY = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(curX * curX + curY * curY);
}
}
⚠️
StampedLock不支持重入,且不支持条件变量(Condition)。
线程池
为什么用线程池
创建线程需要调用操作系统内核 API,分配大量资源,线程是重量级对象,频繁创建销毁代价极高。线程池采用生产者-消费者模式:调用方(生产者)提交任务到队列,池内线程(消费者)循环消费。
ThreadPoolExecutor 七个参数
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // corePoolSize:核心线程数,常驻
8, // maximumPoolSize:最大线程数
60L, // keepAliveTime:空闲线程存活时间
TimeUnit.SECONDS, // unit
new ArrayBlockingQueue<>(100), // workQueue:有界队列(强烈推荐)
new ThreadFactory() { // threadFactory:自定义线程名
private final AtomicInteger n = new AtomicInteger(0);
public Thread newThread(Runnable r) {
return new Thread(r, "biz-pool-" + n.getAndIncrement());
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // handler:拒绝策略
);
| 参数 | 说明 |
|---|---|
corePoolSize | 常驻线程数,即便空闲也不回收 |
maximumPoolSize | 队列满后可临时扩展到的最大线程数 |
keepAliveTime | 超出 core 的线程空闲超时后回收 |
workQueue | 任务缓冲队列,必须有界(防 OOM) |
threadFactory | 自定义线程名,便于排查问题 |
handler | 队列满且线程数达上限时的拒绝策略 |
四种拒绝策略
| 策略 | 行为 | 适用场景 |
|---|---|---|
AbortPolicy(默认) | 抛出 RejectedExecutionException | 需要感知过载 |
CallerRunsPolicy | 由提交线程自己执行 | 降级处理,不丢任务 |
DiscardPolicy | 直接丢弃,不抛异常 | 可接受丢失 |
DiscardOldestPolicy | 丢弃队列最老的任务 | 优先保证新任务 |
线程数设置原则
CPU 密集型:线程数 ≈ CPU 核心数 + 1
IO 密集型:线程数 ≈ CPU 核心数 × (1 + IO 等待时间 / CPU 计算时间)
最佳实践
// 1. 异常处理:execute() 提交的任务异常会被吞掉
executor.execute(() -> {
try {
// 业务逻辑
} catch (RuntimeException e) {
// 显式捕获处理
log.error("task failed", e);
}
});
// 2. 优雅关闭
executor.shutdown(); // 不再接受新任务,等待已提交任务完成
// 或
executor.shutdownNow(); // 中断所有任务
⚠️ 禁止使用
Executors.newFixedThreadPool():默认使用无界LinkedBlockingQueue,高负载下极易 OOM。
并发容器
同步容器 vs 并发容器
Java 1.5 之前的同步容器(Vector、Hashtable、Collections.synchronizedXxx())基于 synchronized 全方法加锁,串行度高,性能差。Java 1.5+ 提供了高性能并发容器。
ConcurrentHashMap
- Java 7:分段锁(Segment),每个 Segment 是一个小 HashMap,并发度 = Segment 数量(默认 16)
- Java 8:CAS + synchronized,锁粒度降至单个桶(数组槽),并发度大幅提升
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("key", 1);
// 原子性的复合操作
map.putIfAbsent("key", 2); // 不存在才插入
map.computeIfAbsent("key", k -> k.length()); // 不存在则计算并插入
⚠️
ConcurrentHashMap的 key 和 value 都不能为 null(HashMap允许)。
CopyOnWriteArrayList
写时复制:写操作在新副本上进行,完成后将引用指向新数组。读操作完全无锁。
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("a"); // 创建新数组副本,写入后替换
| 特性 | 说明 |
|---|---|
| 适用场景 | 读多写极少(如配置列表、监听器列表) |
| 注意事项 | 写操作代价高;读到的数据可能不是最新(快照语义) |
| 迭代器 | 只读,不支持 remove() |
阻塞队列
| 类型 | 实现 | 特点 |
|---|---|---|
| 单端阻塞 | ArrayBlockingQueue | 有界,数组实现,公平/非公平 |
| 单端阻塞 | LinkedBlockingQueue | 可有界可无界,链表实现 |
| 单端阻塞 | SynchronousQueue | 无缓冲,生产者必须等消费者 |
| 单端阻塞 | PriorityBlockingQueue | 按优先级出队,无界 |
| 单端阻塞 | DelayQueue | 延时出队,无界 |
| 双端阻塞 | LinkedBlockingDeque | 双端,链表实现 |
| 单端非阻塞 | ConcurrentLinkedQueue | 无界,CAS 实现 |
⚠️ 线程池的 workQueue 必须用有界队列,避免 OOM。
并发容器选型
| 需求 | 推荐 |
|---|---|
| 线程安全 Map | ConcurrentHashMap |
| 有序 key 的线程安全 Map | ConcurrentSkipListMap(跳表,O(log n)) |
| 读多写极少的 List | CopyOnWriteArrayList |
| 生产者-消费者队列 | ArrayBlockingQueue(有界)或 LinkedBlockingQueue(有界) |
原子类与 CAS
CAS 原理
CAS(Compare And Swap)是一条 CPU 原子指令:只有当内存中的值等于期望值时,才将其更新为新值。
// CAS 的伪代码
synchronized int cas(int expect, int newValue) {
int cur = count;
if (cur == expect) {
count = newValue;
}
return cur; // 返回写入前的值
}
// CAS + 自旋实现无锁累加
void addOne() {
int newVal;
do {
newVal = count + 1;
} while (count != cas(count, newVal));
}
原子类体系
// 1. 基本类型
AtomicInteger count = new AtomicInteger(0);
count.getAndIncrement(); // i++
count.incrementAndGet(); // ++i
count.compareAndSet(1, 2); // CAS
// 2. 对象引用(处理 ABA 问题用 AtomicStampedReference)
AtomicReference<String> ref = new AtomicReference<>("v1");
AtomicStampedReference<String> stampedRef =
new AtomicStampedReference<>("v1", 0);
// 3. 高并发累加器(比 AtomicLong 更快)
LongAdder adder = new LongAdder();
adder.increment();
adder.sum();
ABA 问题
线程 T1 读到值 A,T2 将 A 改为 B,T3 再改回 A,T1 的 CAS 成功但实际上值已经变过——这就是 ABA 问题。
解决方案:AtomicStampedReference 附加版本号:
AtomicStampedReference<Integer> ref =
new AtomicStampedReference<>(100, 0);
int[] stampHolder = new int[1];
int val = ref.get(stampHolder);
int stamp = stampHolder[0];
// 必须 value 和 stamp 都匹配才能更新
ref.compareAndSet(val, val + 1, stamp, stamp + 1);
原子类 vs 互斥锁
| 维度 | 原子类(CAS) | 互斥锁 |
|---|---|---|
| 性能 | 高(无阻塞,无上下文切换) | 低(系统调用) |
| 适用场景 | 单个变量的简单操作 | 多个变量的复合操作 |
| 死锁风险 | 无(但可能活锁/饥饿) | 有 |
ThreadLocal
核心用途
为每个线程提供独立的变量副本,彻底消除共享,从根本上避免并发问题。
// 典型场景:线程安全的 SimpleDateFormat
class SafeDateFormat {
static final ThreadLocal<DateFormat> tl = ThreadLocal.withInitial(
() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
);
static DateFormat get() {
return tl.get();
}
}
实现原理
关键设计:ThreadLocalMap 属于 Thread,而非 ThreadLocal。
Thread
└── threadLocals: ThreadLocalMap
└── Entry[](数组,Key 是 ThreadLocal 的弱引用,Value 是变量值)
- 每个线程持有自己的
ThreadLocalMap ThreadLocal.get()实际是:Thread.currentThread().threadLocals.getEntry(this)- Key 是
WeakReference<ThreadLocal>,ThreadLocal 对象可被 GC 回收 - Value 是强引用,这是内存泄漏的根源
内存泄漏防范
在线程池中使用 ThreadLocal 极易内存泄漏:线程池的线程长期存活,ThreadLocalMap 不会被回收,Value 持续占用内存。
ExecutorService executor = Executors.newFixedThreadPool(4);
ThreadLocal<Object> tl = new ThreadLocal<>();
executor.execute(() -> {
tl.set(new LargeObject());
try {
// 业务逻辑
} finally {
tl.remove(); // 必须手动清理!
}
});
⚠️ 在线程池中使用 ThreadLocal,必须在 finally 块中调用
tl.remove()。
InheritableThreadLocal
子线程可以继承父线程的 ThreadLocal 值,但不建议在线程池中使用:线程池中的线程是复用的,继承关系可能错乱,导致业务逻辑错误。
并发设计模式
不可变模式(Immutability)
不可变对象天然线程安全,无需任何同步。
// 使用 final 字段 + 不提供修改方法
public final class ImmutablePoint {
private final double x;
private final double y;
public ImmutablePoint(double x, double y) {
this.x = x;
this.y = y;
}
// 修改操作返回新对象,不修改原对象
public ImmutablePoint translate(double dx, double dy) {
return new ImmutablePoint(x + dx, y + dy);
}
}
Copy-on-Write 模式
写时复制:写操作先复制一份,在副本上修改,完成后原子替换引用。适合读多写少场景。
// 自定义 COW 容器示例
class CopyOnWriteList<T> {
private volatile List<T> list = new ArrayList<>();
public synchronized void add(T e) {
List<T> newList = new ArrayList<>(list);
newList.add(e);
list = newList; // 原子替换引用
}
public List<T> get() {
return list; // 读操作无锁
}
}
生产者-消费者模式
解耦生产和消费速度,通过阻塞队列作为缓冲区:
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(100);
// 生产者
Thread producer = new Thread(() -> {
while (true) {
queue.put(generateTask()); // 队列满时阻塞
}
});
// 消费者
Thread consumer = new Thread(() -> {
while (true) {
Task task = queue.take(); // 队列空时阻塞
process(task);
}
});
常见问题排查
死锁
条件:互斥、占有且等待、不可抢占、循环等待(四个条件同时成立)。
检测:jstack <pid> 会自动识别并打印死锁信息。
预防:
- 按固定顺序获取锁(破坏循环等待)
- 使用
tryLock()超时机制(破坏不可抢占) - 减少锁的粒度
// 转账场景:按账户 ID 顺序加锁,避免死锁
void transfer(Account from, Account to, int amount) {
Account first = from.id < to.id ? from : to;
Account second = from.id < to.id ? to : from;
synchronized (first) {
synchronized (second) {
from.balance -= amount;
to.balance += amount;
}
}
}
活锁与饥饿
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 死锁 | 线程永久阻塞,等待永不发生的条件 | 按序加锁、超时机制 |
| 活锁 | 线程不断重试但都失败,CPU 飙高 | 引入随机退避(exponential backoff) |
| 饥饿 | 某线程长期得不到 CPU 或锁 | 使用公平锁,避免长时间持有锁 |
性能问题排查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| CPU 100% | 死循环、自旋过多 | jstack 查看线程状态,找 RUNNABLE 线程 |
| 线程大量 BLOCKED | 锁竞争激烈 | jstack 查看锁持有情况,减小锁粒度 |
| OOM | 无界队列堆积、ThreadLocal 泄漏 | jmap -histo 查看大对象 |
| 吞吐量低 | 线程数不足或过多 | 调整线程池参数,监控队列积压 |
参考资料
- Java SE 21 并发包文档
- JSR-133: Java Memory Model and Thread Specification
- 《Java 并发编程实战》(Brian Goetz 等著)
- ../../05 计算机基础/07 并发与协程/03 各语言的协程权衡(Java Virtual Thread 与传统线程的对比,以及与 Kotlin coroutine 的选型)
评论 (0)