文章
Java 并发编程进阶:从线程池到 AQS
暂存笔记,持续补充中。并发是 Java 后端面试的「分水岭」,也是线上故障的高发区。
一、JMM:Java 内存模型
JMM 规定了多线程间共享变量的可见性规则,是一套抽象规范,不是物理内存布局。
线程A 工作内存 ←→ 主内存 ←→ 线程B 工作内存
(本地缓存) (共享变量)
三大特性:
| 特性 | 含义 | 保障手段 |
|---|---|---|
| 原子性 | 操作不可分割 | synchronized、Lock、AtomicXXX |
| 可见性 | 一个线程修改,其他线程立即可见 | volatile、synchronized、final |
| 有序性 | 禁止指令重排影响结果 | volatile、synchronized、happens-before |
happens-before 规则(前一个操作的结果对后一个可见):
- 程序顺序规则:单线程内前面的代码先于后面的。
- 监视器锁规则:解锁先于后续加锁。
- volatile 规则:写先于读。
- 传递性:A→B,B→C,则 A→C。
- 线程启动/终止规则:
start()先于线程内所有操作;线程内所有操作先于join()返回。
二、volatile 的两大作用
public class Flag {
private volatile boolean running = true;
public void stop() { running = false; }
public void run() {
while (running) { /* ... */ } // 若无 volatile,可能永远读不到 false
}
}
- 保证可见性:写操作立即刷回主内存,读操作从主内存读,并使其他 CPU 缓存行失效(MESI 协议)。
- 禁止指令重排:通过内存屏障实现。
注意:volatile 不保证原子性。
i++是「读-改-写」三步,多线程下仍会丢更新,必须用AtomicInteger或加锁。
经典应用:双重检查锁单例
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(); // 分三步:分配→初始化→赋值
}
}
}
return instance;
}
}
若没有
volatile,new的三步可能重排为「分配 → 赋值 → 初始化」,其他线程可能拿到未初始化完毕的对象。
三、synchronized 原理与锁升级
字节码层面:同步代码块用 monitorenter / monitorexit 指令;同步方法用方法访问标志 ACC_SYNCHRONIZED。
对象头 Mark Word 记录了锁状态,锁升级路径(不可逆):
无锁 → 偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(OS 互斥量)
| 锁状态 | 适用场景 | 开销 |
|---|---|---|
| 偏向锁 | 同一线程反复进入 | 几乎无(JDK 15 起默认禁用) |
| 轻量级锁 | 竞争少、交替执行 | CAS 自旋,无用户态/内核态切换 |
| 重量级锁 | 竞争激烈 | 涉及系统调用,线程阻塞挂起 |
JDK 15 起偏向锁默认关闭(
-XX:-UseBiasedLocking),因为撤销偏向锁的 STW 代价在无竞争场景下得不偿失。
四、CAS 与 ABA 问题
CAS(Compare And Swap):compareAndSwap(oldVal, newVal),由 CPU 的 cmpxchg 指令保证原子性。
public class Counter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 内部就是 CAS 自旋
}
}
三个问题:
- ABA 问题:A→B→A,CAS 检查值相等但中间已被改过。解决:
AtomicStampedReference加版本号。 - 自旋开销:竞争激烈时长时间自旋浪费 CPU。解决:
LongAdder分段累加(JDK 8 推荐替代AtomicLong)。 - 只能保证一个变量:多变量用
AtomicReference封装成对象。
五、AQS:并发工具类的基石
AQS(AbstractQueuedSynchronizer) 用一个 volatile int state + CLH 双向队列,统一实现锁与同步器。
state = 0 → 空闲
state > 0 → 已占用(可重入时递增)
acquire: CAS 抢 state,失败则入队并 park 等待
release: state 减到 0,唤醒队列头结点
基于 AQS 的组件:
| 组件 | 独占/共享 | 特点 |
|---|---|---|
ReentrantLock |
独占 | 可重入、可公平/非公平 |
ReentrantReadWriteLock |
共享(读)/独占(写) | 读读不互斥 |
Semaphore |
共享 | 限流,控制并发数 |
CountDownLatch |
共享 | 一次性,等 N 个任务完成 |
CyclicBarrier |
— | 可循环,等 N 个线程到齐 |
// CountDownLatch 典型用法:主线程等待所有子任务
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
new Thread(() -> {
try { doSomething(); } finally { latch.countDown(); }
}).start();
}
latch.await(5, TimeUnit.SECONDS); // 一定要设超时!
CountDownLatchvsCyclicBarrier:前者是「一个等多个」,计数归零后不可复用;后者是「多个互相等」,可循环使用。
六、线程池:生产环境必懂
核心参数(7 个):
new ThreadPoolExecutor(
5, // corePoolSize 核心线程数
20, // maximumPoolSize 最大线程数
60L, TimeUnit.SECONDS, // keepAliveTime 非核心线程空闲存活
new ArrayBlockingQueue<>(200), // workQueue 任务队列
new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build(), // 线程工厂
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
执行流程(重要):
提交任务
├─ 核心线程未满 → 创建核心线程执行
├─ 核心线程已满 → 任务入队列
├─ 队列已满 → 创建非核心线程
└─ 线程数达上限 → 执行拒绝策略
四种拒绝策略:
| 策略 | 行为 | 适用 |
|---|---|---|
AbortPolicy(默认) |
抛 RejectedExecutionException |
需要感知失败 |
CallerRunsPolicy |
调用者线程执行 | 可降级、削峰 |
DiscardPolicy |
静默丢弃 | 允许丢任务(如日志) |
DiscardOldestPolicy |
丢最旧任务 | 保留最新 |
⚠️ 禁止用
Executors.newFixedThreadPool()/newCachedThreadPool():
newFixedThreadPool用无界队列LinkedBlockingQueue(容量Integer.MAX_VALUE),任务堆积会导致 OOM。newCachedThreadPool最大线程数为Integer.MAX_VALUE,可能创建海量线程导致 OOM。- 必须手写
ThreadPoolExecutor,明确队列容量与拒绝策略。
参数怎么定:
- CPU 密集型:
核心数 = CPU 核数 + 1。 - IO 密集型:
核心数 = CPU 核数 × (1 + 平均等待时间 / 平均计算时间),经验值 2N ~ 4N。 - 最终以压测为准。
七、并发容器与 ThreadLocal
| 容器 | 机制 |
|---|---|
ConcurrentHashMap |
JDK 8:CAS + synchronized 锁单个桶 |
CopyOnWriteArrayList |
写时复制,读多写少场景 |
BlockingQueue |
生产者-消费者模型的基础 |
ThreadLocal 与内存泄漏:
Thread → ThreadLocalMap → Entry(弱引用 Key, 强引用 Value)
- Key 是弱引用,GC 后 Key 变 null,但 Value 是强引用,导致 Entry 无法回收。
- 必须用 try-finally 手动
remove():
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
public void handle() {
try {
CURRENT_USER.set(user);
// 业务逻辑
} finally {
CURRENT_USER.remove(); // 不写这行,线程池复用时会串数据 + 内存泄漏
}
}
线程池 + ThreadLocal = 双重陷阱:线程被复用,上一个请求的数据会残留给下一个请求,造成用户数据串号这种严重事故。
八、实战避坑清单
- 加锁顺序一致,否则死锁。
- 缩小锁粒度,不要锁整个方法(尤其别锁 IO 操作)。
- 避免在锁内调用外部方法(可能被阻塞)。
wait()必须写在while循环里,防止虚假唤醒。- 线程池必须给线程命名,否则
jstack时全是pool-1-thread-1,排查困难。 - 用
CompletableFuture编排异步任务,比手动Future.get()更灵活:
CompletableFuture.supplyAsync(() -> queryUser(id), executor)
.thenCombine(CompletableFuture.supplyAsync(() -> queryOrder(id), executor),
(user, orders) -> buildVO(user, orders))
.exceptionally(e -> { log.error("聚合失败", e); return null; })
.join();
九、小结
- JMM 三特性:原子、可见、有序。
volatile保可见与有序,不保原子。 - 锁升级:偏向 → 轻量 → 重量,理解 Mark Word 是基础。
- AQS 是
ReentrantLock、Semaphore、CountDownLatch的共同底座。 - 线程池:手写
ThreadPoolExecutor,明确队列容量与拒绝策略,按 CPU/IO 类型估算核心数。 - ThreadLocal:用完必
remove(),线程池下尤其致命。