1. 多线程这件事,为什么值得反复聊
Android 开发干到一定年头,你会发现一个规律:UI 卡顿、ANR、数据错乱、内存泄漏,追到根上十有八九都跟线程调度脱不了干系。我刚入行那会儿,觉得new Thread().start()就能解决一切,结果项目里线程越开越多,最后连崩溃日志都看不出是谁闯的祸。后来才明白,Android 的多线程不是“会不会开线程”的问题,而是“在什么场景下选什么工具、怎么管住它们”的问题。
这篇内容想聊的就是 Android 实现多线程的几种主流方式,从最原始的Thread到HandlerThread、ExecutorService、AsyncTask(虽然已废弃但老项目里还大量存在)、IntentService、Kotlin协程,再到WorkManager这类偏调度的方案。每一种我都会说清楚它解决什么问题、适合什么场景、坑在哪里。适合谁看?刚接触 Android 不久、对线程切换还犯迷糊的新人,以及写过一些代码但总觉得线程管理“差点意思”的中级开发者。读完你至少能做到:拿到一个需求,能快速判断该用哪种线程方案,而不是条件反射地new Thread。
需要先明确一个前提:Android 的主线程(UI 线程)负责处理用户交互和界面刷新,任何耗时操作放在主线程超过 5 秒左右就会触发 ANR。所以多线程的核心诉求其实就两条——把耗时任务挪出主线程,以及把结果安全地送回主线程。所有的方案本质上都是围绕这两件事在做文章,区别只在于封装程度、调度能力和生命周期管理。
2. 先搞清楚线程模型这块地基
2.1 主线程与工作线程的分工边界
Android 应用启动时会自动创建一个主线程,也叫 UI 线程,它内部维护着一个Looper,不断从MessageQueue里取消息来执行。你在onCreate里写的代码、按钮点击回调、View的绘制,全都跑在这个线程上。工作线程则是我们手动创建的,它默认没有Looper,执行完run()方法就结束,不能直接更新 UI。
这里有个新手常踩的坑:在工作线程里直接调用TextView.setText(),运气好没崩,运气差直接抛CalledFromWrongThreadException。原因是 Android 的 View 体系不是线程安全的,系统干脆用检查机制把这个口子堵死。所以“切回主线程”这件事,是每种多线程方案都必须回答的问题。
2.2 线程切换的本质是消息传递
很多人把线程切换想得很玄乎,其实底层就是Handler+Looper+MessageQueue这套机制。工作线程把结果封装成Message或Runnable,通过Handler投递到主线程的MessageQueue,主线程的Looper循环取出并执行。理解了这一点,你再看runOnUiThread、View.post、Handler.post,会发现它们只是同一套机制的不同马甲。
我习惯用一个类比:MessageQueue是传送带,Handler是往传送带上放包裹的人,Looper是那个不停从传送带上取包裹拆开看的工人。工作线程想跟主线程说话,就得通过Handler这个“放包裹的人”把话递过去。这个模型想通了,后面所有方案的理解都会顺很多。
2.3 线程安全问题的根源在哪
多线程带来的不只是切换问题,还有共享数据的竞争。两个线程同时对一个int变量做自增,结果可能少加了一次,因为i++不是原子操作,它包含读取、计算、写回三步。Android 里常见的场景是:网络请求回来后更新一个列表,同时用户又在滑动列表触发读取,如果没做同步,就可能抛ConcurrentModificationException。
解决手段无非几种:加锁(synchronized、ReentrantLock)、使用线程安全容器(ConcurrentHashMap、CopyOnWriteArrayList)、或者干脆用消息机制把并发变成串行(HandlerThread就是这个思路)。选哪种取决于你的数据访问频率和冲突概率,这个后面会结合具体方案展开。
3. 几种主流实现方式逐个拆解
3.1 最原始的 Thread:简单但不好管
Thread是 Java 层面的基础能力,Android 完全继承了过来。写法很直接:
new Thread(new Runnable() { @Override public void run() { // 耗时操作,比如读文件、解析数据 String result = doSomethingHeavy(); // 切回主线程更新 UI runOnUiThread(() -> textView.setText(result)); } }).start();它的优点是零依赖、上手快,适合一次性、短生命周期的任务。但问题也很明显:每次任务都新建线程,线程创建和销毁本身有开销;线程数量不受控,开多了会拖垮系统;没有统一的取消机制,任务跑到一半想停都停不下来;异常也没人兜底,run()里抛出的异常会直接导致线程终止,日志里还不一定看得清楚。
我的经验是,Thread只适合写 Demo 或者极其简单的场景。真实项目里如果到处new Thread,后期维护会非常痛苦。曾经接手过一个项目,光搜索new Thread就出来上百处,排查一个偶现的并发问题时几乎无从下手。
3.2 HandlerThread:给线程配一个消息队列
HandlerThread的本质是一个自带Looper的Thread。普通Thread执行完就没了,而HandlerThread启动后会进入Looper.loop()的无限循环,不断从自己的MessageQueue里取任务执行,直到你调用quit()。
HandlerThread handlerThread = new HandlerThread("worker"); handlerThread.start(); Handler workerHandler = new Handler(handlerThread.getLooper()); workerHandler.post(() -> { // 这里的任务会串行执行 processTask(); });它最大的价值是把并发变成了串行。所有投递到这个Handler的任务都排在同一队列里,一个执行完再执行下一个,天然避免了多线程竞争。适合什么场景?比如本地数据库的连续写入、日志的按序落盘、需要保证执行顺序的串行任务链。
坑在于:它是串行的,如果你投了一个耗时很长的任务进去,后面的任务全得等着。另外用完必须调用quitSafely()释放,否则线程一直挂着,在Activity销毁后还持有引用就会泄漏。我一般会在onDestroy里显式清理,别指望 GC 帮你兜底。
3.3 ExecutorService:线程池才是正经干活的主力
线程池是我在项目里用得最多的方案,没有之一。它的核心思想是复用线程、控制并发数量、统一管理任务队列。Android 里常用的创建方式有几种:
// 固定数量线程池,适合任务量稳定、需要控制并发的场景 ExecutorService fixedPool = Executors.newFixedThreadPool(4); // 缓存线程池,适合大量短小任务,线程数按需增长 ExecutorService cachedPool = Executors.newCachedThreadPool(); // 单线程池,保证任务按提交顺序串行执行 ExecutorService singlePool = Executors.newSingleThreadExecutor(); // 定时/周期任务 ScheduledExecutorService scheduledPool = Executors.newScheduledThreadPool(2);不过在实际项目里,我更推荐直接用ThreadPoolExecutor手动配置参数,因为Executors的几个工厂方法藏着坑。比如newCachedThreadPool的最大线程数是Integer.MAX_VALUE,任务暴涨时可能创建海量线程直接把内存撑爆;newFixedThreadPool和newSingleThreadExecutor用的是无界队列,任务堆积过多会 OOM。
手动配置长这样:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 4, // 核心线程数 8, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲线程存活时间 new ArrayBlockingQueue<>(64), // 有界任务队列 new ThreadFactory() { // 自定义线程名,方便排查 private final AtomicInteger count = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { return new Thread(r, "biz-pool-" + count.getAndIncrement()); } }, new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );参数怎么定?核心线程数一般参考 CPU 核数,计算密集型任务设为CPU 核数 + 1,IO 密集型可以设大一些,比如CPU 核数 * 2。队列容量要结合任务产生速度和消费速度来估,宁可设小一点配合合理的拒绝策略,也别用无界队列把问题藏起来。拒绝策略里CallerRunsPolicy比较温和,队列满了就让提交任务的线程自己跑,起到一个天然的降速作用。
线程工厂里给线程起名字这个细节,很多人忽略,但排查问题时特别有用。崩溃日志里看到biz-pool-3和看到pool-1-thread-3,定位效率完全不一样。
3.4 AsyncTask:曾经的宠儿,如今的遗产
AsyncTask在早期 Android 开发里几乎是标配,它把“后台执行 + 进度更新 + 结果回调”封装成了一个模板方法。写法上继承AsyncTask,重写doInBackground、onPostExecute、onProgressUpdate等方法。
但它在 Android 11(API 30)已经被正式标记为废弃。原因有几条:一是它内部默认用串行执行器,多个AsyncTask实例之间容易互相阻塞;二是不处理生命周期,Activity销毁后回调还可能触发,导致崩溃或泄漏;三是异常处理很别扭,doInBackground里抛的异常不会自动传到onPostExecute。
老项目里如果还在用,我的建议是逐步替换成协程或线程池 +Handler的组合,别在新代码里继续引入。如果只是维护,至少注意两点:不要在Activity销毁后还持有AsyncTask引用,以及cancel()要配合isCancelled()检查使用,否则取消不生效。
3.5 IntentService:后台串行任务的旧方案
IntentService继承自Service,内部用HandlerThread实现,所有通过startService传进来的Intent会排队串行处理,处理完自动停止。它适合“触发一次、后台做完就结束”的任务,比如上传日志、同步数据。
public class UploadService extends IntentService { public UploadService() { super("UploadService"); } @Override protected void onHandleIntent(Intent intent) { // 在后台线程执行,处理完自动 stopSelf uploadData(intent.getStringExtra("path")); } }它在 Android 8.0 之后受后台执行限制影响很大,加上本身在 API 30 也被废弃,现在基本被WorkManager取代。了解它的存在主要是为了读懂老代码,新项目不建议再用。
3.6 Kotlin 协程:现代 Android 的首选
如果项目已经上了 Kotlin,协程基本是当前多线程方案的最优解。它最大的优势是用同步的写法表达异步的逻辑,把回调地狱拍平了。
lifecycleScope.launch { val data = withContext(Dispatchers.IO) { // 自动切到 IO 线程池执行 repository.fetchData() } // 自动切回主线程 textView.text = data }Dispatchers.IO适合网络和磁盘操作,Dispatchers.Default适合 CPU 密集型计算,Dispatchers.Main就是主线程。withContext会自动处理线程切换和结果返回,代码读起来跟同步调用一样顺。
协程还有两个杀手锏:结构化并发和取消传播。lifecycleScope绑定生命周期,Activity销毁时协程自动取消,不用手动管理;父协程取消时子协程跟着取消,不会出现任务跑飞的情况。异常处理也统一,try-catch直接包住withContext就行。
需要注意的是,协程不是“没有线程”,它底层还是跑在线程池上,只是调度更聪明。另外别在GlobalScope里随便launch,那样就失去了结构化并发的意义,跟裸开线程区别不大。
3.7 WorkManager:需要保证执行的任务交给它
WorkManager严格来说不是“多线程方案”,而是“可延迟、可保证执行的后台任务调度框架”。它适合那些“不要求立即执行,但要求最终一定执行”的任务,比如数据同步、日志上报、定期清理缓存。
val request = OneTimeWorkRequestBuilder<SyncWorker>() .setConstraints( Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() ) .build() WorkManager.getInstance(context).enqueue(request)它内部会根据系统版本和任务条件选择合适的执行方式,可能用线程池,也可能用JobScheduler。约束条件(网络、电量、充电状态)和设备重启后的恢复能力是它的核心价值。但如果你需要立即拿到结果,或者任务跟 UI 强相关,那它就不合适,老老实实用协程或线程池。
4. 方案选型的判断逻辑与对比
4.1 按场景对号入座
选型这件事,我总结了一个简单的判断路径:任务要不要立即执行?要,就看是不是跟 UI 生命周期绑定,绑定就用协程的lifecycleScope,不绑定就用线程池;不要立即执行、但要保证最终执行,用WorkManager;需要严格串行且带消息循环,用HandlerThread;只是写个临时脚本或极简 Demo,Thread也不是不能用。
下面这张表是我平时给团队新人做参考的对比,参数和结论都来自实际项目经验:
| 方案 | 线程复用 | 生命周期感知 | 任务取消 | 适用场景 | 现状建议 |
|---|---|---|---|---|---|
| Thread | 否 | 否 | 困难 | 极简一次性任务 | 仅限 Demo |
| HandlerThread | 是(单线程) | 否 | 需手动 quit | 串行消息处理 | 特定场景可用 |
| ExecutorService | 是 | 否 | 通过 Future | 通用并发任务 | 主力方案之一 |
| AsyncTask | 是 | 否 | 有但不可靠 | 老项目遗留 | 逐步替换 |
| IntentService | 是(单线程) | 否 | 有限 | 老项目后台任务 | 已被取代 |
| Kotlin 协程 | 是 | 是 | 自动传播 | 现代 Android 开发 | 首选 |
| WorkManager | 是 | 部分 | 支持 | 可延迟保证执行 | 后台调度首选 |
4.2 线程数量到底开多少合适
这个问题没有标准答案,但有个估算思路。假设你的任务以网络请求为主,属于 IO 密集型,线程大部分时间在等网络返回,CPU 是闲着的,那线程数可以适当多一些,比如 8 到 16 个;如果是图片压缩、数据加密这类 CPU 密集型任务,线程开多了反而因为上下文切换变慢,一般控制在 CPU 核数附近。
我通常的做法是先按经验值配一个初始参数,然后在测试环境用ThreadPoolExecutor的getActiveCount()、getQueue().size()观察实际负载,再微调。别一上来就追求“最优参数”,先跑起来看数据,比拍脑袋靠谱。
4.3 生命周期管理是绕不开的坎
不管用哪种方案,只要任务跟Activity或Fragment绑定,就必须考虑生命周期。工作线程持有Activity引用,Activity销毁后线程还在跑,轻则回调空指针,重则内存泄漏。协程的lifecycleScope和viewModelScope之所以好用,就是因为把这件事自动化了。
用线程池的话,我一般会在onDestroy里取消所有未完成的任务,或者用弱引用包装回调。Handler的话记得removeCallbacksAndMessages(null)。这些清理动作写起来琐碎,但不写迟早出事。
5. 实操中的坑与排查实录
5.1 常见问题速查
下面这些是我和团队在实际项目里反复遇到的,整理成表格方便对照:
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 偶发 ANR | 主线程有耗时操作 | 看 ANR 日志的主线程堆栈 | 把耗时逻辑挪到工作线程 |
| 更新 UI 崩溃 | 在非主线程操作 View | 检查回调所在线程 | 用 Handler 或协程切回主线程 |
| 数据错乱 | 共享变量无同步 | 检查多线程读写点 | 加锁或用线程安全容器 |
| 内存泄漏 | 线程持有 Activity 引用 | 用内存分析工具看引用链 | 弱引用或及时取消任务 |
| 任务不执行 | 线程池队列满或已 shutdown | 看拒绝策略和池状态 | 调整队列容量或拒绝策略 |
| 回调不触发 | 任务被取消或线程已退出 | 检查 cancel 和 quit 调用 | 确认生命周期与取消时机 |
5.2 一个真实的排查案例
之前有个页面偶尔卡死,日志里没有明显异常。后来用主线程堆栈抓了几次,发现卡在一个synchronized块上。原因是工作线程持锁做网络请求,主线程等这把锁去读缓存,网络一慢主线程就跟着卡。这种问题的根子在于锁的粒度太大,把不该保护的耗时操作也包进去了。
改法是把网络请求挪到锁外面,锁只保护内存数据的读写,并且换成读写锁ReentrantReadWriteLock,读多写少的场景下并发度明显提升。这个案例说明,多线程问题很多时候不是“线程开错了”,而是“共享数据的访问方式设计得不对”。
5.3 几条踩出来的经验
第一,给线程起名字。不管是线程池的ThreadFactory还是HandlerThread的构造参数,都别偷懒。排查问题时,日志里一个有意义的名字能省半小时。
第二,别在循环里创建线程。见过有人在for循环里new Thread,一千条数据开一千个线程,手机直接卡成幻灯片。正确做法是交给线程池,或者用协程的async并发处理再awaitAll。
第三,异常一定要兜住。工作线程里抛出的未捕获异常,默认行为是终止线程,而且不一定打到你能看到的日志里。线程池可以设置UncaughtExceptionHandler,协程用CoroutineExceptionHandler,别让异常悄悄溜走。
第四,测试并发问题要有耐心。并发 bug 往往在低概率下才出现,本地跑十次没事不代表没问题。我习惯在关键路径上加一些日志埋点,配合压力测试多跑几轮,比单纯靠肉眼 review 靠谱。
6. 我个人的选型习惯
干到现在,我自己的默认选择很明确:新项目一律 Kotlin 协程打底,跟 UI 相关的用lifecycleScope或viewModelScope,纯后台计算用Dispatchers.Default,网络和磁盘用Dispatchers.IO;需要保证最终执行的后台任务交给WorkManager;只有在维护老 Java 代码或者需要极精细控制线程池参数时,才手动配置ThreadPoolExecutor。Thread、AsyncTask、IntentService这些,基本只在读老代码时才会碰。
协程虽好,但也别神化它。它解决的是调度和生命周期的麻烦,不解决你代码本身的逻辑问题。该加锁的地方还得加锁,该注意的线程安全一样跑不掉。工具是工具,思路才是根本。