Android第十一次面试多线程篇
面试官:
“你在项目里用过Handler吗?能说说它是怎么工作的吗?”
候选人:
“当然用过!比如之前做下载功能时,需要在后台线程下载文件,然后在主线程更新进度条。这时候就得用Handler来切回主线程。简单来说,Handler就像个快递员,负责把子线程的任务包裹(比如更新UI的指令)送到主线程去执行。”
面试官追问:
“比喻挺有意思。那这个‘快递员’是怎么把消息从子线程送到主线程的?”
候选人:
“其实核心是靠三个东西:Handler
、Looper
和MessageQueue
。比如主线程启动时,系统会默认创建一个Looper
,它内部维护了一个消息队列(MessageQueue
)。当我在子线程通过Handler
发送消息,这个消息会被放到主线程的队列里。然后主线程的Looper
会循环检查队列,一有消息就取出来,交给对应的Handler
处理。”
面试官:
“那如果我在子线程里直接new一个Handler,会有什么问题吗?”
候选人:
“这里有个坑!如果子线程没有提前准备Looper
,直接new Handler会崩溃。比如这样——”
new Thread(() -> {// ❌ 错误写法:子线程默认没有LooperHandler handler = new Handler();
}).start();
“正确的做法是先调用Looper.prepare()
创建Looper,再启动循环:”
new Thread(() -> {Looper.prepare(); // ✅ 初始化LooperHandler handler = new Handler();Looper.loop(); // 启动消息循环
}).start();
面试官:
“提到主线程的Looper,你知道它是怎么初始化的吗?”
候选人:
“这个我之前研究过源码!主线程的Looper
是在ActivityThread
的main()
方法里初始化的。大概流程是:”
- 启动App:系统调用
ActivityThread.main()
。 - 准备Looper:调用
Looper.prepareMainLooper()
,创建主线程的Looper和消息队列。 - 开启循环:调用
Looper.loop()
,让主线程进入无限循环,不断处理消息(比如点击事件、UI更新)。
“所以主线程的Handler能一直运行,全靠这个死循环撑着。”
面试官:
“实际开发中有没有遇到过Handler导致的问题?比如内存泄漏。”
候选人:
“遇到过!比如在Activity里声明一个非静态内部类的Handler,如果Activity关闭时还有未处理的消息,Handler会持有Activity的引用,导致内存泄漏。我们项目里用了一个经典解法——”
// ✅ 正确写法:静态内部类 + 弱引用
static class SafeHandler extends Handler {private WeakReference<Activity> mActivity;SafeHandler(Activity activity) {mActivity = new WeakReference<>(activity);}@Overridepublic void handleMessage(Message msg) {Activity activity = mActivity.get();if (activity == null) return;// 处理消息...}
}
“另外,在Activity的onDestroy()
里,还要调用handler.removeCallbacksAndMessages(null)
清空所有消息。”
面试官:
“假设现在有个需求:每隔1秒更新一次UI上的计时器。用Handler怎么实现?”
候选人:
“可以用postDelayed()
递归调用。比如这样——”
private int count = 0;
private Handler handler = new Handler();private void startTimer() {handler.postDelayed(new Runnable() {@Overridepublic void run() {textView.setText(String.valueOf(++count));handler.postDelayed(this, 1000); // 递归调用}}, 1000);
}// 停止时调用
private void stopTimer() {handler.removeCallbacksAndMessages(null);
}
“不过要注意及时移除回调,否则退出页面时可能还在后台跑。”
面试官:
“如果不用Handler,还有其他方式实现线程间通信吗?”
候选人:
“当然!比如用AsyncTask
(虽然过时了)、LiveData
+协程,或者RxJava
的线程切换。比如协程可以这样写——”
// 在ViewModel里
fun startTask() {viewModelScope.launch(Dispatchers.IO) {val data = fetchData() // 后台执行withContext(Dispatchers.Main) {updateUI(data) // 切回主线程}}
}
“不过Handler的优势是更底层,适合需要精细控制消息队列的场景,比如实现定时任务或延迟操作。”
面试官:
“最后一个问题:为什么主线程的Looper不会导致ANR?”
候选人:
“这是个好问题!虽然Looper.loop()
是死循环,但主线程大部分时间处于休眠状态,通过Linux的epoll
机制监听消息队列。当没有消息时,线程会释放CPU进入休眠;有新消息(比如点击事件、屏幕刷新信号)时,线程被唤醒处理消息。所以只要不在主线程做耗时操作,循环本身不会阻塞,也就不会ANR。”
面试官:
“回答得很清晰。你还有什么问题想问吗?”
候选人:
“咱们项目中有没有特别依赖Handler的场景?比如自定义消息协议或者复杂定时任务?”
面试官:
(假设回答)
“有的!比如IM模块的消息重发机制,用Handler管理消息的延迟重试;还有首页的轮播图动画,用Handler的postDelayed
实现自动切换。之后你可以参与这部分优化。”
Handler 的工作流程与底层原理
1. 核心组件及其关系
- Handler:消息的发送者和处理者。
- Looper:消息循环的核心,每个线程有且只有一个。
- MessageQueue:消息队列,按时间排序存储消息(单链表实现)。
- Message:携带数据和目标Handler的单元。
关系图:
Thread├── Looper│ └── MessageQueue└── Handler(关联到Looper)
2. 工作流程
步骤1:初始化Looper(子线程)
- 子线程中必须手动调用
Looper.prepare()
创建Looper。 Looper.loop()
启动消息循环。
new Thread(() -> {Looper.prepare(); // 初始化Looper(内部创建MessageQueue)Handler handler = new Handler(Looper.myLooper());Looper.loop(); // 开始循环处理消息
}).start();
步骤2:发送消息
- 通过
Handler.sendMessage()
或Handler.post(Runnable)
发送消息。
Message msg = Message.obtain();
msg.what = 1;
msg.obj = "Data";
handler.sendMessage(msg); // 消息入队到MessageQueue
步骤3:消息处理
- Looper从MessageQueue取出消息,调用目标Handler的
dispatchMessage()
。 - 最终触发
handleMessage()
或Runnable.run()
。
public void handleMessage(Message msg) {switch (msg.what) {case 1: updateUI((String) msg.obj); break;}
}
3. 底层原理
消息存储(MessageQueue)
- 数据结构:单链表按
when
(执行时间)排序。 - 入队操作:
enqueueMessage()
添加消息到链表合适位置。 - 出队操作:
next()
取出下一条消息(可能阻塞)。
消息循环(Looper.loop())
- 无限循环:持续调用
MessageQueue.next()
。 - 阻塞唤醒机制:
- 队列为空时,通过
epoll
或pipe
进入休眠。 - 新消息入队时唤醒线程(通过
nativeWake()
)。
- 队列为空时,通过
线程隔离(ThreadLocal)
- Looper存储:每个线程的Looper实例通过
ThreadLocal<Looper>
保存。 - 获取方式:
Looper.myLooper()
返回当前线程的Looper。
4. 主线程的Looper
- 默认初始化:ActivityThread的
main()
方法调用Looper.prepareMainLooper()
。 - 永不退出:主线程的Looper循环保证应用持续响应事件。
// ActivityThread.java
public static void main(String[] args) {Looper.prepareMainLooper(); Looper.loop(); // 主线程进入无限循环
}
5. 性能优化与注意事项
- 避免阻塞主线程:耗时操作仍要放在子线程。
- 内存泄漏:Handler持有外部类(如Activity)引用时,需用弱引用。
static class SafeHandler extends Handler {private WeakReference<Activity> mActivity;SafeHandler(Activity activity) {mActivity = new WeakReference<>(activity);}public void handleMessage(Message msg) {Activity activity = mActivity.get();if (activity == null) return;// 处理消息}
}
6. 代码示例:子线程与主线程通信
// 主线程创建Handler
Handler mainHandler = new Handler(Looper.getMainLooper());// 子线程执行任务后更新UI
new Thread(() -> {String result = doBackgroundWork();mainHandler.post(() -> textView.setText(result)); // 切换到主线程
}).start();
Kotlin协程详解:原理、优势与应用场景
一、协程的核心概念
协程(Coroutine) 是一种轻量级的并发设计模式,允许以同步编码风格实现异步任务。它通过挂起(Suspend)和恢复(Resume)机制管理任务,避免传统多线程开发的复杂性。
二、协程为何优于线程?
对比维度 | 协程 | 线程 |
---|---|---|
创建开销 | 极低(约几十字节) | 高(约1MB内存 + 系统调用开销) |
切换成本 | 无需操作系统介入(用户态切换) | 需内核介入(上下文切换开销大) |
并发数量 | 单线程可运行数万协程 | 受限于系统资源(通常数百个) |
开发复杂度 | 同步代码风格,逻辑清晰 | 需处理锁、回调、线程同步等问题 |
资源利用率 | 挂起时释放线程,避免阻塞 | 线程阻塞时资源浪费 |
三、协程的工作原理
1. 挂起与恢复
- 挂起函数(Suspend Function):
使用suspend
关键字标记的函数,可在不阻塞线程的情况下暂停执行(如等待网络响应)。suspend fun fetchData(): String {delay(1000) // 模拟耗时操作,非阻塞return "Data loaded" }
- 底层机制:
协程通过状态机和Continuation实现挂起恢复。编译器将挂起函数转换为带有回调的状态机代码。
2. 协程调度器(Dispatchers)
- 调度线程池:
launch(Dispatchers.IO) { /* 执行IO操作 */ } launch(Dispatchers.Main) { /* 更新UI */ }
- 调度策略:
- IO:适用于网络、文件操作(线程池优化)
- Default:CPU密集型任务(如排序、计算)
- Main:UI线程操作(Android主线程)
3. 结构化并发
- 协程作用域(CoroutineScope):
管理协程生命周期(如viewModelScope
、lifecycleScope
),确保任务不会泄漏。viewModelScope.launch {val data = fetchData() // 自动绑定ViewModel生命周期updateUI(data) }
四、协程如何避免回调地狱?
1. 回调地狱示例
// 传统嵌套回调
api.getUser { user ->api.getProfile(user) { profile ->api.getFriends(profile) { friends ->updateUI(friends) // 嵌套层级深,难维护}}
}
2. 协程解决方案
viewModelScope.launch {try {val user = api.getUser() // 挂起,不阻塞线程val profile = api.getProfile(user)val friends = api.getFriends(profile)updateUI(friends) // 顺序执行,逻辑清晰} catch (e: Exception) {showError(e)}
}
- 优势:
- 线性代码:消除嵌套,可读性高
- 统一异常处理:
try/catch
捕获所有异步错误
五、协程的典型应用场景
- 网络请求:
suspend fun loadData() = withContext(Dispatchers.IO) {retrofitService.fetchData() }
- 数据库操作:
fun insertUser(user: User) = viewModelScope.launch(Dispatchers.IO) {database.userDao().insert(user) }
- 并发任务组合:
val result1 = async { task1() } val result2 = async { task2() } val combined = result1.await() + result2.await() // 并行执行
- 超时与重试:
val data = withTimeoutOrNull(5000) { // 5秒超时retry(3) { // 重试3次fetchData()} }
六、协程与线程的底层协作
- 线程池复用:
协程调度器基于ExecutorService
,通过线程池复用减少开销。 - 挂起优化:
挂起时释放线程资源,交由其他协程使用,最大化利用CPU。
Kotlin协程实战
面试官:
“你在项目里用过Kotlin协程吗?能举个实际例子说说它解决了什么问题吗?”
候选人:
“当然!之前做商品详情页的时候,需要同时调三个接口:商品信息、用户评论、推荐列表。如果用传统的回调,代码会嵌套三层,像俄罗斯套娃一样,维护起来特别头疼。后来换成协程,代码直接‘拉直’了——”
viewModelScope.launch {try {// 同步写法,其实是异步执行!val product = api.fetchProductDetails() // 第一个接口val comments = api.fetchComments(product.id) // 等第一个完成后调第二个val recommendations = api.fetchRecommendations() // 等前两个都完成// 统一更新UIshowData(product, comments, recommendations)} catch (e: Exception) {// 一个try/catch抓住所有网络错误showErrorToast("加载失败,请重试")}
}
“这样一来,代码像写同步逻辑一样直观,新人也能快速看懂,而且异常处理集中,不会漏掉某个回调里的错误。”
面试官追问:
“听起来确实简洁。那协程到底是怎么做到‘假装’同步的?底层不会卡住主线程吗?”
候选人:
“这就是协程的聪明之处!比如api.fetchProductDetails()
是个挂起函数,执行到的时候,协程会悄悄挂起,把线程让出来去处理其他任务。等网络数据回来了,协程再‘醒来’,从刚才挂起的位置继续执行。整个过程主线程完全没被阻塞,用户滑屏幕照样流畅。”
面试官:
“那如果我要同时调三个接口,等所有结果一起回来再刷新界面,用协程怎么优化?”
候选人:
“这时候可以用async
并发!比如这样——”
viewModelScope.launch {// 同时发起三个请求val productDeferred = async { api.fetchProductDetails() }val commentsDeferred = async { api.fetchComments() }val recommendationsDeferred = async { api.fetchRecommendations() }// 等三个全部完成(实际耗时等于最慢的那个接口)val product = productDeferred.await()val comments = commentsDeferred.await()val recommendations = recommendationsDeferred.await()updateUI(product, comments, recommendations)
}
“比串行调用快多了!之前用回调得用计数器或者RxJava的zip
操作符,协程两行代码搞定。”
面试官:
“协程会不会导致内存泄漏?比如页面关了但请求还没回来。”
候选人:
“这就是结构化并发的优势了!比如用viewModelScope
启动协程,当ViewModel被销毁时,所有关联的协程会自动取消。如果这时候正在等网络请求,会直接中断,避免内存泄漏。我们之前有个页面没注意这个,用户快速进出会导致旧的请求继续回调,用了viewModelScope
后问题彻底解决。”
面试官:
“如果遇到老代码用回调的API,怎么接入协程?”
候选人:
“Kotlin给了逃生舱!比如有个老版SDK用回调返回数据,可以用suspendCoroutine
把它包成挂起函数——”
suspend fun legacyFetchData(): String = suspendCoroutine { continuation ->legacyApi.getData(object : Callback {override fun onSuccess(result: String) {continuation.resume(result) // 成功时恢复协程}override fun onFailure(error: Throwable) {continuation.resumeWithException(error) // 抛异常}})
}// 新代码里直接调用
viewModelScope.launch {val data = legacyFetchData() // 像普通挂起函数一样用
}
“这样旧代码也能融入协程体系,团队迁移成本低。”
面试官:
“假设你要下载10张图片,怎么用协程控制并发,同时不拖垮手机?”
候选人:
“这时候得用协程的调度器!比如这样——”
// 限制最多同时3个下载
val dispatcher = Dispatchers.IO.limitedParallelism(3)
viewModelScope.launch {val jobs = (1..10).map { index ->launch(dispatcher) { // 限制并发的协程池downloadImage(index) }}jobs.joinAll() // 等所有下载完成showToast("下载完成!")
}
“如果用普通线程池,开10个线程内存可能扛不住。协程的Dispatchers.IO
自带线程复用,加上并发限制,既高效又省资源。”
面试官:
“最后一个问题:为什么说协程适合Android开发?”
候选人:
“三个字——快、稳、省!
- 快:代码写起来像同步,维护效率高,再也不用和回调地狱搏斗。
- 稳:结构化并发自动管理生命周期,结合
viewModelScope
/lifecycleScope
,内存泄漏少一半。 - 省:一个线程跑几百个协程,用过的都说像‘开挂’,尤其低端机上效果明显。”
面试官:
“回答得很到位。你还有什么问题吗?”
候选人:
“咱们团队用协程遇到过印象深刻的坑吗?比如和LiveData、Room结合时的注意点?”
面试官:
(假设回答)
“早期在Room里混用协程和RxJava时有过线程冲突,后来统一用协程+Flow就解决了。你提到的Dispatchers.IO
限制并发,我们在大文件上传模块也用过类似优化!”
线程池实战
面试官:
“你在项目中是怎么管理多线程任务的?比如同时处理多个网络请求。”
候选人:
“我们用的是线程池。比如在APP首页要同时加载多个图片,如果每个请求都开一个新线程,手机扛不住,容易卡顿或者OOM。线程池就像个‘线程调度中心’,复用已有的线程,避免频繁创建销毁的开销。”
面试官追问:
“那具体怎么配置线程池?比如核心线程数、队列这些参数怎么定?”
候选人:
“得看任务类型。比如我们有个需求是批量上传图片,这种IO密集型任务,核心线程数可以设成CPU核心数的两倍左右,比如4核手机就设8个。队列用无界队列(比如LinkedBlockingQueue
),保证任务不丢失。但如果是CPU密集型任务,比如视频转码,核心线程数应该和CPU数差不多,避免线程切换拖慢速度。”
面试官:
“如果任务太多,线程池处理不过来了怎么办?”
候选人:
“这时候就得看拒绝策略了。比如我们遇到过用户疯狂点击触发大量请求,队列满了之后默认策略是抛异常,结果直接崩溃。后来改成了CallerRunsPolicy
,让提交任务的线程自己执行,这样至少不会崩,但得注意别阻塞主线程。”
ThreadPoolExecutor executor = new ThreadPoolExecutor(4, // 核心线程8, // 最大线程30, TimeUnit.SECONDS,new LinkedBlockingQueue<>(100), // 队列容量100new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
面试官:
“提到线程池类型,你知道Executors.newCachedThreadPool()
有什么坑吗?”
候选人:
“这个坑踩过!CachedThreadPool
的最大线程数设置的是Integer.MAX_VALUE
,如果任务无限提交,线程数暴增,直接OOM。我们之前有个日志上报功能用了它,结果用户疯狂操作时内存爆炸。后来改成自定义线程池,限制最大线程数,问题才解决。”
面试官:
“如果让你设计一个定时任务,比如每隔5秒检查一次消息,用线程池怎么实现?”
候选人:
“可以用ScheduledThreadPool
,比如这样——”
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {checkNewMessage(); // 执行任务
}, 0, 5, TimeUnit.SECONDS);
“不过要注意任务执行时间别超过间隔,否则会堆积。比如任务要跑10秒,间隔设5秒,就会变成每10秒执行一次。”
面试官:
“实际开发中,怎么确保线程池里的任务不被重复执行?”
候选人:
“我们项目里遇到过!比如用户快速点击按钮触发多次提交。解决办法是给任务加唯一ID,用ConcurrentHashMap
记录正在执行的任务,提交前先检查是否存在——”
ConcurrentHashMap<String, Boolean> taskMap = new ConcurrentHashMap<>();void submitTask(String taskId, Runnable task) {if (taskMap.putIfAbsent(taskId, true) == null) {executor.execute(() -> {try {task.run();} finally {taskMap.remove(taskId);}});}
}
面试官:
“最后一个问题:为什么推荐用ThreadPoolExecutor
而不是Executors
?”
候选人:
“Executors
的方法虽然方便,但隐藏了参数细节,容易踩坑。比如newFixedThreadPool
用的无界队列,任务堆积可能内存溢出。直接new ThreadPoolExecutor
可以明确指定核心线程数、队列容量和拒绝策略,对资源控制更精细。”
面试官:
“回答得很到位。你还有什么问题想问吗?”
候选人:
“咱们项目里线程池一般用在哪些场景?有没有特别复杂的配置案例?”
面试官:
(假设回答)
“比如首页的瀑布流图片加载用了IO密集型线程池,后台数据同步用了单线程池保证顺序。有个复杂案例是结合PriorityBlockingQueue
实现任务优先级,高优先级任务插队执行。”
Android第三次面试总结之activity和线程池篇(补充)_android线程池面试题-CSDN博客https://blog.csdn.net/2301_80329517/article/details/147700637
Android第三次面试总结(activity和线程池)_android线程池面试题-CSDN博客https://blog.csdn.net/2301_80329517/article/details/146325189?spm=1011.2415.3001.5331