前言的前言
你好,我是 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 是最快的内存类型,但其大小通常有限。高端设备通常具有最大的 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 均可。
🌟 注意:在做内存优化时不能简单地比对 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 中删除。
如果可用内存量低于特定阈值,系统会开始杀死进程以回收进程占用的内存。
Low-memory killer
很多时候,kswapd
不能为系统释放足够的内存。在这种情况下,系统会使用 onTrimMemory()
通知应用内存不足,应该减少其分配量。如果这还不够,内核会开始杀死进程以释放内存。它会使用低内存终止守护进程 (LMK) 来执行此操作。
不同于 OOM Killer
,lmk
会每隔一段时间检查一次,当达到触发阈值时,便开始工作。
那么 lmk
根据什么来杀死进程呢?这便引出了 进程类型/进程优先级 的概念。
常用的进程类型
为了确定在内存不足时应该终止哪些进程,Android 会根据每个进程中运行的组件以及这些组件的状态,将它们放入「重要性层次结构」。这些进程类型包括(按重要性排序):
前台进程(foreground process)
前台进程 是用户执行当前操作所需的进程。如果以下任何一个条件成立,该进程被视作前台进程:
- 该进程运行一个用户正在交互的 Activity,即 Activity 的 onResume 被调用
- 该进程正在运行一个 BroadcastReceiver,即 BroadcastReceiver 的 onReceive 方法正在执行
- 该进程有一个 Service 正在执行 onCreate、onStart、onDestroy 中的代码
此类进程是最重要的进程,在系统内数量有限。因此系统会尽可能地保持此类进程的正常运行。除非内存低至连此类进程都无法继续运行。
可见进程(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 源码。
上图中颜色标识的便是上一小节介绍的常用的进程模式。
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 源码。
🌟 不同版本略有差异。
例如 Android 10 中
PROCESS_STATE_FOREGROUND_SERVICE_LOCATION = 3
,Android 11 删除了该属性并且值依次提前。
如何查询进程优先级
adb shell dumpsys meminfo
我们可以使用 adb shell dumpsys meminfo
命令来查看 进程 ADJ 值:
adb shell dumpsys activity o
也可以使用 adb shell dumpsys activity o
查询 OOM 相关的信息。
adb shell dumpsys activity p
还可以使用 adb shell dumpsys activity p
查看每个进程详细的信息
来点更直观的体验?😍
下图使用的是 Android 10 的设备,因此 procstate 值与上表略有不同,但属性名是相同的。
显示日志是因为我将 ActivityTaskManagerDebugConfig
中的 DEBUG_ALL
打开了,手头上没有显示的设备的小伙伴可以在 这篇文章的文末 下载。
-
在桌面上打开测试 app,adj = 0,此时 app 进程为前台进程,AMS 中进程状态为 TOP
-
点击 home,此时瞬间有两个变化
-
activity pause,此时 adj = 200,属于用户可感知进程
-
activity stop,此时 adj = 700,属于上一个应用进程(优先级比缓存进程高),AMS 中进程状态为 LAST
-
-
打开图库(其它任意应用均可),此时 测试 app adj 没有变化,仍为上一个应用进程。(用户可从最近任务列表,或手势操作切回测试 app)
-
再次点击 home,此时上一个应用进程为 图库 所在进程。测试 app 所在进程 adj = 900,属于缓存进程,AMS 中进程状态为 CAC(缓存进程,包含 activity)
相信你已经对这部分内容有一个直观的认识了,你还可以自己尝试更多的场景。😉
我就想知道进程是怎么没的😝
Linux 杀进程方式
我们都知道,进程间通信有一个方式叫作「信号」。Linux 提供了几十种信号,分别代表不同的意义。信号之间依靠它们的值来区分。信号可以在任何时候发送给某一进程,进程需要为这个信号配置信号处理函数。当某个信号发生的时候,就默认执行这个函数就可以了。这就像一个系统应急手册,当遇到什么情况,做什么事情,都事先准备好,出了事情照着做就可以了。
一旦有信号产生,用户进程对信号有以下的处理方式:
-
执行默认操作
Linux 对每种信号都规定了默认操作。
-
捕捉信号
我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
-
忽略信号
有两个信号是应用进程无法捕捉和忽略的,即
SIGKILL
和SEGSTOP
,它们用于在任何时候中断或结束某一进程。
Linux 杀死进程的方式便是依托 SIGKILL
信号,它的值是 9。
Android 底层杀进程方式
Android 杀进程底层也是使用的信号的方式。
frameworks/base/core/java/android/os/Process.java 使用了三种信号:
SIGNAL_QUIT
= 3SIGNAL_KILL
= 9SIGNAL_USR1
= 10
该类还封装了三个杀死进程的静态方法,它们最终会调用相应的 native 方法。底层通过系统调用进入内核。
🌟 其中
killProcessQuiet
与killProcessGroup
被标记为 @hide,app 层的开发者只能调用 killProcess(int pid)。而 killProcess 与 killProcessQuiet 的唯一区别是 前者打印日志,后者不打印。上层(AMS)杀进程均是对这三个方法的调用。
killProcess
虽然是一个静态方法,开发人员可以调用,但 app 层只能调用该方法来实现「自杀」。如果能随意杀死其他进程,那么可就「天下大乱」了。
Process.killProcess(Process.myPid())
上一小节我们提到,信号值为 9 的信号既不能被忽略也不能被捕捉,因此直接由内核处理,无法有其他操作。这有点像「君让臣死,臣不得不死」,并且没有一丝丝犹豫,没带走一片云彩。
对信号 3 和 10,则是交由目标进程(art 虚拟机)的 SignalCatcher 线程来捕获完成相应操作的。
这部分源码详情可参见 Gityuan 的 理解杀进程的实现原理。
上层 AMS 杀进程方式
在 Java 世界,AMS 中封装着杀死进程的方法,不过本质上都是上面 Process 的三个 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.java 的 onCommand
方法。
该方法会根据传入的 cmd 字符串进入到不同的分支,而 force-stop 命令会执行 runForceStop
方法,该方法内部最终调用到 AMS 的 forceStopPackage
方法。其主要代码如下:
接下来我们简单梳理一下 AMS 的 forceStopPackage 方法:
该方法主要有两个操作:
- 清除进程,四大组件
- 移除 Alarm Notification
详细内容可参考 Gityuan 的 Android进程绝杀技–forceStop。虽然该文是根据 6.0 的源码解析的,但笔者对比了 Android 11 的源码,其核心逻辑没有太大变化,如果想深入了解这部分源码,这篇文章很仍有很大的参考价值。
手机设置-应用详情的强行停止,就是 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 会建立「生死与共」的强关系:
但笔者在 Android 10 设备上并没有看到上述现象,欢迎了解这方面知识的小伙伴在评论区留言。
案例分析——超级清理王
近日在邓老师的群里看到这样一个流氓软件,即使用户手动点击强行停止,该软件也能重启。
这个案例 MIUI 的大佬已经解释了,贴图在本节末尾。而这一节我们主要介绍根据前文的内容来分析该软件的流氓行为。
我们通过 adb shell ps
命令并过滤该应用 uid 得到以下结果:
从图中可以看出,该应用有 6 个进程:
其中前 4 个进程的 PPID(父进程 id)为 782(经查询其父进程为 Zygote 而并非 Zygote64,这意味着它是个 32 位应用)。
最后 2 个进程(
main
,daemon
)的 PPID 为 1(init 进程),它们是 native 进程。
接着我们使用 adb shell am force-stop com.dn.cpyr.qlds
命令或手动点击「强行停止」按钮杀掉该应用进程,随后再次使用 ps 命令打印进程信息。
我们发现该应用的进程还在,但 pid 不同了,这意味着 之前的进程已被杀掉,但随后重启。
我们可以通过系统日志来验证上述结论是否正确。
从上方日志可以看出,原有进程的确被杀,而后其创建的 native 进程发送 crash,紧接着新的进程便创建起来,完成重生。
对此,我在群中找到了这样的解释:
上图来自邓老师微信群
其实,该应用为了实现「杀不死」,主要从在两个方向上进行了处理:
- 被杀后重启,即上面提到的
- 通过各种手段提高进程优先级
笔者对该应用进行了反编译,对其提高进程优先级的手段整理如下:
-
UI 进程与 Service 进程分离
-
使用 MediaPlayer 播放无声音乐
-
使用 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 具有很强大的力量,所以不要使用所谓的「黑科技」占用设备内存
今天的文章Android Detail:进程篇——进程内存分配与优先级分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/14273.html