文章

Java 并发编程进阶:从线程池到 AQS

牛耕田

暂存笔记,持续补充中。并发是 Java 后端面试的「分水岭」,也是线上故障的高发区。

一、JMM:Java 内存模型

JMM 规定了多线程间共享变量的可见性规则,是一套抽象规范,不是物理内存布局。

线程A 工作内存 ←→ 主内存 ←→ 线程B 工作内存
(本地缓存)      (共享变量)

三大特性

特性 含义 保障手段
原子性 操作不可分割 synchronizedLockAtomicXXX
可见性 一个线程修改,其他线程立即可见 volatilesynchronizedfinal
有序性 禁止指令重排影响结果 volatilesynchronizedhappens-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
    }
}
  1. 保证可见性:写操作立即刷回主内存,读操作从主内存读,并使其他 CPU 缓存行失效(MESI 协议)。
  2. 禁止指令重排:通过内存屏障实现。

注意: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;
    }
}

若没有 volatilenew 的三步可能重排为「分配 → 赋值 → 初始化」,其他线程可能拿到未初始化完毕的对象

三、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 自旋
    }
}

三个问题

  1. ABA 问题:A→B→A,CAS 检查值相等但中间已被改过。解决:AtomicStampedReference 加版本号。
  2. 自旋开销:竞争激烈时长时间自旋浪费 CPU。解决:LongAdder 分段累加(JDK 8 推荐替代 AtomicLong)。
  3. 只能保证一个变量:多变量用 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);   // 一定要设超时!

CountDownLatch vs CyclicBarrier:前者是「一个等多个」,计数归零后不可复用;后者是「多个互相等」,可循环使用。

六、线程池:生产环境必懂

核心参数(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 = 双重陷阱:线程被复用,上一个请求的数据会残留给下一个请求,造成用户数据串号这种严重事故。

八、实战避坑清单

  1. 加锁顺序一致,否则死锁。
  2. 缩小锁粒度,不要锁整个方法(尤其别锁 IO 操作)。
  3. 避免在锁内调用外部方法(可能被阻塞)。
  4. wait() 必须写在 while 循环里,防止虚假唤醒。
  5. 线程池必须给线程命名,否则 jstack 时全是 pool-1-thread-1,排查困难。
  6. 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 是基础。
  • AQSReentrantLockSemaphoreCountDownLatch 的共同底座。
  • 线程池:手写 ThreadPoolExecutor,明确队列容量与拒绝策略,按 CPU/IO 类型估算核心数。
  • ThreadLocal:用完必 remove(),线程池下尤其致命。