上个月有个做社交产品的朋友找我,说他们在 oppo 手机上后台被杀得特别惨,推送收不到,音乐播放器也经常断。他想了一堆“黑科技”想去保活,问我行不行。我跟他讲,你先别急着上那些骚操作,你先把 Android 的进程和线程机制吃透,优先级到底是怎么算的,多进程到底坑在哪,线程池怎么配才合理,再回头看你这个问题,你可能会换一种解法。
那篇大几百页的 Android 源码里,进程和线程这一块其实不算难啃,但特别绕。网上讲这块的帖子很多,多是罗列概念,什么进程优先级五个等级、线程和进程的区别,看着都对,但落到自己的项目里,你还是不知道该怎么调,出了问题不知道怎么查。这篇文章我从源码层面帮你把进程模型、优先级、保活策略、多进程架构、线程池参数、协程调度这几条线全部串起来,附带一些我实际踩过的坑和验证过的手段。这不是一篇概念科普,那是一份可以直接拿回去对照自己项目做优化的排查手册。
1. 先说清楚:进程和线程在 Android 里到底是什么
1.1 从 Linux 的视角理解进程和线程的关系
Android 的底层是 Linux 内核,所以一切关于进程和线程的讨论,都得先回到 Linux 的模型上。很多初学者背概念背得很熟——“进程是资源分配的最小单位,线程是 CPU 调度的最小单位”,但你要问他,这两个单位具体体现在哪里,他多半就卡壳了。
我用一个比方来拆开讲。进程像一家公司,有自己的办公室、账本、固定资产;线程就是公司里的员工,员工在同一个办公室办公,共享公司的账本和资产,但每个人有自己的工位、自己的工作状态。公司倒闭,所有员工都得走人;一个员工的工位乱了套,一般不会影响其他员工。
在 Linux 源码里,线程其实不是什么特殊的东西,它本质上还是一个进程,只不过是通过clone系统调用创建的、与父进程共享地址空间和文件描述符的“轻量级进程”。内核里每次调度,看的是一个叫做task_struct的结构体,进程和线程在它眼里就是一个任务,没有本质区别。区别只在于它们是否共享内存空间。
Android 跑的 Java 层Thread,底层会通过pthread_create创建一个 native 线程,这个线程再与 Java 层的Thread对象关联起来。所以你在 Java 层new Thread(),本质上就是告诉虚拟机:请帮我创建一个包装好的 native 线程。而进程不一样,一个 App 的进程是由 Zygote 进程 fork 出来的,每个进程拥有一套独立的虚拟机实例和内存空间。
所以说,进程的隔离性是内核给的,线程的数据共享是进程让出来的。这套机制决定了 Android 里很多设计——进程间通信要靠 Binder,而线程间通信只需要共享变量加锁。
1.2 一个 App 可以拥有多个进程吗
默认情况下,你启动一个 App,四大组件都会跑在同一个进程里,进程名就是你的 applicationId,比如com.example.myapp。但是在 AndroidManifest.xml 里,任何组件都可以通过android:process属性指定它要跑的进程。
这个属性有两种写法。第一种是:remote这种冒号开头的私有进程,系统会自动在它前面拼上应用包名,比如com.example.myapp:remote。这种进程是 App 私有的,别的应用碰不到,而且它的 Application 是独立创建、独立运行一套生命周期回调的。第二种是全称写法,比如com.example.myapp.push,这种情况相当于声明了一个全局进程,理论上其他应用如果有相同 UID 和权限,也是可以跑进这个进程里的。实际开发中绝大多数用的是私有进程。
那么问题来了:一个组件被指定到新进程后,它所属的 Application 会发生什么?答案是,系统会重新执行一遍 Application 的onCreate。也就是说,如果你的 App 是多进程架构,Application 的初始化代码会被执行多次,每启动一个新进程就执行一次。这个坑我在项目里踩得很深。当时我们的 SDK 在 Application 里做了一些全局变量的初始化,结果发现主进程和远程进程拿到的数据不一致,查了半天才发现是这个原因。
对大多数应用来说,默认单进程就够了。多进程不是为了炫技,而是为了解决特定问题才引入的。但一旦引入,它要付出的代价远超你的想象。这个我们在第四章专门展开。
2. 进程优先级:系统决定谁先死的依据
2.1 从顶层到底层:五个优先级等级全拆解
Android 系统的内存是托管式的,当内存不足时,内核会通过 Low Memory Killer(LMK)机制杀掉一些进程来释放内存。问题是,杀哪个不杀哪个,谁先死谁后死,这个决策就是靠进程优先级来定的。
如果你去看 AOSP 源码,可以在ActivityManagerService里找到一套完整的、动态变化的优先级计算逻辑,但是对外我们一般把它归纳成五档。
第一档是前台进程。当前正在与用户交互的 Activity 就属于这一档,比如你正在刷的页面;另外一个进程如果正在执行BroadcastReceiver.onReceive(),也就是它在处理广播事件,也算前台进程;还有正在执行Service生命周期回调的进程,也算。总之,用户正在直接感知到的,就是前台进程,这种进程属于“被杀会导致明显异常”的级别,系统基本不会动它。
第二档是可见进程。Activity 已经调用了onPause(),但你还能看到它,比如被一个对话框部分遮挡,或者透明 Activity 压在下面,这种进程依然持有可见的窗口,它也算重要进程,但优先级比前台进程低一些。还有一种场景是,某个 Service 绑定到了可见 Activity 上,这个 Service 所在的进程也会被提升为可见进程。
第三档是服务进程。通过startService()启动的、没有绑定到任何可见界面的 Service,其所在进程会被分到服务进程这一类。注意,Service 本身虽然是个重量级组件,但系统资源的压力一旦大起来,这一档的进程是会被优先回收的。很多保活技术折腾的就是这一档和下面一档之间的距离。
第四档是后台进程。Activity 已经调用了onStop(),用户完全看不到它,但 Activity 实例还保留在任务栈里的进程,就被归为后台进程。后台进程是回收的主要对象,系统在内存不足时会优先清理这些进程,以腾出空间给前台进程。你按 Home 键回到桌面,正在聊天的应用,如果没有任何 Service 在撑着,它就属于这一档。
第五档是空进程。进程里没有任何活跃的四大组件,也没有任何任务在运行,纯粹是作为缓存存在的空壳。系统杀这种进程的代价最小,回收它们几乎是零风险,所以杀得也最果断。
2.2 优先级不是静态的,它是一条动态变化的曲线
如果只是死记五个等级,那你对进程优先级的理解还停留在表面。实际的优先级计算,是围绕“组件活跃度”动态变化的,我举个例子你感受一下。
用户按下 Home 键,你的 MainActivity 会依次走到onPause→onStop,此时主进程从“前台进程”掉到“后台进程”,优先级瞬间降了两档。如果这时系统内存吃紧,LMK 会优先清理包括你在内的后台进程。但是,如果你在 Activity 里启动了一个 Service,并且是前台服务,那么这个进程会被强行拉回“前台进程”级别,系统就不太会杀你了——这就是前台服务的价值。
优先级还有一个很重要的连带效应:进程被杀后,用户把 App 切回来,看到的并不是原来那个界面,而是冷启动重建。这也是为什么我们监测一些应用的“后台存活率”时,后台进程和空进程是最容易挂的。
理解这条动态曲线,比理解五个静态等级有用得多。因为你在做保活、做任务迁移、做进程架构的时候,本质上都是在和这条曲线博弈:你想让你的某些任务在后台继续执行,就必须让承载这些任务的进程尽量维持在较高的优先级,否则它可能活不过 10 分钟。
2.3 源码里优先级是怎么算出来的
AOSP 里ProcessRecord这个类封装了进程的各种状态,其中curAdj字段就是进程当前的 adj 值,它直接决定了 LMK 杀进程的优先级。这个值是一个整数,数值越大越容易被杀,越小越受保护。比如前台进程的curAdj很低,空进程的curAdj很高。
ActivityManagerService.updateOomAdjLocked()这个方法负责遍历所有进程,根据它们当前持有的组件状态——Activity 可见性、Service 是否 start、是否有前台广播在收发、是否有 ContentProvider 被调用——动态调整每个进程的curAdj。这里面最复杂的点在于组件之间的关联关系。比如进程 A 有一个 Service,进程 B 正在绑定这个 Service,那么进程 A 会获得一个adj提升,它就不会因为自身是后台进程而被轻易杀掉。源码里解释这种连带关系的注释非常详细,核心就是一句话:承载高活跃组件的进程,必须得到保护。
看完这套逻辑你就明白了,保活的根本目的是提高进程的adj值,而不是靠一些旁门左道去对抗系统。理解了这一点,你就不会再迷信那些“1 像素 Activity”、“双进程互相拉起”的黑科技了。
3. 进程保活:正向思路与误导性方案的边界
3.1 先泼一盆冷水:那些年我们追过的保活黑科技
保活这个话题,在 Andorid 开发社区里已经快被聊烂了。从最早的 1 像素 Activity(启动一个看不见的 1 像素透明界面,把进程优先级提回前台),到双进程互相守护(两个进程用 Service 绑定互相拉起),再到监听锁屏广播启动 Activity、用前台服务做“永不消失的通知”,五花八门,层出不穷。
这些方案在今天的主流 ROM 上,基本都失效了。小米、华为、OPPO、vivo 这些厂商在系统层面做了非常严厉的后台管理策略,一是靠自启管理权限拦截应用启动,二是靠智能省电模式限制后台进程,三是系统级的应用清理会连杀带防,把之前那套互相拉起的套路封得死死的。而且,这些黑科技在用户那里是负分印象。试想一下:用户明明点了清后台,为什么这个 App 过几分钟又悄悄出现在电池统计里?因为你觉得你保住了 KPI,其实你是在挥霍用户对产品的好感。
所以在展开保活方案之前,我必须先把话放前面:我们应该保的,是那些必须保住的业务场景——音乐播放、导航、运动记录、语音通话。与此同时,那些没必要的常驻后台,该放就放。与其耗尽体力去对抗系统,不如用正确的方式告诉系统你要干什么。
3.2 合理保活的三个正向姿势
先说最正统的:前台服务。如果你确实需要一个长时间运行的 Service——比如在线音乐播放器——那么把它定义成前台服务是合规且推荐的方案。当 Service 进程因为某种原因被杀掉,系统稍后会尝试重启它。所以如果你看到某个播放器的通知栏常驻、删不掉,那不是流氓行为,那其实就是前台服务在起作用,它把自己的进程分到了高优先级档位,同时给用户一个可见的提示。
第二个方案,是借助定时任务系统帮你兜底。比如你要做“每 30 分钟同步一次用户偏好”这种事,别自己去起一个 Service 死等,你应该用WorkManager。它是 Google 官方推出的任务调度库,设计上就是为了替代各种自建的后台任务轮询。WorkManager会根据系统状态、设备电量、用户使用习惯,智能化地延迟或者合并任务执行时机,它不需要你保活进程,任务在进程被杀后依然会被调度——因为它把数据存在了本地数据库,执行时由JobScheduler或者AlarmManager唤醒一个全新的进程来跑你的任务。这就是“进程被杀但任务不丢”的正确姿势。
第三个方案,是引导用户把你加入电池优化白名单。不同的 ROM 叫法不同,有叫“自启动管理”的,有叫“后台运行权限”的,实际上都是系统的电池优化白名单机制。你可以通过Intent跳转到系统的电池优化设置页,提示用户手动添加。这个动作用户自愿怎么做,决定权在用户手里,强推引导弹窗只会增加卸载率。
3.3 为什么有些保活方案你根本不需要
很多团队在提需求的时候会说:我要保证 App 在后台不被杀,这样我才能实时监控用户位置、实时收取服务器消息。这种需求听上去合理,但百分之七十的场景是伪需求,或者说是产品经理没想清楚。
举例来说,定位这种场景,你要的是“用户到达某个地点,App 收到一条消息”,而不是“App 7×24 小时在后台监控用户”。如果是后者,正确的做法是使用系统级的地理围栏,也就是GeofencingApi,系统来感知位置变化,位置有变化时才唤醒你的 App,根本不需要你一直活着。
消息推送也是一个道理。现在国内头部厂商都有自己的推送通道,小米、华为、OPPO、vivo 都有系统级的推送服务,应用可以把自己的推送消息交给厂商通道,用户在系统层面设置开启,App 可以做到自己在后台完全被杀死,推送消息还是能到达系统通知栏。这比你自己维持一个长连接进程要省电、省内存、稳定得多。
所以,保活的正确思路是:先梳理业务场景,把必须常驻的留给前台服务,把可延迟执行的交给系统调度,把可以交给系统能力的交给系统能力,剩下的,就坦然接受系统裁决。这个思路比任何黑科技都靠谱。
4. 多进程架构:什么场景才值得引入
4.1 多进程不是银弹,但有些场景确实必须要用
我在 1.2 里说大多数应用单进程就够了,但也有几个场景,多进程是绕不开的方案。
第一个是播放器场景。音视频解码是一件很重的事,既要吃 CPU,又要吃内存,而且解码器底层大都是 C/C++ 实现的,这部分要是崩了,整个 Java 进程都会被带崩。把播放器拆到独立进程里,主进程就不会因为解码器崩溃而白屏闪退,用户只会看到播放失败,界面还在,可以重试。现在很多播放器 SDK 都是支持运行在独立进程的,比如腾讯的 IJKPlayer、Google 的 ExoPlayer 都默认不能保证独立进程,但很多大厂自研播放器会把播放核心放在一个:player进程里。
第二个是 WebView 场景。WebView 在 Android 上是一个历史包袱很重的组件,版本碎片化导致的兼容问题、内存泄漏问题一直是老大难。把 WebView 放到独立进程里,好处是 WebView 加载复杂页面时的内存开销不会算在主进程头上,而且 WebView 或 Chromium 崩了,主进程依然健在,用户只是看到 WebView 页面重新加载了一下。这种方案在新闻资讯类 App 里非常常见。
第三个是共享数据采集场景。一些 SDK 需要后台长连接保持通信,或者周期性采集传感器数据、地理位置数据,这些工作放到独立进程里,可以避免因为主进程被系统杀死而丢失核心能力。但这里有个反面案例:很多年以前,主流推送 SDK 各自为战,App 里装了三四个推送 SDK,每个都要保持一条长连接,结果就是 App 内光推送进程就开了三四个,每个都有自己的进程名和 Application 实例,内存和电量开销极其夸张。后来行业转向厂商推送通道,这个问题才慢慢缓解。所以多进程的启动成本真的不低——一个进程就意味着多一份 Application 初始化、多一份内存占用、多一个进程间通信的成本。
4.2 踩过一遍才会懂的三个大坑
多进程第一个坑,就是 Application 初始化次数变多了。每个进程启动都会创建一个新的 Application 实例,执行onCreate。如果你的onCreate里有初始化全局变量的逻辑,比如一个静态的mUserInfo,主进程已经设置了值,远程进程再启动时读取就是个 null。这就是很多 App 风控 SDK、统计 SDK 在多进程下出 Bug 的直接原因。
解决办法一般是:在Application.onCreate里通过ActivityManager拿到当前进程名,判断如果是主进程才做主进程的初始化,其他进程做精简初始化。很多人喜欢用Process.myProcessName()这个方法,但它内部要遍历ActivityManager,进程启动阶段调用会有一定耗时,我自己的经验是在初始化过程中尽早调用,影响不大。
第二个坑,是静态变量和单例完全失效。进程间内存不共享,你在进程 A 里写了一个静态变量,进程 B 是看不到的。这一点对应到业务上很容易被忽略:比如你在主进程设置了一个sIsLogin = true,然后在子进程里去判断,得到的永远是false。要跨进程共享数据,就得走 Binder、ContentProvider、共享存储或者MemoryFile这些正经的 IPC 手段。这不是一个技术选型问题,而是一个架构约束问题,必须在设计阶段就明确哪些数据是进程内共享、哪些是要跨进程共享的。
第三个坑,是进程间通信是有开销的。ContentProvider的query走 Binder,底层要经历一次跨进程拷贝,你在主线程频繁 query 一个很大数据的 ContentProvider,是会明显卡顿的。AIDL的接口调用,如果参数是大对象或者 List,序列化开销也不小。所以多进程架构下,要尽量避免高频次的进程间通信,尽量做到“接口少、数据小、频率低”。
4.3 为什么说 ContentProvider 是最容易上手的跨进程方案
如果你只是要跨进程共享一些结构化数据,完全没有必要一上来就写 AIDL 服务。ContentProvider 是系统帮我们封装好的 Binder 通信方式。你在子进程里通过content://查询数据,系统会路由到主进程的 ContentProvider 实例,执行你的query方法,返回一个 Cursor,整个过程半透明。
用 ContentProvider 做跨进程,优点很明显:接口清晰、不用管理 Binder 连接的生命周期、系统支持在多个进程间共享同一份数据。很多跨进程数据共享的场景,我都推荐用 ContentProvider 封装。
但它也有明显的短板。ContentProvider 的查询默认是在 Binder 线程池里执行的,如果你的query方法里有耗时逻辑,比如查数据库大表,它会阻塞 Binder 线程,进而影响其他跨进程调用。所以 ContentProvider 的query实现一定要快,有什么耗时逻辑另开线程,返回一个空结果占位。
5. 线程机制与线程池:手写线程没什么了不起
5.1 为什么主线程不能卡,子线程不能碰 UI
主线程,也就是 UI 线程,它要做的事情太多了:接收用户触摸事件、执行 View 的measure/layout/draw、执行Choreographer的帧回调、处理Handler消息……这些工作挤在一个消息循环里执行,所以它不能干耗时的活儿。这里有一个 5 秒的硬约束:主线程连续 5 秒没有处理输入事件,系统就认为它卡死了,会弹出 ANR 对话框。
那么子线程为什么不能碰 UI?有人说是因为线程安全问题,这种说法对但不完全对。实际上是比线程安全更简单粗暴的规则:ViewRootImpl 在checkThread()这个方法里,直接对比当前线程是不是创建 ViewRootImpl 的线程,不是就抛CalledFromWrongThreadException。所以从设计层面,Android 就不允许你用子线程去改 UI,哪怕你的修改逻辑加了锁也不能碰,因为这套检查机制拦的就是“线程不同”。
但这种限制也带来了正面的东西:因为 UI 只能在单线程里操作,你在写 UI 代码的时候就不需要考虑 UI 数据竞态的问题,减少了大量心智负担。这也是为什么 Handler + Looper + MessageQueue 这套机制能够长期作为 Android 线程通信的基础设施。
5.2 ThreadPoolExecutor 七个参数,到底该怎么配
先看一个标准的线程池配置,这是我认为最规范的写法:
ThreadPoolExecutor executor = new ThreadPoolExecutor( corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), new NamedThreadFactory("business-task"), new ThreadPoolExecutor.CallerRunsPolicy() );对应着理解,这七个参数分别是:核心线程数、最大线程数、非核心线程的空闲保活时长、保活时长单位、任务队列、线程工厂、拒绝策略。
先看核心线程数和最大线程数怎么定。这两个数的取值,取决于你的任务类型,我分两种常见情况算给你看。
如果是 CPU 密集型任务,核心线程数建议等于 CPU 核心数加 1。加 1 的原因是,虽然这类任务几乎不阻塞,但偶尔也可能遇到缺页中断或者 GC 停顿,多一个线程可以在这种短暂的停顿期把 CPU 用起来。所以如果你的设备是 8 核,corePoolSize定为 9 是比较合理的。
如果是 IO 密集型任务,比如网络请求、数据库读写、文件解析,这些任务有很大一段时间是在等待 IO 返回,CPU 是空闲的,所以核心线程数可以设多一些。经验公式是CPU 核心数 * 2,或者CPU 核心数 / (1 - 阻塞系数),阻塞系数取 0.8 到 0.9。比如 8 核手机,IO 密集型的corePoolSize可以设成 16 到 40 之间。但我个人在实际项目中,很少把 IO 池开到 40 这么大,因为线程数越多,上下文切换开销越大,对移动端来说 16 个线程已经够用了。
这两个数也不是永远固定的。corePoolSize是常驻线程的最小值,maximumPoolSize是峰值时能临时增加的线程上限。当任务队列满了、现有线程数还没到上限时,线程池才会创建非核心线程去执行新的任务。当非核心线程空闲时间超过keepAliveTime,就会被回收掉,以减少内存和 CPU 浪费。
任务队列的选择有三个:SynchronousQueue不缓存任何任务,来一个任务立刻创建一个线程去执行,适合任务量波动大但不积压的场景;LinkedBlockingQueue可以设一个有界大小,防止任务无限堆积导致内存暴涨;ArrayBlockingQueue是有界数组队列,一般用于替代LinkedBlockingQueue。我的经验是,移动端为了稳定,一定要用有界队列,任务真的多到队列放不下时,宁可走拒绝策略,也不要让它撑爆内存。
拒绝策略默认有四种:AbortPolicy直接抛异常,CallerRunsPolicy让提交任务的线程自己跑这个任务,DiscardPolicy静默丢弃,DiscardOldestPolicy丢弃队列里最老的任务。实际项目里我用得最多的是CallerRunsPolicy,它的好处是:不会丢失任务,同时因为提交任务的是主线程,任务在主线程上执行,相当于把线程池的压力“反压”回了调用方,这在某些场景下能起到天然的限流作用。但要注意,如果主线程上执行了比较耗时的任务,界面会卡一下,所以如果任务本身比较重,不要用这种策略,直接用AbortPolicy加日志,方便排查。
另外,一定要给线程池里的线程起名字。用ThreadFactory的newThread方法,把线程名的前缀设成你的业务名,这样线上出现线程问题,抓出来的线程栈可读性会好很多。我见过很多线上障碍,一线同事拿到的日志里全是pool-3-thread-1这种名字,完全不知道是哪个模块创建的,排查效率极低。如果你在ThreadFactory里写成HttpTask-pool-thread-1,排查范围瞬间缩小到网络库这个模块。
5.3 全局线程池怎么设计才能不乱
很多项目里线程相关的代码写得非常随意:这里new Thread().start(),那里AsyncTask一把梭,需求急了直接Executors.newCachedThreadPool()。等业务逻辑复杂到一定程度,线程管理就成了灾难。
我的建议是,在项目里定义几个全局的、有明确用途的单例线程池。比如:网络 IO 专用线程池、数据库操作专用线程池、计算密集型专用线程池、文件操作专用线程池。每个线程池的命名、队列策略、拒绝策略,都根据它的业务特性来定制。然后在代码规范里明确:不允许在业务代码里随手 new Thread,必须通过统一的线程池工厂获取。
这样做的好处,第一是线程数量可控,不会出现同一时间十几个模块同时在开线程,把 CPU 和内存吃满;第二是方便监控,你在工厂里打个日志,统计每个线程池当前活跃线程数和任务积压量,出现异常时能第一时间发现;第三是避免重复造轮子,线程创建和销毁的成本很低,但无节制的创建和销毁,对 GC 和内存的影响还是不容忽视的。
5.4 别再用 Executors 的快捷方法了
Executors.newFixedThreadPool()和Executors.newCachedThreadPool()确实方便,但它们封装的都是无界队列,任务积压时队列长度可以无限膨胀。这在服务端可能无所谓,但在移动端,内存是稀缺资源,队列里堆积几万个任务,分分钟 OOM。
而且newCachedThreadPool()是同步无界线程的,任务增长速度一快,它创建的线程数就能达到几百上千,带来的上下文切换开销是非常夸张的,手机直接卡死。所以我一直强调,移动端不要去用Executors的快捷方法,老老实实 new 一个ThreadPoolExecutor,把队列设成有界,把拒绝策略选好。代码长一点,但换来的是稳定。
6. 协程:线程池之上的更高层抽象
6.1 协程不是线程,它是线程框架的升华
协程是 Kotlin 引入的一套异步框架,但很多初学者用协程的时候,还是把它当成“轻量级线程”来理解,这个认知是有偏差的。准确地说,协程是跑在线程上的代码块,它可以挂起(suspend)而不阻塞线程。挂起是什么意思?就是说协程在执行到某个挂起点时,会把当前执行的线程让出去,线程可以去执行别的任务,等条件满足了,协程再被调度到某个线程上继续执行。
这跟线程切换有本质区别。线程切换是内核态的,每次切换都有内核介入,有栈的保存和恢复开销;协程切换是用户态的,它是纯 Kotlin 层面的状态机跳转,没有内核参与。所以协程可以极低成本地创建成千上万个,而线程开几百个就已经很吃力了。
用生活场景类比:线程就像一条银行柜台,每次只能服务一个顾客,顾客多了就得排队。协程就像号排队系统,客户取个号,可以去逛街,等到叫号了再回来办业务,银行柜台依然只有一个,但整个大厅的服务效率高了很多。在这个比喻里,协程是“号”,线程是“柜台”。
在 Android 上,Kotlin 协程最终还是编译成普通的 JVM 字节码,跑在普通的 Java 线程上。它做的工作,是把复杂的异步逻辑翻译成状态机 + 线程池调度。
6.2 Dispatcher 的底层实现:还是线程池
协程的调度靠的是各种Dispatcher,最常用的有三个:Dispatchers.Main跑在主线程;Dispatchers.IO用于 IO 密集型任务;Dispatchers.Default用于 CPU 密集型任务。
这三个 dispatcher 并不是三个完全独立的线程池。在 Kotlin 协程的实现里,Dispatchers.Default背后是一个基于ScheduledThreadPoolExecutor的固定线程池,线程数默认就是 CPU 核心数(至少 2)。Dispatchers.IO和Default共享同一组线程,只是 IO 池允许创建更多的线程,它用一个单独的阻塞任务队列来标记任务为“阻塞式”的,实际上当IO池线程不够用时,它会把线程数临时扩展到一个很大的上限(默认 64 个),用完再回收。
这点理解很重要:协程的调度不是魔法,说到底还是在用线程池。所以你在协程里频繁切换withContext(Dispatchers.IO)时,本质上还是在往 IO 线程池里丢任务,线程切换的开销依然存在。协程真正省掉的,是在编写代码时手动切换线程的那部分心智负担,而不是底层的线程调度成本。
withContext的原理是:启动一个子协程,在指定的 dispatcher 上执行,执行完后把结果传回父协程的上下文。它内部也会做线程切换,但因为代码被编译器改写成状态机,切换是透明的、确定的,不会像我以前写回调那样,A 线程回调里嵌套 B 线程,最后自己都分不清当前跑在哪条线程上。
6.3 Flow 怎么和协程配合处理数据流
Flow 是协程里处理数据流的设计,它和 RxJava 在很多方面相似,但原生集成挂起函数,用起来更轻。在 Android 的开发中,Flow 最常见的场景是配合 Room 和 Retrofit。
看一下这段示例,它是一个典型的“网络请求 → 本地数据库缓存 → UI 展示”的数据流:
val newsListFlow: Flow<List<News>> = flow { // 先发缓存 val cached = localDataSource.getCachedNews() emit(cached) // 再发网络 val latest = remoteDataSource.fetchLatestNews() if (latest != cached) { emit(latest) localDataSource.save(latest) } } .flowOn(Dispatchers.IO) // 上游的数据产生逻辑放到 IO 线程 .conflate() // 只保留最新值,丢弃中间冲突,防止 UI 跟不上 .stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())这段代码里有几个值得琢磨的地方。flowOn(Dispatchers.IO)决定了flow代码块里所有操作的执行线程;conflate()处理的是背压,如果 UI 消费数据的速度跟不上生产者,它只保留最新的数据,中间的状态丢弃,这在刷新列表场景下非常合适,你不需要把中间 100 条状态全部渲染到 UI 上;stateIn把冷流转换为热流,让多个观察者共享同一个数据流,避免了每个订阅者都重新触发一次网络请求。
SharingStarted.WhileSubscribed()这个参数也要解释一下。它表示当没有订阅者时会停止共享数据流,有订阅者再重新启动。这个特性在 Android 上很实用,因为 UI 从后台回到前台时,订阅者重新出现,数据流会被重新激活,保证拿到的是最新数据。
Flow 和协程结合,比传统的 Handler + Runnable 要优雅太多,但它也不是万能的。Flow 的collect是挂起函数,它不能像 RxJava 的subscribeOn那样随意切换线程,必须在协程作用域内执行。而且 Flow 本身不解决任务调度问题,它只解决数据流处理问题,真正执行任务还是靠 dispatcher 背后的线程池。
7. 高频问题排查实录:从 ANR 到线程泄漏
7.1 ANR 到底是谁引起的
ANR 是每个 Android 开发者迟早会遇到的问题,它的本质是主线程被阻塞太久,系统不再等你了。这个“太久”有几个硬指标:输入事件 5 秒没处理完、广播前台 10 秒后台 60 秒没执行完、Service 前台 20 秒后台 200 秒没启动完成。
排查 ANR,第一步是看/data/anr/目录下的traces.txt,这里面保存了发生 ANR 那一刻所有线程的堆栈。主线程的堆栈会明确告诉你它当时卡在哪里,是 IO 等待、是死锁、还是在一个巨大 for 循环里。但是,打印堆栈本身就是全局暂停线程的,线上环境常常会因为抓 traces 导致二次卡顿,所以 Google 后来推出了ANR 回调的降级方案,让Application可以监听系统回调,把现场更快速地上报到 APM 平台。
我自己在线上一套很有效的排查办法是:在主线程消息循环里埋一个“消息过期”检测的钩子,也就是在一个IdleHandler里注册任务,每次消息处理完都记录时间戳,如果发现某次消息从投递到执行完的时间超过了阈值,说明主线程有卡顿,把当时的堆栈打出来。这个方案比直接等系统 ANR 通知要早很多,定位也准很多。
7.2 线程数暴涨,怎么找出幕后黑手
线程数异常增长是另一种常见的线上问题。表现就是 APP 越来越卡,点开线程信息一看,好家伙,上千个线程,每个都在pthread_create附近或者某个锁等待上。
排查的第一步,用adb shell top -H -p <pid>可以看到当前进程里所有线程的实时 CPU 占用。第二步,用debuggerd -b抓一下 native 线程的堆栈,看哪些线程是java层创建的,哪些是 native 库创建的。第三步,重点查线程池。线程池创建的线程名,如果当初没有自定义,抓出来就是pool-1-thread-1这种,看不出归属。所以我在 5.2 里反复强调,一定要通过ThreadFactory命名,这一步不是可选项,是线上排查的刚需。
还有一种线程暴涨的来源,是第三方 SDK 内部逻辑不规范。遇到过某个聚合 SDK,在每次应用切前后台时都会偷偷起一个线程池,但并没有好好回收,时间一长线程数就线性增长。这种问题靠应用层代码很难根治,只能通过“线程数异常时主动触发一次 GC 和进程重启”这类兜底策略来缓解。
7.3 主线程卡顿的另类元凶:锁竞争
很多主线程卡顿,主线程自己的堆栈并没有明显问题,打开线程抓取才会发现,它是在等一把锁。这把锁可能被一个正在跑 IO 的子线程持有,也可能被一个SharedPreferences的写操作持有。这类问题在线上特别隐蔽,因为现场主线程堆栈压根不显示持锁线程的状态。
排查这类问题,用adb shell am profile start或者 Perfetto 抓 CPU 时间线,看哪段时间主线程是“等待状态”而不是“运行状态”。如果主线程在等待锁,那么sched_switch记录里会有对应的事件。定位到锁后,就该考虑怎么优化这把锁的粒度和持有时间。一些老代码为了图省事,在SharedPreferences的 apply 上耗时,锁的持有时间一下就变长了,一旦有人并发读,整个主线程就会被阻塞住。这种问题去追线程栈是没有用的,必须从锁的粒度去解决。
7.4 线程池任务堆积,不是线程池的错
还有一个高频问题:线程池看起来明明设了maximumPoolSize,为什么任务还是积压?任务执行的延迟很高?很多人会误以为线程数不够,盲目调大maximumPoolSize,结果线程数是上去了,任务执行依然慢。
这里要说清楚线程池的一个执行规则:只有任务队列满了,才会去创建非核心线程。如果你用了一个无界队列,比如LinkedBlockingQueue()默认容量是Integer.MAX_VALUE,那么核心线程数永远跑不满,非核心线程永远创建不出来,任务全塞在队列里排队。排队的任务越来越多,执行的延迟自然就高了。所以没设线程数之前,先看看队列是不是已经被任务堆满了,这个概率比maximumPoolSize不够大得多。
解决思路也很明确:要么把队列设成有界,要么调整corePoolSize,让活跃任务量直接到达核心线程的上限,再考虑排队。线上排查时,如果你在监控面板看到线程池的queue.size()一直在涨,那就是队列已经吃不消了,此时调大线程池只是在粉饰问题,真正要优化的是任务的执行效率。
8. 写在最后:这套机制值得你用一整个项目周期去体会
这几年来,我陆续在几个中型项目里反复验证过这套进程和线程的知识体系。一个很直接的感受是:把进程优先级这条线吃透之后,你再看系统为什么杀你的 App,心里会非常有数,你不会再去抱怨厂商变态,而是会反过来审视自己的代码架构是不是从一开始就走错了方向。把线程池的参数和协程的调度器理清楚之后,你写的异步代码不仅稳定,而且可读性强很多,后来接手的人不用再去猜这段代码是干嘛的。
我个人最推荐的做法是,每个项目都应该有一个“线程体检表”。每个季度做一次全局排查:当前开了多少个线程池、核心参数是否合理、线程是否按业务命名、任务积压情况如何、主线程卡顿点在哪里。这套体检做下来,比你临时抱佛脚处理线上问题要省心太多。
最后分享一个我在项目里用得最多的小技巧:在Application里启动一个定时任务,每 30 分钟打一次当前所有线程的堆栈摘要,存到本地日志里。这个日志平时不用看,一旦后续出现卡顿或者线程数异常,直接翻出来对照时间点,就能快速定位到是哪个模块在搞鬼。这个方法我用了三年,帮我排掉了至少七八个线上疑难杂症,比很多昂贵的企业级监控工具都管用。
进程和线程,表面上是操作系统的老话题,但在 Android 这个复杂的生态里,它牵动着应用的生命周期、性能表现和用户体验。把这个维度掌握好,你的底子就比大多数人扎实了。