多线程并发专题
Android 并发题要同时答 Java 基础和移动端场景: 线程池参数背得出只是起点, 还要知道 HandlerThread, IntentService, 协程调度, 锁, CAS, ANR/OOM 怎么落地排查.
一, Android 线程模型与 HandlerThread
主线程负责 UI, 输入事件和生命周期回调, 耗时任务必须离开主线程. HandlerThread 是带 Looper 的后台线程, 适合串行处理一类任务.
val thread = HandlerThread("worker").apply { start() }
val handler = Handler(thread.looper)
handler.post { /* 串行后台任务 */ }
特点:
- 一个 HandlerThread 对应一个 Looper/MessageQueue, 任务按消息顺序串行执行.
- 适合相机回调, 轻量 IO, SDK 内部串行状态机.
- 不适合大量并行任务; 任务太慢会阻塞后续消息.
- 退出时调用
quitSafely(), 避免线程泄漏.
二, IntentService / JobIntentService 的边界
IntentService 本质是 Service + HandlerThread, 按 Intent 串行执行, 执行完自动停止. 它曾适合后台串行任务, 但在后台执行限制增强后使用场景变窄.
| 机制 | 特点 | 现状 / 边界 |
|---|---|---|
| IntentService | 后台线程串行处理 Intent | 已不推荐作为新方案, 受后台限制影响 |
| JobIntentService | 兼容低版本, 高版本走 JobScheduler 思路 | 也不是长期首选, 复杂任务更推荐 WorkManager |
| WorkManager | 可约束, 可重试, 可持久化 | 延迟 / 保证执行类后台任务首选 |
怎么答: 如果是页面内短任务, 用协程 / 线程池; 如果是可延迟, 需约束, 进程死后仍要执行的任务, 用 WorkManager, 不要滥用 Service 常驻后台.
三, Thread, Runnable, Callable 与 Future
Thread 是执行线程的对象, Runnable 表示不返回结果的任务, Callable<T> 可以返回 T 且声明受检异常. ExecutorService.submit() 会把 Runnable 或 Callable 封装为 Future, 调用方可查询完成状态, 获取结果或取消任务.
| 方式 | 结果与异常 | 取消 | 适用场景 | 不适用边界 |
|---|---|---|---|---|
Thread | 无返回值; 未捕获异常交给线程异常处理器 | interrupt() 只是协作信号 | 极少量需直接控制线程生命周期的底层代码 | 页面或业务任务逐个新建线程 |
Runnable | 无返回值; 直接 run() 的异常由调用方处理 | 由执行器或线程协作中断 | 无结果的短任务 | 需要返回值或检查受检异常的任务 |
Callable<T> | 返回 T; 异常会由 Future.get() 包装为 ExecutionException | Future.cancel(mayInterruptIfRunning) 请求取消 | 有结果, 可失败的后台计算 | 忽略取消和异常的长任务 |
Future<T> | get() 取得结果或抛出取消 / 执行异常 | 可查询, 取消和判断完成 | 后台协调与超时控制 | 主线程直接 get() 等待结果 |
run() 只是当前调用线程中的普通方法调用, 不会创建新线程; start() 才会让 JVM 创建并调度新线程, 再由该新线程调用 run(). 同一个 Thread 不能重复 start().
NEW -- start() --> RUNNABLE -- 运行或让出 CPU --> RUNNABLE
RUNNABLE -- 竞争 synchronized 监视器 --> BLOCKED
BLOCKED -- 获得监视器 ----------------> RUNNABLE
RUNNABLE -- wait() ------------------------> WAITING
WAITING -- notify()/notifyAll() -----------> BLOCKED -- 获得监视器 --> RUNNABLE
RUNNABLE -- join()/park() -----------------> WAITING
WAITING -- join 目标结束/unpark() ---------> RUNNABLE
RUNNABLE -- wait(timeout) -----------------> TIMED_WAITING
TIMED_WAITING -- notify()/notifyAll()/超时 -> BLOCKED -- 获得监视器 --> RUNNABLE
RUNNABLE -- sleep()/join(timeout)/park(timeout) --> TIMED_WAITING
TIMED_WAITING -- sleep 超时/join 目标结束或超时/unpark() --> RUNNABLE
任意可执行状态 -- run() 结束或未捕获异常 --> TERMINATED
这是 Java Thread.State 的语言级状态, 不等同于操作系统调度器的运行, 就绪或内核等待状态. 从 RUNNABLE 竞争 synchronized 监视器进入 BLOCKED; 无超时的 wait(), join() 或 park() 进入 WAITING; sleep() 或带超时的 wait(), join(), park() 进入 TIMED_WAITING. Object.wait() 被 notify()/notifyAll() 唤醒或超时后, 必须重新竞争同一监视器, 竞争期间为 BLOCKED, 获得监视器后才回到 RUNNABLE. sleep() 到期直接回到 RUNNABLE; join() 的目标结束或超时, 以及 park()/unpark() 的返回不涉及监视器时也直接回到 RUNNABLE.
wait/notify 与 sleep
wait(), notify() 和 notifyAll() 必须由持有同一对象监视器的线程在 synchronized (monitor) 中调用, 否则抛出 IllegalMonitorStateException. wait() 会释放该监视器并进入等待集; 被通知后还要重新竞争锁. 通知可能早于等待, 也可能发生虚假唤醒, 因此条件必须使用 while 重查. sleep() 仅让当前线程在一段时间内不运行, 不释放已持有的锁, 不能替代条件协作.
// 上下文片段:多个消费者等待同一队列时使用 notifyAll, 并始终在 while 中重查条件.
synchronized (monitor) {
while (queue.isEmpty()) {
monitor.wait();
}
Item item = queue.remove();
}
synchronized (monitor) {
queue.add(item);
monitor.notifyAll();
}
手写监视器协作容易遗漏中断, 关闭和异常路径. 生产者消费者优先选择后文的 BlockingQueue; 需要多个条件队列或可中断获取时选择 Condition/ReentrantLock.
四, Executor 与 ThreadPoolExecutor
线程池用于复用线程, 限制并发, 隔离任务类型. ThreadPoolExecutor 关键参数不是背诵, 要能讲执行流程:
提交任务
-> 工作线程数 < corePoolSize: 新建核心线程
-> 否则尝试入队 workQueue
-> 队列满且线程数 < maximumPoolSize: 新建非核心线程
-> 仍无法处理: RejectedExecutionHandler
| 参数 | 含义 | Android 坑点 |
|---|---|---|
| corePoolSize | 核心线程数 | 太大增加调度和内存压力 |
| maximumPoolSize | 最大线程数 | 配无界队列时通常不起作用 |
| workQueue | 等待队列 | 无界队列可能堆积导致 OOM |
| keepAliveTime | 非核心线程存活时间 | 可回收突发线程 |
| rejectedHandler | 拒绝策略 | 要可观测, 不要静默丢任务 |
五, 协程 Dispatchers 与线程池关系
Kotlin 协程不是 “没有线程”, 它是挂起 / 恢复的调度模型, 最终仍运行在线程上.
Dispatchers.Main: 主线程, 更新 UI.Dispatchers.IO: IO 密集型任务, 适合网络, 磁盘, 底层有弹性线程池策略.Dispatchers.Default: CPU 密集型任务, 如排序, JSON 大计算, 图片算法.withContext: 切换执行上下文并等待结果.
怎么落地: Repository 做网络 / 数据库可用 withContext(IO); CPU 计算不要丢到 IO; UI 收集 Flow 用生命周期感知 API, 避免页面销毁后继续更新.
六, 锁, volatile, CAS 与 Atomic
并发控制要区分 “可见性, 原子性, 有序性”.
| 工具 | 解决什么 | 适合场景 | 注意点 |
|---|---|---|---|
| synchronized | 互斥 + 可见性 | 简单临界区 | 持锁不要做 Binder/IO |
| ReentrantLock | 可中断/可尝试/公平锁 | 复杂锁控制 | 必须 finally unlock |
| volatile | 可见性 + 禁止部分重排 | 状态标记, 双检锁引用 | 不能保证复合操作原子性 |
| CAS/Atomic | 无锁原子更新 | 计数, 状态机引用 | ABA (值从 A 变 B 又变回 A, CAS 只比较 A 会误判未变化), 自旋开销 |
private val running = AtomicBoolean(false)
fun startOnce() {
if (running.compareAndSet(false, true)) {
// only one caller can enter
}
}
悲观锁与乐观锁
悲观锁假设冲突常见, 先通过 synchronized 或 ReentrantLock 排他地保护临界区, 适合写入冲突高且失败重试代价大的共享资源. 乐观锁假设冲突较少, 先读后以 CAS 或版本号提交; 冲突时重读并重试, 适合短小的无阻塞更新.
CAS 的重试不是没有成本: 高竞争下自旋会消耗 CPU, 复杂操作也无法自然地一次 CAS 完成. 值从 A 变为 B 又回到 A 时, 只比较值的 CAS 看不出中间变化, 即 ABA 问题; 需要识别版本语义时使用 AtomicStampedReference 或显式版本号. 不能因为使用了 Atomic 就假定跨多个字段的业务不变量已经成立.
BlockingQueue 与并发集合
BlockingQueue 将生产者消费者的交接, 等待与容量边界封装起来. 有界队列能把过载显式化: 队列满时 put() 阻塞, offer() 可按超时失败, 调用方可降速, 合并或拒绝任务, 而不是让无界积压持续占用内存.
// 上下文片段:容量和超时应按业务延迟, 内存预算及丢弃语义确定.
BlockingQueue<Job> queue = new ArrayBlockingQueue<>(64);
if (!queue.offer(job, 100, TimeUnit.MILLISECONDS)) {
rejectOrCoalesce(job);
}
| 容器 | 读写特征 | 适用场景 | 误用边界 |
|---|---|---|---|
BlockingQueue | 可阻塞交接; 有界实现可提供背压 | 生产者消费者, 线程池任务队列 | 在主线程无超时 put/take, 或用无界队列掩盖过载 |
ConcurrentHashMap | 多线程读写, 不锁整张表; 单个复合业务操作仍需原子 API 或外部协议 | 并发缓存, 按 key 聚合 | 把 “先查再改” 多步骤当成天然原子, 或存入非线程安全的可变 value |
CopyOnWriteArrayList | 写时复制整个数组, 读迭代无需锁且是快照 | 读多写极少的监听器列表 | 高频写入, 大列表或要求迭代实时反映修改 |
ConcurrentLinkedQueue | 非阻塞链表队列, 高并发入队 / 出队 | 不需要容量控制的短暂并发交接 | 需要背压, 严格容量或任务无限堆积的场景 |
七, ThreadLocal 与共享状态
ThreadLocal 为每个线程保存独立副本, 解决的是上下文隔离, 不是共享对象的互斥. synchronized 则让多个线程按顺序访问同一份共享状态. 两者不能互相替代.
// 上下文片段:requestId 在线程内隔离; balance 是共享状态, 必须在同一把锁下更新.
private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
private final Object balanceLock = new Object();
private int balance;
void handle(String requestId, int delta) {
try {
REQUEST_ID.set(requestId);
synchronized (balanceLock) {
balance += delta;
}
} finally {
REQUEST_ID.remove();
}
}
在线程池中, 工作线程会被下一个任务复用. 未调用 remove() 的值可能被后续请求观察到, 或长期持有 Activity, Context, 大缓冲区等对象. 因此每次 set() 后都应在同一任务的 finally 调用 remove(); 能通过方法参数传递的上下文不应滥用 ThreadLocal. Java 定义和弱键泄漏机制可回看 Java 与 JVM 基础.
八, AsyncTask 的历史边界
历史原理: AsyncTask 将 onPreExecute(), 后台 doInBackground() 和主线程 onPostExecute() 串成便捷模板, 内部使用线程池和 Handler 回投结果. Android 3.0 (API 11) 起, execute() 默认串行执行; 需要并行时曾可调用 executeOnExecutor(), 因此不能把它笼统说成始终串行或始终并行.
废弃或限制状态: AsyncTask 在 API 30 起废弃. 它的回调容易脱离 Activity/Fragment 生命周期, 造成页面销毁后的更新, 取消语义不清和任务持有 View/Context. 新项目不应继续使用, 也不应以 executeOnExecutor() 规避这些问题.
现代替代: 页面内短任务使用生命周期绑定的协程, 在 viewModelScope/lifecycleScope 中调度并将状态暴露给 UI; 需约束, 重试或跨进程存活的延迟后台任务使用 WorkManager. Activity/Fragment 的生命周期语境见 四大组件与基础.
九, 死锁与线程安全排查
死锁通常满足互斥, 持有并等待, 不可抢占, 循环等待. Android 里高频场景是 “主线程等后台锁, 后台线程切回主线程” 或 “持锁发同步 Binder”.
排查路径:
- 抓 ANR traces 或线程 dump, 看主线程卡在哪把锁/哪个 Future/Binder.
- 找持锁线程, 看它是否等待主线程, IO, 网络或另一个锁.
- 检查锁顺序是否全局一致, 是否在锁内做耗时操作.
- 用超时, tryLock, 缩小临界区或串行队列替代复杂嵌套锁.
怎么答: 不要只说 “加锁解决线程安全”, 还要补 “锁粒度, 锁顺序, 锁内不做耗时 / 跨进程调用”.
十, 线程池导致 ANR/OOM 的坑
线程池配置错会从 “优化” 变成 “事故”.
- ANR: 主线程等待线程池结果, 但线程池被长任务占满; 或回调切主线程后主线程被阻塞.
- OOM: 无界队列堆积大量 Runnable/闭包持有 Activity/Bitmap; 线程数过多导致栈内存暴涨.
- 线程饥饿 / 队头阻塞: 低优先级长任务占满池, 高优先级 UI 相关任务排队.
- 任务泄漏: 页面销毁后任务仍持有 View/Context.
| 坑 | 规避方式 |
|---|---|
newCachedThreadPool 滥用 | 限制最大线程数, 按任务类型隔离线程池 |
| 无界 LinkedBlockingQueue | 设置有界队列和拒绝策略 |
主线程 Future.get() | 用回调 / 协程挂起, 不要阻塞主线程 |
| IO/CPU 混用一个池 | IO 与 CPU 任务隔离, 避免互相饿死 |
十一, 并发设计落地模板
面试讲项目时可以按这个模板说明并发方案:
- UI 事件进入 ViewModel, 用协程保证生命周期自动取消.
- Repository IO 任务切到
Dispatchers.IO, CPU 计算切到Default. - SDK 内部串行状态用 HandlerThread 或单线程 Executor.
- 共享状态用不可变快照, Atomic 或小粒度锁.
- 线程池设置有界队列, 命名线程, 异常日志和拒绝策略.
结构化并发与可验证性
Android 并发不仅是锁和线程池: 协程任务应绑定 owner scope, 父子取消和异常传播可解释; 不用 GlobalScope 逃避生命周期. 原子性, 可见性和竞态结论用可重复测试, stress test/trace 辅助, 不能以 “本机没复现” 证明安全. UI 状态只在主线程或受控单写者模型更新, 阻塞 I/O 和重 CPU 任务按性质调度.
参数决策与故障演练
CPU 密集任务以可用核心数附近的有限并发起步; 阻塞 I/O 可更高, 但受远端容量, 超时, 内存和队列等待约束. UI 只在主线程提交状态, 不能在主线程 get() 等待. 以下上下文片段的数字仅是压测起始假设; 省略 java.util.concurrent imports, downloadAndDecode(), recordRejected() 和应用退出时的 ioPool.shutdown(), 须记录 active count, 队列长度, 拒绝次数和 P95 等待时间后调整:
val ioPool = ThreadPoolExecutor(2, 4, 30, TimeUnit.SECONDS, ArrayBlockingQueue(32), ThreadFactory { r -> Thread(r, "image-io") }, ThreadPoolExecutor.AbortPolicy())
try { ioPool.execute { downloadAndDecode() } } catch (e: RejectedExecutionException) { recordRejected("image-io") }
拒绝频繁时先合并, 取消或施加背压, 不要无限扩队列. volatile 适合停止标志但不能保证 count++ 原子性. 需要版本语义时用 AtomicStampedReference 或版本号.
伪造 trace (教学, 非真实事故): main: waiting lock B held by worker, worker: waiting lock A held by main 是循环等待证据. 先查锁 owner 是否又在 I/O, Binder 或等待主线程, 再统一锁顺序或移除嵌套锁; 修复后用并发重复测试与线程 dump 验证.
高频面试题
Q1: HandlerThread 适合什么场景? 和线程池区别? HandlerThread 是一个带 Looper 的单后台线程, 任务按消息串行执行, 适合相机, SDK 状态机, 轻量 IO 等需要顺序的任务. 线程池适合多个独立任务并发执行, 但要控制队列和线程数.
Q2: ThreadPoolExecutor 的执行流程是什么? 先看工作线程是否小于 corePoolSize, 是则建核心线程; 否则入队; 队列满且线程数小于 maximumPoolSize 时建非核心线程; 仍处理不了就走拒绝策略. 无界队列会让 maximumPoolSize 基本失效.
Q3: volatile 能保证 i++ 线程安全吗?
不能. volatile 保证可见性和一定有序性, 但 i++ 是读 - 改 - 写复合操作, 不具备原子性. 需要 synchronized, Lock 或 AtomicInteger/CAS.
Q4: 协程 Dispatchers.IO 和 Default 怎么选? IO 用于网络, 磁盘, 数据库等阻塞 IO; Default 用于 CPU 密集计算. 协程最终仍在线程上执行, 选错调度器会导致线程饥饿或 CPU 争用.
Q5: 线程池为什么会导致 ANR 或 OOM? 主线程等待线程池结果会 ANR; 线程池被长任务占满会让关键任务排队. 无界队列堆积大量 Runnable, 线程数过多导致栈内存增长, 任务闭包持有大对象, 都可能 OOM.
易错点 / 追问
- 协程不是替代线程的魔法, 挂起恢复最终仍依赖 Dispatcher 背后的线程.
volatile不能保证复合操作原子性, 回答时要区分可见性和原子性.- 锁内不要做 IO, 网络, 同步 Binder 或切主线程等待, 这是死锁 / ANR 高频点.
- 无界队列 + 大量任务比 “线程数很多” 更隐蔽, 也更容易拖到 OOM.
- IntentService/JobIntentService 不是现代后台任务万能解, 可延迟可靠任务优先考虑 WorkManager.