线程与异步优化:线程池正确使用、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,问题解决。

四、知识体系总览

下面这张图是我自己整理的异步优化知识体系,你可以对照着看看自己哪里还有盲区。

Android异步优化知识体系 线程池 核心线程数计算 拒绝策略选择 队列大小控制 线程复用机制 AsyncTask 内存泄漏风险 串行执行问题 屏幕旋转坑 建议迁移 HandlerThread 串行任务处理 避免耗时操作 记得quit() 不要跨线程 协程 轻量级并发 自动取消 结构化并发 异常处理 核心原则 用最少的线程做最多的事 · 避免内存泄漏 · 善用协程 性能对比(500并发请求) 线程池:50线程 / 80MB内存 协程:4线程 / 10MB内存

说实话,异步优化这块没有银弹。线程池、HandlerThread、协程各有各的适用场景。我的建议是:新项目直接用协程,老项目逐步迁移。如果实在动不了,至少把AsyncTask换掉,用线程池+Handler兜底。

嗯,今天就聊到这儿。记住一句话:异步优化的本质不是「开更多线程」,而是「用最少的资源,做最多的事」。


公众号:蓝海资料掘金营,微信deep3321