作为 Android 开发者,相信大家都碰到过 Java OOM 问题,导致 OOM 的原因可能是应用存在内存泄漏,也可能是因为手机的 heapsize 比较小不能满足复杂应用对内存资源的大量需求。对于 Java 内存泄漏治理,业界已经有比较成熟的方案,这里不做介绍,本文主要针对第二点尝试进行分析和优化。
举个例子:我们在监控平台查看稳定性数据,发现 heapsize=256M 的设备发生的 OOM 崩溃最多,而 heapsize=512M 的设备很少发生 OOM 崩溃。且除此之外,还有一个特点:OOM 崩溃绝大多数发生在 Android 8.0 之前的设备。
对于这种 heapsize 较小难以满足业务复杂度的情况,可能有以下几种方式来解决:
1. 增加 heapsize
如果我们已经设置了 largeHeap,也就没有常规的提升 heapsize 的方式了;再想往前一步,可以尝试从虚拟机中突破这个限制,因为 heapsize 是虚拟机的配置,是否抛出 OOM 异常也是在虚拟机中决定的;修改虚拟机运行逻辑是有一定可能的,但是其难度和可行性与想要修改的内容相关性较大,修改方案的稳定性也需要非常深厚的功力才能保证,而如果运气不好,找不到好的切入点,甚至从理论上都无法保证其稳定性,那么达到上线的难度就更大了,本文不在这个方向深入。
2. 降低业务复杂度,裁剪应用功能
这个方案也不在我们的考虑范围之内,实际上很多应用都有推出极速版,但是功能都会有所裁剪,对于使用常规版本的用户,我们也不能推送极速版,因为使用体验会有很大变化。
3. 分析 Java Heap 里的内容都是什么,尝试发现主要矛盾进行优化,对症下药
实际上本文就是从这个方向经过调查后,找到了一个相对稳定的突破口。下面是结合 OOM 堆栈、android 版本、heapsize 维度对 OOM 整体概况的一个分析:
- 最常见 OOM 堆栈
出现最多的堆栈就是 Bitmap 创建时内存不足从而 OOM 崩溃,那么是不是已使用的内存大多都是 Bitmap 呢 ?不能 100%确定,因为直接触发 OOM 崩溃的原因是最后一次内存分配失败,而真正的原因是 OOM 之前的内存分配;但是仍然有一定可能性,因为总是出现同一个堆栈可能并不是巧合,可以在一定程度上说明这个堆栈执行的比较频繁,而且 Bitmap 一般占用内存较大。
这里先做一个不 100%确认的初步推断:OOM 时 Java heap 中占用内存较多的对象是 Bitmap。
- OOM 在不同 android 版本、heapsize 上的表现
继续对 OOM 数据做总结后发现了 OOM 的分布规律如下图:
上图红色地雷代表 OOM,横坐标是 android 版本,纵坐标是 heapsize,原点是:(android8.0, 384M);可以看到:
简单总结就是:
第四象限的数据说明,即便在 heapsize 较小的情况下,在 android 8.0 之后的版本上也不容易发生 OOM,结合上面的初步推断信息“OOM 时 Java heap 中占用内存较多的对象是 Bitmap”,很容易想到,应该是 Bitmap 在 android 8.0 前后的实现变化导致了当前的 OOM 分布现象:
Bitmap 变化:
- 在 Android 8.0 之前,Bitmap 像素占用的内存是在 Java heap 中分配的
- Android 8.0 及之后,Bitmap 像素占用的内存分配到了 Native Heap
由于 Native heap 的内存分配上限很大,32 位应用的可用内存在 3~4G,64 位上更大,虚拟内存几乎很难耗尽,所以在前面的推测 “OOM 时 Java heap 中占用内存较多的对象是 Bitmap” 成立的情况下,应用更不容易 OOM。
而第三象限数据,则进一步佐证了前面的推测,Android 8.0 之前,Bitmap 像素内存在 Java heap 中分配时,即便 heap size 大到 512M,OOM 发生也比较多。
至此,得到了确定的结论:
根据上述结论,目标也就比较清晰了:
使 Android 8.0 之前 Bitmap 的像素内存也从 Native 层分配,从而减少 Java OOM 崩溃。
想要使得 Android 8.0 之前的设备 Bitmap 像素内存也分配在 Native heap,需要先把 Bitmap 的创建流程调查清楚。
如下堆栈描述了 Bitmap 的创建:
Bitmap 的构造方法是不公开的,在使用 Bitmap 的时候,一般都是通过 Bitmap、BitmapFactory 提供的静态方法来创建 Bitmap 实例。下图中以 Bitmap.createBitmap 说明了 Bitmap 对象的主要创建过程:
从上图可以看到 Java Bitmap 对象是在 Native 层通过 NewObject 创建的。图中的两个函数:
allocateJavaPixelRef 通过 newNonMovableArray 从 Java 堆上为 Bitmap 像素分配内存,然后再构造 Native Bitmap 对象,对应的构造函数如下:
构造函数中发现 Native Bitmap 构造时对应的 mPixelStorageType 是 PixelStorageType::Java,表示 Bitmap 的像素是保存在 Java 堆上,所以尝试看下 PixelStorageType 总共有几种,是否可能有把 pixels 数据存储到 Native 层。查找代码发现 PixelStorageType 只有三类,如下:
这个信息可以作为一个切入点,在后面进行深入调查。
allocateHeapBitmap 主要是通过 calloc 为 Bitmap 的像素分配内存,这个分配就在 Native 堆上了。
通过初步的分析,初步有两个思路可以先进行尝试:
这个思路看起来想要实现目标,做一下替换就可以了,但实际上没有这么简单,存在的问题如下:
所以这个思路基本可以断定不可行。
前面的调查发现 PixelStorageType 只有三类,如下:
其中 External 方式存储 Bitmap 像素,在源码中没有看到相关使用,无法参考;Java 类型就是默认的 Bitmap 创建方式,像素内存分配的 Java 堆上;Ashmem 方式存储 Bitmap 像素的方式在源码中有使用,主要是在跨进程 Bitmap 传递时使用,对应的场景主要是 Notification 和截图场景:
查看其实现:
从代码中看到 allocateAshmemPixelRef 这个函数是通过 mmap ashmem 内存来创建 native Bitmap 对象,且参数、返回值都与 allocateJavaPixelRef 相同,所以使用 Ashmem 方式存储 Bitmap 像素看起来有一定可行性,只需把 allocateJavaPixelRef 的调用替换为 allocateAshmemPixelRef 即可达到从 Native 层为 Bitmap 像素分配内存的目的。
但经过详细的源码分析以及实际验证,其可行性仍然很低,主要原因如下:
1 . allocateAshmemPixelRef 实现只在 android 6.0 ~ android7.1 上存在,所以这个方案即便能够实现,也只能覆盖 android 6.0 ~ android 7.1
实际情况中,6.0 系统的 OOM 占了非常大一部分,如果这个方案可行,也可以解决一部分问题,所以不会因为这个原因阻碍对这种方案的尝试,还可以继续尝试
2 . ashmem 方式存储 Bitmap 像素,每个 Bitmap 需要对应一个 fd,应用的 Bitmap 使用数量是能够达到 1000+ 的,这样可能会导致 fd 资源使用耗尽,从而发生崩溃
这个问题基本是无解的,但如果方案可行,可以尝试只给一定数量的 Bitmap 使用 ashmem 方式申请像素内存,比如 500 个;所以方案还可以继续尝试
3 . 最终尝试后发现这种方式影响 Bitmap 正常功能(一些视频动图不能正常展示),经分析主要原因是使用 ashmem 申请的 Bitmap 无法进行 reconfigure :
上图 Bitmap 的 reconfigure 代码中可以看到没有 mBuffer 的 Bitmap 不支持 reconfigure,Ashmem 方式创建的 Bitmap 没有从 Java 堆申请 mBuffer,所以一定是不支持 reconfigure 的。当然到这里之后还没有完全堵死这个方式,还可以继续尝试在 ashmem 方式申请 Bitmap 时给其一个假的 mBuffer 来绕过这个限制,但接下来要做的调查和改动势必很大,因为 ashmem 方式申请 Bitmap 本身不支持 mBuffer 的管理,新创建的 buffer 就难以找到合适的时机进行释放。
结合上述 3 个点综合判断,这个方案限制比较多,也有一定风险,所以暂时将当前的方案暂时挂起,作为备用方案。
上述的两种思路不成功其实有一定的必然性,毕竟对应代码的设计并不是为了给我们取巧做切换用的。既然没有办法这么容易实现,就深入调查清楚为 Bitmap 从 Java 堆申请内存的流程和这个内存的使用流程,再尝试从这些流程中找到切入点进行修改。
调查思路:
实际就是查找 hook 点的思路,先分析内存是如何分配的,分配出来的内存是如何使用的(主要指分配出内存后,指针或者对象的传递路径),尝试把从 Java 堆分配内存的关键点替换为使用 malloc/calloc 函数从 Native 堆上进行分配,并把分配出来的内存指针构造成原流程中使用的数据结构,并保证其能够正常运行。
Android 8.0 之前 Bitmap 内存申请和使用如下图:
上图为简化后的核心内存分配流程,框起来的部分就是为 Bitmap 从 Java heap 申请像素内存的代码。其中:
这里需要先说明一下 java byte array 的内存布局(对应代码在 ART 虚拟机中):
前面的 8 个字节是 Object 成员,length_ 是这个数组的长度,first_element_ 数组用来实际存放 byte 数据,数组的长度由 length_/4 来决定。addressOf(arrayObj) 获取到的就是 first_element_地址;arrayObj 和 addr 的传递在上图已经用分别用绿色和红色虚线箭头标记出来了。
想要把 Bitmap 内存分配改为在 Native 层分配,就需要从分配这里入手, 所以必须要把 arrayObj 和 addr 使用梳理清晰,为后续替换和适配做好铺垫。arrayObj 和 addr 使用如下:
1. 在 Native 层使用,即在 android::Bitmap 对象中使用
2. 在 Java Bitmap 对象中引用,对应 Bitmap 的 mBuffer 成员
小结:arrayObj 对象的引用只在 Bitmap native 对象和 Java 对象中,作用分别是用来管理 arrayObj 的生命周期以及使用它的 length 来获取 Bitmap 像素占用的内存大小。
在为 Bitmap 分配 nonMovableArray 之后,通过 addr = addressOf(arrayObj)获取:
在创建 native bitmap 时,作为指针传递给其成员 mPixelRef:
上述参数 mStorage 就是 addr,其关键使用点是在 WrappedPixelRef 的 onNewLockPixels 被调用时,赋值给 LockRec 的 fPixels 成员:
mPixelRef 会被设置给 skBitmap。
每个 nativeBitmap 对应一个 skia 的 skBitmap 对象,在创建 Bitmap 时会把 native bitmap 的成员 mPixelRef 设置给 skBitmap:
在 skia 中 SkBitmap 绘制 Bitmap 需要使用内存来处理 Bitmap 像素数据时,就会通过 mPixelRef->onNewLockPixels() 来获取存放 Bitmap 像素的内存地址,即 arrayObj 的元素地址 addr,其是作为指针类型数据来使用的。
小结:addr 指向的内存是在 java 堆上,其会在需要的时候被传递给 skia 用来处理 bitmap 像素数据。
Bitmap 内存使用总结:
存储 Bitmap 像素数据使用的内存是通过 NewNonMovableArray 从 Java heap 申请的 byte 数组 arrayObj,arrayObj 对象的引用只在 Bitmap native 对象和 Java 对象中,作用分别是用来管理 arrayObj 的生命周期以及使用它的 length 来获取 Bitmap 像素占用的内存大小。
skia 中并不会为 Bitmap 的像素数据分配内存,它把 Java heap 上 byte 数组的元素首地址转换为 void* 来使用;也就是说在当前实现中,Bitmap 像素内存不一定非得是在 Java heap 上分配,我们可以 malloc 一块内存传递给 skia 使用,并不需要再给 skia 做任何适配。
有了上面这些信息,把 android 8.0 之前的 Bitmap 像素内存改到在 Native 层分配目标就看到了希望,因为不需要在 skia 层适配,可以降低一定难度。
根据上面的分析,只需要找好 hook 的切入点,并完成 3 个关键点的替换即可,如下图:
上述 3 个关键点中,前两个点比较好实现,都是 native 层的代码,hook 点也比较好找,这里不再赘述。而第 3 个点需要特殊处理,因为 Java 层 Bitmap 通过 mBuffer.length 获取 Bitmap size,目前没有稳定的 Java hook 方案,且我们又不能真的给它一个长度为 Bitmap size 大小的 byte[](那样就又从 Java 堆上进行 Bitmap 的内存分配了),所以只能给个假的。
那么如何构造一个假的 byte array ?前面分析过 java byte array 的内存布局:
实际上 array.length 的就是 array 对象的 length_ 值,而虚拟机又提供了 addressOf 来获取一个 array 的首元素地址,也即 first_element_ 地址,所以可以尝试通过 first_element_ 来定位 length_ 的位置,进行修改即可。
这样就可以在 java heap 上申请一个比较小的 byte array,并把它的长度伪造成与 Bitmap size 相等。申请的这个小 size 的 byte array 本身占用的内存就作为 Bitmap 内存转移到 Native 层的代价。
这种方式看起来好像不太稳定,但是可以通过校验来保证,比如我们在执行方案之前先尝试伪造一个 byte array 来进行验证,如下代码就是申请了 1 字节长度的 byte array,把它的长度伪造成 36,然后进行校验,校验失败则不再执行 NativeBitmap 方案。
至此,Bitmap 内存申请从 Java heap 转移到 native heap 所需要解决的关键问题都解决了,离最终的目标还有 50% 的距离。接下来需要完成 malloc 出来的 Bitmap 内存的释放逻辑。
原生 Bitmap 的像素内存存放在 byte array (mBuffer)中,Bitmap 的内存释放流程就对应于 mBuffer 对象的释放,这个释放流程在 android 5.x ~7.x 大体相同,只有细微差别,下述以 android 6.0 代码为例进行说明。Bitmap 像素内存释放主要有两种方式触发:一种是 Java Bitmap 对象不再被引用后,GC 回收 Java Bitmap 对象时析构 Native Bitmap ,从而释放 Bitmap 像素内存;一种是主动调用 Bitmap.recycle() 来触发 Bitmap 像素内存的释放:
这个 mBuffer 是在 Native 层申请的 Java 对象,主要在两个地方引用:
而这两个引用的释放顺序是先通过 DeleteGlobalRef 删除全局强引用(Skia 中不再使用这个 Bitmap 时会触发强引用删除),再通过 DeleteWeakGlobalRef 来删除全局弱引用,最终这个 byte array 对象被 GC 回收。
但实际运行过程中不完全是这样的顺序,mBuffer 的回收必然是在 DeleteGlobalRef 之后,但却不一定是在 DeleteWeakGlobalRef 之后,因为一旦 bytearray 只被 Weak glabal ref table 引用时,只要发生 GC,就会把它回收掉。
原生的 Bitmap 像素内存释放是通过回收 mBuffer 引用的 byte array,而 NativeBitmap 方案将像素内存转移到 Native 内存之后,存在两份内存需要被释放:
实现释放有两个关键点:
1 . malloc 出来的指针需要与 mBuffer 关联,这样才能在 mBuffer 释放时找到对应的内存进行释放
解决方式:由于此时的 mBuffer 是伪造的 byte array,可以把 malloc 出来的 bitmap 指针保存在 byte array 中,当 byte array 被释放时,先从中取出 bitmap 指针进行 free,再进行 byte array 释放即可
2 . 需要使 mBuffer 的释放逻辑固定,这样便于确认 hook 点,原生的 mBuffer 释放逻辑是在 DeleteGlobalRef 之后的首次 GC 时,比较难以操作
解决方式:给 mBuffer 额外添加一个引用,放到 Global Reference Table 中,保证 mBuffer 不被提前释放,从而保证 mBuffer 的释放时机稳定保持在 Bitmap::doFreePixels() 中的 DeleteWeakGlobalRef(mBuffer) 位置,在这里从 mBuffer 中取出 malloc 出的 bitmap 指针执行 free,然后再依次删除给 mBuffer 额外添加的 Global Reference 和 Weak global ref。
新的释放逻辑与原生释放逻辑变化不大,如下图,主要是固定了 mBuffer 的释放时机在 DeleteWeakGlobalRef(mBuffer) 时,以及在此时释放 malloc 出来的 bitmap 内存:
至此,malloc 出来的内存也能够找到合适的时机进行释放,把 Bitmap 的像素内存从 Java heap 转移到 Native heap 上的方案理论上完全可以实现,且需要的改动不大,只需要在原生 Bitmap 的创建流程和释放流程中做好修改即可。
根据上述思路 3 的方案,最终实现如下:
改造后在 Bitmap 创建过程中做了两个 hook,对应上图中两条紫色箭头指向的代码:
1. hook newNonMovableArray 函数
当为一个 Bitmap 在 java 堆上通过 newNonMovableArray 申请一个 bitmapSize 大小的 byte array 时,通过代理改造,实际只申请大小为 (sizeof(int) + sizeof(jobject) + sizeof(void*)) 的 byte array(32 位上大小为 12 字节,64 位上为 16 字节)。
修改这个 byte array 的 size 为 bitmapSize,以供 Java 层 Bitmap 使用它获取 bitmap 的真实 size。
在 byte array 的 element 首地址开始的前 4 个字节保存 0x13572468 作为 magic number,用以判断这是一个改造之后的 byte array。
通过 NewGlobalRef(fakeArrayObj) 把这个 byte array 对象添加到 Global Ref table 中,以保证 byte array 的释放时机一定是在 DeleteWeakGlobalRef 之后,并保存到 byte array 中,以便后续释放时使用;实际创建的 array 内存布局如下,这个 array 称为 fakeArray。
这个 array 的实际 length 是 12 字节(32 位),此时 1~4 字节存放 magic:0x13572468,5~8 字节存放 globalRef,9~12 字节暂时没有存放数据
2. hook addressOf 函数
在 addressOf 的代理函数中根据前 4 个字节数据是否是 magic number 来判断传入进来的 array 是否是被改造的 array,如果不是则调用原函数进行返回,如果是则继续进行下述步骤;
此时 fake array 中存放数据如下:
在后面释放 Bitmap 相关内存时会使用到 byte array 中填充的这些数据。
在前面提到过申请的 fakeArray 本身占用的内存就作为 Bitmap 内存转移到 Native 层的代价,到这里及可以计算一出 Bitmap 被转移到 Native 层需要付出的内存代价是多少 ?
答案是:在 32 位上是 12 字节,在 64 位上是 16 字节,多使用的内存就是 fakeArray 中 0x13572468,globalRef,bitmap 这三个数据占用的内存。一个进程如果使用 1000 个 Bitmap,最多额外占用 16* 1000 = 15KB+,是能够被接受的。
前述 Bitmap 创建过程的改造已经保证了 Bitmap 成员 mBuffer 的释放一定是在 Bitmap::doFreePixels() 的 DeleteWeakGlobalRef 之后了,所以只需要按照之前思路 hook DeleteWeakGlobalRef 函数即可:
上图中虚线上方为原生的释放流程,虚线下方是在原生流程上新添加的释放流程。其中右侧的代码就是新的逻辑下对 Bitmap 像素数据和辅助数据释放的关键代码。释放逻辑已经在第二大节中的 [新的释放逻辑] 中说明,这里不再复述。
上述对 Bitmap 创建和释放流程的改造即可实现从 Native heap 给 Bitmap 申请像素内存,但这样的改造必然会影响原有的 java heap GC 的发生,因为 Bitmap 使用的像素内存被转移到了 Native 层,Java heap 内存的压力会变小,但 Native heap 内存的压力会变大,需要有对应的 GC 触发逻辑来回收 Java Bitmap 对象,从而回收其对应的 Native 层像素内存。
这种情况可以通过在 native 内存申请和释放时通知到虚拟机,由虚拟机来判断是否达到 GC 条件,来进行 GC 的触发。实际上 android 8.0 之后 Bitmap 内存申请和释放就是使用的这个方式。
对应的代码在 VMRuntime 中实现:
只需要在给 Bitmap 申请内存时调用 registerNativeAllocation(bitmapSize),在释放 Bitmap 内存时调用 registerNativeFree(bitmapSize)即可。
目前该方案支持到 android 5.1.x ~ 7.x 的系统。4.x~5.0 的系统较早,实现差异较大,待后续完善。
使用一台 android 6.0 的手机机型验证,java heapsize 是 128M。
在测试代码中尝试把一个 bitmap 缓存 5001 次:
private static ArrayList<Bitmap> sBitmapCache = new ArrayList<>();
void testNativeBitmap(Context context) {
NativeBitmap.enable(context);
for (int i = 0; i <= 5000; i++) {
Bitmap bt = BitmapFactory.decodeResource(context.getResources(),R.drawable.icon);
if (i%100 == 0) {
Log.e("hanli", "loadbitmaps: " + i);
}
sBitmapCache.add(bt);
}
}
在不开启 NativeBitmap 时,load 1400+ 张图片后,应用的 Java 堆内存耗尽,发生 OOM 崩溃:
17979 18016 E hanli: loadbitmaps: 0
17979 18016 E hanli: loadbitmaps: 100
...
17979 18016 E hanli: loadbitmaps: 1300
17979 18016 E hanli: loadbitmaps: 1400
17979 18016 I art : Alloc concurrent mark sweep GC freed 7(208B) AllocSpace objects, 0(0B) LOS objects, 0% free, 127MB/128MB, paused 280us total 15.421ms
17979 18016 W art : Throwing OutOfMemoryError "Failed to allocate a 82956 byte allocation with 7560 free bytes and 7KB until OOM"
完成加载 5001 个 Bitmap,并且应用仍能够正常使用:
17516 17553 D hanli: NativeBitmap enabled.
17516 17553 E hanli: loadbitmaps: 0
17516 17553 E hanli: loadbitmaps: 100
...
17516 17553 E hanli: loadbitmaps: 4800
17516 17553 E hanli: loadbitmaps: 4900
17516 17553 E hanli: loadbitmaps: 5000
针对 heapsize 为 256M 及以下的设备启用,当 Java heap 使用率达到 heapsize 的 70% 之后开始打开 NativeBitmap,Java OOM 崩溃影响用户数-56.4785%,OOM 次数降低 72%。
针对 heapsize 为 384M 及以下的设备启用,当 Java heap 使用率达到 heapsize 的 80% 之后开始打开 NativeBitmap,Java OOM 崩溃影响用户数降低 63.063%,OOM 次数降低 76%。
在使用中我们对 NativeBitmap 方案的使用做了限制,因为 Bitmap 内存转移到 Native 层之后会占用虚拟内存,而 32 位设备的虚拟内存可用上限为 3G~4G,为了减少对虚拟内存的使用,只在 heap size 较小的机型才开启 NativeBitmap。我们在持续的优化中发现 Android 5.1.x ~ 7.1.x 版本上,已经有很多设备是 64 位的,所以当用户安装了 64 位的产品时,就可以在 heap size 较大的机型上也开启 NativeBitmap,因为此时的虚拟内存基本无法耗尽。在 64 位产品上把开启 NativeBitmap 的 heap size 限制提升到 512M 之后,Java OOM 数据在优化的基础上又降低了 72%。
有两个问题做一下说明:
1 . 是否使用了 NativeBitmap 就一定不会发生 Java OOM 了?
答:并不是,NativeBitmap 只是把应用内存使用的大头(即 Bitmap 的像素占用的内存)转移到 Native 堆,如果其他的 Java 对象使用不合理占用较多内存,仍然会发生 Java OOM
2 . 方案可能产生的影响?
Bitmap 的像素占用的内存转移到 Native 堆之后,会使得虚拟内存使用增多,当存在泄漏时,可能会导致 32 位应用的虚拟内存被耗尽(实际上这个表现和 Android8.0 之后系统的表现一致)。
所以,方案的目标实际是为了使老的 android 版本能够支持更复杂的应用设计,而不是为了解决内存泄漏。
Copyright© 2013-2019