11.OOM异常优化实践
目录介绍
- 01.常见OOM分类说明
- 1.1 OOM简单分类
- 1.2 OOM各种占比
- 1.3 OOM泄漏场景
- 1.4 如何避免OOM
- 02.OOM治理思路
- 2.1 堆内存治理思路
- 2.2 FD超限治理思路
- 2.3 线程OOM治理思路
- 03.使用那些分析工具
- 3.1 分析工具介绍
- 3.2 工具优势和弊端
- 04.常见OOM出现场景
- 4.1 堆内存分配失败
- 4.2 创建线程失败
- 05.OOM如何监听
- 5.1 OOM如何监听设计
- 5.2 OOM需要收集什么
- 06.OOM具体优化事项
- 6.1 针对大对象优化
- 6.2 针对小对象优化
- 6.3 针对图片处理优化
- 6.4 字符串使用优化
- 6.5 手机低内存优化
- 6.11 OOM可以被try…catch吗
01.常见OOM分类说明
1.1 OOM简单分类
- Java堆内存超限
- 堆内存单次分配过大。
- 多次分配累计过大【StringBuilder,Array等】
- 堆内存积累分配触顶
- 文件描述符(fd)数目超限
- pthread_create
- 线程数超限
- 虚拟内存空间不足
1.2 OOM各种占比
- pthread_create 问题占到了总比例大约在百分之 50,Java 堆内存超限为百分之 40 多,剩下是少量的 fd 数量超限。其中 pthread_create 和 fd 数量不足均为 native 内存限制导致的 Java 层崩溃,对这部分的内存问题也做了针对性优化,主要包括:
- 线程收敛、监控
- 线程栈泄漏自动修复
- FD 泄漏监控
- 虚拟内存监控、优化
- 虚拟机抛出异常
- Android 虚拟机最终抛出OutOfMemoryError的代码位于/art/runtime/thread.cc
1.3 OOM泄漏场景
- OOM错误可以分为以下几种类型:
- Java堆内存溢出(Java Heap Space):这是最常见的OOM错误类型。当应用程序在Java堆中分配的对象超过了堆的容量限制时,就会发生堆内存溢出。这通常是由于内存泄漏、大对象的创建或者堆设置不合理导致的。
- Native堆内存溢出(Native Heap Space):除了Java堆,Android应用还可以使用Native堆来分配内存,例如通过JNI调用C/C++代码。如果Native堆中分配的内存超过了堆的容量限制,就会发生Native堆内存溢出。
- 图片内存溢出(Bitmap Out of Memory):在Android中,加载大型图片时容易发生内存溢出。由于图片占用的内存较大,如果同时加载多张大图或者没有正确释放图片资源,就会导致图片内存溢出。
- 视图层级过深(View Hierarchy):Android应用中的视图层级(View Hierarchy)是由多个嵌套的视图组成的。如果视图层级过深,即嵌套层级过多,会导致内存消耗过大,从而引发OOM错误。
- 内存泄漏(Memory Leaks):内存泄漏是指应用程序中的对象在不再使用时没有被正确释放,导致内存占用不断增加。如果内存泄漏问题严重,最终会导致内存耗尽,引发OOM错误。
- 说一下在实际开发中避免内存泄漏的场景:
- 资源型对象未关闭: Cursor,File
- 注册对象未销毁: 广播,回调监听
- 类的静态变量持有大数据对象
- 非静态内部类的静态实例
- Handler 临时性内存泄漏: 使用静态 + 弱引用,退出即销毁
- 容器中的对象没清理造成的内存泄漏
- WebView: 使用单独进程
1.4 如何避免OOM
- 可以从如下几个方面去展开说明:
- AutoBoxing(自动装箱): 能用小的坚决不用大的。
- 内存复用,适用对象池
- 使用最优的数据类型
- 枚举类型: 使用注解枚举限制替换 Enum
- 图片内存优化(这里可以从 Glide 等开源框架去说下它们是怎么设计的)
- 选择合适的位图格式bitmap 内存复用,压缩图片的多级缓存
- 基本数据类型如果不用修改的建议全部写成 static final,因为 它不需要进行初始化工作,直接打包到 dex 就可以直接使用,并不会在 类 中进行申请内存
- 字符串拼接别用 +=,使用 StringBuffer 或 StringBuilder
- 不要在 onMeuse, onLayout, onDraw 中去刷新 UI
02.OOM治理思路
2.1 堆内存治理思路
- 从 Java 堆内存超限的分类来看,主要有两类问题:
- 1.堆内存单次分配过大多次分配累计过大。
- 触发这类问题的原因有数据异常导致单次内存分配过大超限,也有一些是 StringBuilder 拼接累计大小过大导致等等。这类问题的解决思路比较简单,问题就在当前的堆栈。
- 2.堆内存累积分配触顶。
- 这类问题的问题堆栈会比较分散,在任何内存分配的场景上都有可能会被触发,那些高频的内存分配节点发生的概率会更高,比如 Bitmap 分配内存。
- 这类 OOM 的根本原因是内存累积占用过多,而当前的堆栈只是压死骆驼的最后一根稻草,并不是问题的根本所在。所以这类问题我们需要分析整体的内存分配情况,从中找到不合理的内存使用(比如内存泄露、大对象、过多小对象、大图等)。
2.2 FD超限治理思路
2.3 线程OOM治理思路
03.使用那些分析工具
3.1 分析工具介绍
- 在研发和测试的时候能够提前发现内存泄漏问题。
- 业界的主流工具也是这个思路,比如 Android Studio Memory Profiler、LeakCanary、Memory Analyzer (MAT)。
3.2 工具优势和弊端
- 检测出来的内存泄漏过多,并且也没有比较好的优先级排序,研发消费不过来,历史问题就一直堆积。另外也很难和业务研发沟通问题解决的收益,大家针对解决线下的内存泄漏问题的 ROI(投入产出比)比较难对齐。
- 线下场景能跑到的场景有限,很难把所有用户场景穷尽。经常遇到一些线上的OOM激增问题,因为缺少线上数据而无从查起。
- Android 端的 HPORF 的获取依赖原生的 Debug.dumpHporf,dump 过程会挂起主线程导致明显卡顿,线下使用体验较差,经常会有研发反馈影响测试。
- LeakCanary 基于 Shark 分析引擎分析,分析速度较慢,通常在 5 分钟以上才能分析完成,分析过程会影响进程内存占用。
- 分析结果较为单一,仅仅只能分析出 Fragment、Activity 内存泄露,像大对象、过多小对象问题导致的内存 OOM 无法分析。
04.常见OOM出现场景
4.1 堆内存分配失败
- 系统源码文件:/art/runtime/gc/heap.cc
void Heap::ThrowOutOfMemoryError(Thread* self, size_t byte_count, AllocatorType allocator_type) 抛出时的错误信息: oss << "Failed to allocate a " << byte_count << " byte allocation with " << total_bytes_free << " free bytes and " << PrettySize(GetFreeMemoryUntilOOME()) << " until OOM";
- 这是在进行堆内存分配时抛出的OOM错误,这里也可以细分成两种不同的类型:
- 为对象分配内存时达到进程的内存上限。由Runtime.getRuntime.MaxMemory()可以得到Android中每个进程被系统分配的内存上限,当进程占用内存达到这个上限时就会发生OOM,这也是Android中最常见的OOM类型。
- 没有足够大小的连续地址空间。这种情况一般是进程中存在大量的内存碎片导致的,其堆栈信息会比第一种OOM堆栈多出一段信息:failed due to fragmentation (required continguous free "<< required_bytes << " bytes for a new buffer where largest contiguous free " << largest_continuous_free_pages << " bytes)"; 其详细代码在art/runtime/gc/allocator/rosalloc.cc中,这里不作详述。
4.2 创建线程失败
- 系统源码文件:/art/runtime/thread.cc
void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) 抛出时的错误信息: "Could not allocate JNI Env" 或者 StringPrintf("pthread_create (%s stack) failed: %s", PrettySize(stack_size).c_str(), strerror(pthread_create_result)));
- 这是创建线程时抛出的OOM错误,且有多种错误信息。源码这里不展开详述了,下面是根据源码整理的Android中创建线程的步骤,其中两个关键节点是创建JNIEnv结构体和创建线程,而这两步均有可能抛出OOM。
image
- 创建线程也可以归纳为两个步骤:
- 调用mmap分配栈内存。这里mmap flag中指定了MAP_ANONYMOUS,即匿名内存映射。这是在Linux中分配大块内存的常用方式。其分配的是虚拟内存,对应页的物理内存并不会立即分配,而是在用到的时候触发内核的缺页中断,然后中断处理函数再分配物理内存
- 调用clone方法进行线程创建。
- 第一步分配栈内存失败是由于进程的虚拟内存不足,抛出错误信息如下:
W/libc: pthread_create failed: couldn't allocate 1073152-bytes mapped space: Out of memory W/tch.crowdsourc: Throwing OutOfMemoryError with VmSize 4191668 kB "pthread_create (1040KB stack) failed: Try again" java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again at java.lang.Thread.nativeCreate(Native Method) at java.lang.Thread.start(Thread.java:753)
- 第二步clone方法失败是因为线程数超出了限制,抛出错误信息如下:
W/libc: pthread_create failed: clone failed: Out of memory W/art: Throwing OutOfMemoryError "pthread_create (1040KB stack) failed: Out of memory" java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memory at java.lang.Thread.nativeCreate(Native Method) at java.lang.Thread.start(Thread.java:1078)
06.OOM具体优化事项
6.1 针对大对象优化
- 主要对三种类型的大对象进行优化
- 全局缓存:针对全局缓存我们按需释放和降级了不需要的缓存,尽量使用弱引用代替强引用关系,比如针对频繁泄漏的 EventBus 我们将内部的订阅者关系改为弱引用解决了大量的 EventBus 泄漏。
- 系统大对象:系统大对象如 PreloadDrawable、JarFile 我们通过源码分析确定主动释放并不干扰原有逻辑,在启动完成或在内存触顶时主动反射释放。
- 动画:用原生动画代替了内存占用较大的帧动画,并对 Lottie 动画泄漏做了手动释放。
- 全局缓存
- 即时释放内存,举个例子
- 弱引用代替强引用
- 降级成局部内存,举个例子
6.2 针对小对象优化
- 如何小对象优化
- 小对象优化我们集中在字段优化、业务优化、缓存优化三个纬度,不同的纬度有不同的优化策略。
- 通用类优化
- 在业务中,视频是最核心且通用的 Model,抖音业务层的数据存储分散在各个业务维护了各自视频的 Model,Model 本身由于聚合了各个业务需要的属性很多导致单个实例内存占用就不低,随着用户使用过程实例增长内存占用越来越大。对 Model 本身我们可以从属性优化和拆分这两种思路来优化。
- 字段优化:针对一次性的属性字段,在使用完之后即使清理掉缓存,比如在视频 Model 内部存在一个 Json 对象,在反序列完成之后 Json 对象就没有使用价值了,可以及时清理。
- 类拆分:针对通用 Model 冗杂过多的业务属性,尝试对 Model 本身进行治理,将各个业务线需要用到的属性进行梳理,将 Model 拆分成多个业务 Model 和一个通用 Model,采用组合的方式让各个业务线最小化依赖自己的业务 Model,减少大杂烩 Model 不必要的内存浪费。
- 业务优化
- 内存缓存限制或清理:首页推荐列表的每一次 Loadmore 操作,都不会清理之前缓存起来的视频对象,导致用户长时间停留在推荐 Feed 时,缓存起来的视频对象过多会导致内存方面的压力。在通过实验验证不会对业务产生负面影响情况下对首页的缓存进行了一定数量的限制来减小内存压力。
- 缓存优化
- 使用 Manager 来管理通用的视频实例。Manager 使用 HashMap 存储了所有的视频对象,最初的方案里面没有对内存大小进行限制且没有清除逻辑,随着使用时间的增加而不断膨胀,最终出现 OOM 异常。
image - 使用 LRU 缓存机制来缓存视频对象。在内存中缓存最近使用的 100 个视频对象,当视频对象从内存缓存中移除时,将其缓存至磁盘中。在获取视频对象时,首先从内存中获取,若内存中没有缓存该对象,则从磁盘缓存中获取。在退出 App 时,清除 Manager 的磁盘缓存,避免磁盘空间占用不断增长。
- 减小对象的内存占用
使用更加轻量级的数据结构
:- 例如,我们可以考虑使用
ArrayMap
/SparseArray
而不是HashMap
等传统数据结构,相比起Android系统专门为移动操作系统编写的ArrayMap
容器,在大多数情况下,HashMap
都显示效率低下,更占内存。另外,SparseArray
更加高效在于,避免了对key与value的自动装箱,并且避免了装箱后的解箱。这个使用场景,比如封装viewholder时,使用SparseArray存储id,详细可以参考我的封装库:https://github.com/yangchong211/YCBaseAdapter
- 例如,我们可以考虑使用
避免使用Enum
:- 在Android中应该尽量使用
int
来代替Enum
,因为使用Enum
会导致编译后的dex文件大小增大,并且使用Enum
时,其运行时还会产生额外的内存占用。
- 在Android中应该尽量使用
6.3 针对图片处理优化
- 图片库
- 针对应用内图片的使用状况对图片库设置了合理的缓存,同时在应用 or 系统内存吃紧的情况下主动释放图片缓存。
- 图片自身优化
- 我们知道图片内存大小公式 = 图片分辨率 * 每个像素点的大小。
- 图片分辨率我们通过设置合理的采样来减少不必要的像素浪费。
//开启采样 ImagePipelineConfig config = ImagePipelineConfig.newBuilder(context) .setDownsampleEnabled(true) .build(); Fresco.initialize(context, config); //请求图片时,传入resize的大小,一般直接取View的宽高 ImageRequest request = ImageRequestBuilder.newBuilderWithSource(uri) .setResizeOptions(new ResizeOptions(50, 50)) .build();mSimpleDraweeView.setController( Fresco.newDraweeControllerBuilder() .setOldController(mSimpleDraweeView.getController()) .setImageRequest(request) .build());
- 而单个像素大小,我们通过替换系统 drawable 默认色彩通道,将部分没有透明通道的图片格式由 ARGB_8888 替换为 RGB565,在图片质量上的损失几乎肉眼不可见,而在内存上可以直接节省一半。
- 减小
Bitmap
对象的内存占用inBitmap
:如果设置了这个字段,Bitmap在加载数据时可以复用这个字段所指向的bitmap的内存空间。但是,内存能够复用也是有条件的。比如,在Android 4.4(API level 19)
之前,只有新旧两个Bitmap的尺寸一样才能复用内存空间。Android 4.4
开始只要旧 Bitmap 的尺寸大于等于新的 Bitmap 就可以复用了。inSampleSize
:缩放比例,在把图片载入内存之前,我们需要先计算出一个合适的缩放比例,避免不必要的大图载入。decode format
:解码格式,选择ARGB_8888
RBG_565
ARGB_4444
ALPHA_8
,存在很大差异。
ARGB_4444:每个像素占四位,即A=4,R=4,G=4,B=4,那么一个像素点占4+4+4+4=16位 ARGB_8888:每个像素占四位,即A=8,R=8,G=8,B=8,那么一个像素点占8+8+8+8=32位 RGB_565:每个像素占四位,即R=5,G=6,B=5,没有透明度,那么一个像素点占5+6+5=16位 ALPHA_8:每个像素占四位,只有透明度,没有颜色。
- 使用更小的图片:
- 在设计给到资源图片的时候,需要特别留意这张图片是否存在可以压缩的空间,是否可以使用一张更小的图片。尽量使用更小的图片不仅仅可以减少内存的使用,还可以避免出现大量的InflationException。假设有一张很大的图片被XML文件直接引用,很有可能在初始化视图的时候就会因为内存不足而发生InflationException,这个问题的根本原因其实是发生了OOM。
- 图片兜底
- 针对因 activity、fragment 泄漏导致的图片泄漏,我们在 onDetachedFromWindow 时机进行了监控和兜底,具体流程如下:
image
- 图片监控
- 关于对不合理的大图 or 图片使用我们在字节码层面进行了拦截和监控,在原生 Bitmap or 图片库创建时机记录图片信息,对不合理的大图进行上报;另外在 ImageView 的设置过程中针对 Bitmap 远超过 view 本身超过大小的场景也进行了记录和上报。
image
6.4 字符串使用优化
StringBuilder
:- 在有些时候,代码中会需要使用到大量的字符串拼接的操作,这种时候有必要考虑使用StringBuilder来替代频繁的“+”。
- 合理优化字符串拼接
- 大量的字符串拼接操作,虽然编译器已经优化成了 StringBuider append,但是深入 StringBuider 源码分析仍在存在大量的动态扩容动作(System.copy),为了优化高频场景触发动态扩容的性能损耗,在 StringBuilder 在 append()的时候,不直接往 char[]里塞东西,而是先拿一个 String[]把它们都存起来,到了最后才把所有 String 的 length 加起来,构造一个合理长度的 StringBuilder。
- 通过使用编译时字节码替换的方式,替换所有 StringBuilder 的 append 方法使用自定义实现,优化后首次安装首页 Feed 滑动 1min 的 FPS 提升 1 帧/S,非首次安装启动,滑动 1min 的 FPS 提升 0.6 帧/S。
6.5 手机低内存优化
- 综合考虑设备内存阈值与其他因素设计合适的缓存大小
onLowMemory()
:- Android系统提供了一些回调来通知当前应用的内存使用情况,通常来说,当所有的background应用都被kill掉的时候,forground应用会收到onLowMemory()的回调。在这种情况下,需要尽快释放当前应用的非必须的内存资源,从而确保系统能够继续稳定运行。
onTrimMemory()
:- Android系统从4.0开始还提供了onTrimMemory()的回调,当系统内存达到某些条件的时候,所有正在运行的应用都会收到这个回调,同时在这个回调里面会传递以下的参数,代表不同的内存使用情况,收到onTrimMemory()回调的时候,需要根据传递的参数类型进行判断,合理的选择释放自身的一些内存占用,一方面可以提高系统的整体运行流畅度,另外也可以避免自己被系统判断为优先需要杀掉的应用
- 资源文件需要选择合适的文件夹进行存放:
- 例如我们只在
hdpi
的目录下放置了一张100100的图片,那么根据换算关系,xxhdpi
的手机去引用那张图片就会被拉伸到200200。需要注意到在这种情况下,内存占用是会显著提高的。对于不希望被拉伸的图片,需要放到assets或者nodpi的目录下。
- 例如我们只在
- 其他建议
- 谨慎使用static对象
- 优化布局层次,减少内存消耗
- 使用FlatBuffer等工具序列化数据
- 谨慎使用依赖注入框架
- 使用ProGuard来剔除不需要的代码
6.11 OOM可以被try…catch吗
正确认识Error和OOM
- 这里 catch 的是一个 Error,原则上,触发了Error时,它的执行状态已经无法恢复了,此时需要终止线程甚至是终止虚拟机。这是一种不应该被我们应用层去捕获的异常。
catch OOM 的先决条件
- 想通过 try-catch 避免 OOM,你需要两个先决条件:
- 触发 OOM 的代码是开发者可控的。
- 在 try 块中,申明对象并会申请了大段内存,导致触发 OOM。
- 只有满足这两个条件,你才可以说,你对 OOM 有控制权,能够将其 catch 住。但是这通常不是合适的做法。
- 想通过 try-catch 避免 OOM,你需要两个先决条件:
是否建议catch住OOM
- 不应该去主动 catch OOM。哪怕在此处catch住了,可App当前的状态也已经处于“濒危”状态。如果不采取措施,此时不崩,换一个地方也会崩。
总结一下
- OOM 能不能被 catch 住?在某些条件下可以。仅在我们可控的代码,并且在 try 块中存在申请大量内存的情况下,此时触发的 OOM,才是可以被 catch 住的。
- 当我们 catch 住 OOM 的时候,我们应该主动释放一些我们可控的内存,做好内存管理,避免在后续的操作中,立即又会触发 OOM,导致崩溃。
抖音 Android 性能优化系列: Java 内存优化篇
- https://juejin.cn/post/6908517174667804680
抖音 Android 性能优化系列:Java OOM 优化之 NativeBitmap 方案
- https://juejin.cn/post/7096059314233671694