Appearance
Java 并发模型总览
一句话定义
Java 并发模型是一套把任务、线程、共享内存、同步原语与内存可见性规则放进统一语义里的工程体系:你既要决定“谁执行”,也要决定“谁能看见什么、在什么时刻生效、出问题时怎么定位”。
代码索引
| 主题 | Lab 说明 | 源码 |
|---|---|---|
| Thread / Runnable / Callable / Future、线程池与拒绝策略 | thread-pool-basics | ThreadPoolBasics.java |
| LinkedBlockingQueue / ArrayBlockingQueue / SynchronousQueue | queue-strategy-compare | QueueStrategyCompare.java |
| volatile / AtomicInteger / synchronized 对比 | volatile-vs-atomic-vs-lock | VolatileVsAtomicVsLock.java |
| 单例、安全发布、DCL | singleton-dcl | SingletonDcl.java |
| HashMap / ConcurrentHashMap / 复合操作 | map-concurrency-compare | MapConcurrencyCompare.java |
前置基础(跨模块):位运算 · CAS · 红黑树 · LRU / LruCache
Java 专题:CHM 速览 · map-concurrency-compare
为什么需要
对高年限工程师来说,Java 并发不是“会不会起线程”的问题,而是以下几个现实约束的交点:
- 吞吐与时延:服务端要把 CPU、IO、队列、线程池压到合理水位;Android 要避免主线程阻塞与后台线程失控。
- 正确性:只要有共享状态,就会遇到可见性、竞态、重复提交、缓存失效、乱序读写。
- 资源边界:线程不是免费的。线程栈、调度切换、上下文切换、锁竞争都会直接反映在内存占用与尾延迟上。
- 工程可维护性:真正难的不是写出并发代码,而是半年后还能解释:为什么这里用
volatile而不是锁、为什么这个线程池不会把机器打死、为什么这个 map 不会在高并发下退化。
如果把并发仅理解为“多线程提高性能”,通常会在三个地方翻车:
- 把并发当并行:任务数变多不等于执行更快,尤其是 IO、锁竞争、串行瓶颈明显时。
- 把 API 当语义:会写
ExecutorService.submit()不代表理解Future.get()形成的 happens-before。 - 把问题留给线上:死锁、活锁、竞态条件往往在本地很难稳定复现,但在线上会以 ANR、CPU 飙升、偶现脏数据、请求超时的形式长期存在。
底层机制
这一节现在只保留总览地图;各主题的详细说明、代码示例、选型边界与排障思路已拆到独立专题文档,避免总览过长、README 重复堆叠。
Java 专题导航
- Thread / Runnable / Callable / Future
- ExecutorService / 线程池
- synchronized / Lock / volatile
- Atomic* / CAS / LongAdder
- ConcurrentHashMap(CHM)速览
- JMM:happens-before / 安全发布
- 死锁 / 活锁 / 竞态定位
1. 任务与线程:Thread / Runnable / Callable / Future
Thread是执行载体;直接new Thread()代表你在手动管理生命周期、调度成本与异常边界。Runnable/Callable是任务描述;前者无返回值,后者有返回值且可抛异常。Future是异步结果句柄,解决的是拿结果、取消、查状态,不是执行机制本身。- 工程上最容易踩坑的是:把
Future.get()放在主线程、关键请求路径或线程池内部互等路径里,导致异步重新退化成阻塞。 - 详见:Thread / Runnable / Callable / Future
2. ExecutorService:把“执行”从“创建线程”提升到“治理任务”
- 线程池真正治理的是:任务排队、线程复用、扩容边界、拒绝策略、生命周期管理。
- 看线程池不能只看“几个线程”,而要同时看
corePoolSize、maximumPoolSize、workQueue、RejectedExecutionHandler如何共同决定系统行为。 - Android / App 场景往往更适合小而有界的线程池和队列,而不是无界排队或激进扩线程。
- 详见:ExecutorService / 线程池
3. synchronized / Lock / volatile:三个层次的问题,不是一组同义词
synchronized:优先解决互斥 + 可见性,适合边界清晰、临界区短的场景。Lock:在需要tryLock()、可中断等待、公平策略、Condition时提供更强控制,但复杂度更高。volatile:解决可见性 + 有序性约束,不解决count++这类复合操作的原子性。- 选型原则不是“谁更高级”,而是你到底要解决互斥、状态可见,还是复杂协调。
- 详见:synchronized / Lock / volatile
4. ConcurrentHashMap / Atomic*:高并发容器与无锁原语
ConcurrentHashMap解决的是高并发共享字典问题;关键不是“线程安全版 HashMap”,而是正确使用computeIfAbsent/putIfAbsent等原子复合 API。Atomic*/LongAdder解决的是单变量原子更新与热点计数问题;它们适合计数器、状态位、引用切换,但不适合承载复杂事务一致性。- 这一层最常见误区是:容器安全 ≠ 业务流程安全,CAS 能跑 ≠ 复杂约束就能不用锁。
- 详见:ConcurrentHashMap(CHM)速览 · Atomic* / CAS / LongAdder
5. JMM:Java Memory Model 才是并发语义的地基
- JMM 解决的是:在编译器优化、CPU 重排序、缓存层次都存在时,多线程结果如何仍然可推理。
- 复习时重点抓三件事:可见性、原子性、有序性,以及它们通过 happens-before 如何建立推理边界。
- 安全发布、DCL、
volatile引用替换,本质上都在处理“别人是否会看到半初始化对象”。 - 详见:JMM:happens-before / 安全发布
6. 死锁 / 活锁 / 竞态:问题类型与定位思路
- 死锁:线程互持资源永久等待;活锁:线程一直在跑但没有有效推进;竞态:结果依赖不可控时序。
- 线上排查先分型:卡死看锁和线程池饥饿,CPU 高看自旋/活锁,偶发脏数据看竞态与发布边界。
- 真正高效的定位手段不是肉眼猜代码,而是线程 dump、JFR / profiler、关键时序日志与最小复现。
- 详见:死锁 / 活锁 / 竞态定位
Android / Flutter / Web / Backend 对照
| 维度 | Java 并发模型 | Android | Flutter | Web | Backend |
|---|---|---|---|---|---|
| 执行单元 | Thread / pool / task | 主线程 + Binder/线程池/HandlerThread | isolate + event loop + Future | event loop + task/microtask + Worker | request thread / event loop / pool |
| 共享状态 | 共享内存为主 | 跨线程共享对象很常见 | isolate 默认不共享内存,靠消息传递 | 主线程 JS 单线程,Worker 间靠消息 | 缓存、连接池、map、队列大量共享 |
| 同步原语 | synchronized / Lock / volatile / Atomic* | 同上,外加 Looper/Handler 约束 | 不靠锁做主模型,更多靠消息隔离 | 不靠锁做主模型,更多靠事件顺序 | 锁、CAS、并发容器是日常 |
| 常见问题 | 死锁、竞态、可见性、池饥饿 | ANR、主线程阻塞、后台争锁 | isolate 切换成本、消息序列化、主 isolate 卡顿 | 长任务阻塞 UI、异步回调顺序错觉 | 线程池打满、队列堆积、热点锁、缓存击穿 |
| 工程心智 | 共享内存 + happens-before | 既要并发正确,也要 UI 时序正确 | 以隔离换简单,以消息换共享 | 以事件循环换线程复杂度 | 以资源治理换吞吐和稳定性 |
对有 Android/Flutter/Web 背景的工程师,最关键的迁移理解是:
- Java 并发默认是共享内存模型,不是 isolate/message passing 模型。
- 因此它的难点不在“怎么异步”,而在“共享状态怎么证明安全”。
- 这也是为什么在 Java 世界里,线程池参数、锁粒度、可见性边界会比语法层 API 更重要。
常见场景
1. Android 图片压缩 / 文件处理池
这是移动端最典型的场景之一:
- 图片压缩
- 缩略图生成
- 文件拷贝/解压
- 大 JSON 本地解析
这类任务共同特点:
- 不能放主线程
- 任务量可能被用户连续点击放大
- 结果通常是“有延迟可以接受,但不能把 App 拖死”
一个更现实的做法是:
- 小而有界的线程池
- 有界队列
- 明确任务取消或页面退出后的善后策略
例如:
java
ThreadPoolExecutor imagePool = new ThreadPoolExecutor(
2,
4,
30L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(8),
r -> new Thread(r, "img-worker"),
new ThreadPoolExecutor.DiscardOldestPolicy()
);这个配置背后的业务假设是:
- 旧图片处理任务如果已经过时,可以丢掉
- 新任务更接近用户当前操作
- 队列上限要明确,避免页面已经退出还积压一堆历史任务
如果业务要求“每个任务都必须成功”,那这个拒绝策略就不合适,可能要换成上层排队/持久化任务方案,而不是线程池里硬扛。
2. 异步日志 / 通知写盘池
这类任务通常更适合:
- 单线程或极小线程池
- 顺序写入
- 明确 flush 时机
例如:
java
ExecutorService logPool = new ThreadPoolExecutor(
1,
1,
0L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
r -> new Thread(r, "log-writer"),
new ThreadPoolExecutor.DiscardPolicy()
);为什么这样配:
- 单线程:避免并发写盘造成顺序混乱
- 小有界队列:不让日志无限堆积吃内存
DiscardPolicy:在极端高峰下,宁可丢低价值日志,也不影响主流程
但前提是:
- 你明确接受“部分日志丢失”
- 核心交易、支付、审计日志不能这么干
3. 后台任务调度(泛化场景)
- 服务端批处理、异步通知、索引构建
- Android 本地数据库、解压、图片处理、日志落盘
- 核心关注:线程池隔离、拒绝策略、超时与取消传播
2. 共享缓存与去重
ConcurrentHashMap做本地缓存、in-flight 请求合并、对象池索引- 关键点:优先使用
computeIfAbsent等原子 API,避免重复创建与竞态 - 详见 map-concurrency-compare
3. 状态位与生命周期控制
- 用
AtomicBoolean/volatile管理一次性启动、停止信号、熔断开关 - 关键点:状态位可见,不代表整个对象状态机天然安全
4. 高并发计数与统计
- QPS、命中数、失败数、限流窗口统计
- 低到中等冲突:
AtomicLong - 热点极高:考虑
LongAdder
5. 生产者-消费者
- 任务队列、事件分发、日志异步写盘
- 关键点:背压、队列上限、消费失败策略,不只是“丢给线程池”
常见坑
直接 new Thread 处理业务任务
- 结果:线程数量不可控、缺乏统一生命周期治理、排障困难。
在线程池内部相互等待
Future.get()- 尤其固定大小线程池中,极易造成线程池饥饿甚至死锁。
把
volatile当线程安全万能钥匙- 它只能解决可见性与部分有序性,解决不了复合操作原子性。
用
ConcurrentHashMap包住不安全复合逻辑- 容器线程安全 ≠ 基于容器的业务流程线程安全。
锁范围过大
- 把 IO、RPC、数据库访问放进锁里,会把临界区从纳秒级放大到毫秒甚至秒级。
锁顺序不固定
- 多资源场景中没有统一获取顺序,是死锁高发源头。
忽视中断语义
- 取消任务只调
cancel(true),但任务内部从不检查中断或调用可中断阻塞 API,取消就形同虚设。
- 取消任务只调
默认线程池参数直接上线
- 不分析任务性质、不限队列长度、不配置命名和监控,最终问题都在线上暴露。
安全发布缺失
- 单例、配置对象、缓存值初始化后直接跨线程读,偶发读到半初始化状态。
与相近概念对比
Thread vs Runnable vs Callable vs Future
| 概念 | 它是什么 | 解决什么问题 | 不解决什么 |
|---|---|---|---|
| Thread | 执行载体 | 真正跑起来 | 任务复用、结果治理、资源控制 |
| Runnable | 无返回值任务 | 描述要执行的动作 | 结果返回、异常建模 |
| Callable | 有返回值任务 | 描述一次异步计算 | 调度策略 |
| Future | 异步结果句柄 | 查询/等待/取消结果 | 线程创建、任务调度 |
synchronized vs Lock vs volatile
| 概念 | 主要能力 | 适合场景 | 典型误区 |
|---|---|---|---|
| synchronized | 互斥 + 可见性 | 临界区明确、简单互斥 | 以为性能一定差、以为过时 |
| Lock | 互斥 + 更强控制 | 可中断、超时、公平、多个条件队列 | 为了“高级感”滥用 |
| volatile | 可见性 + 有序性约束 | 状态位、引用发布 | 当成轻量锁使用 |
Atomic* vs Lock
| 维度 | Atomic* | Lock |
|---|---|---|
| 机制 | CAS 自旋重试 | 阻塞/唤醒 + 临界区互斥 |
| 优势 | 低冲突下轻量、单变量表达直接 | 能保护复杂复合逻辑 |
| 劣势 | 高冲突烧 CPU、表达复杂事务差 | 切换和等待成本更高 |
| 适用 | 计数、标记位、引用更新 | 多字段一致性、长临界区、复杂约束 |
ConcurrentHashMap vs synchronizedMap
| 维度 | ConcurrentHashMap | Collections.synchronizedMap |
|---|---|---|
| 并发粒度 | 更细 | 整体串行化倾向更强 |
| 复合原子操作 | 提供 putIfAbsent / compute* | 需外部再加锁 |
| 高并发吞吐 | 更适合 | 容易形成全局锁瓶颈 |
对应实验
各章节内已嵌入跳转链接;此处汇总全部 Lab:
| Lab | 说明 | 源码 |
|---|---|---|
| thread-pool-basics | thread-pool-basics | ThreadPoolBasics.java |
| queue-strategy-compare | queue-strategy-compare | QueueStrategyCompare.java |
| volatile-vs-atomic-vs-lock | volatile-vs-atomic-vs-lock | VolatileVsAtomicVsLock.java |
| singleton-dcl | singleton-dcl | SingletonDcl.java |
| map-concurrency-compare | map-concurrency-compare | MapConcurrencyCompare.java |
建议补齐的实验说明如下:
thread-pool-basics
- 演示
Thread/Runnable/Callable/Future的差异 - 重点观察:
Future.get()阻塞、异常传播、取消行为
- 演示
queue-strategy-compare
- 用不同
core/max/queue/reject组合压测线程池 - 重点观察:排队、扩线程、拒绝策略对吞吐和尾延迟的影响
- 用不同
volatile-vs-atomic-vs-lock
- 同一个计数器分别用
volatile、AtomicInteger、synchronized - 重点观察:错误结果、正确性、吞吐差异
- 同一个计数器分别用
singleton-dcl
- 对比错误单例、方法同步单例、DCL、静态内部类、enum 单例
- 重点观察:安全发布、懒加载、实现复杂度与工程推荐度边界
map-concurrency-compare(已建立)
- 对比 HashMap / synchronizedMap / ConcurrentHashMap
- 重点观察:
get-then-put与computeIfAbsent、重复初始化
deadlock-and-thread-dump
- 构造双锁死锁并抓线程 dump
- 重点观察:BLOCKED 状态、锁拥有者链路
deadlock-and-thread-dump
- 构造双锁死锁并抓线程 dump
- 重点观察:BLOCKED 状态、锁拥有者链路
jcstress-safe-publication
- 验证对象未安全发布时的异常可见结果
- 重点观察:JMM 问题为何不能靠肉眼推理
复习检查题
为什么说
Future是结果句柄,不是并发模型本身?答:
Future只负责拿结果、取消、查状态(get/cancel/isDone),不负责调度与执行线程;真正跑任务的是ExecutorService、线程或框架调度器。把它当「并发模型」会误把票据当成发动机。固定线程池里任务 A 等待任务 B 的
Future.get(),在什么条件下会导致线程池饥饿或死锁?答:当池大小有限,且池内任务互相
get()等待彼此时:所有工作线程都在阻塞等Future,队列里的任务得不到线程执行,形成池内死锁/饥饿。典型于固定小池 + 任务内同步等待同池提交的子任务。修法:扩大池、子任务用独立池、或避免在池内阻塞get()。volatile能保证什么,不能保证什么?请用count++解释。答:保证可见性与禁止特定重排序,建立有限 happens-before。不保证复合操作原子性。
count++= 读、加、写三步,多线程仍可能基于同一旧值各自加一,最终少计。计数应改用AtomicInteger或锁。为什么
ConcurrentHashMap不能自动让if (get == null) put()变成线程安全?答:CHM 保证的是单次 API 调用原子,而
get与put是两次独立操作,中间可被别的线程插入。两线程都可能看到null后各put一次,产生重复初始化或覆盖。应使用putIfAbsent/computeIfAbsent等原子复合 API。synchronized与ReentrantLock的选择边界是什么?什么情况下Lock的额外复杂度是值得的?答:默认
synchronized:临界区短、语义简单、JVM 优化成熟。ReentrantLock在需要tryLock、可中断等待、公平策略、Condition多条件队列时值得引入。代价是必须try/finally释放,控制流更复杂;不是「更高级就该用」。JMM 里的可见性、原子性、有序性分别解决什么问题?它们为什么不是一回事?
答:可见性:别的线程何时能看见我的写。原子性:操作会不会被看到中间态(如
count++三步)。有序性:代码书写顺序与多线程观察顺序是否一致(重排序)。三者独立:例如volatile改善可见性/有序性,但不给count++原子性。什么叫安全发布?为什么「双重检查锁 + volatile」少了
volatile就不成立?答:安全发布指其他线程看到的引用指向已完全构造好的对象。
new可能被重排为:先赋引用、再执行构造。无volatile时,另一线程可能在第二步就看到非空引用,读到半初始化对象。volatile禁止这种有害重排,使发布与初始化顺序对读者可见。死锁、活锁、竞态条件在线上症状上通常各长什么样?
答:死锁:线程长期
BLOCKED/WAITING,吞吐归零,dump 见环路持锁。活锁:CPU 高、线程在跑,但业务无进展(如 CAS 疯狂重试、双方礼让循环)。竞态:偶现、难复现的数据错、重复初始化、取消后仍回调,依赖时序碰运气。Android 中哪些问题表面像 UI 卡顿,根因其实是后台线程争锁或线程池治理失衡?
答:主线程
Future.get()/同步锁等待后台;图片/网络池过小导致任务堆积后反压到主线程(如CallerRunsPolicy在主线程跑重任务);Binder/共享缓存争锁传导到 UI 线程;Handler 队列被阻塞任务拖慢。ANR trace 常显示主线程在等锁或get(),而非布局本身慢。对一个高并发共享计数场景,你会如何在
AtomicLong、LongAdder、锁之间做取舍?答:低冲突、逻辑简单:
AtomicLong。 极高并发写、可接受读时求和:LongAdder。 计数与多字段状态联动、需事务性:锁或不可变状态整体替换。避免在热点上用粗粒度锁;也避免在复杂一致性上硬凑 CAS。
速记
- Thread 是载体,Runnable/Callable 是任务,Future 是票据,ExecutorService 是治理层。
- 共享内存并发的核心不是“能不能异步”,而是“共享状态如何安全”。
synchronized先解决简单互斥,Lock只在需要更强控制时引入。volatile只保可见性和部分有序性,不保复合原子性。- Atomic* 适合单变量原子更新;复杂一致性问题别硬上 CAS。
- ConcurrentHashMap 保证容器级并发安全,不替你保证业务复合逻辑安全。
- JMM 记一句:没有 happens-before,就不要脑补另一个线程一定能看见。
- 线上排查先分型:卡死看锁和线程 dump,CPU 高看自旋/活锁,脏数据看竞态和发布。
- 线程池参数就是系统行为参数;默认值不是架构设计。