Java 并发面试一
Java 并发面试一
并发简介
【简单】并发和并行有什么区别?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:5 min | 🏷 标签:并发简介 / 基础概念
💎 关键结论
二者最关键的区别在于是否同时发生:并发是多个任务在同一时间段内交替执行(逻辑上的同时),并行是多个任务在多核上真正同时执行(物理上的同时)。并发是执行模型,并行是硬件能力。
⚡记忆卡片
- 口诀:并发交替、并行同时;一人多事是并发,多人多事是并行
- 关键词:交替执行 / 同时执行 / 多核
- 链路:单核时间片轮转 → 并发 → 多核同时运算 → 并行
📖 核心知识
- 并发(Concurrency):指多个任务在同一时间段内交替执行,单核 CPU 通过快速的任务切换制造"同时运行"的假象,目的是提高系统效率和资源利用率。
- 并行(Parallelism):指具备同时处理多个任务的能力,需要多个 CPU 核心,任务在物理时间上真正同时执行。
- 二者关系:并行一定并发,并发不一定并行;单核只能并发,多核可并发也可并行。
案例:生活中的生动比喻
下面是我见过最生动的说明,摘自 并发与并行的区别是什么?——知乎的高票答案
- 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
- 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
- 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
🔬 扩展知识
详情
- 【L3】并发能否成立取决于调度器:操作系统通过抢占式时间片轮转(通常毫秒级)实现任务交替;Java 线程调度即抢占式,委托给操作系统完成。
- 【L4】虚拟线程(JDK 21 正式,JEP 444)把并发推向量变:数百万虚拟线程复用少量载体线程,本质仍是并发模型,并行度不超过载体线程数(默认等于 CPU 核数),见本文档「Java 传统线程和虚拟线程有什么区别?」。
🔀 发散问题
- Q:单核 CPU 能跑多线程吗? → 能,通过时间片轮转交替执行,实现的是并发而非并行,见本文档「单核 CPU 支持 Java 多线程吗?」。
- Q:并发一定比串行更快吗? → 不一定,取决于任务类型、核数与锁竞争程度,见本文档「并发一定比串行更快吗?」。
【简单】同步和异步有什么区别?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:5 min | 🏷 标签:并发简介 / 基础概念
💎 关键结论
同步和异步关注的是调用结果的通信机制:同步指调用方必须等待当前任务执行完、拿到结果才能继续;异步指调用发出后无需等待结果,可以先做别的事,结果就绪后通过回调、通知等方式告知调用方。
⚡记忆卡片
- 口诀:同步像打电话,不挂不断;异步像发短信,发完即走
- 关键词:调用方等待 / 结果通知 / 回调机制
- 链路:发起调用 → 同步:等结果才继续 / 异步:先做别的事 → 结果就绪通知
📖 核心知识
- 同步:指任务必须按顺序依次执行,等待当前任务完成才能继续,调用期间调用方处于等待状态。
- 异步:指任务可以独立执行,无需等待当前任务完成即可处理其他任务,结果通过通知或回调返回。
- 比喻:同步就像是打电话:不挂电话,通话不会结束;异步就像是发短信:发完短信后,就可以做其他事,当收到回复短信时,手机会通过铃声或振动来提醒。
🔬 扩展知识
详情
- 【L3】在 Java 中,同步对应阻塞式调用(如
InputStream.read()、Future.get());异步对应CompletableFuture、NIO、虚拟线程等机制。 - 【L4】虚拟线程的价值正是"以同步的写法获得异步的性能":阻塞 I/O 时虚拟线程自动卸载、让出载体线程,开发者无需回调嵌套。
🔀 发散问题
- Q:同步/异步与阻塞/非阻塞是一回事吗? → 不是,两个正交维度:同步/异步看结果如何获得,阻塞/非阻塞看调用方是否挂起,四种组合都存在,见本文档「阻塞和非阻塞有什么区别?」。
【简单】阻塞和非阻塞有什么区别?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:5 min | 🏷 标签:并发简介 / 基础概念
💎 关键结论
阻塞和非阻塞关注的是程序在等待调用结果时的状态:阻塞指调用发起后当前线程挂起,必须等操作完成才能继续;非阻塞指调用立即返回、不挂起线程,可继续执行其他操作,之后再轮询或等待通知获取结果。
⚡记忆卡片
- 口诀:阻塞排队等奶茶,不拿到不走;非阻塞点完就逛街,短信通知再取
- 关键词:调用方状态 / 挂起等待 / 立即返回
- 链路:发起调用 → 阻塞:挂起直到结果返回 / 非阻塞:立即返回 → 轮询或通知拿结果
📖 核心知识
- 阻塞:指任务执行时必须等待某操作完成才能继续,调用线程在整个等待期间处于挂起状态。
- 非阻塞:指任务执行时无需等待,可立即返回并执行其他操作,结果通过轮询或事件通知获取。
- 比喻:阻塞像排队等奶茶,不拿到不走;非阻塞像点完奶茶去逛街,店员短信通知后再取。
🔬 扩展知识
详情
- 【L3】阻塞/非阻塞常与 I/O 模型搭配:BIO 是阻塞,NIO 通过 Selector 实现非阻塞多路复用,二者分别对应"一线一连接"与"一线多连接"。
- 【L4】虚拟线程从另一个角度解决问题:保持阻塞式编程模型,但阻塞时只挂起虚拟线程、不占用 OS 线程,兼得阻塞的简单与异步的高并发。
🔀 发散问题
- Q:异步一定是非阻塞吗? → 不一定,异步只说明结果由通知返回,调用方在等待通知期间仍可能选择阻塞等待;二者的划分维度不同。
【中等】进程、线程、协程、管程有什么区别?⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:10 min | 🏷 标签:并发简介 / 并发单元
💎 关键结论
四者定位不同:进程是资源分配的基本单位,隔离性强;线程是 CPU 调度的基本单位,共享进程内存;协程是用户态轻量级线程,由程序员协作式调度;管程不是执行单元,而是管理共享资源的同步机制。前三者回答"谁在跑",管程回答"怎么安全地共享"。
⚡记忆卡片
- 口诀:进程管资源、线程管调度、协程用户态、管程管同步
- 关键词:资源分配 / CPU 调度 / 用户态调度 / 同步机制
- 链路:进程(独立内存)→ 线程(共享内存)→ 协程(无内核切换)→ 管程(锁/条件变量)
📖 核心知识
进程、线程、协程、管程对比:
| 概念 | 定义 | 特点 | 适用场景 |
|---|---|---|---|
| 进程 | 可视为一个正在运行的程序 | 独立内存空间 切换开销大 进程间通信(IPC)较复杂 | 需要高隔离性的任务(如浏览器多标签) |
| 线程 | CPU 调度的基本单位(属于进程) | 共享进程内存 切换开销较小 需同步(锁)避免竞态 | 高并发任务(如 Web 服务器处理请求) |
| 协程 | 用户态轻量级线程(协作式调度) | 无内核切换开销 由程序员控制切换( yield)单线程内并发 | I/O 密集型高并发(如爬虫、异步编程) |
| 管程 | 管理共享资源的同步机制(如锁、条件变量) | 封装线程同步逻辑 避免手动操作锁(如 Java synchronized) | 多线程共享资源(如线程安全的数据结构) |
小结:
- 进程:隔离性强但开销大。
- 线程:CPU 调度的基本单位,共享内存但需同步。
- 协程:用户态线程,高效但需主动让出控制权。
- 管程:同步工具,简化多线程资源共享。
进程和线程的差异
- 一个程序至少有一个进程,一个进程至少有一个线程。
- 线程比进程划分更细,所以执行开销更小,并发性更高
- 进程是一个实体,拥有独立的资源;而同一个进程中的多个线程共享进程的资源。

JVM 在单个进程中运行,JVM 中的线程共享属于该进程的堆。这就是为什么几个线程可以访问同一个对象。线程共享堆并拥有自己的堆栈空间。这是一个线程如何调用一个方法以及它的局部变量是如何保持线程安全的。但是堆不是线程安全的并且为了线程安全必须进行同步。
线程和协程的差异

🔬 扩展知识
详情
- 【L3】线程共享堆但各自拥有栈:局部变量在栈上,天然线程安全;堆上对象需同步保护,这是并发安全问题的根源。
- 【L4】Java 虚拟线程(JDK 21,JEP 444)本质就是 JVM 实现的用户态协程,但对外暴露为
ThreadAPI,无需显式yield;而 Kotlin 协程需suspend函数染色,见本文档「Java 传统线程和虚拟线程有什么区别?」。
⚠️ 常见误区
详情
常见误区:
- ❌ "线程切换和进程切换开销差不多" → 进程切换需切换页表、刷新 TLB,开销明显大于线程切换。
- ❌ "协程是操作系统调度的" → 协程完全在用户态由程序/运行时调度,内核不感知。
🔀 发散问题
- Q:为什么 Java 选线程而不是进程作为并发基本单元? → 线程共享内存、创建切换成本低,适合高频协作;代价是需同步机制保护共享数据。
- Q:管程在 Java 中的体现是什么? →
synchronized就是管程模型:每个对象关联 Monitor,互斥进入临界区,配合wait()/notify()条件等待。
【中等】Java 线程和操作系统的线程有什么区别?⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:10 min | 🏷 标签:并发简介 / 线程模型
💎 关键结论
现代 HotSpot JVM 采用 1:1 映射,每个 Java 线程直接对应一个 OS 内核线程,区别主要在抽象层级:Java 线程是 JVM 用户态抽象,跨平台、可用虚拟线程实现 M:N 优化;OS 线程由内核直接管理、抢占式调度。早期 JVM 曾用用户线程(绿色线程)M:1 映射。
⚡记忆卡片
- 口诀:早期多对一,现代一对一,虚拟线程 M 对 N
- 关键词:1:1 映射 / 内核线程 / 跨平台抽象
- 链路:Java 线程(用户态抽象)→ JNI → OS 内核线程 → 内核抢占式调度
📖 核心知识
- 映射演进:早期 JVM 使用用户线程(绿色线程),多个 Java 线程映射到一个 OS 线程(M:1);现代主流(HotSpot JVM)采用 1:1 映射,每个 Java 线程直接对应一个 OS 内核线程。
- 对比表:
| 对比维度 | Java 线程 | 操作系统线程 |
|---|---|---|
| 抽象层级 | JVM 层面的用户态抽象(现代 JVM 1:1 映射到 OS 线程) | 内核直接管理的原生线程(内核态) |
| 调度机制 | 依赖 OS 调度,但可通过协程(如虚拟线程)优化 | 完全由内核抢占式调度 |
| 创建/切换开销 | 高(需系统调用),但线程池可优化 | 高(上下文切换涉及用户态-内核态切换) |
| 并发模型 | 支持 1:1(默认)和 M:N(虚拟线程) | 仅 1:1,并发数受内核限制 |
| 平台依赖性 | 跨平台(JVM 统一行为,底层实现因 OS 而异) | 直接依赖 OS 和硬件特性(如线程优先级实现不同) |
| 同步机制 | 高级抽象(如synchronized,映射为 OS 原语) | 底层原语(如pthread_mutex) |
| 栈内存占用 | 默认 1MB(可调),虚拟线程仅 KB 级 | Linux 默认 8MB(不可跨线程共享) |
| 典型应用场景 | 通用并发编程,高并发推荐虚拟线程 | 直接系统编程,需精细控制线程行为的场景 |
🔬 扩展知识
详情
- 【L3】1:1 模型的代价:创建 Java 线程需系统调用(Linux 上
pthread_create/clone),栈默认 1MB(-Xss可调),所以单机平台线程通常只能开到数千,这也是线程池成为标配的原因。 - 【L4】线程模型的三种映射:1:1(现代 JVM 默认)、M:1(早期绿色线程,一阻塞全停)、M:N(虚拟线程,兼顾并发度与吞吐)。
🔀 发散问题
- Q:为什么不用回 M:1 用户线程? → 一个用户线程阻塞会拖住整个 OS 线程,且无法利用多核,被历史淘汰。
- Q:Java 线程优先级能影响 OS 调度吗? → 只是提示,实际映射到 OS 优先级,不同系统行为不一致,不可依赖,见本文档「高优先级的 Java 线程一定先执行吗?」。
【中等】Java 传统线程和虚拟线程有什么区别?⭐⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:12 min | 🏷 标签:并发简介 / 虚拟线程
💎 关键结论
虚拟线程用更少的资源支持更高的并发:传统线程 1:1 映射 OS 线程,栈约 1MB、数千个就接近极限;虚拟线程(JDK 21 正式版,JEP 444,Project Loom)由 JVM 用户态调度、与 OS 线程 M:N 映射,栈仅 KB 级,可轻松创建数百万个,阻塞时只挂起虚拟线程而不占用 OS 线程。
⚡记忆卡片
- 口诀:传统线程内核管,虚拟线程 JVM 管;阻塞不占载体,百万并发随便开
- 关键词:M:N 映射 / 载体线程 / KB 级栈 / Pinning
- 链路:虚拟线程阻塞 → unmount 卸载 → 载体线程执行其他虚拟线程 → mount 恢复
📖 核心知识
- 定位:虚拟线程(Virtual Threads,JDK 21 正式版,Project Loom)实现与 OS 线程 M:N 映射,显著提升并发能力,且编程模型与传统
ThreadAPI 完全一致。
案例:传统线程 vs 虚拟线程对比表
| 维度 | 传统线程(OS 线程) | 虚拟线程 |
|---|---|---|
| 并发数量 | 数千个 | 数百万个 |
| 内存开销 | 每个约 1MB | 每个约 1KB |
| 创建成本 | 高(内核操作) | 极低(用户态) |
| 阻塞代价 | 整个 OS 线程阻塞 | 仅虚拟线程挂起 |
| 调度方 | 操作系统内核 | JVM(用户态调度器) |
| 编程模型 | Thread API | 相同的 Thread API |
- 载体线程(Carrier Thread):虚拟线程运行在载体线程(即 OS 内核线程)上。当虚拟线程执行阻塞操作时,JVM 会自动将虚拟线程从载体线程上卸载(unmount),载体线程可以去执行其他虚拟线程;阻塞结束后,虚拟线程被重新挂载(mount)到载体线程上继续执行。
- Pinning(针住问题):当虚拟线程在
synchronized块或native方法中执行阻塞操作时,虚拟线程无法从载体线程卸载,导致载体线程被占用(JDK 24 JEP 491 已解决synchronized场景)。
// 不良实践:synchronized + I/O 会导致 Pinning
synchronized (lock) {
httpClient.send(request, bodyHandler); // 虚拟线程被钉在载体线程上
}
// 最佳实践:用 ReentrantLock 替代 synchronized
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
httpClient.send(request, bodyHandler); // 虚拟线程可正常卸载
} finally {
lock.unlock();
}- 最佳实践:
- 适用场景:I/O 密集型任务(HTTP 调用、数据库查询、文件读写);不适用:CPU 密集型计算(虚拟线程无法加速纯计算)。
- 避免使用 ThreadLocal:虚拟线程数量可达百万,ThreadLocal 内存开销巨大,推荐用 Scoped Values(JDK 21 预览)。
- 避免 synchronized 包裹阻塞操作:改用
ReentrantLock避免 Pinning。 - 不池化虚拟线程:创建成本极低,用
Thread.ofVirtual().start()直接创建即可。 - 配合 ExecutorService:
Executors.newVirtualThreadPerTaskExecutor()每个任务一个虚拟线程。
// JDK 21 推荐用法
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // 不占用 OS 线程
return i;
});
});
}🔬 扩展知识
详情
- 【L3】载体线程池默认是
ForkJoinPool,并行度默认等于 CPU 核数(-Djdk.virtualThreadScheduler.parallelism可调),上限默认 256(-Djdk.virtualThreadScheduler.maxPoolSize)。 - 【L4】跨语言对比——Go goroutine 与 Erlang Actor 模型:
(一)Go goroutine 的 M:N 调度
Go 语言的 goroutine 与 Java 虚拟线程在调度理念上高度相似,都是 M:N 用户态调度:
- GOMAXPROCS 决定并行度:在 Go 中,
GOMAXPROCS决定了同时运行 OS 线程的最大数量(默认等于 CPU 核数),这与 Java 虚拟线程的载体线程并行度jdk.virtualThreadScheduler.parallelism的设定理念一致——二者都是把用户态协程复用到固定数量的内核线程上。 - G-M-P 调度模型:Go 的调度器使用 G(goroutine)、M(machine/OS 线程)、P(processor/逻辑处理器)三层模型。P 在数量上受
GOMAXPROCS限制,M 是实际的 OS 线程,G 在 P 的本地队列中排队。当一个 goroutine 发生阻塞系统调用时,P 会与当前 M 分离,与另一个 M 绑定继续调度其他 G——这与虚拟线程的 mount/unmount 机制的语义如出一辙。 - 抢占式调度 vs 协作式调度:区别在于,Go 1.14 之前 goroutine 依赖协作式抢占(函数入口插入栈检查),而 Java 虚拟线程从第一天起就支持真正的抢占式调度(
Continuation.yield()可在任意安全点触发)。
(二)Erlang Actor 模型对比
Erlang 的并发模型走的是完全不同的一条路——Actor 模型:
| 维度 | Java 虚拟线程(结构化并发) | Erlang Actor 模型 |
|---|---|---|
| 并发单元 | 轻量级线程(Thread) | 轻量级进程(Process) |
| 通信方式 | 共享内存(锁/原子类)+ 结构化并发 | 纯消息传递(不共享任何状态) |
| 调度 | JVM M:N 调度器(ForkJoinPool) | BEAM 虚拟机抢占式调度(每进程约 300 字节) |
| 隔离性 | 共享堆,需同步机制 | 完全内存隔离,无需锁 |
| 故障处理 | try-catch(同 JVM 进程内) | "Let it crash" 哲学 + Supervisors 树 |
| 适用场景 | I/O 密集型高并发、微服务后端 | 分布式、容错系统(如 RabbitMQ、WhatsApp、Discord) |
关键认知:Erlang 的 "不共享" 是其最大的闪光点,每个 Erlang 进程有独立的堆和 GC,崩溃不会影响其他进程。Java 虚拟线程仍然共享 JVM 堆,因此死锁、竞态条件等问题依然需要程序员自己处理。但好处是虚拟线程能直接复用 Java 生态中所有线程安全的数据结构和类库,而 Erlang 需要其专属的 OTP 框架。
⚠️ 常见误区
详情
常见误区:
- ❌ "虚拟线程比平台线程执行更快" → 虚拟线程提升的是并发度而非单任务速度,CPU 密集型任务无收益。
- ❌ "虚拟线程应该像平台线程一样池化" → 虚拟线程即用即抛,池化反而引入队列与同步瓶颈。
🔀 发散问题
- Q:虚拟线程底层如何实现挂起/恢复? → 靠
Continuation做栈帧的 mount/unmount,详见本文档「虚拟线程的实现原理是什么?」。 - Q:虚拟线程上 ThreadLocal 有什么问题? → 百万级虚拟线程会放大 ThreadLocal 内存开销,应改用 ScopedValue。
【困难】虚拟线程的实现原理是什么?⭐⭐⭐⭐
🎯 目标等级:L3-L4 | ⏱ 建议用时:15 min | 🏷 标签:虚拟线程 / 实现原理
💎 关键结论
虚拟线程是 JVM 在用户态实现的轻量级线程:通过 M:N 调度把海量虚拟线程复用到少量载体线程上;可挂起能力来自 Continuation——阻塞时把栈帧拷回堆(unmount),载体线程立即转干别的,阻塞结束后再拷回恢复(mount),全程无内核切换。
⚡记忆卡片
- 口诀:M 对 N 调度、Continuation 挂栈、阻塞就卸载、恢复再挂载
- 关键词:M:N 调度 / Continuation / mount·unmount / Stack Chunk
- 链路:阻塞操作 → Continuation.yield() → 栈帧拷入堆 → 载体线程转执行其他虚拟线程 → 就绪后 mount 恢复
📖 核心知识
(1)M:N 调度模型
- 平台线程(Platform Thread)是 OS 内核线程的 1:1 封装,调度完全依赖操作系统内核;虚拟线程则是 M 个虚拟线程映射到 N 个载体线程(M >> N),调度由 JVM 在用户态完成。
- 载体线程池默认是
ForkJoinPool,并行度默认等于 CPU 核数,可用-Djdk.virtualThreadScheduler.parallelism调整;载体线程数上限默认 256,由-Djdk.virtualThreadScheduler.maxPoolSize控制。 - 调度发生在用户态意味着:一次虚拟线程切换没有系统调用、没有用户态-内核态上下文切换。
(2)Continuation:mount/unmount 的本质
虚拟线程底层由 JDK 内部的 Continuation 支撑:
- unmount(卸载):虚拟线程执行阻塞操作(网络/文件 I/O、
Thread.sleep、LockSupport.park)时,JVM 调用Continuation.yield(),把当前栈帧链复制回堆内存(Stack Chunk 对象),载体线程栈被清空,立刻可以运行下一个虚拟线程。 - mount(挂载):阻塞结束后虚拟线程进入就绪队列,再次被调度时,JVM 把堆中保存的栈帧拷回某个载体线程的栈(不一定是原来那个),从 yield 点继续执行,仿佛从未中断。
- 由于栈保存在堆中,虚拟线程的内存占用是按需伸缩的栈对象(初始仅几百字节),而不是平台线程预先保留的约 1MB 线程栈。
(3)定量对比
| 维度 | 平台线程 | 虚拟线程 |
|---|---|---|
| 栈内存 | 固定约 1MB(-Xss 可调) | 初始几百字节,按需增长 |
| 创建成本 | 系统调用,微秒~十微秒级 | 纯堆对象分配,亚微秒级 |
| 上下文切换 | 内核态切换,约 1μs~5μs | 用户态栈拷贝,远小于内核切换 |
| 单机可支撑数量 | 数千(受内存与内核限制) | 百万级 |
| 阻塞代价 | OS 线程被占满直到阻塞结束 | unmount 后载体线程零占用 |
(4)生产陷阱
- CPU 密集型无收益:吞吐上限 = 载体线程数(≈CPU 核数),纯计算任务用虚拟线程不会更快,只多一层调度开销。
- Pinning:在
synchronized块或native方法中阻塞时无法 unmount(JDK 24 JEP 491 已解决synchronized场景),排查与解决详见并发(三)Pinning 专项题。 - ThreadLocal 失控:百万级虚拟线程叠加 ThreadLocal 会带来巨大内存开销,应改用 ScopedValue,详见并发(二)专项题。
- 不要池化:虚拟线程即用即抛,池化反而引入同步瓶颈,原因详见并发(三)专项题。
(5)版本演进
| 版本 | JEP | 里程碑 |
|---|---|---|
| JDK 19 | JEP 425 | 虚拟线程首次预览 |
| JDK 20 | JEP 436 | 第二次预览 |
| JDK 21 | JEP 444 | 正式发布 |
| JDK 24 | JEP 491 | synchronized 不再 Pinning 载体线程 |
🔬 扩展知识
详情
- 【L3】Stack Chunk 是普通 Java 对象,随 GC 回收;yield 时只保留栈帧不拷贝数据,mount 时才拷回载体线程,换来极低的挂起开销。
- 【L4】与 Go goroutine 实现差异(Continuation vs Stack Copying):
(一)两种截然不同的用户态栈管理策略
Java 虚拟线程和 Go goroutine 虽然都是 M:N 用户态调度,但底层栈的管理机制有本质差异:
| 维度 | Java 虚拟线程(Continuation) | Go goroutine(Stack Copying) |
|---|---|---|
| 栈存储位置 | 堆中的 Stack Chunk 对象 | 堆上分配的连续内存段 |
| 栈初始大小 | 约 200~400 字节 | 2KB(Go 1.4+) |
| 栈增长策略 | 按需分配新 Stack Chunk,形成链表结构 | 栈拷贝(Copying):栈满时分配 2x 更大空间,拷贝旧栈内容 |
| 阻塞时行为 | yield 时栈帧保留在堆中(Continuation.yield()),不移动数据 | goroutine 阻塞时栈原地保留,调度器切换到另一个 goroutine 的栈 |
| 恢复时行为 | mount 时把堆中栈帧拷回载体线程栈(不一定是原来的载体线程) | 调度器挑一个 goroutine 恢复执行,栈已是完整连续的 |
| GC 影响 | Stack Chunk 是普通 Java 对象,随 GC 回收 | Go 的并发 GC 需要对每个 goroutine 的栈进行栈扫描 |
| 核心优势 | 零拷贝 yield(仅切换指针),mount 时才拷贝 | 连续栈利于 CPU 缓存局部性,运行期内无碎片 |
原理对比:
- Java 方案(Continuation on heap):虚拟线程的栈不是一段连续的栈内存,而是由多个 Stack Chunk 对象构成的链表。Stack Chunk 是普通的 Java 对象,分配在堆上,创建极快。阻塞时调用
Continuation.yield()直接将当前运行时的栈帧保留在 Stack Chunk 中,不拷贝任何数据——mount 时才拷贝回载体线程。这牺牲了运行时的缓存局部性,但换来了极低的挂起开销。 - Go 方案(Segmented Stack → Copying Stack):Go 早期使用分段栈(segmented stack),即多个不连续的内存段用链表链接,但发现 hot split 问题后彻底改为连续栈拷贝方案。每次栈满就分配 2x 大小的新连续内存,把旧栈数据拷过去,这虽然拷贝有开销,但运行时栈是连续的,CPU 缓存友好。
(二)与 Project Loom 之前的 async/await 方案对比
在虚拟线程出现之前,Java 生态的异步编程方案主要有:
| 方案 | 代表 | 编程模型 | 痛点 |
|---|---|---|---|
| Callback | Netty、Vert.x | 回调嵌套 | "回调地狱",难以调试 |
| CompletableFuture | JDK 8+ | 链式组合 | 复杂业务逻辑链式调用冗长,异常处理分散 |
| Reactive Streams | RxJava、Project Reactor | 响应式流 | 学习曲线陡峭,堆栈追踪不可读 |
| Kotlin Coroutines | Kotlin | suspend 关键字,编译器状态机 | 与 Java 生态有两套心智模型,函数染色问题 |
| 虚拟线程(Project Loom) | JDK 21+ | 同步代码写异步逻辑 | 最佳:无需函数染色,堆栈追踪完整,调试体验好 |
Kotlin Coroutines 的函数染色问题:一个 suspend 函数只能被另一个 suspend 函数或协程调用,这导致一旦在调用链的某个环节引入 suspend,整个上游调用链都必须标记为 suspend——这就是所谓的"函数染色"(function coloring)问题。Java 虚拟线程彻底消除了这个问题:Thread.sleep() 在虚拟线程中自动卸载载体线程,而调用方完全无感知,不需要任何 suspend/await 标记。
本质差异:async/await 方案是在语言层面将异步回调改写为看似同步的代码(编译器生成状态机),而虚拟线程是在运行时层面让真正的同步代码获得异步的性能。前者改变了开发模型,后者改变了执行模型。
🏭 实战场景
详情
以一台 8 核网关服务为例:传统方案用 200 线程的线程池处理下游 HTTP 调用,线程栈合计约 200MB,并发上限即 200,请求排队导致 P99 飙升;切换 JDK 21 + Executors.newVirtualThreadPerTaskExecutor() 后,可支撑 10 万级在途请求,栈内存按需伸缩仅约数百 MB 总量,载体线程池保持 8 个(默认 parallelism = CPU 核数),且消除了线程池排队延迟。前提是把阻塞路径上的 synchronized 换成 ReentrantLock(JDK 24 之前),避免 Pinning。
⚠️ 常见误区
详情
常见误区:
- ❌ "虚拟线程能提升 CPU 密集型任务吞吐" → 吞吐上限是载体线程数(≈核数),纯计算无收益。
- ❌ "JDK 21 中 synchronized 包裹阻塞 I/O 无所谓" → 会 Pinning 载体线程,需用 ReentrantLock(JDK 24 JEP 491 后才解决)。
- ❌ "虚拟线程栈也是 1MB" → 初始仅几百字节,随调用深度按需增长,存于堆中。
🔀 发散问题
- Q:如何确认是否发生 Pinning? → 启动参数
-Djdk.tracePinnedThreads=full可打印被钉住的调用栈。 - Q:虚拟线程与响应式编程怎么选? → I/O 密集场景虚拟线程以同步写法得到相近吞吐,堆栈完整、心智负担更低;已深度使用 Reactor 的存量系统无需强迁。
【中等】单核 CPU 支持 Java 多线程吗?⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:8 min | 🏷 标签:并发简介 / 线程调度
💎 关键结论
单核 CPU 可以支持 Java 多线程,但多个线程无法真正并行执行,而是通过时间片轮转(分时调度)在单个核心上交替运行,实现的是并发而非并行。Java 采用抢占式调度,由操作系统基于优先级和时间片分配 CPU。
⚡记忆卡片
- 口诀:单核能多线程,不能多并行;时间片轮转,抢占式调度
- 关键词:时间片轮转 / 抢占式调度 / 并发非并行
- 链路:多线程 → 单核交替执行 → 时间片用完 → 内核抢占切换 → 宏观"同时"假象
📖 核心知识
- 结论:单核可以跑多线程,但任一时刻只有一个线程在 CPU 上执行,靠快速切换制造同时运行的假象。
- 两种调度方式:
- 抢占式调度(Preemptive Scheduling):操作系统决定何时暂停当前线程并切换到另一个线程,通常由时钟中断(时间片轮转)或高优先级事件触发;有上下文切换开销,但公平性和 CPU 利用率好,不易阻塞。
- 协同式调度(Cooperative Scheduling):线程执行完毕后主动通知系统切换;上下文切换少,但公平性差,一个线程不让出就会阻塞全局。
- Java 的选择:Java 线程调度是抢占式的,JVM 不负责调度,而是委托给操作系统;操作系统基于线程优先级和时间片调度,高优先级线程获得更多时间片的机会更多。
🔬 扩展知识
详情
- 【L3】一次线程上下文切换开销约 1μs~5μs;单核上线程数过多时,切换开销占比升高,吞吐反而下降,这就是"合理控制并发度"的原因。
- 【L4】Java 线程优先级(1~10)只是提示,映射到 OS 优先级时行为因平台而异,不应依赖它保证执行顺序。
🔀 发散问题
- Q:单核上开 1000 个线程有意义吗? → 对 I/O 密集任务有意义(等待 I/O 时让出 CPU),对纯计算任务只会徒增切换开销。
- Q:协同式调度现在还有应用吗? → 有,用户态协程(Go goroutine、Java 虚拟线程的协作式让出)就带有协同调度的影子。
【简单】并发一定比串行更快吗?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:5 min | 🏷 标签:并发简介 / 性能认知
💎 关键结论
并发不一定比串行更快!多核并行计算、I/O 密集场景并发更快;单核切换、高锁竞争、简单任务场景串行更快。黄金法则:I/O 多用并发,计算多用多核,避免无脑加线程。
⚡记忆卡片
- 口诀:I/O 多、上并发;计算多、靠多核;锁竞争、白折腾
- 关键词:多核 / I/O 密集 / 锁竞争 / 并发度
- 链路:任务类型判定 → I/O 密集/多核 → 并发收益 / 单核/高竞争/小任务 → 串行更优
📖 核心知识
并发更快的情况
- 多核 CPU:真正并行执行计算任务
- I/O 密集型:网络/磁盘操作时,CPU 可切换做其他事
串行更快的情况
- 单核 CPU:线程切换反而增加开销
- 高竞争场景:锁争用导致线程空等
- 简单任务:并发管理开销超过收益
黄金法则
- I/O 多用并发,计算多用多核
- 避免无脑加线程,合理控制并发度
【简单】什么是并发安全?有哪些线程不安全的情况?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:8 min | 🏷 标签:并发简介 / 并发安全
💎 关键结论
并发安全指保证程序的正确性,使并发处理结果符合预期,需同时满足可见性、原子性、有序性三大特性。典型不安全情况:竞态条件、非原子操作、可见性问题、死锁、资源泄漏;核心应对思路是减少共享数据、合理加锁。
⚡记忆卡片
- 口诀:可见原子有序,三性保安全;少共享、慎加锁
- 关键词:可见性 / 原子性 / 有序性
- 链路:共享可变数据 → 竞态/可见性/重排问题 → 锁/原子类/不可变 → 并发安全
📖 核心知识
- 并发安全定义:保证程序的正确性,使得并发处理结果符合预期。需保证三大特性:
- 可见性:一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,
volatile就是负责保证可见性的。 - 原子性:简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制(加锁:
synchronized、Lock)实现。 - 有序性:保证线程内串行语义,避免指令重排等。
- 可见性:一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,
- 线程不安全的情况:
- 竞态条件:多线程同时修改共享变量(如
count++) - 非原子操作:多步骤操作被中断(如
if(x==null) x=new Object()) - 可见性问题:线程 A 的修改对线程 B 不可见
- 死锁:多个线程互相持有对方需要的锁
- 资源泄漏:线程未释放资源(如连接、文件)
- 竞态条件:多线程同时修改共享变量(如
- 解决办法:同步(
synchronized、Lock)、原子类(AtomicInteger)、不可变对象(final)、并发容器(ConcurrentHashMap)。核心:减少共享数据,合理加锁。
🔬 扩展知识
详情
- 【L3】三大问题的硬件根源:缓存导致可见性问题、线程切换导致原子性问题、编译优化导致有序性问题,见本文档「为什么会有并发安全问题?」。
- 【L4】JMM 通过 happens-before 规则统一约束三大特性,见本文档「什么是 Happens-Before 规则?有什么用?」。
🔀 发散问题
- Q:不可变对象为什么天然线程安全? → 状态创建后不再变化,无需同步;
final字段的安全发布由 JMM 特殊保证。 - Q:哪些场景要额外警惕并发问题? → 见本文档「哪些场景需要额外注意并发安全问题?」。
【中等】为什么会有并发安全问题?⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:10 min | 🏷 标签:并发简介 / 并发安全根源
💎 关键结论
并发安全问题源于三处硬件/软件优化:缓存导致可见性问题、线程切换导致原子性问题、编译优化导致有序性问题。它们都是为了性能而牺牲了天然一致性,需要 JMM 与同步机制来补偿。
⚡记忆卡片
- 口诀:缓存丢可见、切换丢原子、重排丢有序
- 关键词:CPU 缓存 / 时间片切换 / 指令重排
- 链路:缓存 → 可见性 / 切换 → 原子性 / 编译优化 → 有序性
📖 核心知识
(1)缓存导致的可见性问题
一个线程对共享变量的修改,另外一个线程能够立刻看到,称为 可见性。
在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。

多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。

(2)线程切换带来的原子性问题
Java 的并发也是基于任务切换。Java 中,即使是一条语句,也可能需要执行多条 CPU 指令。一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。
CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。违背直觉的是,高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。
- 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
- 指令 2:之后,在寄存器中执行+1 操作;
- 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
因此,执行 count += 1 不是原子操作。

(3)编译优化带来的有序性问题
有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:a=6; b=7; 编译器优化后可能变成 b=7; a=6;,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。
🔬 扩展知识
详情
- 【L3】
count += 1的丢失更新(lost update)是竞态条件的经典形态:两个线程各自增一次,结果只增加 1,根因是"读-改-写"三步可在任意步骤间被切换。 - 【L4】三大问题分别对应同步工具:可见性 →
volatile,原子性 → 锁/CAS,有序性 → 内存屏障,统一由 JMM 的 happens-before 规则约束,见本文档「什么是 Java 内存模型?」。
🔀 发散问题
- Q:单核 CPU 是否就没有可见性问题? → 不是,线程切换后寄存器/编译器优化仍可能导致修改未及时反映;只是多核缓存使问题更突出。
- Q:为什么高级语言的一条语句不是原子的? → 原子性以 CPU 指令为准,高级语句往往编译为多条指令,切换可发生在任意两条之间。
【中等】哪些场景需要额外注意并发安全问题?⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:10 min | 🏷 标签:并发简介 / 并发安全实践
💎 关键结论
凡是"多线程 + 共享可变状态"的地方都要警惕:共享可变数据、线程时序协作、单例/静态容器、数据库外部资源、线程池与 ThreadLocal、check-then-act 拆分原子操作。通用原则:最小化共享资源,优先用线程安全类,控制锁粒度,避免死锁。
⚡记忆卡片
- 口诀:共享可变必设防,单例静态易受伤;查改拆分要包裹,ThreadLocal 勤清理
- 关键词:共享可变数据 / 单例·静态容器 / check-then-act
- 链路:识别共享可变状态 → 选同步手段(原子类/并发容器/锁)→ 控制粒度 → 工具验证
📖 核心知识
通用原则:最小化共享资源,优先用线程安全类,控制锁粒度,避免死锁,事后工具验证。
- 共享可变数据:多线程读写普通变量 / 非安全集合(如
ArrayList)→ 用原子类(AtomicXXX)、线程安全容器(ConcurrentHashMap)或锁(synchronized/Lock)。 - 线程时序协作:需按顺序执行或等待资源(如 A 初始化后 B 读取)→ 用
wait/notify、Condition,或工具类(CountDownLatch)、阻塞队列。 - 单例 / 静态容器:懒汉单例、静态变量并发访问→ 单例用双重检查锁 + volatile / 枚举,静态容器用并发安全容器。
- 数据库 / 外部资源:多线程操作同数据(如扣库存)→ 数据库用悲观 / 乐观锁,分布式场景用 Redis 锁,业务层保证 "查 - 改" 原子性。
- 线程池与 ThreadLocal:任务共享资源、
ThreadLocal未清理→ 任务内同步,ThreadLocal 在 finally 中 remove。 - 原子操作拆分:
if-check-and-then操作(如if (count<10) count++)→ 用原子类 compareAndSet 或锁包裹整体操作。
🔬 扩展知识
详情
- 【L3】check-then-act 是最隐蔽的竞态:单看每步都对,但两步之间可被插入;解决思路永远是"把多步合成一个原子操作"(CAS、锁、数据库事务)。
- 【L4】分布式环境下还要叠加分布式锁/数据库锁,JVM 内的锁无法跨进程生效,这是"扣库存"类题的核心考点。
🔀 发散问题
- Q:HashMap 在并发下会出什么问题? → 并发 put 可能丢数据、死循环(JDK 7 头插法扩容)、size 不准;应换 ConcurrentHashMap。
- Q:线程池里 ThreadLocal 为什么要 remove? → 线程复用会导致上次任务的值残留,引发数据串流与内存泄漏。
【困难】什么是死锁?如何发现死锁?如何避免死锁?⭐⭐⭐⭐⭐
🎯 目标等级:L3-L4 | ⏱ 建议用时:15 min | 🏷 标签:并发安全 / 死锁
💎 关键结论
死锁是一组互相竞争资源的线程因互相等待而永久阻塞。必须同时满足互斥、占有并等待、不可抢占、循环等待四个条件;发现靠 jstack/ThreadMXBean,预防靠破坏必要条件(按序加锁、tryLock 超时、一次性申请)。
⚡记忆卡片
- 口诀:互斥占有不可抢,循环等待四条件;按序加锁破环,超时放弃保平安
- 关键词:四必要条件 / jstack / 按序加锁 / tryLock
- 链路:四条件齐备 → 环形等待图 → 永久阻塞 → 破坏任一条件 → 预防
📖 核心知识
- 定义:一组互相竞争资源的线程因互相等待,导致"永久"阻塞的现象。
- 产生死锁的四个必要条件:
- 互斥:该资源任意一个时刻只由一个线程占用。
- 占有并等待:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不可抢占:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待:若干线程之间形成一种头尾相接的循环等待资源关系。

案例:必然死锁的示例
import java.util.concurrent.CountDownLatch;
public class DeadlockWithCountDownLatch {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
private static final CountDownLatch latch1 = new CountDownLatch(1);
private static final CountDownLatch latch2 = new CountDownLatch(1);
private static final CountDownLatch startLatch = new CountDownLatch(1);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
try {
startLatch.await(); // 等待统一开始
synchronized (lock1) {
System.out.println("T1 持有 lock1");
latch1.countDown(); // 通知 T2:我已持有 lock1
latch2.await(); // 等待 T2 持有 lock2
System.out.println("T1 尝试获取 lock2");
synchronized (lock2) { // 此时 lock2 被 T2 持有,阻塞
System.out.println("T1 获取 lock2");
}
}
} catch (InterruptedException e) {}
});
Thread t2 = new Thread(() -> {
try {
startLatch.await();
synchronized (lock2) {
System.out.println("T2 持有 lock2");
latch2.countDown(); // 通知 T1:我已持有 lock2
latch1.await(); // 等待 T1 持有 lock1(此时 latch1 已减,通过)
System.out.println("T2 尝试获取 lock1");
synchronized (lock1) { // lock1 被 T1 持有,阻塞
System.out.println("T2 获取 lock1");
}
}
} catch (InterruptedException e) {}
});
t1.start();
t2.start();
startLatch.countDown(); // 同时启动两个线程
Thread.sleep(3000); // 观察死锁
System.out.println("主线程:疑似死锁发生");
}
}- 如何发现死锁:
(1)使用 jstack 工具
运行程序后,执行命令:
jstack <PID> # PID 是 Java 进程 ID如果存在死锁,输出会显示
Found one Java-level deadlock,并列出死锁的线程和资源。
(2)使用 ThreadMXBean 检测(代码方式)
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
public class DeadlockDetector {
public static void main(String[] args) {
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = threadMXBean.findDeadlockedThreads(); // 检测死锁线程
if (deadlockedThreads != null) {
System.out.println("发现死锁!涉及线程:");
for (long threadId : deadlockedThreads) {
System.out.println(threadId);
}
} else {
System.out.println("无死锁。");
}
}
}输出示例:
发现死锁!涉及线程:
12345
67890(3)使用 VisualVM 或 JConsole(可视化工具)
连接 Java 进程后,查看线程选项卡,死锁会被明确标记。
- 如何预防/避免死锁:
预防死锁——破坏死锁的产生的必要条件即可:
- 互斥:难以避免
- 占有并等待:一次性申请所有资源
- 不可抢占:超时释放锁
- 循环等待:按序申请资源
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3……Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 <P1、P2、P3.....Pn> 序列为安全序列。
生产环境最常用的两种编码手段
(1)按序加锁(破坏「循环等待」,最常用):所有线程按固定顺序获取锁。以银行转账为例,按账户 ID 排序后再加锁,转账双方无论谁先发起都不会形成环:
public void transfer(Account from, Account to, BigDecimal amount) {
// 按账户 ID 排序,保证所有线程加锁顺序全局一致
Account first = from.getId() < to.getId() ? from : to;
Account second = (first == from) ? to : from;
synchronized (first) {
synchronized (second) {
if (from.getBalance().compareTo(amount) >= 0) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
}(2)tryLock 超时放弃(破坏「不可抢占」):拿不到第二把锁就释放已持有的锁,随机退避后重试(随机退避同时可避免活锁):
while (true) {
if (lockA.tryLock()) {
try {
if (lockB.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
doTransfer();
return;
} finally {
lockB.unlock();
}
}
} finally {
lockA.unlock(); // 拿不到 lockB,主动释放 lockA
}
}
Thread.sleep(ThreadLocalRandom.current().nextInt(50)); // 随机退避
}(3)线上巡检:生产系统可用定时任务周期调用 ThreadMXBean.findDeadlockedThreads(),发现死锁立即告警并输出线程 Dump;也可开启 JFR 的 jdk.ThreadDump 事件做周期快照。
🔬 扩展知识
详情
- 【L3】
findDeadlockedThreads()通过分析锁等待图的环来检测,只覆盖 JVM 管理的锁(synchronized/java.util.concurrent锁),数据库、RPC 层面的死锁它无能为力。 - 【L4】跨语言死锁检测与消除机制对比:
(一)Go Race Detector 对比 Java 死锁检测工具
Go 语言提供了内置的竞态检测器(Race Detector),与 Java 的死锁检测形成了鲜明对比:
| 维度 | Go Race Detector (go run -race) | Java 死锁检测 (jstack / ThreadMXBean) |
|---|---|---|
| 检测对象 | 数据竞态(data race):两个 goroutine 无同步地并发读写同一内存 | 死锁(deadlock):线程因锁循环依赖而永久阻塞 |
| 实现原理 | 编译期插桩 + 运行时 Thread Sanitizer(TSan)算法,追踪每次内存访问的 happens-before 关系 | 运行时分析等待图,通过 JVM TI / JMX 检测锁的循环依赖 |
| 检测时机 | 运行时,当竞态实际发生时才会报告(不是静态分析) | 可主动轮询 findDeadlockedThreads() 或被动分析线程 Dump |
| 性能开销 | 内存增加 5~10x,CPU 慢 2~20x(仅限测试环境) | ThreadMXBean 开销极小,几乎可生产环境实时检测 |
| 覆盖范围 | 覆盖所有内存访问(包括无锁算法、channel 操作等),但不能检测死锁本身 | 仅检测 JVM 管理的锁(synchronized / java.util.concurrent 锁),不能检测数据竞态 |
| 工程实践 | go test -race 是 CI 流水线必备环节,Google 内部所有 Go 代码均要求 race-free | 生产系统通过定时任务 findDeadlockedThreads() + JFR 实时监控锁竞争 |
关键差异:Go 的 Race Detector 检测的是数据竞态(并发访问的正确性问题),而 Java 的 findDeadlockedThreads() 检测的是死锁(线程活跃性问题)。二者解决的问题不同,但同样重要。Java 缺少内置的轻量级数据竞态检测器(虽然有 jcstress 并发测试框架,但不如 -race 使用便捷)。
(二)Rust 的所有权系统——编译期消除死锁
Rust 语言在最底层就消除了绝大部分并发问题的可能性,这得益于其三大核心机制:
1. 所有权(Ownership)+ 借用检查(Borrow Checker)——编译期消除数据竞态
Rust 的类型系统在编译期静态保证:
- 任意时刻,要么只有一个可变引用(
&mut T),要么有多个不可变引用(&T),二者不可共存。 - 编译期借用检查器(Borrow Checker)对每条语句验证引用生命周期,违反规则直接编译失败。
这意味着 Rust 程序的数据竞态在编译期就被杜绝——不像 Java 需要通过 synchronized、volatile 或原子类来在运行时保护,也不像 Go 需要 -race 来事后检测。
2. Send 和 Sync trait——编译期保证线程安全边界
Send:标记类型可以安全地将所有权转移到另一个线程(几乎所有 Rust 类型默认实现Send,但Rc等非线程安全类型不会实现)。Sync:标记类型可以安全地在多个线程间共享引用(Arc<T>实现了Sync,而Rc<T>没有)。
编译器会在编译期检查:如果某个类型没有实现 Send/Sync,试图跨线程传递/共享它就是编译错误。这比 Java 依赖程序员的 synchronized 判断要安全得多——Java 中把一个非线程安全的 ArrayList 传给多个线程是编译通过但运行出错的,而在 Rust 中类似行为(把 Rc<Vec<T>> 传给另一个线程)直接编译失败。
3. 死锁呢?——Rust 并非万能
需要澄清一个重要事实:Rust 的借用检查器无法消除死锁。死锁是运行时资源依赖问题(A 等 B、B 等 A),不是内存安全问题。Rust 程序依然可能写出典型的 mutex1.lock() → mutex2.lock() 的死锁代码。
但 Rust 社区形成了强约束实践:
- 优先使用 Channel 通信(
std::sync::mpsc),遵循 "Do not communicate by sharing memory; instead, share memory by communicating" 哲学。 - 当必须使用锁时,使用
Mutex<T>包裹数据而非保护代码块,锁在离开作用域时自动释放(RAII),避免了 Java 中忘记unlock()的问题。 - 使用
parking_lot等第三方库的Mutex支持try_lock_for超时机制。
| 对比维度 | Java | Go | Rust |
|---|---|---|---|
| 数据竞态检测 | 无内置(需 jcstress) | 内置 -race(TSan) | 编译期杜绝(Borrow Checker) |
| 死锁检测 | findDeadlockedThreads() / jstack | 运行时死锁检测器(goroutine Dump) | 无(编译期不保证,运行时需第三方) |
| 死锁防止 | 编码规范 + tryLock 超时 | Channel 通信优先 + sync.Mutex | RAII 自动释放锁 + Channel 优先 |
| 并发安全哲学 | 程序员自行保证 | 工具辅助检测 | 编译器静态保证 |
🏭 实战场景
详情
转账类业务是死锁重灾区:账户 A→B 与 B→A 两笔转账并发时,若各自按"先锁付款方"加锁即形成环。生产实践:按账户 ID 全局排序加锁,配合 tryLock(100ms) 超时 + 随机退避(0~50ms),并用定时任务每 5s 调用 ThreadMXBean.findDeadlockedThreads()(单次开销毫秒级),发现死锁自动 dump 线程栈并告警,将死锁从"用户反馈才发现"变为分钟级自愈重试。
⚠️ 常见误区
详情
常见误区:
- ❌ "单线程不会死锁" → 单线程对同一
synchronized以外的锁(如重复获取不可重入锁)或与其他线程交叉持锁时仍可能死锁;另外两个以上线程才是典型场景,但"自旋等自己"的活锁/饥饿也要区分。 - ❌ "死锁和饥饿是一回事" → 死锁是所有相关线程永久阻塞;饥饿是部分线程长期拿不到资源但其他线程正常,见本文档「什么是饥饿问题?如何避免饥饿?」。
- ❌ "tryLock 能解决一切死锁" → tryLock 破坏的是不可抢占条件,若不配合退避,多线程同时重试可能演变为活锁。
🔀 发散问题
- Q:活锁与死锁有何区别? → 活锁中线程在运行但无进展(互相谦让),死锁中线程完全阻塞,见本文档「什么是活锁?如何避免活锁?」。
- Q:分布式系统如何检测死锁? → 等待图(wait-for graph)+ 超时机制:数据库(如 InnoDB)会主动检测事务环并回滚代价最小的事务。
- Q:jstack 能看到哪些锁信息? → 能看到
synchronized和 J.U.C 锁的持有/等待关系,native 锁和数据库锁看不到。
【中等】什么是活锁?如何避免活锁?⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:8 min | 🏷 标签:并发安全 / 活锁
💎 关键结论
活锁是线程都在运行(不阻塞),但因相互谦让或重复响应对方的状态变化,谁都无法向前推进。解法很简单:谦让时等待一个随机时间,打破同步碰撞,Raft 等分布式算法也用同样思路。
⚡记忆卡片
- 口诀:死锁都不动,活锁都在动;谦让加随机,碰撞自然消
- 关键词:互相谦让 / 无进展 / 随机等待
- 链路:同时谦让 → 同步碰撞 → 反复重试无进展 → 随机退避 → 错峰推进
📖 核心知识
- 定义:多个线程/进程在执行时,虽然都在运行(不阻塞),但通过相互谦让或重复响应对方的状态变化,导致谁都无法向前推进的状态。
案例:走廊相遇的比喻与 Worker 示例
想象这样一个例子:两个人在狭窄的走廊里相遇,二者都很礼貌,试图移到旁边让对方先通过。但是他们最终在没有取得任何进展的情况下左右摇摆,因为他们都在同一时间向相同的方向移动。

如图所示:两个线程想要通过一个 Worker 对象访问共享公共资源的情况,但是当他们看到另一个 Worker(在另一个线程上调用)也是"活动的"时,它们会尝试将该资源交给其他工作者并等待为它完成。如果最初我们让两名工作人员都活跃起来,他们将会面临活锁问题。
- 与死锁的区别:死锁中线程完全阻塞;活锁中线程在运行但无进展,CPU 甚至可能很忙。
- 解决方案:谦让时,尝试等待一个随机的时间就可以了。由于等待的时间是随机的,同时相撞后再次相撞的概率就很低了。"等待一个随机时间"的方案虽然很简单,却非常有效,Raft 这样知名的分布式一致性算法中也用到了它。
🔬 扩展知识
详情
- 【L3】tryLock 失败后若所有线程立即同步重试,就是典型的活锁;
Thread.sleep(ThreadLocalRandom.current().nextInt(50))式随机退避是最常见的配套手段。 - 【L4】Raft 选举超时(election timeout)随机化、以太网 CSMA/CD 的指数退避,都是同一思想在分布式/网络层的应用。
🔀 发散问题
- Q:活锁会自己解除吗? → 可能,但不可依赖;不加随机性时同步碰撞会持续,必须用随机退避主动打破。
- Q:活锁和饥饿有什么区别? → 活锁是当事各方互相谦让都无法推进;饥饿是某线程被其他线程持续挤占拿不到资源,见本文档「什么是饥饿问题?如何避免饥饿?」。
【中等】什么是饥饿问题?如何避免饥饿?⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:10 min | 🏷 标签:并发安全 / 饥饿
💎 关键结论
饥饿是某些线程由于长期无法获取所需资源(CPU 时间、锁、I/O 等),导致任务无法执行或执行缓慢。常见原因是优先级不合理、非公平锁竞争、资源分配不均;应对:公平锁、合理优先级、tryLock 超时、缩短持锁时间。
⚡记忆卡片
- 口诀:死锁全卡死,活锁空转圈,饥饿是个别线程吃不上饭
- 关键词:长期无资源 / 非公平锁 / 公平锁
- 链路:资源被持续抢占 → 部分线程长期拿不到 → 任务停滞 → 公平锁/超时/降持锁时间
📖 核心知识
- 定义:某些线程由于长期无法获取所需资源(如 CPU 时间、锁、I/O 等),导致任务无法执行或执行缓慢。
- 与死锁/活锁的区别:
- 死锁:所有相关线程都被阻塞,无法继续。
- 活锁:线程在运行,但无法取得进展。
- 饥饿:部分线程能正常运行,但某些线程长期得不到资源。
- 常见原因:
| 原因 | 示例 |
|---|---|
| 线程优先级不合理 | 高优先级线程总是抢占 CPU,低优先级线程长期得不到执行。 |
| 锁竞争不公平 | 某些线程总是抢不到锁(如synchronized是非公平锁)。 |
| 资源分配不均 | 线程池任务调度不合理,某些任务被长时间搁置。 |
| I/O 或网络阻塞 | 某些线程因 I/O 操作被阻塞,而其他线程持续占用 CPU。 |
- 避免手段:
(1)使用公平锁(Fair Lock):ReentrantLock 支持公平策略,synchronized 是非公平的,无法直接设置公平性。
ReentrantLock fairLock = new ReentrantLock(true); // true 表示公平锁(2)合理设置线程优先级:避免滥用高优先级;Java 线程优先级 1~10,默认 5。
thread.setPriority(Thread.NORM_PRIORITY); // 5(3)避免长时间占用资源:减少锁的持有时间,只在必要时加锁;使用 tryLock() 设置超时,防止无限等待:
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try { /* 临界区 */ }
finally { lock.unlock(); }
}(4)优化线程池任务调度:使用 newFixedThreadPool 或 newCachedThreadPool 时,结合 BlockingQueue 避免任务堆积;可改用 ForkJoinPool 进行任务拆分,提高公平性。
(5)监控与调整:使用 VisualVM、JConsole 等工具观察线程状态,发现长期阻塞的线程,结合日志分析优化资源分配策略。
🔬 扩展知识
详情
- 【L3】公平锁通过 FIFO 队列保证先来先得,但每次获取都要排队检查,吞吐量低于非公平锁,所以
ReentrantLock默认非公平。 - 【L4】优先级饥饿在 JVM 层很难彻底解决:Java 优先级只是给 OS 的提示,跨平台行为不一致,工程上更可靠的做法是队列/信号量级别的公平策略。
🔀 发散问题
- Q:公平锁一定没有饥饿吗? → 基本能避免锁层面的饥饿,但若持锁线程执行时间过长,等待者依然要等很久,公平不等于快。
- Q:为什么 synchronized 不能设置为公平锁? → 其基于 Monitor 的
_cxq/_EntryList唤醒策略不保证 FIFO,设计上就不提供公平性开关。
【简单】简单介绍一下 Java 并发编程?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:8 min | 🏷 标签:并发简介 / 并发编程总览
💎 关键结论
并发编程可以抽象成三个核心问题:分工(如何拆解任务分配给线程)、同步(线程间如何协作)、互斥(同一时刻只允许一个线程访问共享资源)。Java 的 java.util.concurrent(J.U.C)包提供了原子类、锁、并发容器、队列、线程池等全套工具。
⚡记忆卡片
- 口诀:分工同步互斥,三问破并发
- 关键词:分工 / 同步 / 互斥 / J.U.C
- 链路:分工(线程池/协程)→ 同步(CountDownLatch/队列)→ 互斥(锁/原子类)
📖 核心知识
- 三大核心问题:
- 分工 - 是指如何高效地拆解任务并分配给线程。
- 同步 - 是指线程之间如何协作。
- 互斥 - 是指保证同一时刻只允许一个线程访问共享资源。

- J.U.C 工具分类:Java 的
java.util.concurrent包(简称 J.U.C)中提供了大量并发工具类,是 Java 并发能力的主要体现(注意,不是全部,有部分并发能力的支持在其他包中)。从功能上,大致可以分为:- 原子类 - 如:
AtomicInteger、AtomicIntegerArray、AtomicReference、AtomicStampedReference等。 - 锁 - 如:
ReentrantLock、ReentrantReadWriteLock等。 - 并发容器 - 如:
ConcurrentHashMap、CopyOnWriteArrayList、CopyOnWriteArraySet等。 - 阻塞队列 - 如:
ArrayBlockingQueue、LinkedBlockingQueue等。 - 非阻塞队列 - 如:
ConcurrentLinkedQueue、LinkedTransferQueue等。 - 线程池 - 如:
ThreadPoolExecutor、Executors等。
- 原子类 - 如:
- 底层基石:J.U.C 包中的工具类是基于
synchronized、volatile、CAS、ThreadLocal这样的并发核心机制打造的。所以,要想深入理解 J.U.C 工具类的特性、为什么具有这样那样的特性,就必须先理解这些核心机制。
Java 内存模型
【中等】什么是 Java 内存模型?⭐⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:12 min | 🏷 标签:Java 内存模型 / JMM
💎 关键结论
Java Memory Model (JMM) 是 Java 规范定义的一套多线程内存访问规则,用于解决并发编程中的可见性、原子性、有序性问题,屏蔽硬件差异,让 Java 程序在不同硬件和操作系统上都能正确执行并发操作;其核心手段是 happens-before 规则与内存屏障。
⚡记忆卡片
- 口诀:缓存重排切换,带来三大问题;JMM 定规则,屏蔽硬件差异
- 关键词:可见性 / 有序性 / 内存屏障
- 链路:CPU 缓存/编译优化/线程切换 → 三大问题 → JMM 规则(happens-before + 屏障)→ 跨平台正确性
📖 核心知识
- 定义:Java Memory Model (JMM) 是 Java 规范定义的一套多线程内存访问规则,用于解决并发编程中的可见性、原子性、有序性问题。目的是让 Java 程序在不同硬件和操作系统上都能正确执行并发操作。
- 问题根源——CPU、内存、I/O 设备存在很大的速度差异(CPU 远快于内存,内存远快于 I/O 设备),为平衡三者速度差异:
- CPU 增加了缓存,以均衡与内存的速度差异;
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用;
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 的速度差异。
- 缓存一致性:缓存导致的可见性问题,编译优化带来的有序性问题,线程切换带来的原子性问题。为了解决缓存一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。

指令重排序:为了使缓存得到更加合理地使用,计算机在执行程序代码的时候,会对指令进行重排序。常见的有 2 种情况:
- 编译器优化重排:编译器在不改变单线程语义的前提下调整语句顺序。
- 指令并行重排:处理器利用指令级并行技术(ILP)调整指令执行顺序(无数据依赖时)。
Java 源代码会经历 编译器优化重排 → 指令并行重排 → 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致,所以在多线程下,指令重排序可能会导致一些问题。解决方案:编译器禁止特定类型的编译器重排序;处理器通过插入内存屏障(Memory Barrier/Fence)禁止特定处理器重排序。
🔬 扩展知识
详情
- 【L3】JMM 是抽象规范而非 JVM 实际内存结构;其中的"主内存/工作内存"只是对 CPU 寄存器、缓存的抽象,不与硬件层次严格对应。
- 【L4】跨语言内存模型对比——C++11 与硬件内存序:
(一)C++11 Memory Model:与 JMM 的"孪生兄弟"
C++11 在 2011 年正式引入了多线程内存模型(std::memory_order),与 JMM(JSR-133,2004 年修订)几乎诞生于同一时代,二者有惊人的相似性:
| 维度 | Java 内存模型 (JMM) | C++11 Memory Model | 相似点 |
|---|---|---|---|
| 核心目标 | 屏蔽硬件差异,定义跨平台的线程间内存访问规则 | 与 JMM 完全相同:定义跨硬件平台的并发语义 | 都是"抽象机"模型,不直接描述硬件行为 |
| happens-before | 偏序关系,定义操作间的可见性约束 | happens-before 概念几乎完全一致(受 Lamport 论文启发) | 同源:都来自 Lamport 1978 年论文 |
| 原子操作与内存序 | 无直接暴露;volatile 提供 acquire/release 语义,CAS 提供全序 | 明确的 6 种顺序:relaxed、consume、acquire、release、acq_rel、seq_cst | C++11 粒度更细,Java 更简化 |
| 顺序一致性 | JMM 默认不保证顺序一致性(允许重排序),volatile + synchronized 组合可近似 | seq_cst 是默认内存序(C++ 原子操作默认最严格) | 默认策略相反:C++ 偏好安全,Java 偏好性能 |
| final 字段安全发布 | JMM 特殊规则:构造函数中 final 字段初始化 + 安全发布 = 对其他线程可见 | 没有对等概念,需手动用 atomic + release/acquire 保证 | Java 特有:面向 JVM 的简化 |
为什么说它们是"同一个时代的孩子"? 在 2004~2011 年间,x86 多核处理器大规模普及,Java、C++ 两个语言社区同时面临同一个问题:如何在不触碰硬件的情况下定义并发语义?JMM 的 JSR-133(2004)和 C++11 Memory Model(2011)都是这个硬件并发革命时代的产物。它们的核心策略一致——通过 happens-before 关系在抽象机层面定义操作间的约束,而非直接绑定某个具体的 CPU 架构。
(二)x86 TSO vs ARM Weak Memory Model:硬件差异的根本来源
Java 和 C++ 的抽象内存模型之所以存在,本质上是不同 CPU 架构的内存模型差异巨大:
x86 TSO(Total Store Order——全序存储模型)
x86 架构(Intel/AMD)是典型的强内存模型:
- Store→Store:写操作对其他核心按 FIFO 顺序可见(写不会被重排序)。
- Load→Load:读操作对其他核心也按序可见(读不会被重排序)。
- 但 Store→Load 可重排序:
Store X = 1; Load Y;可能被重排为Load Y; Store X = 1;——这是 x86 唯一的重排序类型,也是为什么volatile写后需要StoreLoad屏障(mfence或lock前缀指令)。
在 x86 上,因为除 StoreLoad 外所有重排序都被硬件禁止,JMM 的 StoreStore、LoadLoad、LoadStore 屏障实际上是零成本的——CPU 硬件本身就保证了这些顺序。只有 StoreLoad 需要真实的屏障指令(mfence / lock)。
ARM/POWER Weak Memory Model(弱内存模型)
ARM 和 POWER 架构是典型的弱内存模型:
- 几乎所有乱序都可能发生:Store→Store、Load→Load、Store→Load、Load→Store 都可能被处理器重排序。
- 需要显式屏障:必须通过
dmb(Data Memory Barrier,ARM)、sync(POWER)等显式指令来强制顺序。 - Java
volatile在 ARM 上的成本远高于 x86——因为StoreStore、LoadLoad、LoadStore都需要真实的屏障指令。
| 维度 | x86 TSO | ARM Weak Memory |
|---|---|---|
| 写-写重排序 | ❌ 不允许 | ✔️ 允许 |
| 读-读重排序 | ❌ 不允许 | ✔️ 允许 |
| 写-读重排序 | ✔️ 允许(唯一) | ✔️ 允许 |
| 读-写重排序 | ❌ 不允许 | ✔️ 允许 |
| volatile 写成本 | 低(仅 StoreLoad 屏障 = lock 前缀 ~20 周期) | 高(需多个 dmb 屏障) |
| volatile 读成本 | 几乎无开销(LoadLoad/LoadStore = NOP) | 需 dmb 屏障 |
关键启示:JMM 和 C++11 Memory Model 都必须为"最坏情况"(ARM/POWER)定义语义。在 x86 上看起来"免费"的 volatile 操作,到了 ARM 上成本可能显著增加。这就是抽象内存模型的价值所在——程序员无需关心底层是 x86 还是 ARM,JMM 保证 volatile 在所有平台上语义一致,代价由 JVM 在屏障插入时根据不同架构动态优化。
参考:Intel® 64 and IA-32 Architectures Software Developer's Manual, Volume 3A, Chapter 8.2 "Memory Ordering";ARM Architecture Reference Manual, Chapter B2.2 "Memory Ordering"。
📚 延伸阅读:全面理解 Java 内存模型
⚠️ 常见误区
详情
常见误区:
- ❌ "JMM 就是 JVM 内存结构(堆/栈/方法区)" → 二者是不同概念:JMM 是关于可见性/有序性的抽象规范,JVM 运行时数据区是实际内存布局。
- ❌ "JMM 规定主内存就是内存、工作内存就是缓存" → 主内存/工作内存只是抽象概念,不与硬件层次严格对应。
🔀 发散问题
- Q:JMM 如何约束重排序? → 通过 happens-before 偏序规则,见本文档「什么是 Happens-Before 规则?有什么用?」。
- Q:JMM 的底层实现是什么? → 内存屏障,见本文档「什么是 Java 内存屏障?有什么用?」。
【困难】什么是 Happens-Before 规则?有什么用?⭐⭐⭐
🎯 目标等级:L3-L4 | ⏱ 建议用时:12 min | 🏷 标签:Java 内存模型 / Happens-Before
💎 关键结论
JMM 为程序中所有操作定义了偏序关系 Happens-Before(先行发生原则),它是 JMM 的核心规则,用于约束指令重排序、保证多线程可见性,是判断数据是否存在竞争、线程是否安全的主要依据。记住 8 条规则(程序顺序、volatile、锁、start、join、中断、终结、传递性)即可一揽子解决两操作间是否可能冲突的问题。
⚡记忆卡片
- 口诀:程序 volatile 锁,start join 中断终结加传递
- 关键词:偏序关系 / 可见性约束 / 8 条规则
- 链路:写操作 A → happens-before → 读操作 B → A 的结果对 B 保证可见
📖 核心知识
- 定位:Happens-Before 是 JMM 的核心规则,用于约束指令重排序和保证多线程可见性。它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地解决并发环境下两个操作间是否可能存在冲突的所有问题。
- 8 条规则:
- 程序顺序规则:单线程内代码顺序执行(但不影响多线程重排序)。
volatile规则:volatile写 Happens-Before 后续的volatile读。volatile 保证可见性 + 禁止指令重排序。- 锁规则:解锁 Happens-Before 后续的加锁(如
synchronized、ReentrantLock)。 - 线程启动规则:
Thread.start()Happens-Before 线程内的所有操作。 - 线程终止规则:线程中的所有操作 Happens-Before
Thread.join()完成。 - 线程中断规则:
Thread.interrupt()Happens-Before 被中断线程检测到中断(isInterrupted()或InterruptedException)。 - 对象终结规则:对象的构造函数执行结束 Happens-Before
finalize()方法被调用。 - 传递性:若 A → B 且 B → C,则 A → C。
🔬 扩展知识
详情
- 【L3】happens-before 不等于时间先后:它描述的是可见性/因果约束,A happens-before B 不代表 A 物理上先于 B 执行;反之,无此关系的操作允许重排且结果不保证可见。
- 【L4】happens-before 源自 1978 年 Lamport 论文《Time, Clocks, and the Ordering of Events in a Distributed System》,用于解决分布式系统中事件时序问题;C++11 内存模型几乎照搬了这一概念,两个语言规范都选择了"抽象机偏序"而非硬件时序。
🏭 实战场景
详情
排查"单例偶发 NPE、开关标志不生效"这类生产问题时,第一步就是检查读写双方之间是否存在 happens-before 链:标志位未加 volatile(缺规则 2)、结果传递未加锁(缺规则 3)是最常见根因。修复后建议用 JMH/JCStress 在 CI 中高并发采样回归,避免"本机跑不出"的偶发问题。
⚠️ 常见误区
详情
常见误区:
- ❌ "happens-before 就是时间上的先后" → 它是可见性偏序约束,不是物理时间顺序。
- ❌ "单线程内代码一定严格按序执行" → 只是保证"看起来"按序(程序顺序规则),底层允许重排,只要单线程语义不变。
🔀 发散问题
- Q:volatile 写读之间到底靠哪条规则保证可见性? → volatile 规则:对同一变量的写 happens-before 后续的读,且结合传递性可传递之前的普通写。
- Q:happens-before 的底层实现是什么? → 内存屏障与锁的获取/释放语义,见本文档「什么是 Java 内存屏障?有什么用?」。
📚 延伸阅读:1978 年,Lamport 在论文 Time, Clocks, and the Ordering of Events in a Distributed System (译文,解读 )中第一次提出了 Happens-Before,阐述了偏序关系(partial ordering)、逻辑时钟(Logical Clocks)概念。Happens-Before 的语义是一种因果关系:如果 A 事件是导致 B 事件的起因,那么 A 事件一定是先于(Happens-Before)B 事件发生的。
【困难】什么是 Java 内存屏障?有什么用?⭐⭐⭐
🎯 目标等级:L3-L4 | ⏱ 建议用时:12 min | 🏷 标签:Java 内存模型 / 内存屏障
💎 关键结论
内存屏障(Memory Barrier/Fence)是 JMM 的底层实现机制,通过限制重排序和强制缓存同步实现多线程的可见性与有序性。JVM 将其抽象为 LoadLoad、StoreStore、LoadStore、StoreLoad 四种,其中 StoreLoad 开销最大;volatile、synchronized、final 的语义都靠它落地。
⚡记忆卡片
- 口诀:四屏障禁重排,刷缓存保可见;写前 StoreStore,写后 StoreLoad
- 关键词:禁止重排 / 缓存同步 / StoreLoad
- 链路:JMM 规则 → 插入四类屏障 → CPU 屏障指令(mfence/lock)→ 可见性+有序性
📖 核心知识
- 定义与作用:内存屏障(Memory Barrier/Fence)是 JMM 的底层机制,通过 限制重排序 和 强制缓存同步,实现多线程程序的 可见性 和 有序性:
- 禁止特定类型的指令重排序(编译器和处理器优化可能导致乱序执行)。
- 强制刷新 CPU 缓存,确保多线程间的 内存可见性。
- 四种屏障(JVM 依赖底层 CPU 的内存屏障指令,如 x86 的
mfence/lfence/sfence,抽象为以下四种):- LoadLoad:确保
Load1的读取操作在Load2及后续读取之前完成。示例:volatile读后的普通读。 - StoreStore:确保
Store1的写入操作在Store2及后续写入之前对其他线程可见。示例:volatile写前的普通写。 - LoadStore:确保
Load1的读取操作在Store2及后续写入之前完成。 - StoreLoad:确保
Store1的写入对所有线程可见后,才执行Load2的读取。开销最大(如volatile写后的volatile读会插入此屏障)。
- LoadLoad:确保
- 应用场景:
volatile变量:写操作插入StoreStore+StoreLoad屏障;读操作插入LoadLoad+LoadStore屏障。synchronized锁:进入临界区(加锁)和退出(解锁)时插入屏障,保证可见性和有序性。final字段:构造函数中的final字段写入后插入屏障,确保正确初始化对其他线程可见。
- 作用总结:禁止重排序(防止优化破坏多线程逻辑,如 DCL 问题);保证可见性(强制工作内存修改刷回主内存并使其他线程缓存失效);保证有序性(happens-before 规则的实现基础)。
案例:volatile 的屏障插入
volatile int flag = 0;
int value = 0;
void write() {
value = 42; // 普通写
// StoreStore 屏障(确保 value=42 先刷入主内存)
flag = 1; // volatile 写
// StoreLoad 屏障(保证写操作对所有线程可见)
}
void read() {
if (flag == 1) { // volatile 读
// LoadLoad + LoadStore 屏障
System.out.println(value); // 保证读到 value=42
}
}🔬 扩展知识
详情
- 【L3】JVM 通过
Unsafe类提供loadFence()/storeFence()/fullFence()方法封装屏障(VarHandle内部使用)。 - 【L3】x86 上
StoreLoad对应mfence(或lock前缀指令),其他屏障通常无实际指令(因 x86 强内存模型已满足大部分需求);ARM/PowerPC 弱内存模型需显式插入更多屏障指令。 - 【L4】屏障成本与架构强相关:x86 上 volatile 读几乎免费、写约 ~20 周期;ARM 上读写都需多个
dmb屏障,这正是"同一份 Java 代码在不同硬件上性能不同"的根源,见本文档「什么是 Java 内存模型?」。
🏭 实战场景
详情
DCL 单例在弱内存模型设备(ARM 服务器/手机)上更容易暴露"读到未初始化对象"问题:x86 强模型下偶发的重排序,在 ARM 上出现概率显著升高。生产上对启动即初始化的单例(静态内部类/枚举)无需屏障开销,而热路径开关标志建议用 volatile,避免用 synchronized 付出锁的屏障+互斥双重代价。
⚠️ 常见误区
详情
常见误区:
- ❌ "内存屏障是 Java 代码可以直接使用的语法" → Java 代码只能通过 volatile/synchronized/VarHandle 间接获得屏障;直接 API 仅 Unsafe/VarHandle 等底层入口。
- ❌ "四种屏障开销都很大" → 在 x86 上除 StoreLoad 外其余屏障基本是零成本,硬件已天然保证。
🔀 发散问题
- Q:volatile 读写的屏障插入规则具体是什么? → 写前 StoreStore、写后 StoreLoad;读后 LoadLoad + LoadStore,见本文档「
volatile有什么作用?」。 - Q:synchronized 的可见性靠什么实现? → 解锁时把工作内存刷回主内存、加锁时重新读取,底层同样是屏障 + Monitor 语义。
【中等】volatile 有什么作用?⭐⭐⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:12 min | 🏷 标签:并发机制 / volatile
💎 关键结论
volatile 是轻量级的线程同步工具:可以保证可见性和有序性,但不保证原子性。适用于状态标志、DCL 单例等"单线程写、多线程读"场景;复合操作仍需锁或原子类,不能滥用。
⚡记忆卡片
- 口诀:可见有序它都管,原子操作它不管;一写多读最合适,复合操作交给锁
- 关键词:可见性 / 禁止重排 / 不保证原子性
- 链路:volatile 写 → 刷主内存+屏障 → 其他线程缓存失效 → 读到最新值
📖 核心知识
volatile 是轻量级的线程同步工具,适用于状态标志、DCL 单例等场景。注意事项:不要滥用,仅适用于简单状态同步,复杂操作仍需锁或原子类;不适用于复合操作,如 check-then-act(需 synchronized 或 CAS)。

- 保证可见性:
- 强制线程每次读取
volatile变量时,直接从主内存获取最新值(跳过工作内存缓存)。 - 强制线程每次写入
volatile变量时,立即同步到主内存,使其他线程立即可见。
- 强制线程每次读取
- 禁止指令重排序:通过插入 内存屏障(Memory Barrier) 禁止编译器和 CPU 对
volatile变量的读写操作进行重排序;双重检查锁(DCL)单例模式 中必须用volatile修饰实例变量,防止对象未初始化完成就被使用。 - 不保证原子性:
volatile不能替代synchronized,例如volatile int i++;仍存在竞态条件(需用AtomicInteger)。适用场景:单线程写、多线程读 的变量(如开关标志)。
案例:volatile 的典型应用场景
状态标志位
volatile boolean running = true;
void stop() { running = false; } // 线程 A
void run() { while (running) { ... } } // 线程 B双重检查锁(DCL)
class Singleton {
private static volatile Singleton instance;
static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 禁止重排序
}
}
}
return instance;
}
}发布不可变对象
volatile Map<String, String> config = readConfig(); // 保证引用可见性🔬 扩展知识
详情
- 【L3】volatile 底层实现原理:写操作插入
StoreStore+StoreLoad屏障,读操作插入LoadLoad+LoadStore屏障。四类屏障的插入规则(JSR-133 规范):
| 屏障 | 插入位置 | 作用 |
|---|---|---|
StoreStore | 每个 volatile 写之前 | 禁止上面的普通写与 volatile 写重排序 |
StoreLoad | 每个 volatile 写之后 | 禁止 volatile 写与其后的读/写重排序 |
LoadLoad | 每个 volatile 读之后 | 禁止下面的普通读与 volatile 读重排序 |
LoadStore | 每个 volatile 读之后 | 禁止下面的普通写与 volatile 读重排序 |
- 【L3】x86 硬件基础:
volatile写最终编译为一条带lock前缀的指令(如lock addl $0, 0(%rsp))。lock前缀触发缓存一致性协议(MESI),将当前核心缓存行写回主内存并使其他核心的对应缓存行失效——这是可见性的硬件基础;同时lock指令本身充当全量内存屏障,等效StoreLoad。这正是 DCL 单例必须加volatile的底层原因:没有volatile,instance = new Singleton()的「分配内存 → 初始化 → 引用赋值」三步中后两步可能重排序(详见本文档「为什么 DCL 单例模式需要 volatile?」)。 - 【L3】硬件视角:MESI 协议与 Store Buffer——为什么 volatile 写不"即时":
(1)MESI 协议的四个状态
现代 x86 CPU 通过 MESI 协议保证多核之间的缓存一致性:
| 状态 | 全称 | 含义 |
|---|---|---|
| M (Modified) | 已修改 | 缓存行仅在本核心,已被修改,与主内存不一致 |
| E (Exclusive) | 独占 | 缓存行仅在本核心,与主内存一致 |
| S (Shared) | 共享 | 缓存行在多个核心中,与主内存一致 |
| I (Invalid) | 失效 | 缓存行无效,读取时需从主内存或其他核心获取 |
(2)Store Buffer —— volatile 写延迟的根源
CPU 核在写入共享缓存行前,必须先通过 MESI 协议将其他核心的对应缓存行置为 I 状态。这个协商过程需要跨核心通信(几十到上百个 CPU 周期)。为了提高执行效率,CPU 引入了 Store Buffer:写操作先进入 Store Buffer,CPU 不等 MESI 协商完成就继续执行后续指令。
这正是 volatile 需要内存屏障的硬件原因:volatile 写后的 StoreLoad 屏障会强制刷新 Store Buffer(等待所有 pending 写入全局可见),保证其他核心后续读取一定能看到最新值。没有这个屏障,写操作可能只在 Store Buffer 中,其他核心读取时从自己的缓存读到旧值。
(3)Invalidate Queue —— volatile 读延迟的根源
当一个核心收到其他核心发来的 Invalidate 消息时,如果立即处理需要等待当前缓存操作完成,CPU 会先将 Invalidate 消息放入 Invalidate Queue 异步处理。这导致:即使写入方已刷新 Store Buffer,读取方可能因 Invalidate Queue 中堆积的消息而未真正使对应缓存行失效,仍读到旧值。
volatile 读后的 LoadLoad + LoadStore 屏障会强制排空 Invalidate Queue,确保读取前所有已收到的失效消息都被处理完毕,读到真正的最新值。
一句话总结:volatile 的可见性不是"魔法瞬间同步",而是通过 CPU 内存屏障强制 Store Buffer 刷新 + Invalidate Queue 排空,代价是流水线停顿(Pipeline Stall),单次 volatile 读约 20-100 个 CPU 周期(对比普通读的 L1 cache hit 约 4 个周期)。
- 【L4】跨语言对比:Java volatile vs C++ atomic vs Go atomic:
| 特性 | Java volatile | C++ std::atomic (默认 seq_cst) | Go sync/atomic |
|---|---|---|---|
| 默认内存序 | 相当于 acq_rel (acquire-release) | seq_cst (顺序一致性,更强但更慢) | seq_cst |
| 原子性 | 仅单次读/写(不保证 RMW) | 保证 RMW(fetch_add 等) | 保证 RMW |
| StoreLoad 屏障 | ✅ 有(x86 lock 前缀) | ✅ 有(更强,含全局顺序) | ✅ 有 |
| 性能代价(x86) | ~20-100 cycles(仅屏障) | ~30-150 cycles(含全局排序) | ~10-50 cycles(更轻量) |
Java volatile:语义上等价于 C++
memory_order_acquire(读)+memory_order_release(写),是三者中设计最简洁的。C++ atomic:提供 6 种内存序(
relaxed/consume/acquire/release/acq_rel/seq_cst),给予极致控制但也引入了极高的心智负担——选错内存序可能导致"逻辑正确但 CPU 仍重排"的 bug。Go atomic:与 Java 设计哲学相反——Go 推荐显式使用
atomic包操作基本类型,而不是依赖"语言关键字保证可见性"。Go 的sync.Mutex才提供类似 Javasynchronized的 happen-before 保证。【L4】volatile vs synchronized:
| 维度 | volatile | synchronized |
|---|---|---|
| 原子性 | ❌ 不保证 | ✔️ 保证 |
| 可见性 | ✔️ 保证 | ✔️ 保证 |
| 有序性 | ✔️ 保证(禁止重排) | ✔️ 保证(加锁串行) |
| 性能 | 高(无锁) | 低(涉及加锁/解锁) |
| 适用场景 | 状态标志、DCL | 复合操作、临界区 |
⚠️ 常见误区
详情
常见误区:
- ❌ "volatile 能替代 synchronized" → volatile 不保证原子性,复合操作(如 i++、check-then-act)必须用锁或原子类,见本文档「
volatile和synchronized有什么区别?volatile能替代synchronized吗?」。 - ❌ "volatile 写之后其他线程立即就能看到" → 可见性由屏障强制 Store Buffer 刷新/Invalidate Queue 排空实现,有几十到上百周期的硬件成本,不是零开销。
🔀 发散问题
- Q:volatile 能保证线程安全吗? → 不能彻底保证,缺原子性,见本文档「volatile 能完全保证并发安全吗?」。
- Q:为什么 DCL 必须加 volatile? → 禁止"分配-初始化-赋值"重排序,见本文档「为什么 DCL 单例模式需要 volatile?」。
【中等】volatile 能完全保证并发安全吗?⭐⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:10 min | 🏷 标签:并发机制 / volatile
💎 关键结论
不能。线程安全需要可见性、原子性、有序性三者兼备,而 volatile 不保证原子性,对 inc++ 这类"读-改-写"复合操作无能为力;修复手段是 synchronized、Lock 或 AtomicInteger。
⚡记忆卡片
- 口诀:volatile 三缺一,原子性它没戏;i++ 要用原子类,或者加锁包起来
- 关键词:复合操作 / 读改写 / AtomicInteger
- 链路:inc++(读-改-写)→ 步骤间被切换 → 丢失更新 → 锁/CAS 修复
📖 核心知识
线程安全需要具备:可见性、原子性、顺序性。volatile 不保证原子性,所以决定了它不能彻底地保证线程安全。
案例:volatile 无法保证 inc++ 原子性
public class VolatileAtomicityDemo {
public volatile static int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(5);
VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo();
for (int i = 0; i < 5; i++) {
threadPool.execute(() -> {
for (int j = 0; j < 500; j++) {
volatileAtomicityDemo.increase();
}
});
}
// 等待 1.5 秒,保证上面程序执行完成
Thread.sleep(1500);
System.out.println(inc);
threadPool.shutdown();
}
}正常情况下,运行上面的代码理应输出 2500。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500。
为什么会出现这种情况呢?不是说好了,volatile 可以保证变量的可见性嘛!也就是说,如果 volatile 能保证 inc++ 操作的原子性的话,每个线程中对 inc 变量自增完之后,其他线程可以立即看到修改后的值;5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。
很多人会误认为自增操作 inc++ 是原子性的,实际上,inc++ 其实是一个复合操作,包括三步:1. 读取 inc 的值;2. 对 inc 加 1;3. 将 inc 的值写回内存。
volatile 是无法保证这三个操作是具有原子性的,可能出现:线程 1 读取 inc 后还未修改,线程 2 又读取并 +1 写回;随后线程 1 再 +1 写回。两个线程各自增一次后,inc 实际上只增加了 1。
如果想要保证上面的代码运行正确也非常简单,利用 synchronized、Lock 或者 AtomicInteger 都可以:
使用 synchronized 改进:
public synchronized void increase() {
inc++;
}使用 AtomicInteger 改进:
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.getAndIncrement();
}使用 ReentrantLock 改进:
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
inc++;
} finally {
lock.unlock();
}
}🔬 扩展知识
详情
- 【L3】丢失更新的时序:读(线程1)→ 读(线程2)→ 写(线程2)→ 写(线程1),后写者覆盖了先写者的结果;volatile 只能让每次读到最新值,不能阻止这个交叉。
- 【L4】高竞争计数场景下
AtomicInteger的 CAS 自旋开销会升高,可升级用LongAdder(分段累加、最后求和),吞吐显著更好。
🔀 发散问题
- Q:volatile 和 synchronized 怎么选? → 简单状态用 volatile,复合操作用锁,见本文档「
volatile和synchronized有什么区别?volatile能替代synchronized吗?」。 - Q:AtomicInteger 靠什么保证原子性? → CAS(Unsafe.compareAndSwapInt)+ CPU 原子指令,属于乐观锁思路。
【中等】volatile 和 synchronized 有什么区别?volatile 能替代 synchronized 吗?⭐⭐⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:10 min | 🏷 标签:并发机制 / volatile
💎 关键结论
volatile 无法替代 synchronized,因为 volatile 无法保证操作的原子性。二者都保证可见性和有序性,但 synchronized 额外保证原子性且基于 Monitor 实现;volatile 轻量无锁,适合一写多读的状态标志。
⚡记忆卡片
- 口诀:volatile 轻无锁,sync 重量管原子;三性差一个,替代不可能
- 关键词:原子性差异 / 内存屏障 / Monitor
- 链路:volatile:屏障+缓存失效(无互斥)/ synchronized:Monitor 互斥+可见+有序
📖 核心知识
- 结论:
volatile无法替代synchronized,核心原因是volatile无法保证操作的原子性。 - 特性区别:
| 特性 | volatile | synchronized |
|---|---|---|
| 原子性 | ❌ 不保证(如 i++) | ✔️ 保证 |
| 可见性 | ✔️ 强制主内存读写 | ✔️ 通过锁机制保证 |
| 有序性 | ✔️ 禁止重排序 | ✔️ 串行化执行 |
| 性能 | ⚡ 轻量级(无锁) | 🔒 较重(上下文切换) |
- 实现区别:
- volatile:通过 内存屏障 禁止指令重排序;强制 CPU 缓存失效 保证可见性;底层使用 LoadLoad/StoreStore 等屏障指令。
- synchronized:通过 Monitor 监视器锁(对象头 Mark Word);包含 偏向锁→轻量级锁→重量级锁 的升级过程;保证 代码块/方法 的排他性访问。
🔬 扩展知识
详情
- 【L3】volatile 只能修饰变量,synchronized 可修饰方法/代码块;volatile 不会阻塞线程,synchronized 竞争失败会阻塞(重量级锁)。
- 【L4】JDK 6 之后 synchronized 经锁升级等优化,低竞争下性能与 volatile 差距缩小;选型看语义(是否需要互斥)而非只看性能,见本文档「JDK 6 对
synchronized进行了哪些优化?」。
⚠️ 常见误区
详情
常见误区:
- ❌ "volatile 是轻量版 synchronized" → 二者语义不同:volatile 不提供互斥,只是可见性/有序性工具。
- ❌ "性能优先就该全用 volatile" → 用错场景(复合操作)直接产生正确性 bug,正确性永远优先于性能。
🔀 发散问题
- Q:volatile 适合什么场景? → 单线程写、多线程读的状态标志、DCL 单例,见本文档「
volatile有什么作用?」。 - Q:ReentrantLock 相比 synchronized 多了什么? → 可中断、可超时、公平锁、多 Condition,适合复杂同步需求。
【中等】synchronized 有什么作用?⭐⭐⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:8 min | 🏷 标签:并发机制 / synchronized
💎 关键结论
synchronized 是 Java 最基础的线程同步机制,通过 原子性、可见性、有序性 保障线程安全,适用于需要强一致性的场景;有三种用法(同步实例方法/静态方法/代码块),锁对象分别是实例、Class 对象、括号内指定对象。
⚡记忆卡片
- 口诀:实例方法锁 this,静态方法锁 Class,代码块锁括号里
- 关键词:互斥 / 三性保证 / 三种用法
- 链路:进入同步块 → 获取 Monitor → 排他执行 → 退出释放 → 可见性同步
📖 核心知识
- 作用:
synchronized通过 原子性、可见性、有序性 保障线程安全,适用于需要强一致性的场景,但需合理控制锁粒度以避免性能问题。 - 3 种应用方式:
- 同步实例方法 - 对于普通同步方法,锁是当前实例对象
- 同步静态方法 - 对于静态同步方法,锁是当前类的
Class对象 - 同步代码块 - 对于同步方法块,锁是
synchronized括号里配置的对象

🔬 扩展知识
详情
- 【L3】同步代码块在字节码层由
monitorenter/monitorexit指令实现,同步方法则由方法标志位的ACC_SYNCHRONIZED标记实现,见本文档「synchronized的实现原理是什么?」。 - 【L4】锁粒度是性能关键:锁住整个方法不如锁住最小临界区;JDK 6 后锁升级、锁消除、锁粗化等优化已大幅改善其性能,见本文档「JDK 6 对
synchronized进行了哪些优化?」。
🔀 发散问题
- Q:synchronized 是可重入的吗? → 是,同一线程可重复获取同一把锁,由 Monitor 的重入计数实现。
- Q:静态同步方法和实例同步方法会互相阻塞吗? → 不会,锁对象不同(Class vs 实例)。
【中等】synchronized 的实现原理是什么?⭐⭐⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:12 min | 🏷 标签:并发机制 / synchronized 原理
💎 关键结论
synchronized 的底层实现涉及 Java 对象头、Monitor(监视器)、锁升级机制:同步代码块靠 monitorenter/monitorexit 字节码,同步方法靠 ACC_SYNCHRONIZED 标志;锁信息存在对象头 Mark Word 中,竞争失败的线程进入 Monitor 的 _EntryList/_cxq 等待。
⚡记忆卡片
- 口诀:代码块 monitorenter,方法看 ACC 标志;锁在 Mark Word,等待靠 Monitor
- 关键词:monitorenter / Mark Word / ObjectMonitor
- 链路:进入同步块 → CAS 抢 _owner → 失败进 _cxq/_EntryList → 阻塞 park → 解锁唤醒
📖 核心知识
- 字节码层面:
synchronized修饰代码块时,在代码块前后植入monitorenter和monitorexit字节码指令,相当于加锁和解锁。- 修饰方法时,会在方法的访问标志上设置
ACC_SYNCHRONIZED标记。线程每次访问方法,会进行检查,若设置了该标记,执行线程将先持有Monitor对象,然后再执行方法;方法运行期间其它线程无法获取该 Monitor,方法执行完成后再释放。
- 对象头与 Mark Word:每个 Java 对象在内存中由 对象头(Header)、实例数据(Instance Data)、对齐填充(Padding) 组成;对象头主要有两部分:Mark Word 和 Klass Pointer。
synchronized的锁信息存储在 对象头的 Mark Word 中,主要包括:锁状态(无锁、偏向锁、轻量级锁、重量级锁)、持有锁的线程 ID、GC 分代年龄、哈希码(HashCode)。Mark Word 在 64 位 JVM 中的长度是 64bit:

- Monitor(监视器):每个 Java 对象都关联一个 Monitor,用于实现同步机制。HotSpot 中 Monitor 由 C++ 的
ObjectMonitor类实现(src/hotspot/share/runtime/objectMonitor.hpp),核心字段:
| 字段 | 作用 |
|---|---|
_owner | 指向当前持有锁的线程 |
_recursions | 锁重入次数(synchronized 可重入的实现基础) |
_EntryList | 竞争锁失败、处于 BLOCKED 状态的线程队列 |
_WaitSet | 调用 wait() 后进入 WAITING 状态的线程队列 |
_cxq | 多线程竞争时先进入的单向链表(与 _EntryList 配合做唤醒策略) |
- 加锁流程:
monitorenter时线程先尝试 CAS 把_owner置为当前线程;失败则进入_cxq/_EntryList自旋或阻塞;monitorexit将_recursions减 1,减到 0 时_owner置空并唤醒等待线程。javac会为同步块生成两条monitorexit(正常退出 + 异常表兜底),保证锁必定释放。
🔬 扩展知识
详情
- 【L3】重量级锁的成本:竞争失败的线程最终通过
ObjectMonitor::EnterI挂起(park),底层依赖操作系统互斥原语(Linux 上是futex),一次线程上下文切换约 1μs~5μs,这就是「重量级」的真正含义。 - 【L4】
wait()与notify()的等待队列正是 Monitor 的_WaitSet,这解释了为什么 wait/notify 必须定义在 Object 上且必须在 synchronized 中调用,见本文档「为什么Object.wait()、Object.notify()和Object.notifyAll()被定义在Object类里?」。
⚠️ 常见误区
详情
常见误区:
- ❌ "异常抛出时 synchronized 锁不会释放" → javac 生成了异常路径的 monitorexit 兜底,异常退出同样会释放锁。
- ❌ "Monitor 是 Java 对象" → HotSpot 的 ObjectMonitor 是 C++ 实现的原生结构,不是 Java 堆上的对象。
🔀 发散问题
- Q:锁状态存在哪里? → 对象头 Mark Word(64 位 JVM 共 64bit),不同锁状态复用这 64bit。
- Q:synchronized 可重入怎么实现? →
_recursions计数,同线程重入加 1,退出减 1。
【困难】JDK 6 对 synchronized 进行了哪些优化?⭐⭐⭐⭐
🎯 目标等级:L3-L4 | ⏱ 建议用时:15 min | 🏷 标签:并发机制 / synchronized 优化
💎 关键结论
JDK 6 以后,synchronized 做了大量优化,性能已与 Lock、ReadWriteLock 基本持平,核心三板斧:锁升级(无锁→偏向→轻量级→重量级,按竞争强度逐级升级)、锁消除(逃逸分析后消除无用锁)、锁粗化(合并相邻同锁同步块)。
⚡记忆卡片
- 口诀:升级消粗三板斧,竞争越烈锁越重
- 关键词:锁升级 / 锁消除 / 锁粗化
- 链路:无竞争偏向 → 少量竞争轻量级 CAS → 竞争激烈重量级阻塞
📖 核心知识
JDK 6 以后,synchronized 做了大量的优化,其性能已经与 Lock 、ReadWriteLock 基本上持平。三大优化:
- 锁升级:JDK 1.6 后采用锁升级机制优化性能,避免直接使用重量级锁带来的性能损耗:
| 锁状态 | 适用场景 | 实现方式 |
|---|---|---|
| 无锁 | 初始状态 | Mark Word 无锁标记 |
| 偏向锁 | 单线程访问 | Mark Word 记录线程 ID |
| 轻量级锁 | 少量线程竞争 | CAS 自旋 |
| 重量级锁 | 高并发竞争 | 操作系统 Mutex 锁 |
- 偏向锁:适用只有一个线程访问同步块;在 Mark Word 中记录线程 ID,后续该线程进入时无需 CAS 操作;如果其他线程尝试获取锁,偏向锁会撤销(Revoke)并升级为轻量级锁。
- 轻量级锁:适用少量线程竞争且交替执行;线程通过 CAS 尝试获取锁,成功则获取锁,失败表示有其他线程持有锁,升级为重量级锁;解锁时 JVM 将对象头中的 Mark Word 恢复为原始值。
- 重量级锁:适用高并发竞争;依赖操作系统 Mutex 锁,未获取锁的线程被阻塞、进入等待队列等待唤醒;释放时 JVM 唤醒所有阻塞的线程再次尝试获取锁。
锁升级功能主要依赖于 Mark Word 中的锁标志位和释放偏向锁标志位,synchronized 同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。

- 锁消除:在即时编译(JIT)时,JVM 会对代码进行逃逸分析。如果发现一段代码中使用的锁对象不会逃逸到方法外部,也就是其他线程无法访问到该锁对象,那么 JVM 会认为该锁是无意义的,从而将锁的代码消除,避免不必要的锁竞争,提高程序的性能。实现原理:(1)逃逸分析:若对象在方法内部创建且不被外部引用,则不会逃逸;(2)锁消除:如
StringBuffer的append是 synchronized 方法,但sb不逃逸,JIT 会消除其中的锁。
案例:锁消除示例
public class LockEliminationExample {
public static String concatString(String s1, String s2, String s3) {
// 创建一个 StringBuffer 对象,它不会逃逸出该方法
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
public static void main(String[] args) {
String result = concatString("Hello", " ", "World");
System.out.println(result);
}
}在这个示例中,StringBuffer 对象 sb 只在 concatString 方法内部使用,不会被其他方法访问。因此,JVM 在即时编译时会进行逃逸分析,并将 append 方法中的锁代码消除。
- 锁粗化:在 JIT 编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程"反复申请、释放同一个锁"所带来的性能开销。如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。
🔬 扩展知识
详情
- 【L3】锁升级的详细过程(批量重偏向、批量撤销、自适应自旋、GC 降级等)见本文档「synchronized 锁升级的详细过程是怎样的?」。
- 【L4】版本演进:JDK 15(JEP 374)起偏向锁被废弃并默认禁用(撤销需全局安全点 STW,现代应用中成本高于收益),JDK 18 彻底移除代码;此后默认路径变为无锁 → 轻量级锁 → 重量级锁。
🏭 实战场景
详情
热点循环内的细粒度同步是优化重点:若循环内反复对同一局部对象加锁(如局部 StringBuffer),JIT 逃逸分析后会直接锁消除,实测可消除每次调用的锁开销;而跨循环的相邻同步块会被锁粗化,减少反复加解锁次数。调优建议:先用 JMH 基准确认瓶颈,再配合 -XX:+PrintFlagsFinal/JITWatch 观察锁优化是否生效,而不是盲目换 ReentrantLock。
⚠️ 常见误区
详情
常见误区:
- ❌ "synchronized 性能远不如 Lock,生产都该用 Lock" → JDK 6 后二者性能基本持平,选型看功能需求(可中断/超时/公平)而非性能。
- ❌ "偏向锁现在仍然默认开启" → JDK 15 起偏向锁已默认禁用(JEP 374),JDK 18 彻底移除。
🔀 发散问题
- Q:重量级锁能不能降级回轻量级锁? → 不能,锁升级单向不可降级(GC 时的内部处理除外)。
- Q:锁消除和锁粗化由谁完成? → JIT 编译器在运行时动态完成,依赖逃逸分析,程序员无需干预。
【困难】synchronized 锁升级的详细过程是怎样的?⭐⭐⭐⭐⭐
🎯 目标等级:L3-L4 | ⏱ 建议用时:15 min | 🏷 标签:并发机制 / 锁升级
💎 关键结论
锁升级是 JDK 6 对 synchronized 的核心优化:无锁→偏向锁(CAS 写线程 ID)→轻量级锁(CAS + 自旋)→重量级锁(OS 互斥量),单向不可降级;偏向锁需全局安全点撤销,JDK 15(JEP 374)起已默认禁用。
⚡记忆卡片
- 口诀:偏向记 ID,轻量 CAS 旋,重量进内核,升级不回头
- 关键词:偏向锁 / 轻量级锁 / 重量级锁 / JEP 374
- 链路:首次进入写线程 ID → 竞争撤销偏向 → CAS 自旋 → 自旋失败升级重量级 → park 阻塞
📖 核心知识
锁升级是 JDK 6 对 synchronized 的核心优化,理解其细节是资深工程师的必备知识。
- 无锁 → 偏向锁:线程首次进入同步块,CAS 将线程 ID 写入 Mark Word,标志位改为
01(可偏向);后续同一线程进入只需比对线程 ID,无需 CAS,性能接近无锁。延迟偏向:JVM 启动后 4 秒才启用偏向锁(-XX:BiasedLockingStartupDelay=4000),避免启动阶段的不必要开销。 - 偏向锁 → 轻量级锁:出现第二个线程竞争时,偏向锁撤销(Revoke):等待全局安全点(safepoint),暂停持有偏向锁的线程;检查持有线程是否在同步块内:是则升级为轻量级锁,否则重置为无锁。批量重偏向:同一类的对象撤销偏向达到 20 次阈值后,该类的新对象直接偏向新线程;批量撤销:撤销达到 40 次后,该类禁用偏向锁。
- 轻量级锁 → 重量级锁:线程在同步块外创建 Lock Record,CAS 将 Mark Word 复制到 Lock Record,并尝试将对象头指向 Lock Record;成功则获取锁,失败则自旋重试;自旋超过阈值(自适应自旋,JDK 6+ 根据历史成功率动态调整)仍失败,升级为重量级锁。重量级锁的
ObjectMonitor底层依赖操作系统互斥原语:竞争失败的线程先进入_EntryList,最终通过park/unpark挂起与唤醒(Linux 上对应futex系统调用),一次线程上下文切换约 1μs~5μs,这就是「重量级」的成本来源。 - 锁降级:锁不可降级,一旦升级为重量级锁,无法回退(为了简化实现,避免状态频繁切换的开销);GC 时若发现锁已无竞争,可能降级(仅 GC 安全点),但这是 JVM 内部优化,不应依赖。
Mark Word 状态对照表(64 位 JVM):
| 锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit(偏向标志) | 2bit(锁标志) |
|---|---|---|---|---|---|---|
| 无锁 | unused | hashCode | unused | 分代年龄 | 0 | 01 |
| 偏向锁 | ThreadID(54) + Epoch(2) | 分代年龄 | 1 | 01 | ||
| 轻量级锁 | 指向栈中 Lock Record 的指针 | 00 | ||||
| 重量级锁 | 指向 Monitor 的指针 | 10 | ||||
| GC 标记 | 空 | 11 |
🔬 扩展知识
详情
- 【L3】自适应自旋是 JDK 6 的关键改进:自旋次数不再固定,JVM 根据该锁上次自旋的成功率动态决定本次自旋多久,避免"白旋"浪费 CPU。
- 【L4】版本演进:JDK 15(JEP 374)偏向锁废弃并默认禁用(
-XX:-UseBiasedLocking),JDK 18 代码彻底移除;JDK 24(JEP 491)synchronized配合虚拟线程不再 Pinning 载体线程,扫清存量 synchronized 代码迁移虚拟线程的最大障碍。
🏭 实战场景
详情
JDK 15+ 迁移案例:存量服务大量使用 synchronized,升级 JDK 17 后默认不再走偏向锁,无竞争路径直接进轻量级锁(一次 CAS);实测对无竞争热点方法性能影响在个位数百分比内,但消除了偏向锁撤销引发的 STW 尖刺(批量撤销场景下全局 safepoint 停顿可达毫秒级)。排查锁状态可用 JOL(org.openjdk.jol)打印对象头,观察锁标志位从 00/01/10 的变化。
⚠️ 常见误区
详情
常见误区:
- ❌ "锁升级后还能降级" → 升级单向不可逆(GC 内部优化除外),不要基于降级假设设计。
- ❌ "现在说 synchronized 还要从偏向锁讲起" → JDK 15 后偏向锁默认禁用,面试中仍把偏向锁当默认第一步会被认为知识停留在旧版本。
🔀 发散问题
- Q:轻量级锁的 Lock Record 在哪里? → 在持有锁线程的栈帧中,是 Mark Word 的拷贝。
- Q:虚拟线程上 synchronized 会怎样? → JDK 24(JEP 491)前会 Pinning 载体线程,之后已解决。
【困难】为什么 DCL 单例模式需要 volatile?⭐⭐⭐⭐
🎯 目标等级:L3-L4 | ⏱ 建议用时:15 min | 🏷 标签:Java 内存模型 / 安全发布
💎 关键结论
因为 new Singleton() 不是原子操作,分分配内存、初始化、赋引用三步,指令重排序可能把后两步对调,导致其他线程拿到未初始化完成的对象。volatile 通过 StoreStore 屏障禁止该重排序,保证安全发布。
⚡记忆卡片
- 口诀:new 三步可重排,先赋引用后构造;volatile 立屏障,DCL 才可靠
- 关键词:非原子 / 指令重排序 / StoreStore 屏障 / 安全发布
- 链路:new 分三步 → 2/3 可重排 → 他线程拿到半成品 → volatile 禁止重排 → 安全发布
📖 核心知识
双重检查锁(Double-Checked Locking, DCL) 单例模式中,volatile 修饰实例变量是必须的,否则在多线程环境下可能出现"获取到未初始化完成的对象"问题。
- 问题根源:对象的创建不是原子操作。
instance = new Singleton();在 JVM 中分三步:
memory = allocate(); // 1. 分配对象内存空间
instance(memory); // 2. 初始化对象(调用构造方法)
instance = memory; // 3. 将引用指向内存地址- 指令重排序可能导致步骤 2 和 3 对调(2、3 之间无数据依赖,编译器和处理器允许重排):
memory = allocate(); // 1. 分配内存
instance = memory; // 2. 引用指向内存(此时对象未初始化!)
instance(memory); // 3. 初始化对象问题场景:
- 线程 A 执行到步骤 2(已赋值但未初始化),此时
instance != null。 - 线程 B 第一次检查
instance == null为 false,直接返回 instance。 - 线程 B 使用了未初始化完成的对象,导致 NPE 或数据错误。
- 线程 A 执行到步骤 2(已赋值但未初始化),此时
volatile 的作用:通过 StoreStore 屏障禁止 2、3 步重排序,保证对象初始化完成后才对其他线程可见。
案例:标准 DCL 单例代码
class Singleton {
private static volatile Singleton instance; // volatile 必须加!
static Singleton getInstance() {
if (instance == null) { // 第一次检查,避免不必要的锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查,避免重复创建
instance = new Singleton(); // volatile 禁止重排序
}
}
}
return instance;
}
}- 更好的替代方案:静态内部类(利用类加载机制保证线程安全,无需 volatile):
class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE; // 类加载时初始化,JVM 保证线程安全
}
}🔬 扩展知识
详情
- 【L3】JSR-133 起(JDK 5+),
volatile写会插入 StoreStore 屏障,确保构造器中对字段的普通写先于引用赋值发布;这正是 happens-before 中「volatile 写 happens-before 后续读」的应用。JDK 5 之前的 DCL 即使加了 volatile 也可能失败(旧 JMM 语义不足)。 - 【L3】字节码层面,
new对应new → dup → invokespecial <init> → astore,重排发生在处理器/编译器层,而非字节码顺序被改变。 - 【L4】安全发布的替代方案横向对比:枚举单例(《Effective Java》推荐,天然防反射/序列化破坏)、静态内部类(懒加载)、饿汉式(类加载即创建);DCL 是唯一需要 volatile 的方案。
🏭 实战场景
详情
该问题在 x86(TSO 强内存模型)上极难复现,但在 ARM/部分国产化芯片(弱内存模型)的线上环境偶发「单例对象字段为 null」类故障。排查时可通过 -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand="print" 查看 JIT 编译后的屏障指令,或用 jcstress 编写重排序测试稳定复现。
⚠️ 常见误区
详情
常见误区:
- ❌ "x86 内存模型强,DCL 不加 volatile 也没问题" → x86 只是概率低,编译器和处理器的重排不受 CPU 内存模型约束,仍属未定义行为,必须加 volatile。
- ❌ "volatile 在这里是为了保证 instance 判空的可见性" → 可见性只是附带效果,核心作用是禁止构造过程的重排序(安全发布)。
- ❌ "synchronized 已经保证线程安全,volatile 多余" → 第一次判空在锁外执行,此时读到的可能是半成品引用,锁保护不了这次读。
🔀 发散问题
- Q:除了 DCL,还有哪些线程安全的单例写法? → 静态内部类(类加载机制)、枚举单例(防反射/序列化)、饿汉式,见本题 📖 核心知识中的替代方案。
- Q:volatile 为什么能禁止指令重排序? → 通过插入内存屏障实现,见本文档「什么是内存屏障?」。
- Q:final 字段是否也需要 volatile 才能安全发布? → 不需要,JMM 对 final 字段的初始化有特殊保证,见本文档「final 关键字可以保证线程的可见性吗?」。
【中等】final 关键字可以保证线程的可见性吗?⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:8 min | 🏷 标签:Java 内存模型 / 安全发布
💎 关键结论
final 本身不直接保证可见性,但 JMM 对 final 字段的初始化有特殊保证:对象构造完成后,final 字段的初始化值对所有线程立即可见,无需额外同步。但这仅限于初始化阶段,持续的状态可见性仍需 volatile。
⚡记忆卡片
- 口诀:final 保初不保改,构造完成即发布;持续可见找 volatile
- 关键词:初始化阶段 / JMM 保证 / 内存屏障 / 引用不可变
- 链路:构造器写 final 字段 → 构造完成插入屏障 → 发布对象 → 他线程可见初值
📖 核心知识
- final 本身不能直接保证线程间的可见性;但 final 修饰的字段在正确初始化后,对其他线程是可见的(JMM 保证):对象构造完成时,final 字段的初始化值对所有线程立即可见,不需要额外同步措施(如
volatile/synchronized)。 - 适用范围仅限初始化阶段:适用于声明不可变常量(如
final int MAX = 100)、构造线程安全对象(如final AtomicReference);需要持续可见性(如状态标志位)仍需volatile或同步机制。
案例:final 与非 final 字段对比
class Example {
final int x = 42; // 构造后所有线程看到 x=42
int y = 10; // 其他线程可能看到 y=0(默认值)或 10
}- 底层机制:JVM 会在构造器末尾插入内存屏障(StoreStore),确保 final 字段初始化后对所有线程可见;对应 happens-before 规则:对象构造结束 happens-before 于其他线程看到该对象。
- 使用限制:
| 场景 | 是否线程安全 | 说明 |
|---|---|---|
| final 基本类型 | ✔️ 安全 | int/long 等初始化后不可变 |
| final 引用类型 | ⚠️ 部分安全 | 引用不可变,但对象内部状态可能变化 |
| 非 final 字段 | ❌ 不安全 | 需要额外同步 |
案例:危险用法与最佳实践
危险示例:
final Map<String, Integer> map = new HashMap<>();
// map 引用不可变,但 map.put() 操作非线程安全!最佳实践:
(1)优先用 final 修饰不可变数据
public class SafeCounter {
private final AtomicLong count = new AtomicLong(0); // 线程安全
}(2)需要跨线程可见的变量应使用 volatile
private volatile boolean running = true;(3)避免以下错误用法:
// 错误!final 不能保证对象内部线程安全
final List<String> unsafeList = new ArrayList<>();🔬 扩展知识
详情
- 【L3】JMM 规定构造器中 final 字段写入与对象引用发布之间插入 StoreStore 屏障(freeze 动作);若构造过程中 this 引用逸出(如在构造器中启动线程/注册监听),该保证失效。
- 【L4】横向对比:volatile 保证任意时刻写入的可见性;final 只保证初始化时写入的可见性;synchronized 同时保证可见性与原子性。三者语义层级不同,不可互相替代。
🔀 发散问题
- Q:为什么 DCL 单例需要 volatile 而 final 字段不需要? → DCL 发布的是对象引用,需禁止构造重排;final 字段由 JMM 的初始化屏障保证,见本文档「为什么 DCL 单例模式需要 volatile?」。
- Q:构造器中 this 引用逸出会有什么后果? → 其他线程可能看到未初始化完成的对象,final 字段的安全发布保证也随之失效,应避免在构造器中启动线程或注册回调。
Java 线程
【中等】Java 线程生命周期有哪些状态?状态之间如何切换?⭐⭐⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:15 min | 🏷 标签:Java 线程 / 生命周期
💎 关键结论
Thread.State 定义 6 种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED,任一时刻线程只能处于其一。关键区分:BLOCKED 是被动等 synchronized 锁,WAITING 是主动等待被唤醒,RUNNABLE 合并了 OS 层的 READY/RUNNING。
⚡记忆卡片
- 口诀:新建可跑阻塞等,定时等待到终止;sleep 不释锁,wait 要释放
- 关键词:Thread.State / 6 状态 / 锁释放 / 唤醒机制
- 链路:NEW →start()→ RUNNABLE → 失锁 BLOCKED / wait/join WAITING / sleep TIMED_WAITING → TERMINATED
📖 核心知识
java.lang.Thread.State 中定义了 6 种不同的线程状态,在给定的一个时刻,线程只能处于其中的一个状态。

- 开始(NEW):尚未调用
start方法的线程处于此状态,即创建的线程尚未启动。 - 可运行(RUNNABLE):已调用
start方法,线程已准备好,一旦被调度器分配 CPU 时间片即可运行。操作系统层面有 READY 和 RUNNING 状态,JVM 层面只能看到 RUNNABLE,故统称 RUNNABLE。 - 阻塞(BLOCKED):线程处于被阻塞状态,表示在等待
synchronized的隐式锁(Monitor lock)。占用锁的线程释放锁、等待线程获得锁时,从 BLOCKED 转回 RUNNABLE。 - 等待(WAITING):无限期等待,直到被其他线程显式地唤醒。与阻塞的区别:阻塞是被动等锁,等待是主动进入。进入/退出方式:
Object.wait()→Object.notify/Object.notifyAllThread.join()→ 被调用的线程执行完毕LockSupport.park()→LockSupport.unpark
- 定时等待(TIMED_WAITING):等待指定时间的状态,进入/退出方式:
Thread.sleep(long)→ 时间结束Object.wait(long)→ 时间结束 /notify/notifyAllThread.join(long)→ 时间结束 / 被调用线程执行完毕LockSupport.parkNanos(long)/parkUntil(long)→unpark
- 终止(TERMINATED):
run()方法执行结束或因异常退出,线程结束生命周期,死亡的线程不可再次复生。
状态切换速查表(面试高频追问):
| 状态切换 | 触发方法/事件 | 是否释放锁 |
|---|---|---|
| NEW → RUNNABLE | Thread.start() | — |
| RUNNABLE → BLOCKED | 竞争 synchronized 锁失败(进入 ObjectMonitor 的 _EntryList) | 不涉及 |
| BLOCKED → RUNNABLE | 获取到 synchronized 锁 | 获得锁 |
| RUNNABLE → WAITING | Object.wait() / Thread.join() / LockSupport.park() | wait() 释放锁 |
| WAITING → RUNNABLE | notify()/notifyAll()(需重新竞争锁)/ 被 join 线程结束 / unpark() | 重新获取锁后进入 |
| RUNNABLE → TIMED_WAITING | sleep(long) / wait(long) / join(long) / parkNanos() / parkUntil() | 仅 wait(long) 释放锁 |
| TIMED_WAITING → RUNNABLE | 超时到期或被唤醒 | 同 WAITING |
| RUNNABLE → TERMINATED | run() 正常结束或抛出未捕获异常 | 释放持有的所有锁 |
高频追问点:
sleep()不释放锁,进入 TIMED_WAITING;wait()释放锁,进入 WAITING(带超时则 TIMED_WAITING)。notify()唤醒的线程不会立即执行,而是进入_EntryList重新竞争锁(BLOCKED → RUNNABLE)。- JVM 把 OS 层面的 READY 和 RUNNING 合并为 RUNNABLE,Java 层面无法区分「在等 CPU」和「正在 CPU 上跑」。
🔬 扩展知识
详情
- 【L3】HotSpot 中线程状态存在 Java 层与 OS 层双重表示:RUNNABLE 可能实际在 OS 层处于 Sleeping(如执行阻塞式 accept/read 时,Java 状态仍是 RUNNABLE),排查线程卡顿时建议用
jstack+vmstate/arthasthread组合判断。 - 【L4】虚拟线程(JDK 21,JEP 444)在阻塞 I/O 或 park 时会从载体线程卸载(unmount),其
Thread.State对外仍呈现 WAITING/RUNNABLE,与传统线程的状态语义不完全一致。
📚 延伸阅读:
⚠️ 常见误区
详情
常见误区:
- ❌ "sleep() 会释放锁" → sleep 只让出 CPU 不释放任何锁,进入 TIMED_WAITING;释放锁的是 wait()。
- ❌ "BLOCKED 和 WAITING 是一回事" → BLOCKED 是被动竞争 synchronized 锁,WAITING 是主动调用 wait/join/park 进入,唤醒机制不同。
- ❌ "notify 后线程立即执行" → 被唤醒的线程要先重新竞争锁(进 _EntryList),拿到锁才能执行。
🔀 发散问题
- Q:sleep 和 wait 的核心区别? → sleep 不释放锁、属于 Thread;wait 释放锁、属于 Object 且需在 synchronized 中调用,见本文档「
Thread.sleep()、Thread.yield()、Thread.join()、Object.wait()有什么区别?」。 - Q:线程 TERMINATED 后能再次 start 吗? → 不能,会抛 IllegalThreadStateException,见本文档「一个线程两次调用
Thread.start()方法会怎样?」。
【中等】Java 中,创建线程有几种方式?⭐⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:10 min | 🏷 标签:Java 线程 / 线程创建
💎 关键结论
常见写法有实现 Runnable、继承 Thread、Callable+FutureTask、线程池等多种,但本质上 Java 只有一种创建线程的方式:new Thread().start(),所有方式最终都依赖它,区别只在于任务定义方式不同。
⚡记忆卡片
- 口诀:写法看似好几种,本质都是 start 起;生产就用线程池
- 关键词:Runnable / Callable / 线程池 / new Thread().start()
- 链路:定义任务(Runnable/Callable)→ 包装为 Thread/提交线程池 → start() 创建 OS 线程
📖 核心知识
- 常见创建线程的方式:
- 实现
Runnable接口(推荐) - 继承
Thread类(不推荐,因为不灵活,Java 不支持多继承) - 实现
Callable接口 +FutureTask,支持返回值 - 通过线程池(生产环境推荐)
- 使用
CompletableFuture
- 实现
- 本质只有一种:虽然看似有多种多样的创建线程方式,但从本质上来说,Java 就只有一种方式可以创建线程,那就是通过
new Thread().start()创建。不管是哪种方式,最终还是依赖于new Thread().start()。 - 生产建议:优先使用线程池统一管理线程的创建/复用/销毁,避免频繁手工 new Thread 带来的资源消耗与失控风险。

🔬 扩展知识
详情
- 【L3】
Thread.start()内部通过 JNI 调用 nativestart0()创建 OS 内核线程,见本文档「Thread.start()的内部原理是什么?」;Runnable/Callable只是任务定义,本身不创建线程。 - 【L4】虚拟线程(JDK 21,JEP 444)新增
Thread.ofVirtual().start(runnable)与Executors.newVirtualThreadPerTaskExecutor(),创建成本降到 KB 级栈内存,适合高并发 I/O 场景。
⚠️ 常见误区
详情
常见误区:
- ❌ "实现 Runnable 和继承 Thread 是两种不同的创建线程机制" → 两者只是任务定义方式不同,启动线程的机制都是 new Thread().start()。
- ❌ "线程池不是创建线程的方式" → 线程池底层也是通过 ThreadFactory 创建 Thread 对象,只是把创建时机与复用策略托管了。
🔀 发散问题
- Q:Runnable 和 Callable 有什么区别? → Callable 有返回值、可抛受检异常,需配合 FutureTask/线程池使用;Runnable 无返回值。
- Q:为什么不推荐继承 Thread 类? → Java 单继承限制,继承后无法再扩展其他类;且任务与线程机制耦合。
【简单】可以直接调用 Thread.run() 方法么?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:5 min | 🏷 标签:Java 线程 / 线程启动
💎 关键结论
可以调用,但 run() 只是普通方法调用,不会启动新线程,会在当前线程同步执行。必须调用 start() 才能启动线程,由 JVM 在新线程中执行 run()。
⚡记忆卡片
- 口诀:run 是执行体,start 才是启动器;直接调 run,单线程白跑
- 关键词:普通方法调用 / 不启动新线程 / start() 委托
- 链路:调 run() → 当前线程同步执行 / 调 start() → 创建新线程 → JVM 委托执行 run()
📖 核心知识
- 可以直接调用
Thread.run()方法,但它的行为和普通方法一样,不会启动新线程去执行。调用start()方法方可启动线程并使线程进入就绪状态,直接执行run()方法不会以多线程的方式执行。 run()方法是线程的执行体,定义了线程要做什么。start()方法负责启动线程,然后 JVM 会让这个线程去执行run()方法;职责分离:启动归 start,执行归 run。
🔬 扩展知识
详情
- 【L3】
start()创建 OS 线程后,新线程从thread_main入口回调Thread.run();若构造时传入了Runnable,run()会委托执行target.run()。 - 【L4】测试中故意直调 run() 可将异步逻辑降级为同步执行,便于单线程调试,这是利用该行为的正向技巧。
🔀 发散问题
- Q:start() 内部到底做了什么? → 状态检查、加入线程组、native start0() 创建 OS 线程、回调 run(),见本文档「
Thread.start()的内部原理是什么?」。
【中等】Thread.start() 的内部原理是什么?⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:8 min | 🏷 标签:Java 线程 / 线程启动
💎 关键结论
start() 由 JVM 实现,做两件事:创建 OS 线程 + 让 OS 线程执行 run()。源码流程为:检查状态(非 NEW 抛异常)→ 加入线程组 → native start0() 创建内核线程 → 新线程回调 run()。
⚡记忆卡片
- 口诀:start 先查态,入组调 start0,新线程跑 run
- 关键词:状态检查 / ThreadGroup / native start0 / 回调 run
- 链路:start() → 检查 NEW 状态 → 加入 ThreadGroup → JNI start0() → OS 线程回调 run()
📖 核心知识
Thread.start() 的核心工作分四步:
- 检查线程状态:若线程非
NEW状态,抛出IllegalThreadStateException(即不能重复 start)。 - 加入线程组:将线程加入其所属的
ThreadGroup。 - 调用 native
start0():通过 JNI 调用底层 OS API 创建内核线程。 - OS 线程启动后回调
run():JVM 会在新线程中调用Thread.run()(若指定了Runnable则委托执行)。
关键点:start() 是由 JVM 实现的,它做了两件事——创建 OS 线程 + 让 OS 线程执行 run()。这就是为什么 start() 只能调用一次:第二次调用时线程已经不是 NEW 状态。
🔬 扩展知识
详情
- 【L3】HotSpot 中
start0()对应JVM_StartThread,内部调用os::create_thread创建平台线程,成功后通过os::start_thread启动;虚拟线程则不走此路径,由 ForkJoinPool 载体线程调度。 - 【L4】线程组
ThreadGroup在现代 Java 中已弱化(主要服务于 SecurityManager,JDK 17 已废弃 SecurityManager,JEP 411),可视为历史遗留。
🔀 发散问题
- Q:两次调用 start() 会怎样? → 抛 IllegalThreadStateException,见本文档「一个线程两次调用
Thread.start()方法会怎样?」。 - Q:为什么不能重复 start? → 线程生命周期单向,底层 OS 线程已创建,重复启动无定义,故在状态检查处直接拒绝。
【中等】如何正确停止 Java 线程?⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:10 min | 🏷 标签:Java 线程 / 线程停止
💎 关键结论
Java 没有安全的强制停止手段,正确方式是协作式中断:通过 Thread.interrupt() 设置中断标志位(不会直接停止线程),由任务自身循环检查 isInterrupted() 主动退出;阻塞中则通过捕获 InterruptedException 响应。
⚡记忆卡片
- 口诀:中断只设标志位,退不退由任务自己定
- 关键词:interrupt / isInterrupted / InterruptedException / 协作式终止
- 链路:interrupt() 置标志 → 任务轮询 isInterrupted() / 阻塞抛 InterruptedException → 主动退出
📖 核心知识
- 最正确的停止线程方式:通过
Thread.interrupt和Thread.isInterrupted配合来控制线程终止:Thread.interrupt():设置线程的中断标志位(不会直接停止线程)。Thread.isInterrupted():检查中断状态。
- 设计思想:协作而非强制。中断只是一种「请求停止」的信号,何时、如何退出由线程任务自身决定,保证任务能安全清理资源后再退出。
案例:正确停止线程的方式——Thread.interrupt
public class ThreadStopDemo {
public static void main(String[] args) throws Exception {
Thread thread = new Thread(new MyTask(), "MyTask");
thread.start();
TimeUnit.MILLISECONDS.sleep(10);
thread.interrupt();
}
private static class MyTask implements Runnable {
private long count = 0L;
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 线程启动");
// 通过 Thread.interrupted 和 interrupt 配合来控制线程终止
while (!Thread.currentThread().isInterrupted() && count < 10000) {
System.out.println("count = " + count++);
}
System.out.println(Thread.currentThread().getName() + " 线程终止");
}
}
}
// 输出(count 未到 10000,线程就主动结束):
// MyTask 线程启动
// count = 0
// count = 1
// ...
// count = 840
// count = 841
// count = 842
// MyTask 线程终止- 阻塞时的处理:若任务中存在
sleep()/wait()等阻塞方法,中断会使其抛出InterruptedException并清除标志位,应在 catch 中调用interrupt()恢复标志并退出。
🔬 扩展知识
详情
- 【L3】中断标志是 Thread 对象上的布尔状态(HotSpot 中存于 OSThread),
interrupted()静态方法检查的是当前线程且会复位标志,isInterrupted()检查目标线程且不复位,两者勿混用。 - 【L3】JUC 中的
Future.cancel(true)、ExecutorService.shutdownNow()底层都是对任务线程调用 interrupt();对不响应中断的阻塞(如传统 BIO 的 socket read)中断无效,需关闭底层资源。 - 【L4】替代方案对比:volatile 标志位仅适用无阻塞简单循环;interrupt 是标准方式;Thread.stop 强制停止已被移除,见本文档「使用
volatile标记方式停止线程正确吗?」。
⚠️ 常见误区
详情
常见误区:
- ❌ "interrupt() 会直接终止线程" → 它只设置中断标志位,线程是否退出完全取决于任务代码是否检查并响应。
- ❌ "捕获 InterruptedException 后不用管它" → 捕获时中断标志已被清除,应重新 interrupt() 恢复标志或向上传递,否则上层无法感知中断。
🔀 发散问题
- Q:为什么不用 Thread.stop 强制停止? → stop 会直接终止线程导致数据完整性问题,已在 JDK 20 移除,见本文档「可以使用
Thread.stop,Thread.suspend和Thread.resume停止线程吗?为什么?」。 - Q:volatile 标志位方式能替代 interrupt 吗? → 仅适用于无阻塞简单循环,阻塞时无法检测标志位,见本文档「使用
volatile标记方式停止线程正确吗?」。
【中等】可以使用 Thread.stop,Thread.suspend 和 Thread.resume 停止线程吗?为什么?⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:8 min | 🏷 标签:Java 线程 / 线程停止
💎 关键结论
不可以,三者均已被废弃:stop 强制终止线程会破坏数据完整性;suspend 不释放锁就挂起,极易引发死锁。Thread.stop 自 JDK 20 起已无条件抛出 UnsupportedOperationException,应改用 interrupt 协作式停止。
⚡记忆卡片
- 口诀:stop 毁数据,suspend 抱锁睡;都不安全,改用中断
- 关键词:@Deprecated / 数据完整性 / 不释放锁 / 死锁
- 链路:stop 强停 → 锁突然释放/数据不一致 / suspend 挂起不释锁 → resume 前死锁
📖 核心知识
Thread.stop,Thread.suspend 和 Thread.resume 方法已经被 Java 标记为 @Deprecated,废弃原因:
Thread.stop会直接把线程停止,没有给线程足够的时间处理停止前保存数据的逻辑,任务戛然而止,导致数据完整性等问题。Thread.suspend不释放锁就进入休眠:若线程被 suspend 时刚好持有一把锁,该锁在 resume 之前不会释放。假设线程 A 调 suspend 让线程 B 挂起,而 B 持有某锁,此时 A 若也想访问这把锁,就会拿不到锁而阻塞,A、B 都无法继续执行——死锁。
案例:Thread.stop 终止线程,导致线程任务戛然而止
public class ThreadStopErrorDemo {
public static void main(String[] args) {
MyTask thread = new MyTask();
thread.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 终止线程
thread.stop();
// 确保线程终止后,才执行下面的代码
while (thread.isAlive()) { }
// 输出两个计数器的最终状态
thread.print();
}
/**
* 持有两个计数器,run 方法中每次执行都会使计数器自增
*/
private static class MyTask extends Thread {
private int i = 0;
private int j = 0;
@Override
public void run() {
synchronized (this) {
++i;
try {
// 模拟耗时操作
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
++j;
}
}
public void print() {
System.out.println("i=" + i + " j=" + j);
}
}
}示例中 stop 在 ++i 之后、++j 之前终止线程,导致 i、j 不一致,且 synchronized 块内的锁会被强制释放,其他线程可能观察到中间状态。
🔬 扩展知识
详情
- 【L3】版本演进:stop/suspend/resume 自 JDK 1.2 起标记 @Deprecated;自 JDK 20 起,
Thread.stop()无条件抛出UnsupportedOperationException(JDK 20 移除 stop;suspend/resume 也已在后续版本移除),调用即报错。 - 【L4】横向对比:stop 是外部强制终止,interrupt 是协作式请求;前者无法保证临界区/事务的完整性,这也是 JUC 全部基于中断机制设计的原因。
🔀 发散问题
- Q:正确的停止方式是什么? → interrupt + isInterrupted 协作式终止,见本文档「如何正确停止 Java 线程?」。
- Q:suspend 导致的死锁如何避免? → 无法安全避免,唯一方案是不使用 suspend,改用 wait/park 等会释放锁的机制。
【简单】一个线程两次调用 Thread.start() 方法会怎样?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:5 min | 🏷 标签:Java 线程 / 线程启动
💎 关键结论
Java 线程不允许启动两次,第二次调用 Thread.start() 会抛出 IllegalThreadStateException。原因是 start() 入口会检查状态,此时线程已非 NEW。
⚡记忆卡片
- 口诀:start 只能调一次,二次必抛状态异常
- 关键词:IllegalThreadStateException / 状态检查 / 单向生命周期
- 链路:首次 start() → 状态 NEW→RUNNABLE → 二次 start() → 状态检查失败 → 抛异常
📖 核心知识
- Java 的线程是不允许启动两次的,第二次调用
Thread.start()会抛出IllegalThreadStateException。 - 原理:
start()源码第一步检查线程状态,只有 NEW 状态才能启动;首次 start 后状态已变,二次调用直接被拒绝。 - 若确实需要「重新执行」任务:应新建一个 Thread 对象(或用线程池再次提交同一 Runnable),而非重启原线程。
🔬 扩展知识
详情
- 【L3】
Thread.start()源码中的if (threadStatus != 0) throw new IllegalThreadStateException()即为该约束的实现点;TERMINATED 状态的线程同样无法再次 start。
🔀 发散问题
- Q:start() 的内部流程是什么? → 状态检查、加入线程组、native start0()、回调 run(),见本文档「
Thread.start()的内部原理是什么?」。
【简单】Thread.sleep()、Thread.yield()、Thread.join()、Object.wait() 有什么区别?⭐⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:10 min | 🏷 标签:Java 线程 / 线程调度
💎 关键结论
四者核心区别在是否释放锁:只有 wait() 释放锁并需 notify 唤醒,属于线程间协作;sleep/yield/join 都不释放锁,分别用于定时暂停、让出 CPU、等待线程结束。
⚡记忆卡片
- 口诀:sleep 定时不释锁,wait 释锁等 notify;yield 让出看缘分,join 等人干完活
- 关键词:释放锁 / 唤醒机制 / 所属类 / 使用场景
- 链路:sleep/yield/join(Thread 静态或实例,不释锁)vs wait(Object,释锁进等待队列)
📖 核心知识
| 方法 | 所属类 | 作用 | 是否释放锁 | 使用场景 |
|---|---|---|---|---|
Thread.sleep(long ms) | Thread | 让当前线程暂停执行指定时间(不释放 CPU 资源) | ❌ 不释放锁 | 模拟耗时操作、定时任务 |
Thread.yield() | Thread | 提示调度器让出 CPU,但可能立即重新竞争(不保证让出) | ❌ 不释放锁 | 优化线程调度,减少竞争(极少使用) |
Thread.join() | Thread | 等待目标线程执行完毕(阻塞当前线程) | ❌ 不释放锁 | 线程顺序执行,如主线程等待子线程结束 |
Object.wait() | Object | 释放锁并进入等待,直到 notify()/notifyAll() 唤醒 | ✔️ 释放锁 | 线程间通信(需在 synchronized 块中使用) |

- 锁的释放:
wait()会释放锁,其他方法不会;sleep()和yield()仅影响线程调度,不涉及锁。 - 唤醒机制:
wait()需依赖notify()/notifyAll()或超时唤醒;sleep()和join()超时后自动恢复;yield()立刻重新参与竞争。 - 用途:
sleep()固定时间暂停(如定时任务);yield()礼貌让出 CPU(实际开发很少用);join()线程依赖(如主线程等待子线程);wait()线程间协作(生产者-消费者模型)。
🔬 扩展知识
详情
- 【L3】wait/notify 基于对象 Monitor 的等待队列(HotSpot ObjectMonitor 的
_WaitSet),wait 时线程先入队再释放锁;join 内部正是基于 wait 实现(对目标线程对象 wait)。 - 【L4】JUC 中 wait/notify 的替代品:
Condition.await()/signal()(可多等待队列、公平可选)、LockSupport.park()/unpark()(无需持锁)。
📚 延伸阅读:Java 并发编程:线程间协作的两种方式:wait、notify、notifyAll 和 Condition
⚠️ 常见误区
详情
常见误区:
- ❌ "sleep 会释放持有的锁" → sleep 不释放任何锁,抱着锁睡觉会阻塞其他线程。
- ❌ "yield 一定能让出 CPU" → yield 只是提示,调度器可忽略,且同优先级线程可能立即重新抢占。
🔀 发散问题
- Q:为什么 wait/notify 定义在 Object 而非 Thread? → 锁是对象级的,任何对象都可作锁,见本文档「为什么
Object.wait()、Object.notify()和Object.notifyAll()被定义在Object类里?」。 - Q:线程状态如何因这些方法变化? → sleep 进 TIMED_WAITING,wait 进 WAITING,见本文档「Java 线程生命周期有哪些状态?状态之间如何切换?」。
【中等】为什么 Thread.sleep()、Thread.yield() 设计为静态方法?⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:5 min | 🏷 标签:Java 线程 / API 设计
💎 关键结论
因为 sleep/yield 只对当前正在运行的线程有意义,设计为静态方法后只能作用于当前线程(Thread.currentThread()),避免开发者误以为可以对其他非 Running 状态的线程调用。
⚡记忆卡片
- 口诀:sleep 只睡自己,静态防误用
- 关键词:Running 状态 / 当前线程 / 静态方法
- 链路:方法只对运行中线程有效 → 设计为静态 → 只能作用于当前线程 → 避免误用
📖 核心知识
Thread.sleep()、Thread.yield()针对的是 Running 状态的线程,在非 Running 状态的线程上执行这两个方法没有意义。- 这就是为什么这两个方法被设计为静态的:它们只针对正在 Running 状态的线程工作,避免程序员错误地认为可以在其他非 Running 状态线程上调用。
- 实际效果:
Thread.sleep(1000)永远是让当前调用线程休眠,而非通过对象引用指定的某个线程。
🔬 扩展知识
详情
- 【L3】对比
wait()/notify()是实例方法:因为它们作用于对象 Monitor 的等待队列,必须绑定具体锁对象,与 sleep 作用于线程本身的语义不同。
📚 延伸阅读:
🔀 发散问题
- Q:为什么 wait/notify 反而设计为实例方法? → 它们是对象锁(Monitor)的行为,需绑定具体锁对象的等待队列,见本文档「为什么
Object.wait()、Object.notify()和Object.notifyAll()被定义在Object类里?」。
【中等】为什么 Object.wait()、Object.notify() 和 Object.notifyAll() 被定义在 Object 类里?⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:10 min | 🏷 标签:Java 线程 / API 设计
💎 关键结论
因为锁是对象的,wait()/notify() 是锁(Monitor)的行为:Java 中任何对象都可作锁,等待队列也与对象强绑定,所以这些方法必须定义在 Object 里才能保证通用性。
⚡记忆卡片
- 口诀:锁是对象的,等队挂在对象上;方法自然归 Object
- 关键词:对象级锁 / Monitor / 等待队列 / 通用性
- 链路:任何对象可作锁 → 每对象关联 Monitor → wait/notify 是 Monitor 操作 → 定义在 Object
📖 核心知识
因为锁是对象的,wait()/notify() 是锁的行为,所以必须定义在 Object 中:
- 锁基于对象:Java 的锁(
synchronized)是对象级别的,每个对象关联一个监视器(Monitor),wait()/notify()是监视器的核心操作,必须属于Object。 - 任何对象都可作为锁:不仅
Thread能作为锁,所有对象都能作为锁,因此这些方法需定义在Object以保证通用性。 - 等待队列绑定对象:调用
wait()的线程会进入该对象的等待队列,notify()唤醒的也是同一对象队列中的线程,与对象强绑定。 - 与
Thread类职责分离:Thread类管理线程生命周期(如sleep()、join()),而wait()/notify()是线程间协作机制,属于锁(对象)的行为。 - 设计一致性与历史原因:遵循 Monitor 模式(操作系统同步原语),保持
Thread简洁,避免功能混淆(如wait()和sleep()的误用)。

🔬 扩展知识
详情
- 【L3】HotSpot 中每个对象的 Monitor(ObjectMonitor)维护
_WaitSet(wait 队列)与_EntryList(锁竞争队列),wait 把当前线程从 _EntryList/临界区移入 _WaitSet 并释放锁;notify 从 _WaitSet 随机移出一个线程回 _EntryList 重新竞争。 - 【L4】若 wait/notify 定义在 Thread 类,则只能实现线程自身的等待语义(类似 join),无法表达「基于任意共享对象的多线程协作」,这正是设计在 Object 的根因。
⚠️ 常见误区
详情
常见误区:
- ❌ "wait/notify 是线程的方法,应该定义在 Thread" → 它们操作的是对象 Monitor 的等待队列,属于锁的行为,与线程生命周期方法(sleep/join)职责不同。
🔀 发散问题
- Q:为什么调用前必须持有锁? → wait 要释放锁,不持锁就无从释放;需 synchronized 保证检查条件与等待的原子性,见本文档「为什么
Object.wait()、Object.notify()和Object.notifyAll()必须在synchronized方法/块中被调用?」。 - Q:notify 和 notifyAll 怎么选? → 只有一个等待者且条件单一时用 notify;多等待者或条件复杂时用 notifyAll,避免丢失信号。
【中等】为什么 Object.wait()、Object.notify() 和 Object.notifyAll() 必须在 synchronized 方法/块中被调用?⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:8 min | 🏷 标签:Java 线程 / 线程协作
💎 关键结论
因为这些方法的语义都依赖当前线程持有的对象锁:wait 要释放锁才能进等待队列,notify 需要锁来保证检查条件与唤醒的原子性。不持锁调用会直接抛 IllegalMonitorStateException。
⚡记忆卡片
- 口诀:wait 要释锁,notify 要持锁;无锁调用抛异常
- 关键词:持有对象锁 / IllegalMonitorStateException / 原子性
- 链路:持锁 → wait 释锁入队 / notify 唤醒 → 同步条件检查与等待不被打断
📖 核心知识
- wait 需要先持锁再释锁:当一个线程需要调用对象的
wait()方法时,必须先拥有该对象的锁,接着它会释放这个对象锁并进入等待状态,直到其他线程调用该对象的notify()。 - notify 也需持锁:调用
notify()时线程需持有对象锁,执行完后释放锁,等待的线程才能重新竞争锁。 - 只能通过 synchronized 实现:由于这些方法都需要线程持有对象的锁,只能通过
synchronized来保证,所以它们只能在synchronized方法/块中被调用;否则抛出IllegalMonitorStateException。 - 避免丢失信号:持锁保证了「检查条件 → wait」与「改条件 → notify」两段逻辑的原子性,否则通知可能在等待前发出,线程永久挂起。
🔬 扩展知识
详情
- 【L3】HotSpot 实现中,
Object.wait()入口先检查THREAD->is_lock_owned((address)monitor),未持有目标 Monitor 即抛 IllegalMonitorStateException;wait 内部通过ObjectSynchronizer::wait完成出队入 _WaitSet + 释放锁。 - 【L4】对比 JUC 的
Condition.await()/signal():同样要求在 lock.lock() 保护下调用,语义一致;而LockSupport.park()无需持锁,因为它不与条件检查绑定。
⚠️ 常见误区
详情
常见误区:
- ❌ "wait 不持锁也能用,只是不释放锁" → 不持锁直接调用会抛 IllegalMonitorStateException,根本无法执行。
🔀 发散问题
- Q:wait 为什么建议在 while 循环里检查条件? → 防止虚假唤醒(spurious wakeup)和被唤醒后条件已不成立,经典生产者-消费者写法均用 while。
- Q:为什么 wait/notify 定义在 Object? → 锁是对象级的,见本文档「为什么
Object.wait()、Object.notify()和Object.notifyAll()被定义在Object类里?」。
【中等】使用 volatile 标记方式停止线程正确吗?⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:8 min | 🏷 标签:Java 线程 / 线程停止
💎 关键结论
仅适用于无阻塞、无锁竞争的简单循环场景,不是通用可靠方案:线程阻塞时无法检测标志位。推荐 Thread.interrupt + isInterrupted 标准方式,能响应阻塞中断。
⚡记忆卡片
- 口诀:标志位管简单循环,阻塞来了就失灵;通用还得靠中断
- 关键词:volatile 标志位 / 非阻塞循环 / 阻塞失效 / interrupt
- 链路:volatile stopped=true → 非阻塞循环下次检查退出 / 阻塞中无法检查 → 失效
📖 核心知识
使用 volatile 标记方式仅适用于简单场景(无阻塞、无锁竞争)。推荐 Thread.interrupt 和 Thread.isInterrupted 方式停止线程:更通用,可处理阻塞操作,是 Java 线程停止的标准方式。
- 适用场景(正确使用):
- ✔️ 非阻塞循环:线程在
while (!stopped)循环中运行,且无阻塞操作(如sleep()、wait()、I/O)。volatile保证标志位的修改对所有线程立即可见。 - ✔️ 短周期任务:适用于纯计算型任务或高频检查标志位的场景。
- ✔️ 非阻塞循环:线程在
- 不适用场景(可能失效):
- ❌ 线程被阻塞(如
sleep()、wait()、I/O):阻塞期间无法检测标志位,必须等阻塞结束才能退出。 - ❌ 依赖外部资源(如锁竞争、网络请求):即使
stopped=true,线程可能因锁或 I/O 阻塞无法立即退出。
- ❌ 线程被阻塞(如
案例:volatile 标志位停止线程
public class MyTask extends Thread {
private volatile boolean canceled = false;
public void run() {
while (!canceled) {
// 执行任务
}
}
public void stopTask() {
canceled = true;
}
}- 局限性:
canceled的可见性由 volatile 保证,单线程写入标志位本身没有竞态问题;但它无法打断阻塞,若任务中存在 sleep/wait/I/O,线程在阻塞结束前不会检查标志位,因此不是可靠的通用停止方式。
🔬 扩展知识
详情
- 【L3】对比
Thread.interrupted()响应阻塞:sleep/wait 被中断时抛 InterruptedException 并清除标志位,能立即从阻塞中醒来退出;volatile 标志位没有这种「唤醒」能力。 - 【L4】工程实践:若任务需同时支持计算循环与阻塞等待,可组合使用:
while (!canceled && !isInterrupted())+ catch InterruptedException,兼顾两类场景。
⚠️ 常见误区
详情
常见误区:
- ❌ "volatile 标志位方式存在竞态条件,可见性也不可靠" → volatile 已保证单写多读场景下标志位的可见性,问题不在可见性而在阻塞期间无法检测标志位。
🔀 发散问题
- Q:标准的停止方式是什么? → interrupt 协作式中断,见本文档「如何正确停止 Java 线程?」。
- Q:volatile 为什么能保证可见性? → 写后强制刷回主存/触发缓存一致性协议,见本文档「volatile 关键字有什么作用?」。
【中等】Java 线程之间如何进行通信?⭐⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:12 min | 🏷 标签:Java 线程 / 线程通信
💎 关键结论
线程间通信(ITC)指多线程协调工作、共享数据或传递消息的机制:基础手段有共享变量、wait/notify;生产优先用 JUC 工具类——数据交换用 BlockingQueue,协作等待用 CountDownLatch,资源控制用 Semaphore。
⚡记忆卡片
- 口诀:状态共享 volatile,协作 wait/notify;生产用 JUC,别造轮子
- 关键词:共享变量 / wait/notify / BlockingQueue / JUC
- 链路:简单标记(volatile)→ 条件协作(wait/notify)→ 队列/门闩/信号量(JUC)
📖 核心知识
线程间通信(Inter-Thread Communication, ITC)是指多个线程之间协调工作、共享数据或传递消息的机制。常见方式:
| 通信方式 | 核心机制 | 适用场景 | 特点 |
|---|---|---|---|
| 共享变量 | volatile/synchronized | 简单状态标记 | 需处理竞态条件 |
wait()/notify() | 对象监视器 | 生产者-消费者 | 需手动同步 |
BlockingQueue | 内置锁和条件队列 | 生产者-消费者 | 无需手动同步 |
CountDownLatch | 计数器 | 主线程等待子线程 | 一次性 |
CyclicBarrier | 屏障 | 多线程同步 | 可重复使用 |
Semaphore | 许可证 | 限流/资源池 | 控制并发数 |
| 管道流 | 字节流 | 线程间数据传输 | 效率较低 |
推荐选择:
- 需要高效数据交换 →
BlockingQueue - 线程协作 →
wait()/notify()或CountDownLatch - 资源控制 →
Semaphore - 避免重复造轮子,优先使用 JUC(
java.util.concurrent)工具类!
🔬 扩展知识
详情
- 【L3】BlockingQueue 内部基于 ReentrantLock + 两个 Condition(notEmpty/notFull)实现阻塞语义;CountDownLatch 与 Semaphore 基于 AQS 的同步状态实现。
- 【L4】通信模型横向对比:共享内存模型(上述全部)vs 消息传递模型(如 Akka Actor、Go channel);Java 21+ 提供
java.util.concurrent.Flow与 StructuredConcurrency(预览)进一步简化协作。
⚠️ 常见误区
详情
常见误区:
- ❌ "共享变量只需 volatile 就线程安全" → volatile 只保证可见性,复合操作(读-改-写)仍需 synchronized/Atomic 类。
- ❌ "CountDownLatch 可以重复使用" → 它是一次性的,计数归零后不可重置;可重复用 CyclicBarrier。
🔀 发散问题
- Q:wait/notify 协作有什么约束? → 必须在 synchronized 中调用、建议 while 循环检查条件,见本文档「为什么
Object.wait()、Object.notify()和Object.notifyAll()必须在synchronized方法/块中被调用?」。 - Q:虚拟线程下线程通信有何变化? → 虚拟线程同样适用上述机制,且每任务一虚拟线程的模型下更推荐用队列/信号量控制并发度。
【简单】高优先级的 Java 线程一定先执行吗?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:5 min | 🏷 标签:Java 线程 / 线程调度
💎 关键结论
不一定。线程优先级范围 [1,10]、默认 5,但 Java 优先级依赖操作系统支持,不同 OS 的优先级体系无法与 Java 一一对应,因此优先级控制并不可靠,不能作为正确性依据。
⚡记忆卡片
- 口诀:优先级只是建议,执不执行看 OS
- 关键词:[1,10] / 默认 5 / OS 依赖 / 不可靠
- 链路:setPriority → 映射到 OS 优先级(可能丢失/合并)→ 调度器自行决定
📖 核心知识
- Java 中线程优先级范围是
[1,10],可通过thread.setPriority(Thread.MAX_PRIORITY)设置,默认优先级为5;高优先级线程在运行时理论上具有优先权。 - 即使设置了优先级,也无法保证高优先级线程一定先执行:Java 线程优先级依赖操作系统支持,不同 OS 的优先级体系各不相同,无法与 Java 优先级一一对应。
- 结论:Java 线程优先级控制并不可靠,不应依赖它保证执行顺序或程序正确性,仅可作为调度提示。
🔬 扩展知识
详情
- 【L3】HotSpot 将 Java 优先级映射到 OS 层:Linux 上多数情况只映射到 nice 值(SCHED_OTHER 下对实时性无实质影响),Windows 上映射到线程优先级类;这也是跨平台行为不一致的根源。
🔀 发散问题
- Q:想控制执行顺序该怎么办? → 用 join、线程池的任务依赖编排或信号量等同步工具显式控制,而非依赖优先级。
【中等】什么是守护线程?用户线程和守护线程有什么区别?⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:8 min | 🏷 标签:Java 线程 / 线程类型
💎 关键结论
守护线程是为其他线程提供服务的特殊线程(如 GC、JIT):当所有用户线程结束时,JVM 不等待守护线程,直接退出。关键区别就在 JVM 退出行为,设置必须在 start() 前调用 setDaemon(true)。
⚡记忆卡片
- 口诀:守护服务他人,用户走完 JVM 就撤;start 前设 daemon
- 关键词:JVM 退出行为 / setDaemon / GC 线程 / start 前设置
- 链路:setDaemon(true) → start → 所有用户线程结束 → JVM 不等守护线程直接退出
📖 核心知识
守护线程(Daemon Thread) 是一种特殊的线程,其作用是为其他线程(用户线程)提供服务。当所有用户线程都结束时,JVM 不会等待守护线程完成,会直接退出。
用户线程和守护线程的区别:
| 对比维度 | 用户线程(User Thread) | 守护线程(Daemon Thread) |
|---|---|---|
| JVM 退出行为 | JVM 会等待所有用户线程结束后才退出 | JVM 不等待守护线程结束,所有用户线程结束时 JVM 直接退出 |
| 典型应用 | 业务线程(如处理请求、计算任务) | GC 线程、JIT 编译线程、心跳检测、后台监控 |
| 设置方式 | 默认创建的线程为用户线程 | thread.setDaemon(true)(必须在 start() 前设置) |
| 继承性 | 子线程默认继承父线程的守护属性 | 守护线程创建的子线程默认也是守护线程 |
| finally 执行 | 正常执行 | JVM 退出时可能不执行 finally 块 |
注意事项:
- 必须在
start()前设置:否则抛出IllegalThreadStateException。 - 避免在守护线程中执行 I/O/资源清理:因为 JVM 退出时守护线程可能被强行终止,导致资源未正确释放。
- 线程池的守护属性:线程池中的线程默认继承创建线程的守护属性,需注意线程池场景下
finally可能不执行。
案例:创建守护线程
Thread daemonThread = new Thread(() -> {
while (true) {
// 后台监控逻辑
}
});
daemonThread.setDaemon(true); // 必须在 start() 前设置
daemonThread.start();🔬 扩展知识
详情
- 【L3】JVM 退出判定:只有非守护线程存活时才继续运行;main 线程也是用户线程,main 结束但其他用户线程存活时 JVM 不退出。GC、JIT、Finalizer 等均为 JVM 内部的守护线程。
- 【L4】工程对比:生产服务通常依赖 Shutdown Hook(Runtime.addShutdownHook)做优雅停机,而非守护线程;因为钩子在 JVM 退出流程中有执行时机保障。
⚠️ 常见误区
详情
常见误区:
- ❌ "守护线程会等任务执行完再退出" → JVM 退出时守护线程会被直接终止,finally 块都可能不执行,不要在其中做持久化/资源释放。
🔀 发散问题
- Q:线程池里的线程默认是守护线程吗? → 默认不是,DefaultThreadFactory 创建的线程非守护;若主线程用守护属性创建池,池内线程会继承,导致 JVM 提前退出,需谨慎。
【中等】什么是 FutureTask?它的原理是什么?⭐⭐⭐
🎯 目标等级:L2-L3 | ⏱ 建议用时:10 min | 🏷 标签:Java 线程 / 异步结果
💎 关键结论
FutureTask 是 Future 接口的标准实现,同时实现 Runnable,既能提交给线程/线程池执行,又能异步获取结果。内部用 volatile state + CAS 管理 7 种状态,get() 阻塞时通过 LockSupport.park 挂起等待线程。
⚡记忆卡片
- 口诀:FutureTask 两接口,能跑又能取结果;state 七态 CAS 推,get 阻塞 park 挂
- 关键词:Future+Runnable / 状态机 / CAS / LockSupport
- 链路:run() 执行任务 → CAS 推进 state → 唤醒等待队列 → get() 返回结果
📖 核心知识
FutureTask 是 Java 中 Future 接口的标准实现,同时实现了 Runnable 接口,因此既可以作为任务提交给线程池执行,又可以异步获取执行结果。
- 核心状态机(volatile int state + 7 个状态常量):
private volatile int state;
private static final int NEW = 0; // 新建,尚未执行
private static final int COMPLETING = 1; // 正在完成(结果已设置但未发布)
private static final int NORMAL = 2; // 正常完成
private static final int EXCEPTIONAL = 3; // 异常完成
private static final int CANCELLED = 4; // 已取消
private static final int INTERRUPTING = 5; // 正在中断
private static final int INTERRUPTED = 6; // 已中断- 核心机制:
- 等待队列基于 LockSupport:
FutureTask内部维护 Treiber 栈结构的等待线程链表,get()时若任务未完成,线程会被挂起(LockSupport.park),任务完成后逐个唤醒等待线程。注意:FutureTask 并非基于 AQS 实现。 - CAS 保证状态转换原子性:所有状态变更(完成、取消、异常)通过 CAS 操作完成。
- 结果可见性:
state为volatile,配合 CAS 保证结果对等待线程可见。
- 等待队列基于 LockSupport:
案例:典型用法
FutureTask<String> futureTask = new FutureTask<>(() -> {
Thread.sleep(1000);
return "result";
});
new Thread(futureTask).start(); // 或 executor.submit(futureTask)
String result = futureTask.get(); // 阻塞直到任务完成- FutureTask vs CompletableFuture:
| 特性 | FutureTask | CompletableFuture |
|---|---|---|
| 链式编排 | 不支持 | 支持(thenApply/thenAccept 等) |
| 异常处理 | 只能 get 时抛出 | 支持 exceptionally/handle |
| 组合操作 | 不支持 | 支持 allOf/anyOf |
| 手动完成 | 支持 complete | 支持 complete |
| 回调机制 | 不支持(只能阻塞轮询) | 支持(任务完成自动触发回调) |
🔬 扩展知识
详情
- 【L3】get() 的等待实现:
awaitDone循环中把当前线程包装为 WaitNode 用 CAS 压入 Treiber 栈(后进先出链表),然后 LockSupport.park;任务完成时finishCompletion遍历弹栈逐个 unpark。 - 【L3】版本演进:JDK 8 之前 get() 的等待即基于上述 Treiber 栈(JDK 5/6 版本曾基于 AQS 的共享模式,JDK 8 重写为现在的实现);现代版本与 AQS 无关。
- 【L4】选型:简单一次性异步结果用 FutureTask/线程池 submit;需要编排、回调、组合时用 CompletableFuture。
⚠️ 常见误区
详情
常见误区:
- ❌ "FutureTask 基于 AQS 实现" → 现代版本(JDK 8+)使用 volatile state + CAS + LockSupport 与 Treiber 栈等待队列,并未继承 AQS;这是常见误传。
🔀 发散问题
- Q:FutureTask 和普通 Future 的关系? → FutureTask 是 Future 的可运行实现,线程池 submit(Callable) 内部正是包装为 FutureTask。
- Q:为什么生产更推荐 CompletableFuture? → 支持链式编排、回调、异常处理与组合,避免 get() 阻塞,见本题对比表。