当前位置:   article > 正文

Java OOM优化之NativeBitmap_bitmap native占用

bitmap native占用

目录

一、背景

二、方向

三、思路

四、具体实施

五、兼容范围

六、扩展思考

七、验证效果

一、背景

产生OOM众多原因中,针对出现次数最多的OOM堆栈是Bitmap创建时内存不足导致。虽然是分配内存不足的缘故,但实际上是因为之前分配内存存在问题(与Java heap的heap size有一定关联)。

推断:OOM时Java heap中占用内存较多的对象是Bitmap

据统计,在android8.0之前,heapsize越小越容易出现OOM;在8.0之后,heapsize大小与OOM基本无联系。

猜想:在android8.0前后的Bitmap实现不同,造成OOM出现频率不同

事实:

  • 在android8.0之前,Bitmap像素占用内存是Java heap中分配
  • 在android8.0之后,Bitmap像素占用内存是Native heap中分配

**补充知识:

在Java出现之前,像C/C++这样的代码都是编译为native code,直接和操作系统交互。对于内存,主要分三部分
1)只能执行的代码;(Native Code)

2)用来保存代码执行时局部信息的stack;(Native Stack)

3)动态找操作系统要/向操作系统归还的heap;(Native heap)

    Native的代码和内存管理主要带来两个问题:

       ① 编译后的代码无法跨平台,毕竟是native的,只能支持被编译平台的操作系统API和指令集

       ② 无法自动GC。因为内存管理是手工和操作系统交互,操作系统并不支持GC。

   因此出现了Java虚拟机的概念,

**

二、方向

 将android8.0之前的Bitmap像素内存放到Native heap分配,减少OOM

三、思路

1、Bitmap创建过程(8.0前后对比)

 一般都是通过 Bitmap、BitmapFactory 提供的静态方法来创建 Bitmap 实例。

 (1) 8.0之前:申请内存allocateJavaPixelRef,走Java heap

newNonMovableArray 从 Java 堆上为 Bitmap 像素分配内存,然后再构造 nativeBitmap 对象,从构造函数中发现 nativeBitmap 构造时对应的 mPixelStorageType 是 PixelStorageType::Java,表示 Bitmap 的像素是保存在 Java 堆上,所以尝试看下 PixelStorageType 总共有几种,是否可能有把 pixels 数据存储到 Native 层。查找代码发现 PixelStorageType 只有三类,

  1. enum class PixelStorageType{
  2. Invalid,//default
  3. External,//源码未找到使用
  4. Java,//Java heap
  5. Ashmem,//mmap映射,跨进程Bitmap传递(Notification等)
  6. };
  7. **补充知识:
  8. mmap主要实现方式是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,
  9. 从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝。

 (2) 8.0之后:申请内存allocateHeapBitmap,走Native heap

 通过 calloc 为 Bitmap 的像素分配内存,这个分配就在 Native 堆上了

小结:两个红思路,最终判定行不通

        ① allocateJavaPixelRef 替换为 allocateAshmemPixelRef,用Ashmem替换Java

            可行性分析:限制多,备用挂起

                - allocateAshmemPixelRef 实现只在 android 6.0 ~ android7.1 上存在,所以这个方案即便能够实现,也只能覆盖 android 6.0 ~ android 7.1

                - ashmem 方式存储 Bitmap 像素,每个 Bitmap 需要对应一个 fd,应用的 Bitmap 使用数量是能够达到 1000+ 的,这样可能会导致 fd 资源使用耗尽,从而发生崩溃

                - 影响 Bitmap 正常功能(一些视频动图不能正常展示),经分析主要原因是使用 ashmem 申请的 Bitmap 无法进行 reconfigure,没有 mBuffer 的 Bitmap 不支持 reconfigure,Ashmem 方式创建的 Bitmap 没有从 Java 堆申请 mBuffer

        ② allocateJavaPixelRef 替换为 allocateHeapBitmap

            可行性分析:不可行

                - allocateHeapBitmap 返回的是 skBitmap,allocateJavaPixelRef 返回的是 android::Bitmap,类型并不匹配

                - 8.0 之前的 Android 版本上没有 allocateHeapBitmap 的实现。如果想要为 8.0 之前的系统写一个全新的实现,只是参数的获取就需要做很多适配,比如无法直接使用 skia 中的 SkBitmap、SkColorTab、SkImageInfo,就没有办法动态获取到要分配的内存 size

                - Bitmap 内存的申请和释放要有匹配的逻辑和合适的时机        

2、深剖Java heap分配Bitmap内存过程

框起来的部分就是为 Bitmap 从 Java heap 申请像素内存的代码。其中:

  • arrayObj 是通过 newNonMovableArray 从 java heap 分配出来的 byte array 对象

  • addr 是 arrayObj 对象存放 byte 元素的首地址

**补充知识:

 java byte array 的内存布局(对应代码在 ART 虚拟机中):

length_ 是这个数组的长度,first_element_ 数组用来实际存放 byte 数据,数组的长度由 length_/4 来决定。

**

addressOf(arrayObj) 获取到的就是 first_element_地址,arrayObj 和 addr 的传递在上图已经用分别用绿色和红色虚线箭头标记出来了,转换到Native heap分配内存的切入点是arrayObj 和 addr

(1)arrayObj在Java和Native对象中使用位置

        ① 在 Java Bitmap 对象中引用,对应 Bitmap 的 mBuffer 成员(arrayObj.length获取Bitmap像素内存占用大小)

          - 在创建 Java Bitmap 时通过 nativeBitmap->javaByteArray()获取对 arrayObj 的引用,并赋值给 Java Bitmap 的 byte[] mBuffer

          - 在 Bitmap.reconfigure 中,需要使用 arrayObj.length(在 Native 层会使用这个 length 判断当前的 Bitmap 能否满足 reconfigure 需求)

          - 在 Bitmap.getAllocationByteCount()中通过 arrayObj.length 获取这个 Bitmap 的像素内存大小

        ② 在 Native Bitmap 对象中使用,即 android::Bitmap 对象(管理arrayObj生命周期)

          - 在创建 Bitmap 时,把 arrayObj 添加到 weak global ref tab 中,并在 Bitmap 的 mPixelStorage.jweakRef 引用 arrayObj

          - 在 Bitmap 的 pinPixelsLocked 中,把 arrayObj 添加到 global ref tab 中,并保存在 Bitmap 的 mPixelStorage.java.jstrongRef 中

          - 在 Bitmap 的 unpinPixelsLocked 中,从 global ref tab 中删除对 arrayObj 的引用

          - 在 Bitmap 的 doFreePixel 中(即释放像素内存),删除 arrayObj 对应的 weak ref

          - 通过 Bitmap 的成员函数 javaByteArray() 向外部提供引用,即 mPixelStorage.jstrongRef (只在创建 Java Bitmap 对象时传递为参数,赋值给 Bitmap 的 mBuffer 成员进行使用)

(2)addr的使用位置

        在创建 native bitmap 时,作为指针传递给其成员 mPixelRef。(在 WrappedPixelRef 的 onNewLockPixels 被调用时,赋值给 LockRec 的 fPixels 成员)

**补充知识:

skia 中并不会为 Bitmap 的像素数据分配内存,它把 Java heap 上 byte 数组的元素首地址转换为 void* 来使用。

**

addr 指向的内存是在 java 堆上,其会在需要的时候被传递给 skia 用来处理 bitmap 像素数据。

3、寻找分配内存的hook点,如图需3个

 native层hook

(1)目标是不再从 java heap 给 Bitmap 分配内存,这一步的 byte[] 申请必然是需要去掉的

(2)通过 malloc 分配内存,交给 PixelRef 引用,间接的就可以被 SkBitmap 使用了(在Native层申请分配)

java层hook

(3)原有实现中 Java Bitmap 通过 mBuffer 成员引用 byte[],主要用 mBuffer.length 获取Bitmap像素大小

第3个hook点还没有一个完善的hook方案,不能真的设置一个Bitmap像素大小的byte[],只能自定义一个伪byte[],解读内存布局:

需要修改的字段是length_(Bitmap size)first_element_(首元素地址),JVM提供了addressOf获取array的首元素地址,可以间接获取length_

因此,在Java heap上申请一个小byte[],并把它长度修改成Bitmap size,申请的这个数组占用的内存是作为从Java heap转到Native heap的代价。(校验:此操作若不成功则不做后续内存转移)

4、修改Bitmap内存释放逻辑

原本逻辑

Bitmap像素内存存放在 byte[](即mBuffer)中,Bitmap 的内存释放流程就对应于 mBuffer 对象的释放。

Bitmap像素内存释放主要有两种方式触发:

(1)Java Bitmap 对象不再被引用后,GC 回收 Java Bitmap 对象时析构 Native Bitmap ,从而释放 Bitmap 像素内存

(2)主动调用 Bitmap.recycle() 来触发 Bitmap 像素内存的释放

mBuffer 是在 Native 层申请的 Java 对象,主要在两个地方引用:

(1)Native层通过 NewWeakGlobalRef(arrayObj) 把它添加到 Weak Global Reference table 中进行引用

(2)Java层 Bitmap 通过 mBuffer 来引用,实际是在 Native 层通过 NewGlobalRef(arrayObj) 把它添加到了 Global Ref table 中,即 mBuffer 是一个关联到 Java byte[] 的全局引用

这两个引用的释放顺序是先通过 DeleteGlobalRef 删除全局强引用(Skia 中不再使用这个 Bitmap 时会触发强引用删除),再通过 DeleteWeakGlobalRef 来删除全局弱引用,最终 byte[] 对象被 GC 回收。因为强弱引用释放顺序,造成mBuffer释放可能在DeleteWeakGlobalRef之前

修改逻辑

新方案是将像素内存转移到Native heap存储,有两处内存需被释放:

(1)Java heap的小size的伪byte[],可以按照原生逻辑释放,无需修改

(2)Native heap的malloc出来存真实Bitmap像素数据的内存,应该在byte[]释放时同步释放掉

为了让byte[]和malloc出的内存生命周期一致,需要做到

① 把malloc出的内存指针与byte[]关联,即mBuffer释放时可以找到对应Native heap的内存释放(可以把内存指针存放在byte[]中,当byte[]被释放时,先取出指针释放掉对应内存后,再释放byte[])

② 固定mBuffer释放位置,便于确认hook点,若mBuffer是在DeleteGlobalRef 之后的首次 GC时,难以操作(给mBuffer额外添加一个Global Reference,放到Global Ref table中,确保mBuffer不被提前GC掉,保证mBuffer的释放时机稳定保持在Bitmap::doFreePixels()中的DeleteWeakGlobalRef位置,此处从mBuffer中取出malloc指针执行释放,再顺次删除给mBuffer额外添加的Global Reference以及 WeakGlobalRef)

修改点是,固定了 mBuffer 的释放时机在 DeleteWeakGlobalRef(mBuffer) 时,以及在此时释放 malloc 出来的 bitmap 内存。

四、具体实施

 1、hook newNonMovableArray

当Bitmap在Java heap上通过newNonMovableArray申请内存时,转换为申请一个(sizeof(int) + sizeof(jobject) + sizeof(void*))的byte[](32位大小12字节,64位大小16字节),如下图:

bitmapSize:供Java层获取真实Bitmap size

0x13572468:1~4 字节,标识是一个改造的byte[]

globalRef:5~8 字节,Global Reference 用于固定mBuffer释放位置

9-12字节暂时不存数据(后有他用)

2、hook addressOf

根据前 4 个字节标识位来判断传入进来的byte[]是否是被改造的byte[],如果不是则调用原函数进行返回,如果是则继续进行下述步骤(转换到Native heap分配空间):

  • 从 byte[] 中获取 bitmapSize,并通过 calloc(bitmapSize,1) 在 Native heap上为 Bitmap 分配内存

  • 把分配出来的 Bitmap 内存指针保存到 byte[] 的 9-12 字节中

  • 把 Bitmap 内存指针返回,由原逻辑在后续传递给 skia 使用

 成果:代价内存就是 byte[] 中 0x13572468,globalRef,bitmap指针 这三个数据占用的内存。一个进程如果使用 1000 个 Bitmap,最多额外占用 16* 1000 = 15KB+,是能够被接受的。

3、hook DeleteWeakGlobalRef(上文的释放逻辑)

 4、补充

以上改造必然会影响原有的 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)即可。

五、兼容范围

android5.1.x ~ 7.x,4.x ~ 5.0系统本文暂不考虑

六、扩展思考

1、是否使用了新NativeBitmap就一定不会产生OOM?

不是,NativeBitmap原理只是把在Java heap上申请的内存转移到Native heap层,若其他java对象不合理使用内存,一样会造成OOM

2、该方案有何弊端?

Bitmap 的像素占用的内存转移到 Native heap之后,会使得虚拟内存使用增多,当存在泄漏时,可能会导致 32 位应用的虚拟内存被耗尽(和 Android8.0 之后系统的表现一致)

所以,方案的目标实际是为了使老的 android 版本能够支持更复杂的应用设计,而不是为了解决内存泄漏。

七、验证效果

使用一台 android 6.0 的手机机型验证,java heapsize 是 128M。

测试代码

在测试代码中尝试把一个 bitmap 缓存 5001 次:

  1. private static ArrayList<Bitmap> sBitmapCache = new ArrayList<>();
  2. void testNativeBitmap(Context context) {
  3.     NativeBitmap.enable(context);
  4.     for (int i = 0; i <= 5000; i++) {
  5.         Bitmap bt = BitmapFactory.decodeResource(context.getResources(),R.drawable.icon);
  6.         if (i%100 == 0) {
  7.             Log.e("hanli""loadbitmaps: " + i);
  8.         }
  9.         sBitmapCache.add(bt);
  10.     }
  11. }

原生流程,只能加载 1400+个 Bitmap

在不开启 NativeBitmap 时,load 1400+ 张图片后,应用的 Java 堆内存耗尽,发生 OOM 崩溃:

  1. 17979 18016 E hanli: loadbitmaps: 0
  2. 17979 18016 E hanli: loadbitmaps: 100
  3. ...
  4. 17979 18016 E hanli: loadbitmaps: 1300
  5. 17979 18016 E hanli: loadbitmaps: 1400
  6. 17979 18016 I art   : Alloc concurrent mark sweep GC freed 7(208B) AllocSpace objects, 0(0B) LOS objects, 0free127MB/128MB, paused 280us total 15.421ms
  7. 17979 18016 W art   : Throwing OutOfMemoryError "Failed to allocate a 82956 byte allocation with 7560 free bytes and 7KB until OOM"

打开 NativeBtimap

完成加载 5001 个 Bitmap,并且应用仍能够正常使用:

  1. 17516 17553 D hanli: NativeBitmap enabled.
  2. 17516 17553 E hanli: loadbitmaps: 0
  3. 17516 17553 E hanli: loadbitmaps: 100
  4. ...
  5. 17516 17553 E hanli: loadbitmaps: 4800
  6. 17516 17553 E hanli: loadbitmaps: 4900
  7. 17516 17553 E hanli: loadbitmaps: 5000

小结:在使用中我们对 NativeBitmap 方案的使用做了限制,因为 Bitmap 内存转移到 Native 层之后会占用虚拟内存,而 32 位设备的虚拟内存可用上限为 3G~4G,为了减少对虚拟内存的使用,只在 heapsize 较小的机型才开启 NativeBitmap。

在持续的优化中发现 Android 5.1.x ~ 7.1.x 版本上,已经有很多设备是 64 位的,所以当用户安装了 64 位的产品时,就可以在 heapsize 较大的机型上也开启 NativeBitmap,因为此时的虚拟内存基本无法耗尽。

在 64 位产品上把开启 NativeBitmap 的 heapsize 限制提升到 512M 之后,Java OOM 数据在优化的基础上又降低了 72% 。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/AllinToyou/article/detail/303730
推荐阅读
相关标签
  

闽ICP备14008679号