先说个上个月真把我问住的场景。
一家车机厂商的二面,面试官姓陈,桌上摊着我那个还没写完的 Mini Profiler 的 README。他手指点在 "oom-watcher" 那一行:
"你 App 在后台,怎么在进程内知道自己离被杀还有多远?"
我张口就来——oom_score_adj 嘛,越大越容易被杀,900 就是 cached。这数我熟,D9 那篇扒得门儿清,闭着眼能背。
他不接,补一句:
"那这个 adj,进程内你怎么读到?"
我卡了一下,下意识答:"读 /proc/<pid>/oom_score_adj 呗。"他笑了一下,没说话。就那一下,我心里咯噔——坏了。
回家当晚我就开干,心想这有啥难的,D9 我在 adb 里 cat 出过 900,白纸黑字。结果第一行命令,啪。
我以为最多是"权限不够"。毕竟去读别人家的东西,被拦下很合理。可系统给我的不是这个——它说的是:No such file or directory。
查无此文件。一个我亲手在 adb 里 cat 出过 900 的文件,到了我自己 App 进程里,系统告诉我:没这东西。
那一刻我才咂摸出陈工那一笑的意思。这道题的坑根本不在"adj 是几",在"你凭什么读得到"。
第一层考点:同样 cat /proc,凭什么 adb 能读、App 读不到——而且不是"没权限",是"没这文件"
先把那个反直觉的报错说清楚。读不到别人的东西,按常理该是 Permission denied——权限不够。可我吃的是 No such file——文件不存在。这俩差得远了:前者承认门在,只是没钥匙;后者连墙都给你抹平了,让你以为这地方压根没盖过房子。
差别在身份。adb shell 的进程是 shell 身份(UID 2000),它被塞进了一个特殊的组——AID_READPROC,GID 3009,有资格翻整个 /proc。而我的 App 是普通应用 UID,不在 3009 组里。
这背后是 hidepid。早在 2015 年,Android 就把 /proc 用 hidepid=2,gid=3009 挂载。这个 =2 是关键:
hidepid=1:别的进程的 /proc/<pid>你看得见,但读内容被拦——给你Permission denied。hidepid=2:别的进程整个隐身,目录列表里都没它——给你 No such file。
Android 选了更狠的 =2。理由很"安全洁癖":连"这个进程存不存在"都不让你探到,泄露的信息更少,还省了一堆 SELinux 的告警。所以普通 App 去读别的 UID 的 adj,不是"没权限",是那个进程在你眼里根本不存在。
// oom-watcher 第一版:在 App 进程里读"别人"的 adj —— 翻车
File("/proc/8847/oom_score_adj").readText()
// FileNotFoundException: ... (No such file or directory)
// 不是没权限,是 hidepid=2 让 8847 这个进程,对你彻底隐形打个比方:你想知道隔壁那栋楼住了谁、欠不欠费。hidepid=1 是给那栋楼装了防盗门,你能看见门牌号,但进不去;hidepid=2 是直接把那栋楼从你的地图上 P 掉了——你站在原地,眼前是一片平地,连有没有过这栋楼都无从知道。
记忆点:进程内只配看自己。别人的 /proc 在 hidepid=2 下不是"读不了",是"看不见"(ENOENT,不是 EACCES)。想看全局,要么走 dumpsys 让系统替你导,要么做平台签名的系统 App 进 3009 组。这是"进程内 profiler 天花板"的第三面墙——Binder(D23)、启动(D24)、OOM(今天),同一面墙我撞了三回。
门没全关:/proc/self 这扇是给你留的
墙归墙,门留了一扇:/proc/self/oom_score_adj。读自己进程的 adj,不受 hidepid 管。看自己,天经地义。
跑起来。前台读到 0;切后台,隔两秒再读——900。系统给我标的价,我亲眼看着它从 0 跳到 900。这就是 lmkd 排队砍人时手里那张号——数越大,越靠前挨刀。
但光盯着这个数没用。它只给你结果,不给你原因。0 跳 900 是因为切了后台,可"切后台"这件事,oom_score_adj 自己不告诉你。想把一条干巴巴的曲线变成能用的报告,"前后台切换"这个因,得我自己去抓。
第二层考点:进程内怎么知道自己"进了后台"
两条路,配着用。
第一条,数 started 计数。用 ActivityLifecycleCallbacks 数"眼下还有几个 Activity 可见",归零就是整个 App 不可见了。听着简单,有个经典的坑——转屏。横竖屏一转,旧 Activity 先 onStop、再新建一个 onStart,计数瞬间 1→0→1,那个 0 会被你误判成"切后台了"。
修法是在 onActivityStopped 里加一句 activity.isChangingConfigurations():为真,说明这是转屏导致的销毁,不算退后台。这一眼就像出门时回头看一下——是真走了(按了 Home,返回 false),还是只是回屋拿钥匙(配置变更,返回 true)。拿钥匙的,别急着锁门喊"人走了"。
Jetpack 的 ProcessLifecycleOwner 底层就是这套计数,但它换了个更聪明的解法:700ms 去抖。进前台立即上报,退后台压一压、等 700ms 再说。这 700ms 就像电梯关门的延迟——有人走出去(onStop)门不会立刻关,要等几百毫秒确认没人马上又进来(新 Activity onResume),才真正关门下行。转屏就是"同一个人出去又立刻进来",门根本没关成,自然不会误报。生产里别自己造轮子,直接用它。
不过得记住一句:ProcessLifecycleOwner 管的是 Activity,不是进程生死。它给你 ON_STOP,只代表"眼下没有可见的 Activity",不代表进程要死了。会不会被 lmkd 拖走,看的是 adj,不是前后台事件。这俩得分层看。
第二条,onTrimMemory(TRIM_MEMORY_UI_HIDDEN)。系统在"App 的 UI 全部离屏"时回调你,跟计数归零几乎同时,还顺手告诉你内存紧不紧。它适合当"UI 离屏"的旁证,但不能当唯一判据——内存真吃紧时,系统可能一刀 SIGKILL,这个回调压根不保证到。
翻车二:onLowMemory 在 Android 14 上死活不触发
oom-watcher 第一版,我"内存压力"那块用的是 onLowMemory()。名字多直白,内存低了就回调。
在 Android 14 车机上测,使劲造内存压力,等回调——一次没进。我把 registerComponentCallbacks 来回查了三遍,代码没毛病。那晚最挫败的不是报错——是连个错都不报。logcat 干干净净,像什么都没发生过。机器没坏,是我对世界的认知,停在了一个已经被废弃的版本里。
翻文档才反应过来:onLowMemory() 从 API 34(Android 14)起,再也不调了。它被 deprecated 了,干脆不发。连带着,"我还在前台但系统快没内存了"那个最吓人的级别 TRIM_MEMORY_RUNNING_CRITICAL,API 34 起也不再通知你了。
仍然可靠的,是这两个:TRIM_MEMORY_UI_HIDDEN(值 20,UI 全离屏)和 TRIM_MEMORY_BACKGROUND(值 40,进了后台 LRU 队头,离被杀更近一步)。判级别永远用 >=,别去比精确值——哪天系统在中间插个新级别,你写死的 == 当场失灵。
说白了,onTrimMemory 原来是把有十几格刻度的水位尺,现在被砍成只剩"没过腰"和"快淹了"两条线。想靠细刻度精确估"水还有多深"的老办法,在新系统上失灵了。要精度,得换个量法——直接去读 adj。
记忆点:Android 14(API 34)废了 onLowMemory(),停发 RUNNING_CRITICAL 这类"运行中"的细级别。trim 只剩 UI_HIDDEN / BACKGROUND 两档可靠,判级别永远用 >=。错过的题,才是真正属于你的题。
importance:系统盖章的"官方地位",和真正逼近"还有多远"的三层
/proc/self/oom_score_adj 给的是内核裸数字,准,但有的厂商会改它的含义。要一个稳的,用 ActivityManager.getMyMemoryState 里的 importance——它是 oom_adj 的官方进程内映射,跨版本语义稳定、跨 ROM 一致,还不用碰 hidepid。
importance 的桶:FOREGROUND(100) → VISIBLE(200) → PERCEPTIBLE(230) → SERVICE(300) → CACHED(400) → GONE(1000)。(小心 PERCEPTIBLE:现行 SDK 是 230,网上一堆二手资料抄成 130——那是 target<26 的老值。面试时随手报 130,懂行的当场给你纠。)
但 importance 和 oom_score_adj 不是一回事,别画等号。打个比方:
importance 是机票舱位(头等 / 商务 / 经济),oom_score_adj 是精确到个位的值机顺序号。两个都是"越靠前越优先登机",可舱位就四五档,顺序号有上千个。cached 这一个舱位,对应的 adj 是 900 到 999 一整片。lmkd 超售砍人时,看的是顺序号,不是舱位。
所以真要回答"离被杀还有多远",是三层往里逼:
- 第一层 · 档位
:getMyMemoryState 的 importance,看自己大概在哪个舱。稳,但糙。 - 第二层 · 真值
:/proc/self/oom_score_adj,看精确序号。0 还是 200 还是 906,一目了然。 - 第三层 · 刀在哪
:lmkd 的阈值,由 ro.lmk.*定。比如ro.lmk.medium默认 800——内存中度紧张时,adj ≥ 800 的先死。你的序号一旦逼近这条线,就该知道自己快上砧板了。
两个一起采,还能互相验:importance 当"系统盖章的官方地位",/proc/self/oom_score_adj 当"内核裸值交叉对照"。俩对不上,多半是这台 ROM 动过 adj 的手脚——这本身就是个值得记一笔的告警。
第三层考点,也是最狠的一击:你算得再准,被杀那一下,一个回调都没有
前面三层逼得再细,都有个绕不过去的硬墙:lmkd 杀进程用的是 SIGKILL。这个信号不可捕获、不可忽略、不可阻塞。系统一刀下来,你的 onTrimMemory、onDestroy、finally 块,统统不执行。你连一句遗言都来不及留。
所以 oom-watcher 的正确闭环,根本不是"事中等回调",而是事前埋 + 事后捞:
- 事前埋
:平时用 setProcessStateSummary()(≤128 字节)把当下的关键状态写进去——正在录制、当前 adj 快照、走到哪一步了。这是你留给"下辈子"的一张便签。 - 事后捞
:下次冷启动第一时间调 getHistoricalProcessExitReasons()(API 30 / Android 11 起),把上一条死亡记录捞回来。reason、status、pss、rss、死时的 importance,加上你埋的便签,凑出一份完整的现场。
这套 API 就是一份法医验尸报告。进程被 SIGKILL 是猝死、当场断气、留不下遗言,所以只能由系统这个法医在体外记录,等家属(下次启动的新进程)回来翻档案。getStatus() 是凶器编号(SIGKILL=9),getPss/getRss 是死时体重,getImportance() 是死时还在不在前台。
这里埋着一个面试高频、线上更高频的坑——千万别张口就说"lmkd 杀进程就返回 REASON_LOW_MEMORY"。
官方文档写得明明白白:不是所有设备都支持上报 REASON_LOW_MEMORY。不支持的机器上,同样是内存压力杀的进程,getReason() 给你的是 REASON_SIGNALED,getStatus() 是 SIGKILL(9)。要不要把"饿死"这个语义回传给你,由系统属性 sys.lmk.reportkills 说了算,先调 isLowMemoryKillReportSupported() 问一声才知道。
这就像死亡证明上写没写具体死因:人都是被同一把刀(SIGKILL)放倒的,制度健全的医院会写明"饥饿致死"(LOW_MEMORY),有的医院只写"他杀-利器"(SIGNALED + SIGKILL)。你得先打听这家医院规不规范,别拿"证明上没写饿死",就断定"人不是饿死的"。归因逻辑只认 LOW_MEMORY 的,在不支持上报的车机上,会把绝大多数内存杀漏成"未知",统计直接失真。
还有两个工程上的暗坑:这份档案是环形缓冲,每个包只留约 16 条,满了覆盖;而且记录没有唯一 ID。所以落地写法只有一种对的姿势——每次进程一启动就立刻读,自己用 getTimestamp() 去重、记下"上次处理到哪条"。像一台只存最近 16 段、还不给文件名打时间水印的行车记录仪:你不一上车就导出归档,下一脚急刹一录,最老那段就没了,还分不清哪段看过。很多博客 demo 只 forEach 打印一遍,一上生产不是漏数据就是重复上报。
记忆点:被 lmkd 杀是 SIGKILL,无回调。闭环靠"事前 setProcessStateSummary 埋便签 + 事后 getHistoricalProcessExitReasons 捞验尸单"。低内存杀的 reason 是"可选上报",不支持的设备给你的是 REASON_SIGNALED+SIGKILL,先问 isLowMemoryKillReportSupported()。顺带——车机是 system/app,有 DUMP 权限,这套还能跨包给别的关键进程验尸,从"自监控"升级成"系统级死因审计"。
2026 最新一拳:Android 17 把"离被杀多远"从猜测,变成了系统划的硬线
写到这你大概看明白了——上面整篇,本质都是一个 App 眯着眼,靠 adj、importance、trim 这些二手信号,猜自己离死还有多远。系统是被动的:它不到内存红线不动手,动手了也不解释。
本来这篇到上一节就该收尾了。直到我顺手翻到一条 2026 年 6 月的官方博客,标题里 "Android 17" 几个字让我把写好的结尾删了重写——它把这套玩法整个掀了。
从 Android 17 起(2026 年中正式版,Beta 4 在 4 月就已开始执行),系统按设备总 RAM 给每个 App 划一条内存上限。它盯的是"匿名内存"——堆,加上不映射文件的 native buffer。一旦越线,直接杀,而且不给堆栈。从"等你闹出事再敲门"的被动,变成了"按你租的平米直接划红线、超一寸就清退"的主动。
被这么杀掉,验尸单上长什么样?不是 LOW_MEMORY,是 REASON_OTHER,外加 getDescription() 里那串只此一家的暗号:MemoryLimiter:AnonSwap。这就是上一节那套 ApplicationExitInfo,在新世界里多出来的一种死法——你得认得这串字,才知道是被新规矩卡死的,而不是普通的低内存杀。
但 Android 17 也没只顾着抡刀。它同时递了把"临刑前留影"的刀法:ProfilingManager(Android 15 引入,能在生产环境程序化抓 Perfetto)新增了 TRIGGER_TYPE_ANOMALY 触发器。当你逼近那条内存红线,系统会在动手杀你之前,先替你抓一份堆转储扔到你的数据目录。官方原话是"this API callback occurs prior to any system imposed enforcements"——回调发生在任何系统强制动作之前。
于是死法升级了:以前是当场断气、靠下辈子翻验尸单(Android 11 的 ApplicationExitInfo);现在是枪响前的那一秒,先给你拍张现场照(heap dump),再扣扳机。你想复盘"到底哪儿把内存吃爆了",终于有了那张最该有的照片。(想本地试?adb shell am memory-limiter manual <pid> <mb> 能手动复现这一刀。)
把这一拳放回全文:开头那道题——"App 怎么知道自己离被杀还有多远"——在 Android 11 的世界里,答案是"自己眯着眼估,死了翻档案"。到了 Android 17,答案变成了:线是系统画的,照是系统帮你拍的。你要做的,从"猜得更准",变成了"认得出那串 MemoryLimiter,接得住那张临终照"。
回到陈工那道题
陈工那道题,从面试桌上一路追到我家电脑前,又追了我大半个月。中间那次"查无此文件",是真把我钉在椅子上动不了——我引以为傲的那个 900,原来连门都没摸到。
要是能再坐回那张桌子,他再问那句"App 在后台,怎么知道自己离被杀还有多远",我不会再抢着报 900 了。我会先把题拆成两半:前半是"读得到吗",后半才是"读到了又怎样"。
读得到吗——进程内只配看自己,/proc/self 是唯一开着的那扇门,别人全在 hidepid 后面隐了身。读到了又怎样——importance、adj、lmkd 阈值三层往里逼,能估个八九不离十;可真到 SIGKILL 那一下,谁都没有回调,只能事前埋便签、事后翻验尸单。
最后我会补一句,这是我现在才敢加的:到了 Android 17,这道题的主语都换了——不再是 App 眯着眼猜自己还有多远,是系统把线画在墙上,还在你撞线前替你拍了张照。
陈工那一笑,我大概再也不用挨了。
一句话总结
进程内做 OOM 监控,铁律是"只配看自己"——别人的进程在 hidepid=2 下不是没权限,是直接隐身(ENOENT)。能读的是
/proc/self/oom_score_adj(内核真值)和 getMyMemoryState 的 importance(官方稳定档位);想逼近"还有多远",importance→adj→lmkd 阈值(ro.lmk.*)三层往里收。前后台靠 started 计数 + isChangingConfigurations 防转屏,或直接用 ProcessLifecycleOwner 的 700ms 去抖。Android 14 废了 onLowMemory 和 RUNNING_CRITICAL,trim 判级别永远用 >=。被杀是 SIGKILL 无回调,靠 setProcessStateSummary 事前埋 + getHistoricalProcessExitReasons 事后捞,且低内存杀的 reason 是可选上报。Android 17 更进一步:按总 RAM 强制划线,超了直接杀(REASON_OTHER + MemoryLimiter:AnonSwap),但 ProfilingManager 的 ANOMALY 触发器会在杀你前先抓一份 heap dump。
面试一答到底
Q1:App 进程内能读别的进程的 oom_score_adj 吗?
普通 App 不能。/proc 用 hidepid=2 挂载,别的 UID 进程对你直接隐身——报的是 No such file(ENOENT),不是 Permission denied(EACCES),因为系统让你连"这进程存不存在"都探不到。adb shell 能读,是因为 shell 在 AID_READPROC(GID 3009)组。进程内合法能读的,只有 /proc/self/oom_score_adj。
Q2:importance 和 oom_score_adj 是什么关系?
同一套优先级的两个观测面,但刻度不同,不能画等号。importance 是 SDK 抽象档位(100/200/.../1000,像舱位),跨版本稳定;oom_score_adj 是内核真值(cached 一档就占 900~999,像精确序号),更准但可能被厂商改。系统先算 adj,再折算成 importance 给 App 看。要精度去读 /proc/self,要稳定用 importance,两个一起采还能交叉验 ROM 有没有动过手脚。
Q3:进程被 lmkd 杀了,能查到死因吗?一定是 REASON_LOW_MEMORY 吗?
SIGKILL 当场没有任何回调,死因只能事后查:getHistoricalProcessExitReasons()(API 30+)。但不一定是 REASON_LOW_MEMORY——它是"可选上报",受 isLowMemoryKillReportSupported() / sys.lmk.reportkills 控制,不支持的设备给的是 REASON_SIGNALED + status=SIGKILL(9)。所以归因要两种都认。进 cached 区就该用 setProcessStateSummary 把状态落盘,冷启动再回捞。
Q4:Android 14 到 17,内存这块有哪些变化要跟?
Android 14:onLowMemory() 不再调用,RUNNING_CRITICAL 等细级别停发,trim 只剩 UI_HIDDEN / BACKGROUND 两档可靠。Android 17:按设备总 RAM 强制每-app 内存上限,超限直接杀且无堆栈,验尸单是 REASON_OTHER + 描述含 MemoryLimiter:AnonSwap;同时 ProfilingManager 的 TRIGGER_TYPE_ANOMALY 会在系统动手前递给你一份 heap dump。趋势很清楚:系统从"被动回收"走向"主动划线 + 临刑留证"。
如果这篇帮到你,欢迎点赞、在看、转发。
另:进程内到底能不能"可靠地"知道自己离被杀还有多远?我赌评论区一多半人的第一反应,都会栽在"读 /proc/<pid>"那一步——你当初是不是也这么答的?
还有 Android 17 这刀:按 RAM 硬卡上限、超了直接杀还不给堆栈——这是终于治了内存泡沫,还是把系统的锅甩给了开发者?留言区聊聊,你站哪边。
夜雨聆风