多线程并发专题
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 常驻后台。
三、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、自旋开销 |
private val running = AtomicBoolean(false)
fun startOnce() {
if (running.compareAndSet(false, true)) {
// only one caller can enter
}
}
六、死锁与线程安全排查
死锁通常满足互斥、持有并等待、不可抢占、循环等待。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 或小粒度锁。
- 线程池设置有界队列、命名线程、异常日志和拒绝策略。
高频面试题
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。