一、 为什么 Android 线程面试 如此重要?
在 Android 应用开发中,UI 线程(主线程)负责界面的绘制与交互,而耗时操作(如网络请求、数据库读写、文件 I/O)必须放在子线程中执行,以避免 ANR(Application Not Responding)。然而,线程的创建与管理并非简单的 `new Thread()` 那么简单。
面试官考察 Android 线程 相关的知识点,主要目的是评估候选人是否具备以下能力:
- ⚡ 对 Android 主线程消息循环机制(Looper/Handler/MessageQueue)的深入理解。
- ⚙️ 合理配置和使用线程池,避免资源浪费和线程安全问题。
- 🔄 掌握现代并发工具,如 RxJava、Kotlin Coroutines(协程)等。
- 🛡️ 能够排查和解决多线程带来的死锁、竞态条件等复杂问题。
本指南将围绕这些核心考点,为你提供一份详尽的 Android 线程面试 复习资料。
二、 核心机制:Handler 消息传递系统
Handler 是 Android 中用于线程间通信的核心组件。它是 Android 线程面试 中最高频的考点之一。很多开发者只会使用它来更新 UI,却不清楚其背后的底层原理。
1. Handler 四大组件
| 组件名称 | 作用描述 | 面试关键点 |
|---|---|---|
| Handler | 发送消息和处理消息 | 绑定 Looper,决定消息在哪个线程处理 |
| Looper | 消息循环,从 MessageQueue 取消息 | 每个线程只有一个 Looper,通过 ThreadLocal 保证 |
| MessageQueue | 消息队列,存储待处理消息 | 链表结构,按时间排序 |
| Message | 消息对象,携带数据和目标 Handler | 使用 MessagePool 池化技术优化性能 |
2. 主线程 Looper 的死循环
面试官常问:“为什么主线程的 Looper.loop() 是死循环,为什么不会导致应用卡死?”
深度解析:
主线程的 ActivityThread 中确实存在一个死循环 `Looper.loop()`。它之所以不会阻塞主线程,是因为 Android 的事件驱动机制。当没有消息时,`MessageQueue.next()` 会进入阻塞状态(调用 native 层的 pollOnce),此时主线程会释放 CPU 资源,处于等待状态。当有事件(触摸、绘制、消息)发生时,会被唤醒处理。因此,这不是一个占用 CPU 的忙等待,而是一个高效的阻塞等待。
3. 内存泄漏问题
在 Android 线程面试 中,Handler 导致的内存泄漏是必考题。主要原因是非静态内部类(或匿名内部类)隐式持有外部类引用。如果 Handler 发送了一条延时消息,该消息会一直存在于 MessageQueue 中,进而持有 Handler 引用,最终持有 Activity 引用,导致 Activity 无法被回收。
解决方案:
- 使用静态内部类 + WeakReference 持有 Activity。
- 在 Activity 的 onDestroy() 中调用 handler.removeCallbacksAndMessages(null)。
三、 线程池:Executors 与 ThreadPoolExecutor
直接 `new Thread()` 创建线程是低效且危险的。Android 官方推荐使用线程池来管理线程。虽然 `Executors` 工具类提供了便捷的工厂方法,但在面试中,面试官更希望你展示对 ThreadPoolExecutor 核心参数的理解。
1. 七大核心参数
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 非核心线程空闲存活时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 任务队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler // 拒绝策略
)
2. 工作流程解析
当一个任务提交到线程池时,执行流程如下:
- 如果当前运行的线程数 < corePoolSize,创建新线程执行任务。
- 如果当前运行的线程数 >= corePoolSize,将任务加入 workQueue 队列。
- 如果队列已满,且当前线程数 < maximumPoolSize,创建非核心线程执行任务。
- 如果队列已满,且线程数 >= maximumPoolSize,触发拒绝策略。
3. 常见线程池陷阱
FixedThreadPool
核心线程数固定,无超时机制。使用 LinkedBlockingQueue(无界队列),任务堆积可能导致 OOM。
SingleThreadExecutor
单线程化,保证任务顺序执行。同样使用无界队列,存在 OOM 风险。
CachedThreadPool
线程数无限大,空闲线程立即回收。使用 SynchronousQueue(不存储元素),突发高并发可能创建大量线程导致 OOM。
⚡ 面试建议:在项目开发中,建议手动创建 ThreadPoolExecutor,并根据业务场景合理设置队列大小和拒绝策略,避免使用 Executors 创建的固定线程池。
四、 现代并发:Kotlin Coroutines 与 RxJava
随着 Kotlin 的普及,协程(Coroutines) 已成为 Android 并发编程的主流方案。在 Android 线程面试 中,了解协程与线程的关系至关重要。
1. 协程 vs 线程
线程是操作系统级别的资源,切换成本高且重量级。协程是用户态的轻量级线程,由 Kotlin 运行时调度,切换成本极低。一个线程可以运行成千上万个协程。
2. 协程的调度器
- ⚙️ Dispatchers.Main:绑定主线程,用于 UI 更新。
- ⚙️ Dispatchers.IO:IO 密集型任务,内部使用线程池(核心数不固定,上限为 64 或 CPU 核数2)。
- ⚙️ Dispatchers.Default:CPU 密集型任务,如数据解析、图片处理,核心线程数等于 CPU 核数。
- ⚙️ Dispatchers.Unconfined:不限制调度器,首次挂起点前在当前线程执行。
3. 协程的挂起与恢复
协程的核心在于“挂起”(suspend)。当调用 suspend 函数时,当前执行上下文会被保存,线程资源被释放去执行其他任务。当异步操作完成时,协程会在指定的调度器上恢复执行。这种机制避免了回调地狱(Callback Hell),使异步代码看起来像同步代码一样清晰。
4. RxJava 操作符面试
如果公司使用 RxJava,面试官可能会考察 map、flatMap、zip、compose 等操作符的区别。
- map:一对一转换,输入一个数据,输出一个转换后的数据。
- flatMap:一对多转换,将数据打平成一个 Observable 序列。
- zip:将多个 Observable 的数据按索引组合成一个新数据。
五、 Android 线程面试 高频问答 Q&A
Q1: 什么是线程安全?如何保证线程安全?
A: 线程安全是指多个线程访问同一个共享资源时,不会产生数据不一致或错误的结果。保证线程安全的方法包括:
- 使用 synchronized 关键字修饰方法或代码块。
- 使用 ReentrantLock 显式锁。
- 使用 volatile 保证可见性和禁止指令重排(不保证原子性)。
- 使用 ThreadLocal 为每个线程提供独立的变量副本。
- 使用原子类(如 AtomicInteger)基于 CAS 机制。
Q2: 如何排查和解决死锁?
A: 死锁是指两个或多个线程互相持有对方需要的锁,且都在等待对方释放,导致程序永久阻塞。
排查工具:
- 使用 jstack 命令查看线程堆栈信息,寻找 BLOCKED 状态的线程。
- 使用 Android Studio 的 Profiler 工具。
解决策略:
- 破坏互斥条件:使用读写锁。
- 破坏请求与保持条件:一次性申请所有资源。
- 破坏不可剥夺条件:允许线程主动释放已持有的锁。
- 破坏循环等待条件:按顺序申请锁。
Q3: 线程池的核心线程数如何设置?
A: 这取决于任务的类型:
- CPU 密集型(如计算、解析):核心线程数 = CPU 核数 + 1,减少上下文切换。
- IO 密集型(如网络、数据库):核心线程数 = CPU 核数 2 或 CPU 核数 / (1 - 阻塞系数),通常设置为较大值(如 10-20)。
- 混合型:根据具体情况调整,必要时拆分为多个线程池。