Android Detail:进程篇——进程内存分配与优先级

4,769 阅读22分钟

前言的前言

你好,我是 Flywith24

已经快两个月没在掘金上发文了,熟悉我的小伙伴可能知道,我经常会将文章分门别类,按照系列来写。

这是因为我一直比较认同两个学习理念:

  • 输入倒逼输出
  • 建立系统化的知识体系

也是基于这两个理念,在 2018 年,大学刚毕业的我使用一个月的工资购买了 扔物线 的 HenCoder Plus 系列课程。而在 19 底,我在订阅了 KunMinX重学安卓 后,开始了自己的写作之路。

在今年的 8 月 24 日,我开启了一个新的系列:Android Detail,该专栏致力于帮助小伙伴们建立系统化的知识体系。就在昨天,专栏迎来了第 100 位订阅者。

唠叨结束,让我们开始这篇文章吧。

前言

很高兴见到你!👋

本文是进程篇的第二篇,前文 介绍了 Android 进程的一些核心概念,而本文将沿着两条线继续介绍进程相关的内容。

第一部分介绍 Android 中内存是如何分配的以及内存不足时的管理策略;第二部分介绍内存不足时清理内存的依据——进程优先级。

了解这些内容,再去看应用的生命周期,Activity 的生命周期等内容就会有不一样的理解。

阅读本文,你将了解:

  • Android 是如何进行进程间的内存分配的
  • 如何统计应用的内存占用
  • 当内存不足时系统使用哪两种手段来释放内存
  • 常用的进程类型
  • 进程的优先级
  • ADJ 与 procstate
  • 通过操作 + 日志直观感受 ADJ 与 procstate 的变化
  • 我就想知道进程是怎么没滴(范伟老师脸😆)
  • 通过一个案例分析流氓软件的恶心行为

推荐阅读

以下内容与本文搭配阅读效果更佳。😉

谈谈 Android 对内存使用的设计理念

Activity 任务,返回栈 一文中我们曾讨论过 Android 多任务的设计理念。为了保持最好的用户体验,Android 被设计为可以同时执行多个任务,换句话说便是允许多个 App 同时运行并使其能够快速相互切换。

而从内存的角度来说,想要实现「丝滑」切换,则必须保证切换到该应用时其对应的进程已创建并加载至内存。理想状态下,我们希望所有应用都处于运行状态。但在软件工程中,「时间」和「空间」总是一对矛盾的存在,想要获得更短的「时间」(丝滑的使用体验),则必须付出更多「空间」(加大内存)。从另一方面讲,应用长时间保持运行状态会耗费更多的电量,导致设备续航能力变差,进而影响用户体验。

Android 内存管理就是在这种矛盾的背景下设计出来的。系统不会立即杀死使用完的进程,反而会对之前创建过的进程进行缓存。当设备内存紧张时,按照一定的策略回收内存。当设备内存低至一定阈值时,系统会按照策略杀死进程以达到释放内存的目的。

本文前半部分介绍 Android 内存管理的主要结构,内存不足时的管理策略;后半部分介绍系统是按照何种策略杀死进程的,有哪些杀进程的方法。

进程间的内存分配

内存类型

Android 设备包含三种不同类型的内存:RAM、zRAM 和存储器。

内存类型 - RAM、zRAM 和存储器

  • RAM 是最快的内存类型,但其大小通常有限。高端设备通常具有最大的 RAM 容量
  • zRAM 是用于交换空间的 RAM 分区。所有数据在放入 zRAM 时都会进行压缩,然后在从 zRAM 向外复制时进行解压。这部分 RAM 会随着页面进出 zRAM 而增大或缩小。设备制造商可以设置 zRAM 大小上限
  • 存储器中包含所有持久性数据(例如文件系统等),以及为所有应用、库和平台添加的代码。存储器比另外两种内存的容量大得多。在 Android 上,存储器不像在其他 Linux 实现上那样用于交换空间,因为频繁写入会导致这种内存出现损坏,并缩短存储媒介的使用寿命

内存页

Android 的物理内存被分为多个「页」(page)。通常,每个页拥有 4KB 的内存。

不同类型的页

不同类型的页有着各自的作用:

  • 已用页(Used Pages

    被进程活跃使用的内存页

  • 缓存页(Cached Pages

    进程正在使用的内存页,缓存页在存储器中有相应的备份,必要时可以回收

  • 空闲页(Free Pages

    未使用的内存

其中 缓存页 又分为 私有页 和 共享页,它们各自又分 干净页 脏页:

  • 私有页:由一个进程拥有且未共享
    • 干净页:存储器中未经修改的文件备份

    • 脏页:存储器中经过修改的文件备份

  • 共享页:由多个进程使用
    • 干净页:存储器中未经修改的文件备份
    • 脏页:存储器中经过修改的文件备份

🌟 注意:干净页包含存在于存储器中的文件(或文件一部分)的精确备份。如果干净页不再包含文件的精确备份(例如,因应用操作所致),则会变成脏页。干净页可以删除,因为始终可以使用存储器中的数据重新生成它们;脏页则不能删除,否则数据将会丢失。

统计内存占用

如何知道应用程序占用的内存呢?

前文我们提到,设备的内存分页管理。Linux 内核会追踪设备上运行的每个进程正在使用的页。

统计内存占用

统计应用程序的内存占用,我们只需计算出应用正在使用的页数即可。这个过程略微复杂,因为还要考虑共享页的情况。使用相同服务或库的应用将共享内存页。例如,Google Play 服务和某个游戏应用可能会共享位置信息服务。这样便很难确定属于整个服务和每个应用的内存量分别是多少。

共享内存页

有以下方式来表示内存占有量:

  • 常驻内存大小 (Resident Set Size - RSS)

    应用使用的 共享页 + 非共享页的数量

  • 按比例分摊的内存大小 (Proportional Set Size - PSS)

    应用使用的 非共享页数量 + 共享页均匀分摊数量(例如,如果三个进程共享 3MB,则每个进程的 PSS 为 1MB)

  • 独占内存大小 (Unique Set Size - USS)

    应用使用的 非共享页数量(不包括共享页)

我们最常用的是 PSS。

我们可以使用 adb shell dumpsys meminfo -s [process] 来查看进程的 PSS。其中 process 输入 pid 和 applicationId 均可。

adb shell dumpsys meminfo -s [pid]

🌟 注意:在做内存优化时不能简单地比对 PSS 值来判断内存占用是否得到优化,因为不同设备,不同配置,同一个应用的不同功能,甚至在同一应用在同一使用场景下由于内存压力的不同,PSS 的值是不同的。

不同内存压力下 PSS 变化

上图中 x 轴代表内存压力,由左向右越来越大,y 轴代表 PSS 值。

蓝线代表原始 app,青色代表优化后的 app。

可以看到,在内存压力较低时,PSS 较为平稳,随着内存压力变大,kswapd 开始工作并回收一些 缓存页 ,其中可能就包括该 app 进程的页,因此 PSS 下降。当内存压力极大时触发 lmk,PSS 变为 0(关于内存不足时的管理下一小节介绍)。

我们找到两个点采样,a 和 b,a 的 PSS 小于 b,因此我们会得到原始 app 比优化后的 app 更好。而这个结论显然是错误的。

在相同的设备内存压力下比较 PSS 值才能得到相对准确的结论。由于很难控制内存压力,因此官方建议在拥有充足 RAM 的设备上进行测试,这样便保证内存压力在一个较低的水平,此时 PSS 值较为稳定,波动很小。才能更准确地判断出所做的优化是否是「负优化」

内存不足管理

Linux 中有着这样的内存管理策略:OOM Killer(Out Of Memory Killer)。这个策略主要是用于在分屏内存不足时触发,将 oom_score 最高的进程杀掉。

Android 有两种处理内存不足情况的主要机制:内核交换守护进程(kernel swap daemon)和低内存终止守护进程(Low-memory killer)。

kernel swap daemon

内核交换守护进程 (kswapd) 是 Linux 内核的一部分,用于将已使用内存转换为可用内存。当设备上的可用内存不足时,该守护进程将变为活动状态。Linux 内核维持可用内存上下限阈值。当可用内存降至下限阈值以下时,kswapd 开始回收内存。当可用内存达到上限阈值时,kswapd 停止回收内存。

kswapd 可以删除干净页来回收内存,因为这些页在存储器中有备份且未经修改。如果某个进程尝试处理已删除的干净页,则系统会将该页从存储器复制到 RAM。此操作称为「请求分页」。

干净页被删除 存储器中有备份

kswapd 可以将缓存的私有脏页和匿名脏页移动到 zRAM 进行压缩。这样可以释放 RAM 中的可用内存(可用页面)。如果某个进程尝试处理 zRAM 中的脏页,该页将被解压缩并移回到 RAM。如果与压缩页关联的进程被终止,则该页将从 zRAM 中删除。

脏页被移至 zRAM 并进行压缩

如果可用内存量低于特定阈值,系统会开始杀死进程以回收进程占用的内存。

Low-memory killer

很多时候,kswapd 不能为系统释放足够的内存。在这种情况下,系统会使用 onTrimMemory() 通知应用内存不足,应该减少其分配量。如果这还不够,内核会开始杀死进程以释放内存。它会使用低内存终止守护进程 (LMK) 来执行此操作。

不同于 OOM Killerlmk 会每隔一段时间检查一次,当达到触发阈值时,便开始工作。

那么 lmk 根据什么来杀死进程呢?这便引出了 进程类型/进程优先级 的概念。

常用的进程类型

为了确定在内存不足时应该终止哪些进程,Android 会根据每个进程中运行的组件以及这些组件的状态,将它们放入「重要性层次结构」。这些进程类型包括(按重要性排序):

前台进程(foreground process)

前台进程 是用户执行当前操作所需的进程。如果以下任何一个条件成立,该进程被视作前台进程:

  • 该进程运行一个用户正在交互的 Activity,即 Activity 的 onResume 被调用
  • 该进程正在运行一个 BroadcastReceiver,即 BroadcastReceiver 的 onReceive 方法正在执行
  • 该进程有一个 Service 正在执行 onCreateonStartonDestroy 中的代码

此类进程是最重要的进程,在系统内数量有限。因此系统会尽可能地保持此类进程的正常运行。除非内存低至连此类进程都无法继续运行。

可见进程(visible process)

可见进程 正在运行用户当先知晓的任务,因此终止该进程会对用户体验造成明显的负面影响。如果以下任何一个条件成立,该进程被视作可见进程:

  • 该进程运行着一个对用户可见但不在前台的 Activity(onPause 被调用)

    例如应用 A 所在进程是一个前台进程,但它的前台 Activity 是一个对话框,后面显示了应用 B 的 Activity。则此时应用 B 所在的进程为 可见进程。

  • 该进程正在运行着一个通过 startForground 启动的前台服务

  • 系统正在使用其托管的服务实现用户知晓的特定功能,例如动态壁纸、输入法服务等

此类进程被认为非常重要,除非内存低至无法保持所有 前台进程 正常运行,否则不会终止此类进程。

服务进程(service process)

服务进程 包含一个已使用 startService 方法启动的 Service。虽然用户无法直接看到这些进程,但它们通常正在执行用户关心的任务(例如后台网络数据上传或下载),因此系统会始终使此类进程保持运行,除非没有足够的内存来保留所有前台和可见进程。

长时间运行(如 30 分钟或更长)的 Service 可能会被 降级 成下面要介绍的 缓存进程。这避免了超长时间运行的服务因内存泄漏或其它问题占用大量内存。

缓存进程(cached process)

缓存进程 是目前不需要的进程,因此如果其它地方需要内存,系统会自由地杀死该类进程。为了更高效地切换应用,系统始终保持有多个 缓存进程 可用,并根据需要定期杀死最早的进程。只有在紧急情况下系统才会达到杀死所有缓存进程的地步,此时开始杀死服务进程。

其实系统内对进程优先级的划分更为详细,使用 oom_score_adj 来描述。

进程优先级

ADJ

在 Android 的 lmk 机制中,会对于所有进程进行分类,对于每一类别的进程会有其 oom_adj 值的取值范围,oom_adj 值越高则代表进程越不重要,在系统执行低杀操作时,会从 oom_adj 值越高的开始杀。进程级别以变量的形式定义在 ProcessList.java 中。

从 Android 7.0 开始,ADJ 采用 100、200、300。在这之前的版本 ADJ 采用数字 1、2、3。这样的调整可以更进一步地细化进程的优先级。

下图基于 Android 11 源码。

ADJ

上图中颜色标识的便是上一小节介绍的常用的进程模式。

PERCEPTIBLE_LOW_APP_ADJ 为 Android 10 新增;

PERCEPTIBLE_RECENT_FOREGROUND_APP_ADJ 为 Android 9 新增。

procstate

ADJ 是以 lmk 的角度对进程优先级的描述,相对比较底层。在 Java 世界中管理着 Android 四大组件和进程的是 AMS(Activity Manager Service)。AMS 对进程优先级的描述为 procstate(Process State),以变量的形式定义在 frameworks/base/core/java/android/app/ActivityManager.java 中。

下图基于 Android 11 源码。

Process State

🌟 不同版本略有差异。

例如 Android 10 中 PROCESS_STATE_FOREGROUND_SERVICE_LOCATION = 3,Android 11 删除了该属性并且值依次提前。

如何查询进程优先级

adb shell dumpsys meminfo

我们可以使用 adb shell dumpsys meminfo 命令来查看 进程 ADJ 值:

adb shell dumpsys meminfo

adb shell dumpsys activity o

也可以使用 adb shell dumpsys activity o 查询 OOM 相关的信息。

adb shell dumpsys activity o

adb shell dumpsys activity p

还可以使用 adb shell dumpsys activity p 查看每个进程详细的信息

adb shell dumpsys activity p

来点更直观的体验?😍

下图使用的是 Android 10 的设备,因此 procstate 值与上表略有不同,但属性名是相同的。

显示日志是因为我将 ActivityTaskManagerDebugConfig 中的 DEBUG_ALL 打开了,手头上没有显示的设备的小伙伴可以在 这篇文章的文末 下载。

直观感受 adj 变化

  1. 在桌面上打开测试 app,adj = 0,此时 app 进程为前台进程,AMS 中进程状态为 TOP

  2. 点击 home,此时瞬间有两个变化

    • activity pause,此时 adj = 200,属于用户可感知进程

    • activity stop,此时 adj = 700,属于上一个应用进程(优先级比缓存进程高),AMS 中进程状态为 LAST

  3. 打开图库(其它任意应用均可),此时 测试 app adj 没有变化,仍为上一个应用进程。(用户可从最近任务列表,或手势操作切回测试 app)

  4. 再次点击 home,此时上一个应用进程为 图库 所在进程。测试 app 所在进程 adj = 900,属于缓存进程,AMS 中进程状态为 CAC(缓存进程,包含 activity)

相信你已经对这部分内容有一个直观的认识了,你还可以自己尝试更多的场景。😉

我就想知道进程是怎么没的😝

范伟老师脸

Linux 杀进程方式

我们都知道,进程间通信有一个方式叫作「信号」。Linux 提供了几十种信号,分别代表不同的意义。信号之间依靠它们的值来区分。信号可以在任何时候发送给某一进程,进程需要为这个信号配置信号处理函数。当某个信号发生的时候,就默认执行这个函数就可以了。这就像一个系统应急手册,当遇到什么情况,做什么事情,都事先准备好,出了事情照着做就可以了。

一旦有信号产生,用户进程对信号有以下的处理方式:

  • 执行默认操作

    Linux 对每种信号都规定了默认操作。

  • 捕捉信号

    我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。

  • 忽略信号

    有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSEGSTOP,它们用于在任何时候中断或结束某一进程。

Linux 杀死进程的方式便是依托 SIGKILL 信号,它的值是 9。

Android 底层杀进程方式

Android 杀进程底层也是使用的信号的方式。

frameworks/base/core/java/android/os/Process.java 使用了三种信号:

  • SIGNAL_QUIT = 3
  • SIGNAL_KILL = 9
  • SIGNAL_USR1 = 10

该类还封装了三个杀死进程的静态方法,它们最终会调用相应的 native 方法。底层通过系统调用进入内核。

Process kill 三剑客

Process kill 三剑客源码

🌟 其中 killProcessQuietkillProcessGroup 被标记为 @hide,app 层的开发者只能调用 killProcess(int pid)。而 killProcess 与 killProcessQuiet 的唯一区别是 前者打印日志,后者不打印

上层(AMS)杀进程均是对这三个方法的调用

killProcess 虽然是一个静态方法,开发人员可以调用,但 app 层只能调用该方法来实现「自杀」。如果能随意杀死其他进程,那么可就「天下大乱」了。

Process.killProcess(Process.myPid())

调用 killProcess 自杀

上一小节我们提到,信号值为 9 的信号既不能被忽略也不能被捕捉,因此直接由内核处理,无法有其他操作。这有点像「君让臣死,臣不得不死」,并且没有一丝丝犹豫,没带走一片云彩。

对信号 3 和 10,则是交由目标进程(art 虚拟机)的 SignalCatcher 线程来捕获完成相应操作的。

这部分源码详情可参见 Gityuan 的 理解杀进程的实现原理

上层 AMS 杀进程方式

在 Java 世界,AMS 中封装着杀死进程的方法,不过本质上都是上面 Process 的三个 kill 方法的调用。

AMS 中 kill 相关的方法

其中 SYSTEM_UID 指配置了与 system sharedUserId,其他权限指在 Manifest 声明相关的权限

上表中功能最强,效果最好的方法是 forceStopPackage

force stop

force stop 是 Android 中杀进程的一把利器,使用它可以 杀死指定包名的进程,清理相关的四大组件,清除已注册的 alarm 和 notification

我们以 adb shell am force-stop 命令为例,梳理一下 force stop 的工作流程。

adb shell am 命令会调用 frameworks/base/services/core/java/com/android/server/am/ActivityManagerShellCommand.javaonCommand 方法。

该方法会根据传入的 cmd 字符串进入到不同的分支,而 force-stop 命令会执行 runForceStop 方法,该方法内部最终调用到 AMS 的 forceStopPackage 方法。其主要代码如下:

adb shell am force-stop 主要代码逻辑

接下来我们简单梳理一下 AMS 的 forceStopPackage 方法:

该方法主要有两个操作:

  1. 清除进程,四大组件
  2. 移除 Alarm Notification

详细内容可参考 Gityuan 的 Android进程绝杀技--forceStop。虽然该文是根据 6.0 的源码解析的,但笔者对比了 Android 11 的源码,其核心逻辑没有太大变化,如果想深入了解这部分源码,这篇文章很仍有很大的参考价值。

手机设置-应用详情的强行停止,就是 force stop

force stop

只看代码比较抽象,我们来更直观的感受一下 force stop 的威力!

感受一下 force stop 的威力

多进程架构

我们在开发过程中经常将 UI 与 Service 分离,使用不同的进程。这样做的目的是为了提高进程优先级,包含 Activity 的 Service 进程与不包含的 Service 进程 ADJ 不同。但 force stop 会将该应用的所有相关进程都 kill 掉。因此不要认为进程分离后便可逃过 force stop 「毒手」

shareUserId

shareUserId 我们在 前文 已有介绍,关于 shareUserId 有两个重要的内容:

  • 只有具有相同签名(以及请求了相同 sharedUserId)的两个应用才能够获得相同的用户 id
  • 具有相同 shareUserId 的应用不代表一定运行在同一进程中

在 Gityuan Android进程绝杀技--forceStop 一文中提到使用相同的 shareUserId 会建立「生死与共」的强关系:

摘自 Gityuan 博客

但笔者在 Android 10 设备上并没有看到上述现象,欢迎了解这方面知识的小伙伴在评论区留言。

案例分析——超级清理王

近日在邓老师的群里看到这样一个流氓软件,即使用户手动点击强行停止,该软件也能重启。

牛逼哄哄的流氓应用

即使点击强行停止(force stop),仍能重启

这个案例 MIUI 的大佬已经解释了,贴图在本节末尾。而这一节我们主要介绍根据前文的内容来分析该软件的流氓行为。

我们通过 adb shell ps 命令并过滤该应用 uid 得到以下结果:

超级清理王 初始进程信息

从图中可以看出,该应用有 6 个进程:

其中前 4 个进程的 PPID(父进程 id)为 782(经查询其父进程为 Zygote 而并非 Zygote64,这意味着它是个 32 位应用)。

最后 2 个进程(maindaemon)的 PPID 为 1(init 进程),它们是 native 进程。

接着我们使用 adb shell am force-stop com.dn.cpyr.qlds 命令或手动点击「强行停止」按钮杀掉该应用进程,随后再次使用 ps 命令打印进程信息。

force stop 后的进程信息

我们发现该应用的进程还在,但 pid 不同了,这意味着 之前的进程已被杀掉,但随后重启

我们可以通过系统日志来验证上述结论是否正确。

日志1

日志2

从上方日志可以看出,原有进程的确被杀,而后其创建的 native 进程发送 crash,紧接着新的进程便创建起来,完成重生。

对此,我在群中找到了这样的解释:

MIUI 大佬解释,摘自群聊

上图来自邓老师微信群

其实,该应用为了实现「杀不死」,主要从在两个方向上进行了处理:

  • 被杀后重启,即上面提到的
  • 通过各种手段提高进程优先级

笔者对该应用进行了反编译,对其提高进程优先级的手段整理如下:

  • UI 进程与 Service 进程分离

    Service 使用单独进程

  • 使用 MediaPlayer 播放无声音乐

    播放无声音乐

  • 使用 AccountManager 备份数据

    使用 AccountManager 备份数据

  • 注册无障碍服务(辅助功能)

    注册无障碍服务

  • 注册设备管理器

    注册设备管理器

这种流氓软件应该直接送它一个操作,再也不见!👎

再也不见!

总结

  • Android 的内存被分为一个个「页」,每个「页」大约 4 KB

  • 查看应用占用内存最常见的方式是查看 PSS,可以使用 adb shell dumpsys meminfo -s [process] 查看

  • 当内存到达 kswapd 工作的阈值范围时,kswapd 通过删除干净页和压缩脏页来回收内存

  • 当内存达到 lmk 工作的阈值范围时, lmk 通过杀死进程来回收内存

  • 进程根据重要性不同有着重要的优先级,优先级低的优先被 lmk 杀死

  • 在系统上层,AMS 管理着进程,对应的进程优先级描述为 procstate

  • 底层的角度,对应的进程优先级描述为 ADJ

  • Linux 中使用信号的方式杀死进程,Android 也采用相同的方式,源码在 Process.java 中

  • force stop 具有很强大的力量,所以不要使用所谓的「黑科技」占用设备内存