标签归档:Bitmap

Android 中解决图像解码导致的 OOM 问题

在上一篇博文 Android Bitmap 内存限制 中我们详细的了解并分析了 Android 为什么会在 Decode Bitmap 的时候出现 OOM 错误,简单的讲就是 Android 在解码图片的时候使用了本地代码来完成解码的操作,但是使用的内存是堆里面的内存,而堆内存的大小是收 VM 实例可用内存大小的限制的,所以当应用程序可用内存已经无法再满足解码的需要时,Android 将抛出 OOM 错误。

这里讲一个题外话,也就是为何 Android 要限制每个应用程序的可用内存大小呢?其实这个问题可能有多方面的解答,目前我自己考虑到的有两点:

  1. 使得内存的使用更为合理,限制每个应用的可用内存上限,可以防止某些应用程序恶意或者无意使用过多的内存,而导致其他应用无法正常运行,我们众所周知的 Android 是有多进程的,如果一个进程 (也就是一个应用) 耗费过多的内存,其他的应用还搞毛呢?当然在这里其实是有一个例外,那就是如果你的应用使用了很多本地代码,在本地代码中创建对象解码图像是不会被计算到的,这是因为你使用本地方法创建的对象或者解码的图像使用的是本地堆的内存,跟系统是平级的,而我们通过 Framework 调用 BitmapFactory.decodeFile() 方法解码时,系统虽然也是调用本地代码来进行解码的,但是 Android Framework 在实现的时候,刻意地将这部分解码使用的内存从堆里面分配了而不是从本地堆里分配的内存,所以才会出现 OOM,当然并不是说从本地堆里分配就不会出现 OOM,本地堆分配内存超过系统可用内存限制的话,通常都是直接崩溃,什么错误可能都看不到,也许会有一些崩溃的错误字节码之类的。
  2. 省电的考虑,呃…,原因我好像也不能很明白地说出来。

回到正题来,我们在应用的设计和开发中可能会经常碰到需要在一个界面上显示数十张图片乃至上百张,当然限于手机屏幕的大小我们通常在设计中会使用类似于列表或者网格的控件来展示,也就是说通常一次需要显示出来图片数还是一个相对确定的数字,通常也不会太大。如果数目比较大的画,通常显示的控件自身尺寸就会比较小,这个时候可以采用缩略图策略。下面我们来看看如果避免出现 OOM 的错误,这个解决方案参考了 Android 示范程序 XML Adapters 中的 ImageDownloader.java 中的实现,主要是使用了一个二级缓存类似的机制, 就是有一个数据结构中直接持有解码成功的 Bitmap 对象引用,同时使用一个二级缓存数据结构持有解码成功的 Bitmap 对象的 SoftReference 对象,由于 SoftReference 对象的特殊性,系统会在需要内存的时候首先将 SoftReference 对象持有的对象释放掉,也就是说当 VM 发现可用内存比较少了需要触发 GC 的时候,就会优先将二级缓存中的 Bitmap 回收,而保有一级缓存中的 Bitmap 对象用于显示。

其实这个解决方案最为关键的一点是使用了一个比较合适的数据结构,那就是 LinkedHashMap 类型来进行一级缓存 Bitmap 的容器,由于 LinkedHashMap 的特殊性,我们可以控制其内部存储对象的个数并且将不再使用的对象从容器中移除,这就给二级缓存提供了可能性,我们可以在一级缓存中一直保存最近被访问到的 Bitmap 对象,而已经被访问过的图片在 LinkedHashMap 的容量超过我们预设值时将会把容器中存在时间最长的对象移除,这个时候我们可以将被移除出 LinkedHashMap 中的对象存放至二级缓存容器中,而二级缓存中对象的管理就交给系统来做了,当系统需要 GC 时就会首先回收二级缓存容器中的 Bitmap 对象了。在获取对象的时候先从一级缓存容器中查找,如果有对应对象并可用直接返回,如果没有的话从二级缓存中查找对应的 SoftReference 对象,判断 SoftReference 对象持有的 Bitmap 是否可用,可用直接返回,否则返回空。

主要的代码段如下:

private static final int HARD_CACHE_CAPACITY = 16;

// Hard cache, with a fixed maximum capacity and a life duration
private static final HashMap<String, Bitmap> sHardBitmapCache = new LinkedHashMap<String, Bitmap>(HARD_CACHE_CAPACITY / 2, 0.75f, true) {
    private static final long serialVersionUID = -57738079457331894L;

    @Override
    protected boolean removeEldestEntry(LinkedHashMap.Entry<String, Bitmap> eldest) {
        if (size() > HARD_CACHE_CAPACITY) {
            // Entries push-out of hard reference cache are transferred to soft reference cache
            sSoftBitmapCache.put(eldest.getKey(), new SoftReference<Bitmap>(eldest.getValue()));
            return true;
        } else
            return false;
    }
};

// Soft cache for bitmap kicked out of hard cache
private final static ConcurrentHashMap<String, SoftReference<Bitmap>> sSoftBitmapCache = new ConcurrentHashMap<String, SoftReference<Bitmap>>(HARD_CACHE_CAPACITY / 2);

/**
* @param id
*            The ID of the image that will be retrieved from the cache.
* @return The cached bitmap or null if it was not found.
*/
public Bitmap getBitmap(String id) {
    // First try the hard reference cache
    synchronized (sHardBitmapCache) {
        final Bitmap bitmap = sHardBitmapCache.get(id);
        if (bitmap != null) {
            // Bitmap found in hard cache
            // Move element to first position, so that it is removed last
            sHardBitmapCache.remove(id);
            sHardBitmapCache.put(id, bitmap);
            return bitmap;
        }
    }

    // Then try the soft reference cache
    SoftReference<Bitmap> bitmapReference = sSoftBitmapCache.get(id);
    if (bitmapReference != null) {
        final Bitmap bitmap = bitmapReference.get();
        if (bitmap != null) {
            // Bitmap found in soft cache
            return bitmap;
        } else {
            // Soft reference has been Garbage Collected
            sSoftBitmapCache.remove(id);
        }
    }

    return null;
}

public void putBitmap(String id, Bitmap bitmap) {
    synchronized (sHardBitmapCache) {
        if (sHardBitmapCache != null) {
            sHardBitmapCache.put(id, bitmap);
        }
    }
}

上面这段代码中使用了 id 来标识一个 Bitmap 对象,这个可能大家在实际的应用中可以选择不同的方式来索引 Bitmap 对象,图像的解码在这里就不做赘述了。这里主要讨论的就是如何管理 Bitmap 对象,使得在实际应用中不要轻易出现 OOM 错误,其实在这个解决方案中,HARD_CACHE_CAPACITY 的值就是一个经验值,而且这个跟每个应用中需要解码的图片的实际大小直接相关,如果图片偏大的话可能这个值还得调小,如果图片本身比较小的话可以适当的调大一些。本解决方案主要讨论的是一种双缓存结合使用 SoftReference 的机制,通过使用二级缓存和系统对 SoftReference 对象的回收特性,让系统自动回收不再敏感的图片 Bitmap 对象,而保有一级缓存也就是敏感的图片 Bitmap 对象。

Android Bitmap 内存限制

在编写 Android 程序的时候,我们总是难免会碰到 OOM 的错误,那么这个错误究竟是怎么来的呢?我们先来看一下这段异常信息:

08-14 05:15:04.764: ERROR/dalvikvm-heap(264): 3528000-byte external allocation too large for this process.
08-14 05:15:04.764: ERROR/(264): VM won’t let us allocate 3528000 bytes
08-14 05:15:04.764: DEBUG/skia(264): — decoder->decode returned false
08-14 05:15:04.774: DEBUG/AndroidRuntime(264): Shutting down VM
08-14 05:15:04.774: WARN/dalvikvm(264): threadid=3: thread exiting with uncaught exception (group=0x4001b188)
08-14 05:15:04.774: ERROR/AndroidRuntime(264): Uncaught handler: thread main exiting due to uncaught exception
08-14 05:15:04.794: ERROR/AndroidRuntime(264): java.lang.OutOfMemoryError: bitmap size exceeds VM budget
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.graphics.BitmapFactory.nativeDecodeAsset(Native Method)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.graphics.BitmapFactory.decodeStream(BitmapFactory.java:447)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.graphics.BitmapFactory.decodeResourceStream(BitmapFactory.java:323)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.graphics.BitmapFactory.decodeResource(BitmapFactory.java:346)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.graphics.BitmapFactory.decodeResource(BitmapFactory.java:372)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at com.xixun.test.HelloListView.onCreate(HelloListView.java:33)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1047)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2459)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2512)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.app.ActivityThread.access$2200(ActivityThread.java:119)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1863)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.os.Handler.dispatchMessage(Handler.java:99)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.os.Looper.loop(Looper.java:123)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.app.ActivityThread.main(ActivityThread.java:4363)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at java.lang.reflect.Method.invokeNative(Native Method)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at java.lang.reflect.Method.invoke(Method.java:521)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:860)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:618)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at dalvik.system.NativeStart.main(Native Method)

从上面这段异常信息中,我们看到了一个 OOM(OutOfMemory) 错误,我称其为 (OMG 错误)。出现这个错误的原因是什么呢?为什么解码图像会出现这样的问题呢?关于这个问题,我纠结了一段时间,在网上查询了很多资料,甚至查看了 Android Issues,确实看到了相关的问题例如 Issue 3405Issue 8488,尤其 Issue 8488 下面一楼的回复,让我觉得很雷人啊:

Comment 1 by romain…@android.com, May 23, 2010

当然我们承认不好的程序总是程序员自己错误的写法导致的 ,不过我们倒是非常想知道如何来规避这个问题,那么接下来就是解答这个问题的关键。

我们从上面的异常堆栈信息中,可以看出是在 BitmapFactory.nativeDecodeAsset(),对应该方法的 native 方法是在 BitmapFactory.cpp 中的 doDecode() 方法,在该方法中申请 JavaPixelAllocator 对象时,会调用到 Graphics.cpp 中的 setJavaPixelRef() 方法,在 setJavaPixelRef() 中会对解码需要申请的内存空间进行一个判断,代码如下:

bool r = env->CallBooleanMethod(gVMRuntime_singleton,

                                   gVMRuntime_trackExternalAllocationMethodID,

                                   jsize);

而 JNI 方法 ID — gVMRuntime_trackExternalAllocationMethodID 对应的方法实际上是 dalvik_system_VMRuntime.c 中的 Dalvik_dalvik_system_VMRuntime_trackExternalAllocation(),而在该方法中又会调用大 HeapSource.c 中的 dvmTrackExternalAllocation() 方法,继而调用到 externalAllocPossible() 方法,在该方法中这句代码是最关键的

heap = hs2heap(hs);

   currentHeapSize = mspace_max_allowed_footprint(heap->msp);

   if (currentHeapSize + hs->externalBytesAllocated + n <=

           heap->absoluteMaxSize)

   {

       return true;

   }

这段代码的意思应该就是当前堆已使用的大小 (由 currentHeapSize 和 hs->externalBytesAllocated 构成) 加上我们需要再次分配的内存大小不能超过堆的最大内存值。那么一个堆的最大内存值究竟是多大呢。通过下面这张图,我们也许可以看到一些线索 (自己画的,比较粗糙)

image

最终的决定权其实是在 Init.c 中,因为 Android 在启动系统的时候会去优先执行这个里面的函数,通过调用 dvmStartup() 方法来初始化虚拟机,最终调用到会调用到 HeapSource.c 中的 dvmHeapSourceStartup() 方法,而在 Init.c 中有这么两句代码:

gDvm.heapSizeStart = 2 * 1024 * 1024;   // Spec says 16MB; too big for us.

gDvm.heapSizeMax = 16 * 1024 * 1024;    // Spec says 75% physical mem

在另外一个地方也有类似的代码,那就是 AndroidRuntime.cpp 中的 startVM() 方法中:

strcpy(heapsizeOptsBuf, "-Xmx");

property_get("dalvik.vm.heapsize", heapsizeOptsBuf+4, "16m");

//LOGI("Heap size: %s", heapsizeOptsBuf);

opt.optionString = heapsizeOptsBuf;

同样也是默认值为 16M,虽然目前我看到了两个可以启动 VM 的方法,具体 Android 何时会调用这两个初始化 VM 的方法,还不是很清楚。不过可以肯定的一点就是,如果启动 DVM 时未指定参数,那么其初始化堆最大大小应该就是 16M,那么我们在网上查到了诸多关于解码图像超过 8M 就会出错的论断是如何得出来的呢?

我们来看看 HeapSource.c 中的这个方法的注释

/*

* External allocation tracking

*

* In some situations, memory outside of the heap is tied to the

* lifetime of objects in the heap.  Since that memory is kept alive

* by heap objects, it should provide memory pressure that can influence

* GCs.

*/

static bool

externalAllocPossible(const HeapSource *hs, size_t n)

{

    const Heap *heap;

    size_t currentHeapSize;

   /* Make sure that this allocation is even possible.

     * Don’t let the external size plus the actual heap size

     * go over the absolute max.  This essentially treats

     * external allocations as part of the active heap.

     *

     * Note that this will fail "mysteriously" if there’s

     * a small softLimit but a large heap footprint.

     */

    heap = hs2heap(hs);

    currentHeapSize = mspace_max_allowed_footprint(heap->msp);

    if (currentHeapSize + hs->externalBytesAllocated + n <=

            heap->absoluteMaxSize)

    {

        return true;

    }

    HSTRACE("externalAllocPossible(): "

            "footprint %zu + extAlloc %zu + n %zu >= max %zu (space for %zu)\n",

            currentHeapSize, hs->externalBytesAllocated, n,

            heap->absoluteMaxSize,

            heap->absoluteMaxSize –

                    (currentHeapSize + hs->externalBytesAllocated));

    return false;

}

标为红色的注释的意思应该是说,为了确保我们外部分配内存成功,我们应该保证当前已分配的内存加上当前需要分配的内存值,大小不能超过当前堆的最大内存值,而且内存管理上将外部内存完全当成了当前堆的一部分。也许我们可以这样理解,Bitmap 对象通过栈上的引用来指向堆上的 Bitmap 对象,而 Bitmap 对象又对应了一个使用了外部存储的 native 图像,实际上使用的是 byte[] 来存储的内存空间,如下图:

image

我想到现在大家应该已经对于 Bitmap 内存大小限制有了一个比较清楚的认识了。至于前几天从 Android123 上看到 “Android 的 Btimap 处理大图片解决方法” 一文中提到的使用 BitmapFactory.Options 来设置 inTempStorage 大小,我当时看完之后就尝试了一下,这个设置并不能解决问题,而且很有可能会给你带来不必要的问题。从 BitmapFactory.cpp 中的代码来看,如果 option 不为 null 的话,那么会优先处理 option 中设置的各个参数,假设当前你设置 option 的 inTempStorage 为 1024*1024*4(4M) 大小的话,而且每次解码图像时均使用该 option 对象作为参数,那么你的程序极有可能会提前失败,在我的测试中,我使用了一张大小为 1.03M 的图片来进行解码,如果不使用 option 参数来解码,可以正常解码四次,也就是分配了四次内存,而如果我使用 option 的话,就会出现 OOM 错误,只能正常解码两次不出现 OOM 错误。那么这又是为什么呢?我想是因为这样的,Options 类似与一个预处理参数,当你传入 options 时,并且指定临时使用内存大小的话,Android 将默认先申请你所指定的内存大小,如果申请失败,就抛出 OOM 错误。而如果不指定内存大小,系统将会自动计算,如果当前还剩 3M 空间大小,而我解码只需要 2M 大小,那么在缺省情况下将能解码成功,而在设置 inTempStorage 大小为 4M 的情况下就将出现 OOM 错误。所以,我个人认为通过设置 Options 的 inTempStorage 大小根本不能作为解决大图像解码的方法,而且可能带来不必要的问题,因为 OOM 错误在某些情况是必然出现的,也就是上面我解释的那么多关于堆内存最大值的问题,只要解码需要的内存超过系统可分配的最大内存值,那么 OOM 错误必然会出现。当然对于 Android 开发网 为何发布了这么一篇文章,个人觉得很奇怪,我想作为一个技术人员发布一篇文章,至少应该自己尝试着去测试一下自己的程序吧,如果只是翻翻 SDK 文档,然后就出来一两篇文章声称是解决某问题的方案,恐怕并不是一种负责任的行为吧。

=================================

还是点到为止吧,希望大家都自己去测试一下,验证一下,毕竟自己做过验证的才能算是放心的。