Java 并发与同步原语深度(JMM / volatile / CAS / synchronized / AQS → Netty 无锁 → String 设计)
10 题 · 平均 2.6/5 · low 3.33 · medium 3 · high 1.33 · 第 1 轮
**核心考点定位**:能否清晰区分并发/并行本质,并延伸到并发编程的工程代价。
**知识点拆解**:
1. **底层定义差异**:并行 = "同一时间点上多个任务真的同时执行",需要多个执行单元(多核 CPU);并发 = "多个任务在一段时间窗口内交替执行、看上去同时",可在单核通过时间片实现
2. **调度机制**:并发依赖操作系统分时调度(time-slicing),上下文切换是核心动作;并行由 OS 把任务分配到多核,各核独立跑,还涉及 NUMA 亲和性
3. **工程代价**:并发引入三大问题——竞态(race condition)、可见性(visibility)、有序性(ordering);这是后续 JMM/volatile/synchronized 的根源
4. **Java 中的体现**:线程(用户态并发)、线程池(复用减少创建开销)、CompletableFuture(异步编排)、虚拟线程(JDK21,M:N 调度让并发更轻量)
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否指出"同一时间点"这个关键差别
- **广度**:能否延伸到线程、线程池、虚拟线程、JMM
- **应用**:能否主动指出并发带来的工程代价
**延伸递进面试题**:
1. **Java 中有哪些并发编程的工具?分别解决什么问题?**
- 解答要点:线程(基础)、线程池(复用)、ExecutorService(抽象)、CompletableFuture(编排)、ForkJoinPool(分治)、AtomicXxx/CAS(无锁)、虚拟线程(轻量)
2. **并发和并行哪个性能更好?**
- 解答要点:并行扩展性线性(受核数约束),并发受上下文切换开销影响;Amdahl 定律限制并行加速比
3. **虚拟线程为什么让并发更简单?**
- 解答要点:M:N 调度(少量载体线程映射大量虚拟线程)、栈内存只占几十 KB、同步阻塞不占平台线程
面试官您好,我先说一下我的理解。并发和并行看着像一回事,但本质是完全两种东西。
**并行的核心是"同一时间点上真的同时"**,它需要多个执行单元——也就是多核 CPU——来支撑。双核 CPU 上两个任务可以真的在同一时钟周期上各占一个核跑。单核 CPU 无论怎么折腾都做不出真正的并行。
**并发的核心是"多个任务在一段时间窗口内交替执行,看上去像是同时"**,它允许在单核上通过操作系统的分时调度实现。OS 给每个任务分配一个时间片,用完就切换上下文,因为切换足够快,用户看上去就是同时在进行。
所以关键区别是:并行强调"同一时间点",并发强调"一段时间窗口"。并行是硬件能力决定的,并发是调度策略决定的。
进一步说,并发会带来工程代价。多个任务同时访问共享资源时有**竞态条件**;一个线程改了共享变量另一个看不到,这是**可见性**问题;指令重排会带来**有序性**问题。这三个是并发编程最头疼的部分。Java 提供了一整套工具来解决:volatile 保证可见性和有序性、synchronized 和 Lock 保证互斥、AtomicXxx 和 CAS 提供无锁原子操作。
Java 生态里常见载体有:线程是基础、线程池做复用减少创建开销、CompletableFuture 编排异步任务、JDK 21 虚拟线程让并发更轻量。
以上就是我对并发和并行的理解。
**核心考点定位**:能否说清"多线程+共享资源"这个前提,以及互斥/可见性/有序性三个层次。
**知识点拆解**:
1. **底层前提**:线程安全只在"多线程 + 共享可变资源"同时成立时才有意义;无共享、无状态、无状态可变都不构成安全问题
2. **三个层次**:
- **互斥(Mutual Exclusion)**:同一时刻只有一个线程能修改,避免"读改写"竞态
- **可见性(Visibility)**:一个线程的修改对其他线程立即可见(涉及内存屏障、CPU 缓存一致性)
- **有序性(Ordering)**:指令不能随意重排破坏业务语义(涉及 JIT 优化、CPU 乱序执行)
3. **实现手段谱系**:
- 强制同步:synchronized、ReentrantLock、ReadWriteLock
- 无锁原子:AtomicXxx、CAS、VarHandle
- 弱化共享:ThreadLocal、不可变对象、无状态化
- 单线程:Redis 主线程、Netty 单 Reactor 线程
4. **反例与陷阱**:ConcurrentHashMap 不是线程安全的(只保证基本操作原子,复合操作仍需同步);synchronized 块内调用外部方法可能丢锁
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否区分"互斥/可见性/有序性"三个层次
- **广度**:能否列举多种实现手段,说出各自的适用场景
- **应用**:能否举出工程中的具体线程安全/不安全案例
**延伸递进面试题**:
1. **ConcurrentHashMap 是线程安全的吗?**
- 解答要点:基本操作(get/put)线程安全,但复合操作(先判断再 put、遍历中修改)不安全;需用 computeIfAbsent 等原子方法
2. **synchronized 和 volatile 分别解决什么问题?**
- 解答要点:synchronized 同时解决互斥+可见性+有序性;volatile 只解决可见性+有序性,不保证互斥(i++ 不行)
3. **有哪些"看起来安全实际不安全"的坑?**
- 解答要点:双重检查单例必须 volatile;HashMap 并发 put 会死循环;SimpleDateFormat 非线程安全;ThreadLocal 在线程池会污染
面试官您好,线程安全的本质是"多线程访问共享可变资源时,任意时刻的执行结果都符合预期"。我把它拆成三个层次来说。
**第一个层次是互斥**——同一时刻只能有一个线程修改共享数据,避免"读-改-写"竞态。经典例子是 i++,其实是三条指令:读 i、i+1、写回,两个线程同时执行就会丢更新。synchronized 和 Lock 就是干这个的。
**第二个层次是可见性**——一个线程改了共享变量,其他线程必须能立刻看到新值。这里有个反直觉的点:现代 CPU 每个核有自己的 L1 缓存,一个核改了主存,另一个核的缓存不会立刻失效,JIT 还会重排指令。volatile 就是给编译器加屏障、给 CPU 加 MESI 协议失效,强制写操作刷到主存、读操作从主存取。
**第三个层次是有序性**——编译器和 CPU 会重排指令来提升性能,但有时候会破坏业务语义,比如双重检查锁单例,如果不加 volatile,实例可能以"半初始化"的状态被其他线程看到。
工程上的应对手段我大概分四类:一是**强制同步**,synchronized 和 ReentrantLock,重量级但灵活;二是**无锁原子**,AtomicXxx 和 CAS,性能好但有 ABA 和自旋开销;三是**弱化共享**,ThreadLocal、不可变对象、纯函数;四是**单线程化**,比如 Redis 主线程、Netty 单 Reactor。
举几个反例说明"看起来安全实际不安全":ConcurrentHashMap 的 get/put 安全,但复合操作还是要同步;HashMap 并发 put 甚至会死循环;SimpleDateFormat 是经典的非线程安全类。
这就是我对线程安全的理解。
---
**核心考点定位**:能否说清 CAS 的三要素、OS 层面实现,以及三个常见痛点。
**知识点拆解**:
1. **底层机制**:CAS 全称 Compare And Swap,三个要素:
- `expected`(期望值):线程认为内存中的当前值
- `actual`(实际值):内存中真实的值
- `new`(新值):期望交换成的值
只有当 `actual == expected` 时才把内存改为 `new`,否则什么都不做(CAS 失败,通常自旋重试)
2. **OS/硬件层面**:靠 CPU 的原子指令实现,x86 上是 `cmpxchg`,单条指令完成比较+交换,不会被中断打断;Java 里通过 Unsafe 或 VarHandle 调用,是用户态的"无锁"原语
3. **三大痛点**:
- **ABA 问题**:A → B → A,中间被改过又改回来了,CAS 看不出差异;解决:AtomicStampedReference(值+版本号)、AtomicMarkableReference(值+boolean 标记)
- **自旋开销**:长时间 CAS 失败会一直占着 CPU,CPU 密集场景反而拖慢整体;解决:LongAdder 分段累加、退避策略
- **单变量原子性**:只能保证一个变量,复合操作(a++ 且 b--)仍要外部同步
4. **典型应用**:AtomicInteger/AtomicLong(计数器)、AtomicReference(引用计数)、ConcurrentHashMap 的 put(cell 级别的 CAS)
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否说清 CAS 在硬件层的实现(cmpxchg)
- **广度**:能否系统覆盖 ABA、自旋、单变量三个问题
- **应用**:能否举出 AtomicXxx 和 ConcurrentHashMap 中的 CAS 用法
**延伸递进面试题**:
1. **ABA 问题在工程里真的遇到过吗?怎么发现的?**
- 解答要点:栈的无锁实现最经典,push 一个元素被两次弹出再压回去;用 AtomicStampedReference 解决
2. **CAS 和 synchronized 怎么选?**
- 解答要点:低竞争 + 临界区极短用 CAS;高竞争或临界区长用 synchronized(锁的线程阻塞让出 CPU)
3. **LongAdder 和 AtomicLong 的区别?**
- 解答要点:AtomicLong 单变量 CAS 竞争激烈;LongAdder 用 Cell 数组分段累加,读时再汇总,高并发写入性能更好
面试官您好,CAS 全称 Compare And Swap,是并发编程里最基础的无锁原子操作。
**它的三个要素是 expected、actual 和 new**:线程先读一下内存值,作为 expected;如果内存中的实际值 actual 等于 expected,就把内存改成 new;否则什么都不做,通常自旋重试。CAS 之所以能"无锁",是因为它依赖 CPU 的原子指令,x86 上是 cmpxchg,一条指令完成比较+交换,不会被中断打断,也不用进内核态拿锁。
**CAS 有三个常见的问题**:
**第一是 ABA 问题**。我先看到值是 A,想把它改成 C;但我准备执行 CAS 的这段时间里,另一个线程把 A 改成了 B,紧接着又把它改回了 A;这时候我的 CAS 看到还是 A,就成功了,但实际上这个"A"已经和当初的"A"不是同一个东西了,中间的业务语义全错了。解决方案是引入版本号,比如 Java 的 AtomicStampedReference 就是"值 + 版本号"配对,每次修改都让版本号递增,ABA 自然被识别出来。
**第二是自旋开销**。CAS 失败要重试,如果竞争激烈,线程就一直自旋占着 CPU,不像锁那样会阻塞让出。极端情况下 CPU 会被打满。工程上遇到这种场景常用 LongAdder,它把累加器拆成 Cell 数组,多线程各写各的分段,读的时候再汇总。
**第三是单变量原子性**。CAS 只能保证一个变量的原子,如果你要做 a++ 和 b-- 这种复合操作,CAS 管不住,还是得回到 synchronized 或者用复合类型的原子方法。
典型应用上,AtomicInteger、AtomicLong、AtomicReference 都是基于 CAS 的,ConcurrentHashMap 的 put 也是靠 cell 级别的 CAS 来保证并发安全。
这就是我对 CAS 的理解。
**核心考点定位**:能否说清 volatile 的两大特性底层机制(MESI + 内存屏障),以及"不保证原子性"的边界。
**知识点拆解**:
1. **可见性的两层机制**:
- **CPU 层**:写 volatile 变量时,Java 会生成 `lock` 前缀指令,触发 **MESI 协议**把本核 L1 缓存的 modified 状态写回主存,同时广播"无效"指令给其他核,让它们下次必须从主存取
- **JVM/编译器层**:写操作强制刷写、读操作强制 reload,禁止指令和数据的缓存优化
2. **有序性的内存屏障**(JMM 定义 4 种):
- StoreStore:volatile 写之前,把之前所有普通变量都刷出去
- **StoreLoad**:volatile 写之后(最贵的一种),确保写之前的写都对其他核可见、以及写之后的读一定拿到新值
- LoadLoad:volatile 读之后,读后续操作不能提前到读之前
- LoadStore:volatile 读之前,确保读的读不会重排到 store 之后
3. **不保证原子性**:volatile 只保证每次单条读/写操作的可见性,`i++` 是三步(读、加、写),两个线程同时执行会丢更新——这是 volatile 最经典的反例
4. **典型应用场景**:
- **状态标志位**:`volatile boolean stop;` 主线程改了从线程立刻看到
- **DCL 单例**:`private volatile static Instance instance;`——不加 volatile 会出现"半初始化"实例被其他线程看到(指令重排:分配内存 → 赋引用 → 初始化构造器)
- **双端指针的 tail 引用**
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否说清 MESI + 内存屏障 + lock 前缀这条链路
- **广度**:能否对比 volatile 和 CAS、synchronized
- **应用**:能否举出 DCL、状态标志位等真实用例
**延伸递进面试题**:
1. **volatile 能保证原子性吗?为什么?**
- 解答要点:不能,i++ 是三步;volatile 只保证单条读/写的可见性,不保证读-改-写的互斥
2. **有了 CAS 为啥还需要 volatile?**
- 解答要点:CAS 保证原子操作但只作用于一个变量;volatile 保证可见性和有序性,是"读/写屏障";两者解决的是不同维度的问题,DCL 必须同时用
3. **DCL 单例为什么必须加 volatile?**
- 解答要点:new 指令会被重排成"分配内存→赋引用→初始化",不加 volatile 时其他线程可能看到"已赋引用但未初始化"的半构造实例
面试官您好,volatile 是 Java 最经典的并发关键字,它保证两件事:**可见性**和**有序性**,但**不保证原子性**。
**先说可见性**。现代 CPU 每个核都有自己的 L1/L2 缓存,线程改了共享变量可能只改了自己核的缓存,别的核看不到。volatile 通过两层机制解决:
第一层是 **CPU 层的 MESI 协议**。Java 在写 volatile 变量时会生成 `lock` 前缀的指令,这条指令会强制把本核 L1 的 modified 数据写回主存,同时广播"无效"命令给其他核,让它们下次读必须从主存重新加载。
第二层是 **JVM/编译器层**。volatile 写会禁止 store-store 重排、volatile 读会禁止 load-load 和 load-store 重排。
**再说有序性**。JVM 层面定义了 4 种内存屏障:StoreStore、StoreLoad、LoadLoad、LoadStore。volatile 写之前是 StoreStore、写之后是 StoreLoad(也是最贵的屏障),volatile 读之后是 LoadLoad 和 LoadStore。这些屏障本质上是禁止 JIT 优化器把指令重排到破坏语义的位置。
**关键的反例是 i++ 不安全**。i++ 其实是三步:读 i、i+1、写回。volatile 只能保证每步的可见性,没法保证三步作为一个整体互斥,两个线程并发执行还是会丢更新。所以要用 AtomicInteger,或者加锁。
**典型应用场景**有几个:
第一个是**状态标志位**,比如 `volatile boolean stop;`,主线程改了从线程能立刻看到。
第二个是**双重检查锁单例**,`private volatile static Instance instance;`。这里必须加 volatile,因为 `new` 指令会被 JVM 重排成"分配内存→赋引用→初始化构造器",不加 volatile 时,另一个线程可能看到"已经赋引用但还没初始化"的半构造实例,直接用就 NPE。
第三个是**双端队列的 tail 引用**,比如 JDK 里 ArrayBlockingQueue 的 last 字段也是 volatile。
总结:volatile 用 MESI + 内存屏障保证可见性和有序性,但不保证原子性,所以适合单变量读写场景,复杂复合操作还是要用 synchronized 或 CAS。
**核心考点定位**:能否说清 AQS 的全称、state 变量、CLH 队列、独占/共享两种模式,以及模板方法的设计。
**知识点拆解**:
1. **全称与定位**:AQS = AbstractQueuedSynchronizer(抽象队列同步器),位于 `java.util.concurrent.locks` 包,是 JUC 并发工具的基座。几乎所有同步器(ReentrantLock、Semaphore、CountDownLatch、CyclicBarrier、ReadWriteLock)都直接或间接继承它
2. **两大核心数据结构**:
- **`volatile int state`**:同步状态,记录锁的"当前情况"。ReentrantLock 用 state 表示重入次数、Semaphore 用 state 表示剩余许可数、CountDownLatch 用 state 表示倒计时
- **CLH 双向队列**(Treiber 队列变种):获取锁失败的线程会被封装成 Node 节点,加入尾链表等待。head 是头节点(已获取锁的线程),tail 是尾指针
3. **两种工作模式**:
- **独占模式(Exclusive)**:一次只有一个线程能持有锁,如 ReentrantLock、ReentrantReadWriteLock 的写锁。子类实现 `tryAcquire` / `tryRelease`
- **共享模式(Shared)**:多个线程可同时持有,如 ReadLock、Semaphore、CountDownLatch。子类实现 `tryAcquireShared` / `tryReleaseShared`
4. **模板方法模式**:父类 AQS 负责排队、唤醒、调度;子类只实现"拿到锁/释放锁"的具体判断逻辑。这是 AQS 设计得优雅的关键——把"如何判断"和"如何调度"解耦
5. **与 synchronized 的区别**:
- synchronized 由 JVM 内置实现,不可中断、不可超时、公平/非公平由 JVM 决定
- AQS 是用户态实现,支持 `lockInterruptibly`、`tryLock(timeout)`、`isFair` 显式控制
- 高竞争下 AQS 通常更快(可优化调度),低竞争下两者差异不大
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否说清 state 变量 + CLH 队列 + 独占/共享两条线
- **广度**:能否列举主要子类并说出它们的 state 含义
- **应用**:能否举出工程中用 ReentrantLock 而非 synchronized 的场景
**延伸递进面试题**:
1. **AQS 为什么用双向链表而不是单向?**
- 解答要点:释放锁时要唤醒前驱的后继节点(独占)或所有后继(共享),单向链表无法高效遍历;且线程中断场景需要反向唤醒
2. **ReentrantLock 怎么实现可重入?**
- 解答要点:state 记录重入次数,同一线程再次获取时 state++,释放时 state--,直到 0 才真正释放
3. **公平锁 vs 非公平锁的区别?**
- 解答要点:公平锁 tryAcquire 时先看 hasQueuedPredecessors(队列里有没有更老的线程);非公平锁直接 CAS 抢,抢到就是它。非公平锁吞吐更高,公平锁避免饥饿
面试官您好,AQS 全称是 **AbstractQueuedSynchronizer,抽象队列同步器**,位于 `java.util.concurrent.locks` 包下,是 JUC 整个并发包的基座。ReentrantLock、Semaphore、CountDownLatch、CyclicBarrier、ReadWriteLock 都是直接或间接继承它。
**AQS 的核心有两个**:
第一是 **`volatile int state` 状态变量**。它记录了同步器的"当前情况",但具体含义由子类决定:ReentrantLock 用 state 表示重入次数,Semaphore 用 state 表示剩余许可数,CountDownLatch 用 state 表示倒计时。state 用 volatile 修饰,保证可见性。
第二是 **CLH 双向队列**。当线程获取锁失败时,会被封装成一个 Node 节点挂到队列尾部,进入等待。队列头节点代表当前持有锁的线程,队列尾指针是 tail。释放锁时要遍历队列唤醒后继线程。
**AQS 有两种工作模式**:
**独占模式**:一次只能一个线程拿到锁,典型例子是 ReentrantLock 和 ReadWriteLock 的写锁。子类实现 `tryAcquire` 和 `tryRelease` 两个方法,告诉父类"我拿到了吗"和"我释放了"。
**共享模式**:多个线程可以同时持有,典型例子是 ReadLock、Semaphore、CountDownLatch。子类实现 `tryAcquireShared` 和 `tryReleaseShared`。
AQS 的设计思想是 **模板方法模式**:父类负责排队、唤醒、调度这些通用逻辑;子类只需要关心"怎么判断能不能拿到锁"和"怎么释放"。这样把"调度"和"策略"解耦,扩展性极强。
**和 synchronized 的对比**:synchronized 是 JVM 内置的,不可中断、不可超时、公平性由 JVM 决定;AQS 是用户态实现,支持 `lockInterruptibly`、`tryLock(timeout)`、`isFair` 显式控制,所以业务上需要这些能力时就用 AQS 的 ReentrantLock。
典型用法上,需要可中断、可超时、读写分离、信号量限流、倒计时这类场景,基本都是 AQS 的子类。
这就是我对 AQS 的理解。
---
**核心考点定位**:能否说清对象头 + Mark Word + 锁升级四条路径 + 字节码层实现。
**知识点拆解**:
1. **对象头结构**(HotSpot 64 位):
- **Mark Word(8 字节)**:存储哈希码、GC 分代年龄、**锁标志位(2 位)**、锁指针/偏向线程 ID
- **Klass Pointer(4-8 字节)**:指向方法区的方法元数据
- 数组对象多一个 length 字段
2. **锁的三种使用场景**:
- 修饰**实例方法**:锁 `this` 对象
- 修饰**静态方法**:锁 Class 对象
- 修饰**代码块**:锁指定的对象
3. **锁升级四条路径**(JDK 6 引入,JDK 15 后偏向锁被默认关闭,JDK 18 彻底移除):
- **无锁 → 偏向锁**:只有一个线程访问时,Mark Word 记录线程 ID,之后同线程访问直接放行,不加锁、不 CAS。适合"一次获取、多次访问"的低竞争场景
- **偏向锁 → 轻量级锁**:第二个线程到达时撤销偏向锁,升级为轻量级锁;用 CAS 自旋尝试获取,不进入 OS 阻塞。适合"锁持有时间极短"的中竞争场景
- **轻量级锁 → 重量级锁**:CAS 自旋失败达到阈值(默认 10 次,可 `-XX:PreBlockSpin` 调),膨胀为重量级锁,线程挂起进入 OS 内核调度
- **重量级锁不可降级**:JVM 保证锁只有升级路径,没有降级路径
4. **字节码层**:
- synchronized 方法:类文件常量池里的方法有 `ACC_SYNCHRONIZED` 标志
- synchronized 代码块:使用 `monitorenter` / `monitorexit` / `athrow` 三条指令
5. **Monitor(管程)**:重量级锁的真正实现,每个对象都有一个隐含的 Monitor 对象,包含 owner、waitSet、entryList 等字段
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否说清对象头 Mark Word 的锁标志位 + 四条升级路径
- **广度**:能否对比 synchronized 和 ReentrantLock
- **应用**:能否举出代码块 vs 方法、实例 vs 静态的选择场景
**延伸递进面试题**:
1. **synchronized 的锁能降级吗?**
- 解答要点:不能。JVM 规定锁只有升级没有降级,避免锁状态频繁切换的开销
2. **偏向锁为什么被废弃了?**
- 解答要点:JDK 15 JEP 374 默认关闭,JDK 18 移除。原因是:偏向锁撤销代价高(需要 STW),现代 GC 让偏向锁优化不划算;STW 时代结束偏向锁失去了主要场景
3. **synchronized 和 ReentrantLock 怎么选?**
- 解答要点:低竞争用 synchronized(JVM 内置、零开销、代码简洁);高竞争、需要中断/超时/公平锁/多条件变量用 ReentrantLock
面试官您好,synchronized 的实现我分三个层次来说:**字节码层、对象头层、锁升级层**。
**字节码层**,synchronized 有三种使用方式:修饰实例方法锁 `this`、修饰静态方法锁 Class 对象、修饰代码块锁指定对象。在字节码层面,synchronized 方法有 `ACC_SYNCHRONIZED` 标志位,synchronized 代码块使用 `monitorenter` 和 `monitorexit` 两条指令(异常路径下还有 `athrow` 保证锁释放)。
**对象头层**是核心。HotSpot 64 位对象的对象头主要由两部分组成:**Mark Word**(8 字节,存哈希码、GC 分代年龄、锁标志位、锁指针/线程 ID)和 **Klass Pointer**(4-8 字节,指向方法区的元数据)。Mark Word 里的锁标志位(最后 2 位)直接决定了对象当前是什么锁状态。
**锁升级层**是 synchronized 性能优化的关键,从低到高四条路径:
第一条是 **无锁 → 偏向锁**。当只有一个线程访问时,Mark Word 会记录这个线程的 ID,之后同线程访问直接放行,不加锁也不 CAS。这是最轻量化的状态,适合"一次获取、多次访问"的低竞争场景。不过要注意 **JDK 15 之后偏向锁被默认关闭,JDK 18 已经彻底移除**了,因为偏向锁撤销代价高、需要 STW,在现代 GC 下不划算。
第二条是 **偏向锁 → 轻量级锁**。当第二个线程到达时撤销偏向锁,升级为轻量级锁;线程用 CAS 自旋尝试获取,不进入 OS 阻塞。适合"锁持有时间极短"的中竞争场景。
第三条是 **轻量级锁 → 重量级锁**。当 CAS 自旋失败达到阈值(默认 10 次,可用 `-XX:PreBlockSpin` 调),就膨胀为重量级锁,线程挂起进入 OS 内核调度,通过 Monitor 的 entryList 排队。重量级锁是不可降级的——JVM 保证锁只有升级没有降级路径。
重量级锁的真正实现是 **Monitor(管程)**,每个对象都隐含一个 Monitor 对象,包含 owner、entryList、waitSet 等字段,wait() 方法就是把线程挂到 waitSet 上。
**和 ReentrantLock 的对比**:低竞争场景 synchronized 更合适(JVM 内置、零开销、代码简洁),高竞争或需要中断/超时/公平锁/多条件变量时用 ReentrantLock。
这就是我对 synchronized 实现的理解。
**核心考点定位**:能否说清"依附 + 互补"两条关系链,而不是把二者对立。
**知识点拆解**:
1. **底层依附关系**:CAS 在 Java 里就是**基于 volatile 实现**的。
- `AtomicInteger` 的核心字段是 `private volatile int value;`
- CAS 依赖 volatile 提供的内存屏障,才能保证"比较"时读到的是主存的最新值;否则 JIT 会把值缓存在寄存器里,永远拿不到更新
- 底层链条:volatile 写加 `lock` 前缀指令 → CPU 触发 MESI 失效广播 → 其他核下次必须读主存 → CAS 才能读到真实值
2. **互补关系(不是替代关系)**:
- **volatile** 保证:**单条读/写操作**的可见性 + 有序性
- **CAS** 保证:**读-改-写复合操作**的原子性
- 二者一个管"看"一个管"改",谁也替代不了谁
3. **典型反例**:
- `i++` 用 volatile 不行(读-改-写三步,volatile 只保证每步的可见性)
- `i++` 用 CAS 就行(compareAndSet 把三步打包成一个原子操作)
- 反过来:`AtomicInteger.get()` 不加 CAS 也能正确读,因为它就是 volatile 读
4. **DCL 单例为什么两者都要**:
```java
private volatile static Singleton s; // volatile 防止指令重排
public static Singleton get() {
if (s == null) { // volatile 读,保证可见性
synchronized (Singleton.class) {
if (s == null) { // 二次检查
s = new Singleton(); // CAS 或 volatile 写
}
}
}
return s;
}
```
- synchronized/CAS 保证"判断+赋值"原子性
- volatile 防止 `new` 被重排成"分配内存→赋引用→初始化"导致其他线程看到半构造实例
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否说清 CAS 依赖 volatile 内存屏障这条链路
- **广度**:能否说清"互补而非替代"
- **应用**:能否举 DCL、AtomicXxx 源码等真实用例
**延伸递进面试题**:
1. **i++ 能不能用 volatile?为什么?**
- 解答要点:不能。i++ 是读-改-写三步,volatile 只保证单条操作的可见性,不保证三步互斥
2. **CAS 失败后为什么会自旋?自旋期间能看到最新值吗?**
- 解答要点:能看到,因为每次重试都是 volatile 读,强制从主存拿最新值
3. **Unsafe 和 VarHandle 的 CAS 实现有什么区别?**
- 解答要点:Unsafe 是老 API(JDK 9 后私有化)、VarHandle 是新 API(JDK 9 引入、JDK 17 推荐),底层都走 cmpxchg,但 VarHandle 支持 lambda、可组合、更受支持
面试官您好,CAS 和 volatile 不是竞争关系,而是**依附 + 互补**的关系,这是我觉得这道题的关键点。
**先说依附关系**。在 Java 里,CAS 其实**就是基于 volatile 实现的**。最典型的例子就是 AtomicInteger,它里面的核心字段是 `private volatile int value`,没有这个 volatile,CAS 根本没法工作。为什么?因为 CAS 要做的是"比较并交换",这个比较动作必须先读到主存的真实值。如果没有 volatile 的内存屏障,JIT 会把值缓存在寄存器里,永远拿不到最新值,CAS 的比较就失去了意义。所以从底层链条看:volatile 写时加 `lock` 前缀指令 → CPU 触发 MESI 协议让其他核缓存失效 → 下次读必须从主存取 → CAS 才能读到真实值。CAS 是 volatile 的"消费者"。
**再说互补关系**。volatile 保证的是**单条读/写操作**的可见性和有序性;CAS 保证的是**读-改-写复合操作**的原子性。一个管"看",一个管"改",谁也替代不了谁。
**最经典的反例是 i++**。用 volatile 修饰 i,i++ 仍然不安全,因为 i++ 是三步:读 i、i+1、写回。volatile 只能保证每步操作的可见性,没法保证三步作为一个整体的互斥。这里必须用 AtomicInteger,它的 `incrementAndGet` 方法内部就是用 CAS 把这三步打包成一个原子操作。反过来,如果只是想读一个值,`AtomicInteger.get()` 其实就是个 volatile 读,不需要 CAS。
**还有一个典型场景是双重检查锁单例**,这里 volatile 和 CAS(或 synchronized)必须同时用:
```java
private volatile static Singleton s;
public static Singleton get() {
if (s == null) {
synchronized (Singleton.class) {
if (s == null) {
s = new Singleton();
}
}
}
return s;
}
```
synchronized 保证"判断+赋值"的原子性,但 `new` 指令会被 JVM 重排成"分配内存→赋引用→初始化构造器",如果不加 volatile,另一个线程可能看到"已经赋引用但还没初始化"的半构造实例,直接用就 NPE。所以 volatile 在这里不是可选的,是必须的。
总结一句话:**volatile 是 CAS 的底层基石,CAS 是 volatile 的能力延伸,两者是配合关系不是替代关系**。
这就是我对这道题的理解。
**核心考点定位**:能否说清"原子性 = 互斥"、"可见性 = 解锁刷内存"、"有序性 = 禁止指令重排"这三条底层机制,以及 happens-before 规则。
**知识点拆解**:
1. **原子性 ← Monitor 互斥机制**:
- 每个对象有一个隐含的 **Monitor(管程)**,包含 owner 指针
- 线程进入 synchronized 块时通过 CAS 尝试设置 Monitor 的 owner;成功后独占执行;其他线程进不来
- 重量级锁状态下线程通过 OS 阻塞挂起到 entryList;轻量级锁通过自旋 CAS 竞争
2. **可见性 ← 解锁刷内存 + 内存屏障**:
- **解锁时**:把工作内存中的修改**强制刷回主存**(StoreLoad 屏障)
- **加锁时**:**强制从主存 reload** 到工作内存,丢弃本地缓存
- 这就保证了"解锁 → 加锁"之间的修改对所有线程可见
3. **有序性 ← 禁止指令重排**:
- JIT 编译器被禁止把 synchronized 块**内部**的指令重排到块**外部**
- CPU 层面的 StoreLoad 屏障防止跨块重排
- 注意:这是**指令级**的有序性,不是"线程获取锁的顺序"
4. **happens-before 规则**(JMM 第 17 条):
- 对一个 synchronized 块的解锁,happens-before 后续对**同一把锁**的加锁
- 这条规则是"可见性 + 有序性"能被 JIT 优化的契约基础
5. **三大特性对比 volatile**:
- volatile:可见性 ✓、有序性 ✓、原子性 ✗(i++ 三步不能打包)
- synchronized:可见性 ✓、有序性 ✓、原子性 ✓(互斥机制保证复合操作整体执行)
- 所以 synchronized 是 volatile 的"超集",但开销更大
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否说清 Monitor + 内存屏障 + JIT 重排禁止这条链路
- **广度**:能否区分 synchronized 和 volatile 各自覆盖的特性
- **应用**:能否举出代码里为什么 synchronized 块比 volatile 更强的场景
**延伸递进面试题**:
1. **synchronized 能保证原子性,为什么不用 volatile 也能保证可见性和有序性?**
- 解答要点:volatile 加内存屏障保证可见+有序,但读-改-写三步仍会交错;synchronized 用互斥把三步包成一个原子块
2. **synchronized 是公平锁吗?为什么说是非公平锁?**
- 解答要点:非公平锁。JVM 内部不保证先到先得;多个线程竞争时随机调度。需要公平性时用 ReentrantLock(true)
3. **happens-before 有哪些传递性规则?**
- 解答要点:锁规则(释放→获取)、volatile 规则(写→读)、传递性、启动线程、传递性;是 JMM 保证并发正确性的最小契约
面试官您好,synchronized 同时保证三个特性:**原子性、可见性、有序性**,但每一条的底层机制是不同的,我分开说。
**先说原子性**。原子性的本质是"互斥"——同一时刻只能有一个线程在 synchronized 块里执行。它的底层是 **Monitor(管程)机制**。每个对象都隐含一个 Monitor 对象,里面有个 owner 指针记录当前的持锁线程。线程进入 synchronized 块时,先尝试用 CAS 把 owner 设成自己;成功了就独占执行,失败了就在队列里等。重量级锁状态下其他线程会挂起到 entryList 阻塞等待,轻量级锁下通过自旋 CAS 竞争。所以从"互斥"这个角度看,原子性其实是锁的基本性质——你进得去,别人就进不来,操作就整体执行。
**再说可见性**。可见性靠的是**解锁和加锁时强制刷内存**。具体规则是:
- **解锁时**:把工作内存里的修改**强制刷回主存**,并加 StoreLoad 内存屏障,确保之前的所有写操作都被刷出去
- **加锁时**:**丢弃本地工作内存**,强制从主存 reload 最新值
这样"解锁 → 加锁"之间的修改对其他线程一定可见。JMM 把它总结成一条规则:**对一个 synchronized 块的解锁,happens-before 后续对同一把锁的加锁**。这是 JMM 第 17 条规则,是并发编程可见性的契约基础。
**第三个是有序性**。这里的有序性不是指"线程获取锁的顺序",而是**指令重排**——JIT 编译器不会把 synchronized 块内部的指令重排到块外部去。CPU 层面的 StoreLoad 屏障也保证了跨块的指令顺序不会被破坏。这个很重要,比如经典的 DCL 单例如果不加 volatile,`new` 指令会被重排成"分配内存→赋引用→初始化构造器",不加锁的话其他线程可能看到半构造实例。synchronized 块里因为有序性保证,指令不会被这样重排。
**对比一下 volatile**:volatile 只保证可见性和有序性,但**不保证原子性**——i++ 是读-改-写三步,volatile 只能让每步都可见,但三步之间可能交错。synchronized 通过互斥机制把三步包成一个整体,所以三个特性都保证。所以可以说 synchronized 是 volatile 的"超集",但开销也更大。
**关于公平性**——这里要纠正一个常见误解:**synchronized 不是公平锁,它是非公平锁**。JVM 内部不保证先到先得,多个线程竞争时随机调度。如果需要公平性,必须用 `ReentrantLock(true)`。
这就是我对 synchronized 三大特性的理解。
---
**核心考点定位**:能否说清 Netty 通过"单线程独占 Channel"和"MPSC 无锁队列 + ThreadLocal 池化"来消除锁竞争,而不是简单理解为"无状态"。
**知识点拆解**:
1. **先纠正概念**:
- **无锁化(lock-free)**:用 CAS、原子变量、MPSC 队列等**无锁数据结构**替代 synchronized/Lock
- **无状态**:代码不保留可变数据(如纯函数、单例工具类)
- Netty 的无锁化是**消除锁竞争**,不是"没有状态"——Channel、EventLoop、ByteBuf 都有大量状态,但它们通过"单线程独占"避免了跨线程共享,因此不需要锁
2. **Reactor 单线程模型(核心)**:
- **主 Reactor(BossGroup)**:负责 accept 连接,把新建的 Channel 注册到 Worker
- **子 Reactor(WorkerGroup)**:每个 Worker 是一个 EventLoop 线程,管理多个 Channel
- **一对一绑定**:每个 Channel 绑定到一个 EventLoop 线程,所有 IO 事件和业务回调都在**同一个线程**里顺序执行
- **关键收益**:单线程内不需要锁;跨线程调用要用 `channel.eventLoop().submit(task)` 把任务投递到 EventLoop 队列
3. **MPSC 无锁任务队列**:
- 多个线程可以 `submit` 任务到 EventLoop 队列(多生产者)
- EventLoop 单线程从队列消费(单消费者)
- 实现用 `MpscLinkedQueue`,基于 CAS 操作无锁完成入队出队
4. **PooledByteBufAllocator 线程局部化**:
- 每个 EventLoop 线程持有自己的 ThreadLocal Chunk 链表
- 分配、释放 ByteBuf 都在同一线程完成,不跨线程竞争
- 相比 JDK 原生 ByteBuffer,内存分配/回收更快、GC 压力更低
5. **Recycler 对象池**:
- 用 ThreadLocal 无锁回收频繁创建的对象(如 ByteBuf、ChannelHandlerContext)
- 避免 GC 压力,也避免多线程竞争
6. **零拷贝(Zero-Copy)**:
- 用 DirectByteBuffer 减少 JVM 堆到内核的内存拷贝
- 用 FileChannel.transferTo 直接在内核态搬运大文件
7. **对比 NIO**:
- NIO 是 Reactor 模式的简化版,需要业务代码自己处理线程安全
- Netty 通过封装 + 单线程独占,把"线程安全"变成了框架的内置能力
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否说清 Reactor 单线程模型 + MPSC 队列 + ThreadLocal 池化这条链路
- **广度**:能否对比 NIO 原生实现
- **应用**:能否举出业务代码里的"跨 EventLoop 调用"场景
**延伸递进面试题**:
1. **业务代码里要修改 Channel 状态怎么办?**
- 解答要点:必须 `channel.eventLoop().submit(...)` 把任务投递到 Channel 绑定的 EventLoop 线程执行,避免跨线程访问
2. **Netty 为什么用 PooledByteBufAllocator 而不用 JDK 原生 ByteBuffer?**
- 解答要点:原生 ByteBuffer 每次 new 都堆外分配,GC 压力大;池化复用减少分配/回收,且线程局部化避免竞争
3. **Netty 的零拷贝体现在哪几个层面?**
- 解答要点:堆外内存、CompositeByteBuf 组合、FileChannel.transferTo 内核态搬运、SliceByteBuf 引用计数切片
面试官您好,我先纠正一个常见的误解:**Netty 的无锁化不是"没有状态"**,而是通过"单线程独占 + 无锁数据结构"来**消除锁竞争**。Channel、EventLoop、ByteBuf 都有大量状态,但它们被设计成不需要多线程访问,所以也就不需要锁。
**第一层是 Reactor 单线程模型**,这是 Netty 的核心。Netty 有一个主 Reactor(BossGroup)负责 accept 新连接,然后把 Channel 注册到子 Reactor(WorkerGroup)。每个 Worker 是一个 EventLoop 线程,管理多个 Channel。**关键点是每个 Channel 绑定到唯一一个 EventLoop 线程**——这个 Channel 的所有 IO 事件、所有业务回调,都在同一个线程里顺序执行。因为单线程内不需要互斥,自然就不需要锁。如果业务代码要从别的线程改 Channel 状态,必须用 `channel.eventLoop().submit(task)` 把任务投递过去,让 EventLoop 线程自己执行。
**第二层是 MPSC 无锁任务队列**。多个线程都可以 submit 任务到 EventLoop 的任务队列(多生产者),EventLoop 单线程从队列消费(单消费者)。Netty 用 `MpscLinkedQueue` 实现,基于 CAS 操作无锁完成入队出队,避免了 synchronized 队列的开销。
**第三层是 PooledByteBufAllocator 线程局部化**。每个 EventLoop 线程持有自己 ThreadLocal 的 Chunk 链表,分配和释放 ByteBuf 都在同一线程完成,不跨线程竞争。相比 JDK 原生 ByteBuffer,内存分配/回收更快、GC 压力更低。这里也用了 **Recycler 对象池**,通过 ThreadLocal 无锁回收频繁创建的对象。
**第四层是零拷贝**。用 DirectByteBuffer 避免 JVM 堆到内核的拷贝;用 FileChannel.transferTo 直接在内核态搬运大文件;用 CompositeByteBuf 组合避免内存复制;用 SliceByteBuf 引用计数切片。
**对比 NIO 原生实现**:NIO 只是给了 Reactor 的骨架,业务代码自己处理线程安全;Netty 通过封装 + 单线程独占,把"线程安全"变成了框架的内置能力,业务代码只需要写单线程逻辑,框架保证了并发正确性。
总结一句话:**Netty 的无锁化是"通过避免共享来实现",而不是"通过没有状态来实现"**。
这就是我对 Netty 无锁化设计的理解。
---
**核心考点定位**:能否识别出 String 里的**享元模式**(最核心)、**不可变模式**、**代理模式**,并理解它们的设计动机。
**知识点拆解**:
1. **享元模式(Flyweight)—— String 最经典的设计模式**:
- **核心思想**:把相同字符串对象共享给多个调用方,减少内存占用
- **实现机制**:**字符串常量池**(String Constant Pool),位于堆内存的方法区/堆中
- **入池规则**:
- 字面量字符串(`String s = "hello";`)自动入池
- 调用 `s.intern()` 手动入池
- `new String("hello")` 创建堆对象,不入池(除非调 intern)
- **效果**:`"hello" == "hello"` 为 true(引用相等),不同变量共享同一个对象
2. **不可变模式(Immutable Object Pattern)**:
- **设计要点**:
- `public final class String`:类不可继承
- `private final byte[] value`:字段不可变
- 无 setter:所有"修改"操作返回新对象(`concat`、`substring`、`toUpperCase`)
- 构造器防御性拷贝(`new String(String original)` 会复制底层数组)
- **设计动机**:
- **线程安全**:不可变对象天生线程安全
- **哈希缓存**:`hashCode()` 可缓存,HashMap key 性能更好
- **安全性**:可作 `System.getProperty` 返回值、可作方法参数不被外部修改
- **可缓存**:可以安全地共享引用
3. **代理模式(Proxy)**:
- **StringBuilder/Buffer 是 String 的可变代理**:频繁拼字符串用 StringBuilder,最终 `toString()` 转回不可变 String
- **JDK 9+ 的 `StringLatin1`/`StringUTF16`**:String 内部用这两个紧凑存储类作为代理,节省内存
- **`new String(byte[])`**:把外部数组的引用代理进 String,并做防御性拷贝
4. **为什么 String 要设计成不可变?**(延伸理解)
- 线程安全:并发场景无需同步
- 哈希稳定:可作 HashMap 的 key
- 安全性:不可被外部篡改
- 常量池可复用:相同内容共享一份
5. **String 常见陷阱**:
- `==` vs `equals`:常量池内 `==` 可能为 true,堆对象 `==` 为 false
- `new String("a")` 和 `"a"` 不是同一个对象(前者在堆,后者在常量池)
- `intern()` 在 JDK 7+ 之后常量池移到了堆中
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否说清享元模式 + 常量池入池规则
- **广度**:能否覆盖不可变模式 + 代理模式
- **应用**:能否举出 String 不可变带来的工程收益
**延伸递进面试题**:
1. **`"a"` 和 `new String("a")` 是同一个对象吗?调 `intern()` 后呢?**
- 解答要点:字面量在常量池,`new` 在堆,引用不同;调 `intern()` 后如果常量池已有就返回常量池引用,否则把堆对象加入常量池
2. **String 不可变有什么好处和代价?**
- 解答要点:好处——线程安全、可缓存、可作 key;代价——每次修改要新建对象,频繁拼字符串性能差,用 StringBuilder
3. **String 内部 JDK 9+ 为什么改成 `byte[]` + coder 字段?**
- 解答要点:JDK 8 用 `char[]`(2 字节/字符),JDK 9 用 `byte[]` + `coder` 区分 Latin1(1 字节)和 UTF16(2 字节),Latin1 字符场景内存减半
面试官您好,String 的设计主要用到三个设计模式:**享元模式、不可变模式、代理模式**。
**第一个是享元模式**,这是 String 最经典的设计。享元的核心思想是"相同对象共享"。在 String 里的体现就是**字符串常量池**:字面量 `"hello"` 会自动放入常量池,第二次写 `"hello"` 直接复用同一个对象。效果就是 `"hello" == "hello"` 为 true,多个变量引用同一个对象,节省大量内存。除了字面量入池,还有 `s.intern()` 手动入池——`new String("hello").intern()` 会返回常量池里的引用。
**第二个是不可变模式(Immutable Object Pattern)**。String 是不可变对象,体现在:类是 `public final` 不能继承;底层 `private final byte[] value` 字段不可变;没有 setter,所有"修改"方法如 `concat`、`substring`、`toUpperCase` 都返回新对象;构造器还做防御性拷贝,防止外部修改内部数组。
**不可变带来四个好处**:
- **线程安全**:不可变对象天生线程安全,并发场景无需同步
- **哈希缓存**:`hashCode()` 可以缓存,作 HashMap 的 key 性能更好
- **安全性**:可作返回值、方法参数不被外部篡改
- **可缓存**:安全共享引用
代价是每次修改都要新建对象,所以频繁拼字符串要用 StringBuilder。
**第三个是代理模式**。String 的不可变性催生了两类代理:
第一类是 **StringBuilder/Buffer 作为可变代理**——频繁修改字符串的场景,先用 StringBuilder 拼接,最后 `toString()` 转回不可变 String。
第二类是 **JDK 9+ 的 `StringLatin1` 和 `StringUTF16`**——String 内部用这两个紧凑存储类作为代理,通过 `coder` 字段区分 Latin1(1 字节)和 UTF16(2 字节)。JDK 8 用 `char[]` 每个字符固定 2 字节,JDK 9 改成 `byte[]` + `coder`,Latin1 字符场景内存减半。
**还有一个常见陷阱**:`"a"` 在常量池,`new String("a")` 在堆,两者引用不同,`==` 为 false;但调 `intern()` 后,如果常量池已有相同内容,就返回常量池的引用,`==` 变 true。
总结一句话:**String 是"享元模式 + 不可变模式 + 代理模式"的典型组合**,既节省内存,又保证了线程安全和哈希稳定性。
这就是我对 String 设计的理解。