ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android进程优先级机制详解:从oom_score_adj到lmkd回收

Android进程优先级机制详解:从oom_score_adj到lmkd回收

做Android开发的这些年,进程优先级是我和新同事交流时最常被误解的一个概念。很多同学以为进程被杀是“系统心情不好”,其实是Android在内存吃紧时按规则做的取舍。只要理解了进程优先级,你不仅能把应用做得更稳,还能在线上出现“进程被回收”问题时快速定位原因。

1. 为什么Android要搞一套进程优先级

1.1 内存有限,系统必须在关键时候做取舍

Android手机的内存不像PC那么宽裕,即便是现在12GB、16GB的旗舰机,系统里跑的应用数量也多得远超想象。加上Android后台默认不清理应用,大部分应用都留下了活跃或半活跃的进程。如果一个应用占着内存不肯释放,而另一个应用又打算启动,系统就必须腾出空间。

如果没有进程优先级,面临内存不足时只有两条路:要么直接触发Linux内核的OOM Killer,随机找进程杀掉;要么一直等到系统彻底卡死甚至重启。这两种结果都是灾难级的。随机杀会把正在前台使用的应用到杀掉,用户当场就崩溃。等到系统已经卡顿再杀,整个手机已经没法用了。所以Android必须提前给所有进程排队打分,明确“谁该舍、谁该留”。

1.2 优先级就是“排队被杀”的号码牌

很多资料把进程优先级翻译成“被杀的顺序”,其实它是一个动态变化的评分体系。系统给每个进程算出一个分数,分数越低越重要,越不容易被回收;分数越高越次要,内存一紧就越先被清理。你可以把它想象成租房时的预约名单,房间不够时,排在后面的先出去,早期看不出来,真到临界点时,名单顺序决定一切。

这个分数在现代Android里一般反映在/proc/<pid>/oom_score_adj这个文件里,取值通常在 -1000 到 1000 之间。进程不会永远保持同一个分数,它会跟前台有没有Activity、有没有被别的进程绑定、有没有运行前台服务、甚至退出后台多久都有关。系统会定期重新计算分数,用来精确控制每轮内存回收该动哪些进程。理解这套机制,是你处理好进程生命周期的基础。

2. Android进程优先级到底怎么分

2.1 官方给出的五档进程分类

Android官方把进程分为五档,按重要性从高到低依次是:前台进程、可见进程、服务进程、后台进程、空进程。

  • 前台进程:正在用户屏幕最顶层运行的Activity、正在执行生命周期回调的BroadcastReceiver,或者正在执行关键回调的Service,用户能直接感受到它。只要不是系统实在撑不住,这类进程通常不应该被杀。
  • 可见进程:界面部分可见,但没有完全处于交互状态。比如被一个半透明弹窗遮住的Activity,或者输入法、动态壁纸这类辅助窗口。用户还能看到它,所以重要性也很高。
  • 服务进程:通过startService启动的服务,不在前台界面也无法直接可见,但后台还在做用户关心的事,比如播音乐、下载。系统一般愿意多留一留。
  • 后台进程:用户按了Home或者切走,Activity已经不可见,但进程还保留着。这类进程随时可能被杀。
  • 空进程:进程里没有Activity、Service等任何运行组件,只留在内存里当缓存,已经成了纯粹的资源占用者,回收时优先级最低。

2.2 从oom_adj到oom_score_adj,数值越高兴越快被回收

老版本Android用的是内核里比较粗的oom_adj,范围 -16 到 15,数值越小级别越高。后来系统改成了更线性的oom_score_adj,范围基本在 -1000 到 1000,好处是计算起来更平滑,也更方便在框架层做微调。

我整理过一份常见的参考数值,不同手机厂商和不同Android版本会有差异,但结构基本一致:

进程状态典型oom_score_adj
系统持久进程约 -800
正在交互的前台应用0
可见进程约 100
前台服务(带通知,但不在前台)约 50
普通后台服务约 200
后台Activity约 400
空进程 / 缓存进程约 900 到 1000

注意,这些数字不是固定死的。不同ROM会在系统进程、厂商应用、白名单应用上做额外调整,但“数值越大越容易被回收”这个基本方向不变。所以当你调试自己应用时,看到oom_score_adj从0变成400,就意味着它已经从“正在交互”降级成了“后台可回收”的对象。

2.3 优先级不是固定不变的,它会跟着组件状态动态变化

很多人写App时会有个误区:以为一个Service启动后,进程就一直是“服务进程”,永远不会变。实际上进程优先级由进程内当前所有组件的最高重要性决定,而且会跟随组件生命周期实时变化。

举个例子:你的App里有一个前台Activity正在运行,此时进程优先级是0。用户按Home键,Activity进入onStop,进程变成了“后台进程”,adj可能直接跳到400。如果同一进程里刚好有个服务在播放音乐,且通过前台服务方式启动,那么进程优先级又会提升到一个较高的档位。如果服务只是普通startService,那它只能让进程在“服务进程”和“后台进程”之间略微挣扎,并不能保证不被回收。

另外,如果另一个前台应用绑定了你这个App的Service,比如系统MediaBrowserService被音乐App连接,你的进程会跟着绑定者的优先级走。这就是为什么有时候你的应用明明在后台,adj却很漂亮,因为它“沾了光”。

3. 系统是怎么把低优先级进程“请走”的

3.1 lmkd的诞生:从内核驱动到守护进程

早期Android使用的回收机制叫lowmemorykiller,是一个内核对进程的判定直接杀人的模块。它会定期检查可用内存剩余量,再对照配置的内存阈值和adj阈值,一旦剩余内存低于某个水位,就把超过对应adj的进程杀掉。低版本上它工作得还行,但有个明显问题:它只是简单看剩余内存,不能很好感知“内存压力到底有没有在累积”。

后来系统引入了lmkd(Low Memory Killer Daemon),这是一个用户空间的守护进程。它一方面能从内核拿到更精细的内存压力信息,比如PSI(Pressure Stall Information),也就是系统在内存上发生阻塞的时间和程度;另一方面可以更灵活地执行内存回收策略,目标还是那些adj值较高的进程,一旦选中就直接发SIGKILL。现在绝大多数Android设备都是用lmkd配合内核watermark来工作的。

3.2 一次真实的内存回收过程

我在调试某款中端设备时遇到过这样的场景:手机运行视频通话App(前台),后台堆了新闻客户端、购物App、游戏、地图导航等多个应用。随着通话时间拉长,系统可用内存逐渐下降,lmkd开始介入。

首先被清掉的,一定是我进程列表里那些adj接近1000的空进程,它们连Activity都没有了,回收无成本。接着是很多后台缓存Activity进程,也就是常见的一堆App虽然看不到界面,但进程还活着;系统杀掉它们时,如果你回去打开对应应用,会看到重新走启动页、重新恢复数据,这就是典型的“进程被回收了”。

然后可能轮到一个只做普通后台任务、没有前台服务也没有可见界面的应用。如果它还在后台挂着一个Service,adj大约200多,在内存进一步紧张时也会被处理。而正在通话的App因为属于前台进程,只要用户还看着界面,lmkd一般不会动它。

3.3 进程被杀之后,系统如何帮你恢复现场

Android进程被系统杀掉不是无痕的。只要进程之前是通过正常栈启动,系统会在用户重新进入应用时,尝试恢复到被杀前的Activity栈。它会读取Activity在onSaveInstanceState里保存的状态,重建Activity和View,再通过onCreate里的savedInstanceState恢复临时数据。

但这里必须说明:这种恢复不是百分百无损。如果应用在进程被杀之前没有处理好状态保存,比如一些内存里的集合、请求队列或者WebSocket连接,恢复后就会丢失。所以要保证进程被系统回收后依然体验正常,重点不是“让进程永远不死”,而是做好状态保存和重建时的恢复逻辑。你可以在onSaveInstanceState、onTrimMemory里保存数据,也可以在Application里维护一个可持久化的临时状态。

4. 开发者如何合理影响自己的进程优先级

4.1 前台服务:合法“提权”最基本的手段

如果应用需要在后台执行用户能明确感知的任务,比如下载大文件、播放音频或录制视频,最标准的方式是开启前台服务并调用startForeground()。前台服务会持有一条常驻通知,让用户知道当前有任务在执行,同时系统的优先级也会相应提升,adj值会明显低于普通后台服务。

用Kotlin写一个典型示例:

class DownloadService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { startForeground( NOTIFICATION_ID, createNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC ) } else { startForeground(NOTIFICATION_ID, createNotification()) } // 在这里执行下载任务 return START_STICKY } }

Android 14开始,前台服务必须声明类型,并且部分类型要在启动时满足系统要求。比如下载任务需要声明FOREGROUND_SERVICE_TYPE_DATA_SYNC,并且从Android 14某个版本开始,某些后台启动前台服务的情况会被系统直接拦截。所以正确做法不是拼手速去绕过,而是在用户主动操作时启动服务,或用系统允许的调度方式(比如WorkManager满足条件时运行)。

4.2 绑定关系带来的优先级提升

除了前台服务,另一个重要手段是“绑定”。当一个高优先级进程绑定到你应用的Service,或者调用你应用的ContentProvider时,你的进程adj会被重要的一方带着往上抬。这个机制经常被忽略,但它非常实用。

比如App里播放器提供一个媒体播放Service,通过MediaStyle通知绑定到系统MediaSession,用户在前台控制播放时,播放Service的进程就能跟着保持较高优先级。再比如,你的App给主应用提供数据能力,作为一个独立进程运行ContentProvider,主应用每次调用时,你的Provider进程都会因为主应用处于前台而临时获得较高优先级。

这个机制也有代价:如果你绑定的客户端进程本身很弱,比如只是一个后台缓存进程,那你的服务顶多从“空进程”等级变成“服务进程”,并不能保证绝对安全。所以判断到底该用前台服务还是绑定,需要结合业务场景思考,而不是单纯为了提高优先级硬绑。

4.3 千万别做的“保活”误区

我见过不少开发者为了不让进程死,会做各种“保活”操作:在Service里循环空转、开一个常驻线程跑心跳、监听系统广播把自己拉活、甚至做双进程互相守护。说句实在话,这些方案在早期Android版本上可能还有部分作用,放到现在,轻则被系统电量和后台限制策略直接清理,重则被应用商店、用户当作流氓应用处理。

系统真正在意的是“用户体感”。你现在就算能让进程一直活着,但如果应用占着内存不做任何有意义的事,系统耗电增加,用户也会去系统设置里管理你的应用。合理的做法应该是:让任务变成前台服务、绑定从而提升优先级,或者干脆不用常驻进程,用JobScheduler、WorkManager、推送通道在合适的时机唤醒执行。进程优先级的存在,本来就不是为了让所有进程永远活着的。

5. 进程优先级的调试与排查实录

5.1 用adb查看进程现任adj

排查问题第一步,是知道自己进程当前到底处于什么优先级。最简单的方式是使用Android自带的dumpsys命令:

adb shell dumpsys activity processes | grep -E "oomScoreAdj |pid.*com.example.app"

输出里会包含一串类似这样的信息:

*APP* UID 10086 PID 12345 com.example.app oomScoreAdj=0 lastActivityTime=-1h ...

看到oomScoreAdj=0说明你现在是前台进程;如果切后台后再跑一次,可能会变成oomScoreAdj=400或更高。有些设备上普通adb shell没有权限读其他应用进程,但看自己应用进程通常是没问题的。

如果发现看不到oomScoreAdj,可以用另一个命令直接看内核文件:

adb shell cat /proc/12345/oom_score_adj

这个方法需要知道进程PID,适合在已经拿到PID后快速确认。不过要注意,部分系统为了安全,普通shell可能临时降权,读不到其他uid的进程节点。这种情况下优先用dumpsys。

5.2 复现一次“被回收”现场

想实测自己应用的进程优先级变化,可以在开发机上做一个简单实验。把应用切到后台,然后让系统产生内存压力;最直接的方式是把其他大型应用一个个打开并切到后台,直到可用内存越来越少。也可以使用开发者选项里的“不保留活动”,模拟系统回收Activity。

我在测试某项目时遇到过:App里有下载任务,用普通Service开在后台,切后台后oom_score_adj大约在200左右。我连续打开了相机、地图、购物和几个游戏,再回来看dumpsys时,发现下载Service所在进程的oomScoreAdj已经变成1000左右,没过多久就被lmkd杀掉了。之后我把这个Service改成前台服务,重新复现同一场景,发现adj能稳定在50附近,整个测试过程没被杀。这说明通过调试找到业务真正的前台化空间,比盲目保活有用得多。

5.3 在代码里监测进程状态

有些应用需要在进程被判死刑前做最后的数据保存,这可以通过onTrimMemory(int level)回调感知。当level为TRIM_MEMORY_RUNNING_LOW时,说明当前还有Activity在前台,但系统已经内存紧张;当level为TRIM_MEMORY_UI_HIDDEN时,说明你的应用已经退到后台,是时候释放不必要的缓存了;当收到TRIM_MEMORY_BACKGROUND及以上,说明进程已经开始排队等待被系统回收,这时候应该保存业务关键数据。

注意,onTrimMemory回调顺序和进程被杀时间不完全对应,不要指望靠它做“临死抢救”。它更适合做资源整理,比如清掉图片缓存、关闭部分网络连接,让进程在后台更“轻”,从而降低被杀概率,或者至少让崩溃后的恢复成本变低。把这些数据保存逻辑放在onPause和onStop里,比收到回调再动手更可靠。

6. 常见问题与避坑指南

6.1 为什么有Service还是被杀

很多人的第一反应是:我开了Service,怎么还会被杀?答案在于普通Service的优先级通常只是“服务进程”档位,当内存压力大,系统会继续提升回收力度,这一档并不能保命。只有前台服务,也就是带通知、调用过startForeground()的Service,才会有比较强的生存能力。

我之前帮一个团队成员排查过类似问题:他们做了一个联网状态监听,用startService常驻,结果在后台运行一段时间后进程消失。dumpsys日志里显示的oomScoreAdj一度接近后台缓存进程。后来我们把监听逻辑并入了一个需要用户主动确认的前台服务,进程才稳定下来。实际开发中,如果业务确实需要后台长期运行,最好先检查有没有实际的前台场景可以绑定,比如用户在看视频、在听音频、在进行上传下载。

6.2 前台服务为什么也会被系统“清掉”

不少人也遇到过前台服务被回收的情况。这个可能因为:第一,前台服务的类型不符合系统要求,例如Android 14里声明了不支持的启动类型;第二,用户主动在系统设置里关闭了这个App的通知或后台运行权限;第三,部分国产ROM有更激进的清理策略,会把长时间后台运行的前台服务也杀掉。

遇到这类问题,先不要急着怪系统。你可以先查一下应用有没有在用户屏幕解锁后偷偷后台启动服务,如果有,那被限制很正常。没有的话,可以在崩溃日志里看是否有ServiceConnection断开等相关记录,也可以配合dumpsys看最近的系统进程清理日志。关键是做好业务降级:如果服务必须运行,就通过系统提供的调度能力定时重试;如果服务只是辅助,就要接受它可能被杀的事实,保证用户重新进入时立刻重启。

6.3 进程优先级不是保命符

最后想强调一点:优先级只是系统在内存总量紧张时评估“先放弃谁”的标准,它不能保证你的应用永远不死。正常使用时,系统不会杀死前台进程,也不应该频繁杀死前台进程;但当某些系统进程、安全组件出问题时,为了不让整机完全死机,系统依然可能强行回收。因此不要给进程优先级“镀金”,它是App质量的辅助要素,不是免死金牌。

我在实际项目中经常看到团队为了“不杀进程”,牺牲启动速度、电量和用户隐私,最后被用户卸载。反而是那些启动快、数据恢复好、后台任务少的应用,用户压根不会在意它是不是被回收过。从这个角度看,进程优先级再高,也不如把活干完、把状态存好、把恢复做顺。

我个人在实际开发里的习惯是:凡是后台任务,先想清楚“是否真的有前台感知”;没有,就交给系统的Job机制去调度;有,就正正经经做成前台服务。调试时用dumpsys观察几个关键节点,并专门做一次“后台空转测试”,模拟内存紧张,看看申请结果如何。这个办法帮我提前发现过不少数据丢失和崩溃问题,也避免了很多无意义的“保活改造”。你也可以从今天开始,把自己App的各个进程adj全扫一遍,很可能会有意外发现。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进