Skip to content

JUC 并发编程

Synchronized 锁升级(偏向/轻量/重量锁)、AQS 底层架构、volatile 内存屏障、CAS 与 ABA、线程池 7 大参数与 4 大拒绝策略。

Updated View as Markdown
For humans

JUC 并发编程

校招考察频度:★ 高频(核心必考·高频生死线) 一句话速记:Synchronized 借 Mark Word 从无锁经偏向、轻量自旋膨胀为内核重量锁;AQS 借 state 与 CLH 队列统领锁实现;volatile 靠 MESI 与内存屏障保可见禁重排;CAS 防 ABA 需加版本号;线程池 7 参数严控饱和,严禁无界队列防 OOM。

Java 的并发包(java.util.concurrent,JUC)由大师 Doug Lea 操刀,是国内各大互联网大厂考察候选人“多线程系统编程内功”的核心阵地。


一、Synchronized 底层原理与锁膨胀升级

在 JDK 1.6 之前,synchronized 是纯粹的重量级锁,每次加锁都会直接调用操作系统的互斥量(Mutex Lock),带来用户态与内核态的高昂切换开销。JDK 1.6 对其进行了全面重构,基于 Java 对象头实现了锁升级机制。

1. 对象头 Mark Word 与锁标志位(64 位 JVM)

一个 Java 对象在内存中由三部分组成:对象头(Mark Word + Klass Word 类型指针)、实例数据、对齐填充。锁状态完全记录在 Mark Word 的最后几位:

锁状态存储内容偏向锁标志位 (1 bit)锁标志位 (2 bits)
无锁 (Unlocked)对象的 hashCode、分代年龄 (4 bits)001
偏向锁 (Biased)偏向的线程 ID (Thread ID)、Epoch、分代年龄101
轻量级锁 (Lightweight)指向加锁线程栈帧中 Lock Record(锁记录) 的指针000
重量级锁 (Heavyweight)指向堆外 ObjectMonitor 监视器对象的指针010
GC 标记空011

2. 锁升级时序流(不可逆过程)

       ┌─────────────────────────────────────────────────────────────┐
       │ 无锁状态 (001)                                               │
       └──────────────────────────────┬──────────────────────────────┘
                                      │ 只有一个线程反复访问
                                      ▼
       ┌─────────────────────────────────────────────────────────────┐
       │ 偏向锁 (101): CAS 将线程 ID 写入 Mark Word,后续无需加锁操作 │
       └──────────────────────────────┬──────────────────────────────┘
                                      │ 出现另一个线程交替竞争 (无激烈冲突)
                                      ▼
       ┌─────────────────────────────────────────────────────────────┐
       │ 轻量级锁 (000): 撤销偏向,在线程栈分配 Lock Record,CAS 自旋抢锁│
       └──────────────────────────────┬──────────────────────────────┘
                                      │ 自旋等待超时,或发生多线程激烈阻塞竞争
                                      ▼
       ┌─────────────────────────────────────────────────────────────┐
       │ 重量级锁 (010): 膨胀为 ObjectMonitor,阻塞线程由内核挂起调度 │
       └─────────────────────────────────────────────────────────────┘
  • 偏向锁:假设绝大多数锁只会被同一个线程多次获取。首次加锁用 CAS 把自己的 thread_id 写入 Mark Word。只要下次还是该线程,直接放行,零额外 CPU 开销。
  • 轻量级锁(自旋锁):当有第二个线程尝试争夺锁,偏向锁被撤销。竞争线程在自己的线程栈中建立 Lock Record,通过 CAS 尝试将 Mark Word 指针指向自己的 Lock Record。未抢到的线程不阻塞,而是进行自适应自旋(Adaptive Spinning),在用户态空转等待锁释放。
  • 重量级锁:当自旋达到一定次数仍未获取锁,或者有第三个线程介入激烈冲突,轻量级锁膨胀为重量级锁。Mark Word 指向操作系统级 ObjectMonitor,竞争失败的线程直接进入 _EntryList 阻塞队列,调用操作系统的 pthread_mutex 挂起,交出 CPU。

二、AQS(AbstractQueuedSynchronizer)核心架构

AQS 是整个 JUC 并发包的心脏。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 全都建立在 AQS 之上。

AQS state 状态机与 CLH 双向同步阻塞队列

1. AQS 三大核心组件

  1. volatile int state 状态变量:
    • 在独占锁(ReentrantLock)中:0 表示无锁;1 表示已被某个线程独占持有;大于 1 表示持有锁的线程重入的次数。
    • 在共享锁(Semaphore)中:表示当前剩余可用许可证配额。
    • 在 CountDownLatch 中:表示剩余未倒数计数的线程数。
  2. CLH 双向 FIFO 阻塞等待队列:
    • 内部由 Node 节点构成的双向链表。每个未抢到锁的线程会被封装成 Node,通过 CAS 压入队尾并调用 LockSupport.park() 挂起。
    • 释放锁时,持有锁的线程更新 state,并调用 LockSupport.unpark() 唤醒队首头节点的后继线程。
  3. CAS 原子状态变更:
    • 所有的 state 更改与队列入队操作全部依赖底层硬件原语 CAS 保证多线程原子安全。

2. 公平锁(FairSync)vs 非公平锁(NonfairSync)

  • 非公平锁(默认):新线程尝试加锁时,不管队列里有没有排队的线程,直接上来 CAS 抢一次!如果恰好锁刚释放,新线程直接“插队”成功。
    • 优点:吞吐量远高于公平锁!因为唤醒队列中挂起线程需要从内核态唤醒调度(存在几微秒到十几微秒的延迟),非公平锁允许刚来的就绪线程立即利用这空闲窗口,减少了线程上下文切换的等待惩罚。
  • 公平锁:加锁时调用 hasQueuedPredecessors(),只要排队队列里有前驱节点正在等待,自己老老实实进入队尾排队。

三、volatile 关键字与内存屏障

1. volatile 的两大核心能力

  1. 保证跨线程可见性:
    • 依据 JMM(Java 内存模型),每个线程拥有独立的工作内存(CPU L1/L2 高速缓存)。
    • 被 volatile 修饰的变量,一旦被线程修改,JVM 会在写指令后发出硬件级总线信号,强制立即刷回主内存(Main Memory);同时依据 CPU 的 MESI 缓存一致性协议,强制使其他 CPU 核心持有的该变量缓存行失效,其他线程读取时必须重新穿透到主内存载入最新值。
  2. 禁止指令重排序:
    • 编译器和处理器为了极致优化吞吐,经常会对不相关的指令进行重新排列。
    • JVM 在 volatile 读写操作前后插入 Memory Barrier(内存屏障指令):
      • 写操作前插 StoreStore,写操作后插 StoreLoad(阻止 volatile 写与前后的读写重排)。
      • 读操作后插 LoadLoad 与 LoadStore(阻止 volatile 读与后续的读写重排)。

2. 经典拷问:DCL 双重检查锁定单例模式为什么必须加 volatile?

public class Singleton {
    // 必须加 volatile,否则多线程下会出现半成品对象!
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查:避免无谓加锁
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查:防止并发下重复创建
                    instance = new Singleton(); // 问题发生的根源行!
                }
            }
        }
        return instance;
    }
}
  • 问题本质:instance = new Singleton() 在 JVM 字节码中分为 3 步:
    1. memory = allocate(); // 在堆中分配内存空间
    2. ctorInstance(memory); // 执行构造器,初始化对象成员
    3. instance = memory; // 将内存指针赋值给引用变量
  • 如果不加 volatile:编译器和 CPU 可能会进行指令重排序,优化执行顺序为:1 ➔ 3 ➔ 2。
  • 灾难时序:线程 A 执行了 1 和 3,此时 instance != null,但对象尚未初始化(步 2 尚未执行);此时线程 B 到达第一次判空,发现 instance 不为 null,直接返回该对象并在业务中使用,导致系统发生空指针异常(NPE)或数据诡异崩溃!加 volatile 彻底阻断了步 2 和步 3 的重排。

四、CAS 与 ABA 问题

1. CAS 原理

  • CAS(Compare-And-Swap,比较并交换)包含三个操作数:内存地址 V、旧的预期值 A、拟写入的新值 B。
  • 当且仅当内存地址 V 的值等于预期值 A 时,才用原子指令将其更新为 B,否则不更新并返回失败,调用方通常死循环自旋重试。
  • 底层映射为 CPU 硬件级原子指令:x86 架构下的 lock cmpxchg。

2. ABA 问题及解决方案

  • ABA 场景:
    1. 线程 1 准备将变量从 A 改为 B。
    2. 此时线程 2 抢先介入,将变量从 A 改为 C,随后又改回 A。
    3. 线程 1 恢复执行,检查变量值仍然为 A,CAS 判定成功并写入 B。
    4. 虽然数值未变,但在此期间对象的实际状态、内部链表指针或时间跨度可能已经发生了翻天覆地的变化!
  • 解决方案:加版本号 / 时间戳
    • 使用 AtomicStampedReference<V>:每次更新不仅比较对象的值,同时必须比较对象的整数版本号(Stamp)。
    CAS([expectedRef, expectedStamp], [newRef, newStamp])
    即使值从 A -> B -> A,版本号依然单调自增(1 -> 2 -> 3),彻底粉碎 ABA 隐患。

五、线程池 7 大参数与饱和策略深度实战

1. ThreadPoolExecutor 7 大核心参数

public ThreadPoolExecutor(
    int corePoolSize,               // 1. 核心线程数 (常驻线程,不会被销毁)
    int maximumPoolSize,            // 2. 最大线程数 (核心 + 临时应急线程上限)
    long keepAliveTime,             // 3. 空闲线程存活时间
    TimeUnit unit,                  // 4. 时间单位 (如 TimeUnit.SECONDS)
    BlockingQueue<Runnable> workQueue, // 5. 阻塞任务等待队列
    ThreadFactory threadFactory,    // 6. 线程创建工厂 (可自定义线程命名规则)
    RejectedExecutionHandler handler// 7. 任务饱和拒绝策略
)

2. 任务提交与线程池调度流程图

ThreadPoolExecutor 任务提交流程图

3. 四大内置拒绝策略

策略类名行为表现适用场景
AbortPolicy (默认)直接抛出 RejectedExecutionException 异常核心业务,要求第一时间感知系统过载报警
CallerRunsPolicy退回给提交任务的主线程自己同步执行绝不能丢弃任务的场景(同时能让主线程忙于干活,天然反压降低提交速度)
DiscardOldestPolicy丢弃阻塞队列最前端(最老)的一个任务,重新尝试提交当前任务适合实时性要求高、旧数据已失去意义的流式处理
DiscardPolicy静默丢弃新来的任务,不报任何异常允许丢失数据的不重要日志上报或监控打点

4. 生产避坑:为什么严禁使用 Executors 快速创建线程池?

《阿里巴巴 Java 开发手册》明文规定:线程池不允许使用 Executors 去创建,而必须通过 ThreadPoolExecutor 的方式。

  • Executors.newFixedThreadPool(n) & newSingleThreadExecutor(): 其内部使用的阻塞队列为 new LinkedBlockingQueue<Runnable>(),默认容量为 Integer.MAX_VALUE(约 21 亿,无界队列)! 在高并发流量洪峰下,任务会不断堆积到队列中,瞬间榨干 JVM 堆内存,触发 OutOfMemoryError: Java heap space 导致服务整体宕机!
  • Executors.newCachedThreadPool(): 其允许创建的最大线程数为 Integer.MAX_VALUE!瞬时并发请求暴涨时,会疯狂创建成千上万个系统级线程,耗尽操作系统线程句柄与内存,直接引发 OOM 和系统雪崩。

5. 核心线程数如何合理配置?

  • CPU 密集型任务(如加密、压缩、数学计算):
    核心线程数 = N_cpu + 1
    (+1 是为了防止某个线程偶尔发生缺页中断等暂停时,备用线程能立刻补上,压满 CPU 满载运行)。
  • I/O 密集型任务(绝大多数后端 Web 接口:查 DB、读 Redis、调 RPC):
    核心线程数 = N_cpu * (1 + I/O 等待时间 / CPU 计算时间) ≈ 2 * N_cpu
    (因为大部分时间线程都在阻塞等待网络或磁盘 I/O 返回,CPU 大量空闲,需要更多的并发线程以交替榨干多核 CPU 吞吐)。

六、校招高频面试追问

  1. ThreadLocal 的原理是什么?会导致内存泄漏吗?
    • 每个 Thread 内部维护一个 ThreadLocalMap,其底层由 Entry[] 数组构成。Entry 的 Key 是弱引用(WeakReference<ThreadLocal<?>>),而 Value 是强引用。
    • 内存泄漏根源:GC 运行时,弱引用的 Key 会被回收变为 null,但 Value 作为强引用依然被存活的 Thread 引用着。如果是在线程池环境下,核心线程长生不灭,这个 Value 对应的内存就永远无法被释放!
    • 防范铁律:使用完 ThreadLocal 后,必须显式在 finally 块中调用 threadLocal.remove() 清理!
  2. ReentrantLock 和 synchronized 有什么区别?怎么选?
    • synchronized 是 JVM 语言原生关键字,自动加锁释放,支持锁升级机制;
    • ReentrantLock 是 API 级别的类,功能更丰富:① 支持公平锁/非公平锁;② 支持响应中断 lockInterruptibly();③ 支持超时尝试获取锁 tryLock();④ 支持绑定多个 Condition 条件变量实现精准线程唤醒。
    • 选型:常规并发场景首选简洁的 synchronized;需要超时、中断或高级条件编排时才选 ReentrantLock。
  3. CountDownLatch 和 CyclicBarrier 有什么区别?
    • CountDownLatch(倒数计数器):基于 AQS 共享锁,一个或多个线程等待其他 N 个线程执行完成(调用 countDown() 直至 0 唤醒),只能使用一次,不可重置。
    • CyclicBarrier(循环栅栏):让一组线程到达一个公共屏障点时全部阻塞,直到最后一个线程到达屏障,屏障才打开让所有线程继续;可以通过 reset() 重复循环使用。
  4. Java 线程的状态有哪些?
    • 依据 Thread.State 枚举,共 6 种状态:
      1. NEW(新建未 start)
      2. RUNNABLE(运行中,包含正在运行与就绪等待 CPU 时间片)
      3. BLOCKED(阻塞等待进入 synchronized 监视器锁)
      4. WAITING(无限期等待,如调用 wait()、join()、LockSupport.park())
      5. TIMED_WAITING(超时等待,如 sleep(ms)、wait(ms))
      6. TERMINATED(已终止死亡)。
Navigation

Type to search…

↑↓ navigate↵ selectEsc close