随笔博文

谁动了我的内存,揭秘 OOM 崩溃下降 90% 的秘密

2022-12-15 12:52:07 michael007js 93

最近一直在做内存和 ANR 相关的优化,接下来我将会花几篇文章梳理一下内存相关的优化,以及我是如何将 OOM 崩溃率下降 90%。

今天这篇文章主要介绍内存相关的知识点,以及那些因素会导致 OOM 崩溃和相对应的解决方案,所以通过这篇文章你将学习到以下内容:

  • 什么是虚拟内存和物理内存

  • 32 位和 64 位设备可用虚拟内存分别是多少

  • 为什么虚拟内存不足主要发生在 32 位的设备上

  • 如何解决虚拟内存不足的问题

  • App 启动完成之后,虚拟内存的分布

  • 如何解决 Java 堆内存不足的问题

  • Java 堆上还有很多可用的内存,为什么还会出现 OOM

  • 做性能优化时,需要关心那些指标数据

不知道小伙伴们有没有经历过,相同的优化方案,A 应用上线之后,崩溃率下降很多,但是 B 应用上线只有一点点收益,每个优化方案,在不同的 App 上所得到的优化效果未必一样,因为每个 App 在不同的国家和地区面对的用户群体不一样,因此机型也都不一样,所以我们需要了解内存相关的知识点,结合线上和线下数据,对自己的 App 进行归因,对症下药,才能取得较大的收益。

内存是极其稀缺的资源,不合理的使用会导致可用内存越来越少,可能会引发卡顿、ANR、OOM 崩溃、Native 崩溃等等,严重影响用户的体验。所以当我们在做性能优化的时候,内存优化是非常重要的环节。

初期在做内存优化的时候,在我们的脑海里都会有一个潜意识「内存占用越少越好」,在某些情况下是不对的。例如在高端机上我们可以多分配点内存,可以提升用户的体验,但是在低端机上内存本身就很小,所以我们应尽量减少内存的分配。例如针对损耗性能的动画、特效等等,在低端机上是不是可以关掉,或者关掉硬件加速、采用其他的方案代替,这样不仅可以减少崩溃,还可以减少卡顿,提高用户体验。

因为 Java 有自动回收机制,所以在开发过程中,很少有人会去关心内存问题,在脑海中都会有一个潜意识 GC 会自动回收,所以用完不会主动释放掉无用资源例如 Bitmap、动画、播放器等等,等待 GC 来回收,在实际项目中,依赖 GC 是不可靠的。首先 GC 自动回收机制具有不确定性,GC 也分为了不同的类型,如果发生 Full GC 时,会触发 stop the work 事件,会使 App 变得更加严重。

另外 GC 的回收机制根据可达性分析算法判断一个对象是否可以被回收,如果存在内存泄露,GC 是不会回收这些资源的,逐渐累积,当达到堆的内存上限时,发生 OOM 崩溃了,所以你要保证自己不要写出内存泄露的代码,以及团队其他人不要写出内存泄露的代码,然而实际情况这是不可能的,所以依靠 GC 自动回收机制这种想法是不可靠的。虽然 Java 有内存回收机制,但是我们应该在脑海中保留内存管理的意识,所以当申请完内存,退出或者不在使用时,及时释放掉内存。真正做到 用时分配,及时释放

可用内存越来越少时,严重时会导致 OOM 崩溃,做过 OOM 优化的朋友应该会发现,线上捕获的大部分 OOM 崩溃堆栈,都是压死骆驼的最后一根稻草,并不是问题的根本所在,所以我们需要对 OOM 崩溃进行归因,找到占用内存的大头。降低整机已使用的内存,从而降低 OOM 崩溃,因此我大概分为了以下几个方面。

  • 虚拟内存和物理内存

  • 堆内存

    • 堆内存泄露,指的是在程序运行时,给对象分配的内存,当程序退出或者退出界面时,分配的内存没有释放或者因为其他原因无法释放

    • 资源泄露,比如 FD、socket、线程等等,这些在每个手机上都是有数量的限制,如果使用了不释放,就会因为资源的耗尽而崩溃,我们在线上就出现过 FD 的泄露,导致崩溃率涨了 3 倍

    • 分配的内存到达 Java 堆的上限

    • 可用内存很多,因为内存碎片化,没有足够的连续段的空间分配

    • 对象的单次分配或者多次分配累计过大,例如在循环动画中一直创建 Bitmap

    • Java 堆内存溢出

    • 内存泄露

  • FD 的数量超出当前手机的阈值

  • 线程的数量超出当前手机的阈值

其中 FD 和线程崩溃占比很低,因此这不是我们前期优化的重点。这篇文章我们重点介绍 虚拟内存和物理内存,下篇文章将会介绍堆内存堆内存是程序在运行过程中为对象分配内存的区域,它也属于虚拟内存的范围。

虚拟内存和物理内存

介绍虚拟内存之前,我们需要先介绍物理内存,物理内存就是实实在在的内存(即内存条),如果应用直接对物理内存操作,会存在很多问题:

  • 安全问题,应用之间的内存空间没有隔离,会导致应用 A 可以修改应用 B 的内存数据,这是非常不安全的

  • 内存空间利用率低,应用对内存的使用会出现内存碎片化的问题,即使还有很多内存可以用,但是没有足够的连续段的内存分配,而导致崩溃

  • 效率低,多个应用同时对物理内存进行读取和写入时,使用效率会非常低

为了解决上面的问题,我们需要为每个应用分配 "中间内存" 最终会映射到物理内存上,这就是接下来要说的虚拟内存。

操作系统会为每个应用分配一个独立的虚拟内存,实现应用间的内存隔离,避免了应用 A 修改应用 B 的内存数据的问题,虚拟内存最终会映射到物理内存上,当应用申请内存时,得到的是虚拟内存,只有真正执行写操作时,才会分配到物理内存,好处是应用可以使用连续的地址空间来访问不连续的物理内存。

每个应用程序可使用的虚拟内存大小受 CPU 位宽及内核的限制。我们常说的 16 位 cpu,32 位 cpu,64 位 CPU,指的都是 CPU 的位宽,表示的是一次能够处理的数据宽度,即 CPU 能处理的 2 进制位数,即分别是 16bit,32bit 和 64bit。而目前市面上常用的是 32 位和 64 的设备。

32 位和 64 位设备可用虚拟内存分别是多少

32 位设备可以使用的虚拟内存大小 3GB

32 位 CPU 架构的设备可使用的地址空间大小为 2^32=4GB, 虚拟内存空间分为 内核空间用户空间,系统提供了三种虚拟地址空间分配的参数,代表用户空间可访问的虚拟地址空间大小。

  • VMSPLIT_3G : 默认值,表示用户空间可使用 3GB 的低地址,剩下的 1GB 高地址分配给内核

  • VMSPLIT_2G : 表示用户空间可使用 2GB 的低地址

  • VMSPLIT_1G : 表示用户空间可使用 1GB 的低地址

64 位应用可以使用的虚拟内存大小 512GB

64 位 CPU 架构的设备虽然拥有 64 位的地址空间,但是不是全部都可以使用的,为了后期的扩展,只能使用部分地址。

Android 默认的虚拟地址的长度配置为 CONFIG_ARM64_VA_BITS=39,即 Android 的 64 位应用可使用的地址空间大小为 2^39=512GB

当 32 位应用在 64 位的设备上运行时,可使用 4GB 虚拟地址空间,而 64 位应用可使用 512GB 的空间。因此在 64 位机器上不存在虚拟空间不足的问题。因此在 2019 年的时候 Google Play 要求除了提供 32 位的版本之外,还需要提供 64 位的版本。

在我们的 OOM 崩溃设备中,32 位的设备占比 50%+ 以上,虚拟内存不足主要发生在 32 位的设备上。

为什么虚拟内存不足主要发生在 32 位的设备上

在 32 位的设备上,受地址空间最大内存 4 GB 限制,内核空间占用 1G,剩下的 3G 是用户空间,我们可以通过解析 /process/pid/smaps 文件,查看当前虚拟内存分配情况。 android.googlesource/frameworks/…

  • 系统资源预分配,包含了 Zygote 进程初始化时,需要加载 Framework 层的代码和资源。供 Fork 出来的子进程可以直接使用。 Framework 资源包含:Framework 层 Java 代码、so、art 虚拟机、各种静态资源字体、文件等等

  • 系统预分配区域中其中 [anon:libwebview reservation] 区域占用 130MB 内存

  • App 自身资源,包括 App 中的代码、资源、 App 直接或者间接开启线程消耗的栈空间、 App 申请的内存、内存文件映射等内容。

  • Java 堆用于分配 Java / Kotlin 创建的对象。由 GC 管理和回收,GC 回收时将 From Space 里的对象复制到 To Space,这两片区域分别为 dalvik-main spacedalvik-main space 1, 这两片区域的大小和我当前测试机 Java 堆大小一样,都是 512 MB,如下图所示

img

根据 Android 源码中的解释,Java 堆的大小应该是根据 RAM Size 来设置的,这是一个经验值,厂商是可以更改的,如果手机 Root 之后,自己也可以改,无论 RAM 多大,到目前为止 Java 堆的上限默认都是 512MB, Google 源码的设置如下如下图所示

img

RAM (MB)-dalvik-heap. Mkheapsize (MB)
phone-hdpi-dalvik-heap. Mk32
512-dalvik-heap. Mk128
1024-dalvik-heap. Mk256
2048-dalvik-heap. Mk512
4096-dalvik-heap. Mk512
无论 RAM 多大,到目前为止堆的上限默认都是 512MB


  • 内存文件映射,mmap 是一种内存映射文件的方法,我们的 APK、Dex、so 等等都是通过 mmap 读取的,会导致虚拟内存增大,mmap 占用的内存跟读写有关系

经过分析内核、系统资源、以及各 APP 的资源占用,最后留给我们使用的内存并不是很多,所以我们要合理使用系统资源,真正做到 "用时分配,及时释放"

如何解决虚拟内存不足的问题

目前业界也有很多黑科技来释放因系统占用的虚拟内存不足的问题,这些黑科技可以参考微信分享的文章 快速缓解 32 位 Android 环境下虚拟内存地址空间不足的“黑科技”,大概有以下几个方面的优化。

  • Native 线程默认的栈空间大小为 1M 左右,经过测试大部分情况下线程内执行的逻辑并不需要这么大的空间,因此 Native 线程栈空间减半,可以减少 pthread_create OOM 崩溃

  • 系统预分配区域中其中 [anon:libwebview reservation] 区域占用 130MB 内存,可以尝试释放 WebView 预分配的内存,减少一部分虚拟内存

  • 虚拟机堆空间减半,在上面提到过有两片大小相同的区域分别 dalvik-main spacedalvik-main space 1,虚拟机堆空间减半其实就是减少其中一个 main space 所占用的内存

  • 快手针对垃圾回收器 jemalloc 的优化,释放的是 anon:libc_malloc 所占用的虚拟内存 快手 Android 内存分配器优化探索 (一)

以下统计的是在 Android 7.0 App 首次启动完成 libc_malloc 占用的虚拟内存 156MB

VssPss Rss name
159744 kB 81789 kB82320 kB[anon:libc_malloc]
复制代码

Android 11 之前使用的垃圾回收器是 jemalloc,Android 11 之后默认使用的垃圾回收器是 scudo

App 启动完成之后,虚拟内存的分布

下图是 App 在 Android 7.0 上启动完成之后所占用的虚拟内存 (Vss),不同系统、不同的 App 虚拟内存的分布都不一样,,我们可以通过解析 /process/pid/smaps 文件,查看自己的 App 虚拟内存分配情况。 android.googlesource/frameworks/…

img

正如上图所示,主要分为三个部分:

  • dalvik(即 Java 堆),程序在运行过程中为对象分配内存的区域

  • 程序文件 dexsooat

  • Native

针对上面的问题,我们在项目中通过以下手段进行优化,重点优化 dalvik 占用的内存,因篇幅问题,将会在后面的文章中,做详细的分析:

  • Android 3.0 ~ Android 7.0 上主要将 Bitmap 对象和像素数据统一放到 Java 堆中,Java 堆上限 512MB,而 Native 占用虚拟内存,32 的设备可使用 3GB,64 位的设备更大,因此我们可以尝试将 Bitmap 分配到 Native 上,缓解 Java 堆的压力,降低 OOM 崩溃,方案可以参考 抖音 Android 性能优化系列:Java OOM 优化之 NativeBitmap 方案

  • 使用第三方图片库时,需要针对高端机和低端机设置图片库不同的缓存大小,这样我们在高端机上保证体验的同时,降低低端机 OOM 崩溃率

  • 收敛 Bitmap,避免重复创建 Bitmap,退出界面及时释放掉资源(Bitmap、动画、播放器等等资源)

  • 内存回收兜底策略,当 Activity 或者 Fragment 泄露时,与之相关联的动画、Bitmap、 DrawingCache 、背景、监听器等等都无法释放,当我们退出界面时,递归遍历所有的子 view,释放相关的资源,降低内存泄露时所占用的内存

  • 收敛线程,祖传代码在项目中有很多地方使用了 new ThreadAsyncTask 、自己创建线程池等等操作,通过统一的线程池等手段减少 App 创建线程数量,降低系统的开销

  • 针对低端机和高端机采用不同的策略,减少低端机内存的占用

  • 内存泄露是永远也解决不完的,所以需要梳理一下 Top 系列泄露问题,重点解决占用内存最多的泄露,以及使用频率最高的场景所产生的泄露

  • 繁创建小对象,堆内存累计过大,这些一般都是有明显堆栈的,根据堆栈信息解决即可。例如在循环动画中一直创建 Bitmap

  • 大对象,堆的单次分配内存过大

  • 删减代码,减少 dex 文件占用的内存

  • 减少 App 中 dex 数量,非必要功能,可以通过动态下发

  • 按需加载 so 文件,不要提前加载所有的 so 文件,需要使用时再去加载

Java 堆上还有很多可用的内存,为什么还会出现 OOM

很多小伙伴们都问过我这么一个问题,大概归因了一下,主要有以下几个原因:

  • 内存碎片化,没有足够的连续段的内存分配

  • 虚拟内存不足

  • 线程或者 FD 的数量超过当前手机的阈值

文章的最后想提一点,我们在做性能优化的时候,不仅要关心性能指标数据,还需要关心对业务指标数据的影响,比如对使用时长、留存等等能提升多少。

为什么需要关心业务指标数据?

性能指标数据,比如 OOM 崩溃率、Native 崩溃率、ANR 等等、可能只有客户端的小伙伴才知道 OOM、Native、ANR 是什么意思,但是其他人(产品经理、老板等等)他们是不知道的,也不会去关心这些,但是他们对使用时长、留存等业务指标数据更加的敏感,更能够体现做这件事的价值,这只是阐述了我自己的观点,每个人站的角度不一样,观点也不一样。

全文到这里就结束了,这篇文章只是梳理一下内存相关的知识点,以及有那些因素会导致 OOM 崩溃和相对应的解决方案。下篇文章将会介绍堆内存堆内存是程序在运行过程中为对象分配内存的区域。


首页
关于博主
我的博客
搜索