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) | 0 | 01 |
| 偏向锁 (Biased) | 偏向的线程 ID (Thread ID)、Epoch、分代年龄 | 1 | 01 |
| 轻量级锁 (Lightweight) | 指向加锁线程栈帧中 Lock Record(锁记录) 的指针 | 0 | 00 |
| 重量级锁 (Heavyweight) | 指向堆外 ObjectMonitor 监视器对象的指针 | 0 | 10 |
| GC 标记 | 空 | 0 | 11 |
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 三大核心组件
volatile int state状态变量:- 在独占锁(
ReentrantLock)中:0 表示无锁;1 表示已被某个线程独占持有;大于 1 表示持有锁的线程重入的次数。 - 在共享锁(
Semaphore)中:表示当前剩余可用许可证配额。 - 在
CountDownLatch中:表示剩余未倒数计数的线程数。
- 在独占锁(
- CLH 双向 FIFO 阻塞等待队列:
- 内部由
Node节点构成的双向链表。每个未抢到锁的线程会被封装成 Node,通过 CAS 压入队尾并调用LockSupport.park()挂起。 - 释放锁时,持有锁的线程更新 state,并调用
LockSupport.unpark()唤醒队首头节点的后继线程。
- 内部由
- CAS 原子状态变更:
- 所有的
state更改与队列入队操作全部依赖底层硬件原语 CAS 保证多线程原子安全。
- 所有的
2. 公平锁(FairSync)vs 非公平锁(NonfairSync)
- 非公平锁(默认):新线程尝试加锁时,不管队列里有没有排队的线程,直接上来 CAS 抢一次!如果恰好锁刚释放,新线程直接“插队”成功。
- 优点:吞吐量远高于公平锁!因为唤醒队列中挂起线程需要从内核态唤醒调度(存在几微秒到十几微秒的延迟),非公平锁允许刚来的就绪线程立即利用这空闲窗口,减少了线程上下文切换的等待惩罚。
- 公平锁:加锁时调用
hasQueuedPredecessors(),只要排队队列里有前驱节点正在等待,自己老老实实进入队尾排队。
三、volatile 关键字与内存屏障
1. volatile 的两大核心能力
- 保证跨线程可见性:
- 依据 JMM(Java 内存模型),每个线程拥有独立的工作内存(CPU L1/L2 高速缓存)。
- 被
volatile修饰的变量,一旦被线程修改,JVM 会在写指令后发出硬件级总线信号,强制立即刷回主内存(Main Memory);同时依据 CPU 的 MESI 缓存一致性协议,强制使其他 CPU 核心持有的该变量缓存行失效,其他线程读取时必须重新穿透到主内存载入最新值。
- 禁止指令重排序:
- 编译器和处理器为了极致优化吞吐,经常会对不相关的指令进行重新排列。
- 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 步:memory = allocate();// 在堆中分配内存空间ctorInstance(memory);// 执行构造器,初始化对象成员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 准备将变量从 A 改为 B。
- 此时线程 2 抢先介入,将变量从 A 改为 C,随后又改回 A。
- 线程 1 恢复执行,检查变量值仍然为 A,CAS 判定成功并写入 B。
- 虽然数值未变,但在此期间对象的实际状态、内部链表指针或时间跨度可能已经发生了翻天覆地的变化!
- 解决方案:加版本号 / 时间戳
- 使用
AtomicStampedReference<V>:每次更新不仅比较对象的值,同时必须比较对象的整数版本号(Stamp)。
即使值从 A -> B -> A,版本号依然单调自增(1 -> 2 -> 3),彻底粉碎 ABA 隐患。CAS([expectedRef, expectedStamp], [newRef, newStamp]) - 使用
五、线程池 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 密集型任务(如加密、压缩、数学计算):
(+1 是为了防止某个线程偶尔发生缺页中断等暂停时,备用线程能立刻补上,压满 CPU 满载运行)。核心线程数 = N_cpu + 1 - I/O 密集型任务(绝大多数后端 Web 接口:查 DB、读 Redis、调 RPC):
(因为大部分时间线程都在阻塞等待网络或磁盘 I/O 返回,CPU 大量空闲,需要更多的并发线程以交替榨干多核 CPU 吞吐)。核心线程数 = N_cpu * (1 + I/O 等待时间 / CPU 计算时间) ≈ 2 * N_cpu
六、校招高频面试追问
ThreadLocal的原理是什么?会导致内存泄漏吗?- 每个
Thread内部维护一个ThreadLocalMap,其底层由Entry[]数组构成。Entry 的 Key 是弱引用(WeakReference<ThreadLocal<?>>),而 Value 是强引用。 - 内存泄漏根源:GC 运行时,弱引用的 Key 会被回收变为
null,但 Value 作为强引用依然被存活的Thread引用着。如果是在线程池环境下,核心线程长生不灭,这个 Value 对应的内存就永远无法被释放! - 防范铁律:使用完
ThreadLocal后,必须显式在finally块中调用threadLocal.remove()清理!
- 每个
ReentrantLock和synchronized有什么区别?怎么选?synchronized是 JVM 语言原生关键字,自动加锁释放,支持锁升级机制;ReentrantLock是 API 级别的类,功能更丰富:① 支持公平锁/非公平锁;② 支持响应中断lockInterruptibly();③ 支持超时尝试获取锁tryLock();④ 支持绑定多个Condition条件变量实现精准线程唤醒。- 选型:常规并发场景首选简洁的
synchronized;需要超时、中断或高级条件编排时才选ReentrantLock。
CountDownLatch和CyclicBarrier有什么区别?CountDownLatch(倒数计数器):基于 AQS 共享锁,一个或多个线程等待其他 N 个线程执行完成(调用countDown()直至 0 唤醒),只能使用一次,不可重置。CyclicBarrier(循环栅栏):让一组线程到达一个公共屏障点时全部阻塞,直到最后一个线程到达屏障,屏障才打开让所有线程继续;可以通过reset()重复循环使用。
- Java 线程的状态有哪些?
- 依据
Thread.State枚举,共 6 种状态:NEW(新建未 start)RUNNABLE(运行中,包含正在运行与就绪等待 CPU 时间片)BLOCKED(阻塞等待进入 synchronized 监视器锁)WAITING(无限期等待,如调用wait()、join()、LockSupport.park())TIMED_WAITING(超时等待,如sleep(ms)、wait(ms))TERMINATED(已终止死亡)。
- 依据