线程与异步优化:线程池正确使用、AsyncTask与HandlerThread的坑、协程在性能优化中的应用
说到Android的异步优化,我这些年踩过的坑真不少。线程用不好,卡顿、ANR、内存泄漏,哪个都够你喝一壶的。今天咱们就把线程池、AsyncTask、HandlerThread和协程这几个东西掰开揉碎了聊一聊。
核心观点:异步优化的本质不是「开更多线程」,而是「用最少的线程,做最多的事」。
一、线程池的正确使用姿势
线程池这东西,说白了就是一个「线程复用池」。你想想看,每次异步任务都new一个Thread,创建销毁的开销可不小。线程池就是把这些线程缓存起来,用完了放回去,下次再用。
1. 核心线程数怎么算?
这个问题我面试过很多人,十个有九个答不上来。核心线程数不是拍脑袋定的,得看你的任务类型。
| 任务类型 | 核心线程数计算公式 | 说明 |
|---|---|---|
| CPU密集型 | N + 1 | N是CPU核心数,+1是为了防止缺页中断导致线程阻塞 |
| IO密集型 | 2N 或更高 | IO等待时CPU空闲,可以多开线程 |
| 混合型 | 根据CPU和IO占比估算 | 建议压测后调整 |
我个人习惯在项目里这样写:
// CPU密集型
int corePoolSize = Runtime.getRuntime().availableProcessors() + 1;
// IO密集型(网络请求、文件读写)
int corePoolSize = Runtime.getRuntime().availableProcessors() * 2;
// 通用线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
corePoolSize * 2,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(128),
new ThreadPoolExecutor.CallerRunsPolicy()
);
注意:队列大小一定要设上限!我曾经见过一个项目,队列设成无界的,结果任务堆积了几万个,内存直接爆了。
2. 拒绝策略怎么选?
线程池满了,队列也满了,新任务怎么办?四种策略:
- AbortPolicy:直接抛异常。生产环境慎用,容易导致崩溃。
- CallerRunsPolicy:谁提交谁执行。我比较推荐这个,至少任务不会丢。
- DiscardPolicy:静默丢弃。嗯……你懂的,坑很大。
- DiscardOldestPolicy:丢弃最老的任务。适合实时性要求高的场景。
我在项目中遇到过一个问题:图片加载线程池满了,用了AbortPolicy,结果用户滑动列表时直接崩溃。后来改成CallerRunsPolicy,虽然主线程会卡一下,但至少不会闪退。
二、AsyncTask与HandlerThread的坑
1. AsyncTask:曾经的王者,现在的坑王
AsyncTask在Android 1.5就引入了,当时确实好用。但现在嘛……我建议你尽量别用了。
坑在哪里?
- 内存泄漏:AsyncTask持有Activity引用,Activity销毁了任务还在跑,GC回收不了。
- 串行执行:Android 3.0之后默认串行,多个AsyncTask排队执行,效率极低。
- 屏幕旋转:旋转后Activity重建,AsyncTask还在跑旧Activity,结果回调到旧界面。
我曾经接手过一个老项目,里面全是AsyncTask。用户反馈「点完按钮没反应」,我一看日志,好家伙,十几个AsyncTask在排队,前面的任务卡住了,后面的全等着。
避坑指南:如果你还在用AsyncTask,赶紧迁移吧。用线程池+Handler,或者直接用协程。
2. HandlerThread:轻量级但别滥用
HandlerThread说白了就是一个自带Looper的线程。适合做「串行任务处理」,比如写日志、处理消息队列。
但要注意:
- 不要做耗时操作:HandlerThread本质还是单线程,一个任务卡住,后面的全堵。
- 记得退出:不用的时候要调用quit()或quitSafely(),否则线程一直活着。
- 不要跨线程共享:HandlerThread的Handler不要传给其他线程用,容易出并发问题。
// 正确的使用方式
HandlerThread handlerThread = new HandlerThread("log-thread");
handlerThread.start();
Handler handler = new Handler(handlerThread.getLooper()) {
@Override
public void handleMessage(Message msg) {
// 处理日志写入
writeLogToFile(msg.obj.toString());
}
};
// 记得在不需要时退出
handlerThread.quitSafely();
三、协程在性能优化中的应用
协程这东西,我一开始是拒绝的。Kotlin刚出协程时,我觉得「不就是回调的语法糖吗?」后来真用了才发现,嗯,真香。
1. 协程为什么省资源?
线程是操作系统级别的,创建、切换、销毁都有开销。协程是用户态的,说白了就是在同一个线程里「模拟」多任务。一个线程可以跑成千上万个协程,切换开销几乎为零。
你想想看,如果用线程池,1000个并发任务得开多少线程?用协程,一个线程就够了。
2. 协程在Android中的最佳实践
我个人习惯这样用:
// 在ViewModel中启动协程
viewModelScope.launch(Dispatchers.IO) {
// 网络请求或数据库操作
val data = repository.fetchData()
withContext(Dispatchers.Main) {
// 更新UI
_uiState.value = UiState.Success(data)
}
}
// 避免全局Scope,用lifecycleScope或viewModelScope
// 这样Activity/Fragment销毁时协程自动取消
关键点:协程的取消是协作式的。如果你在协程里做了阻塞操作(比如Thread.sleep()),协程是取消不了的。记得用delay()代替sleep(),用isActive检查状态。
3. 协程 vs 线程池:性能对比
| 对比项 | 线程池 | 协程 |
|---|---|---|
| 内存开销 | 每个线程约1MB栈空间 | 每个协程约几KB |
| 并发数 | 几百个就吃力了 | 几万个没问题 |
| 切换开销 | 内核态切换,微秒级 | 用户态切换,纳秒级 |
| 代码可读性 | 回调嵌套,难维护 | 顺序代码,易读 |
我在项目中做过一个压测:同时发起500个网络请求。线程池方案用了50个线程,内存涨了80MB。协程方案只用了4个线程,内存涨了不到10MB。差距就是这么明显。
4. 协程的坑也得注意
- 不要用GlobalScope:它不会自动取消,容易泄漏。
- 不要在主线程做耗时操作:就算用了协程,Dispatchers.Main上也不能做耗时操作。
- 异常处理要到位:协程里的异常默认会传播到顶层,记得用try-catch或CoroutineExceptionHandler。
我曾经踩过的坑:用GlobalScope启动了一个协程做定时任务,Activity销毁了协程还在跑,结果回调时操作了已销毁的View,直接空指针。后来改成lifecycleScope,问题解决。
四、知识体系总览
下面这张图是我自己整理的异步优化知识体系,你可以对照着看看自己哪里还有盲区。
说实话,异步优化这块没有银弹。线程池、HandlerThread、协程各有各的适用场景。我的建议是:新项目直接用协程,老项目逐步迁移。如果实在动不了,至少把AsyncTask换掉,用线程池+Handler兜底。
嗯,今天就聊到这儿。记住一句话:异步优化的本质不是「开更多线程」,而是「用最少的资源,做最多的事」。
公众号:蓝海资料掘金营,微信deep3321