深入 Android Bitmap 内存模型:从 Java 堆分配到 Hardware Bitmap 的演进与优化

做过 Android 性能优化的人多少都碰到过这种情况:Java 堆内存明明没到上限,应用却因为 Bitmap OOM 崩了;又或者在 MAT 里翻半天,Bitmap 对象只占几十字节,真正的像素数据不知道躲哪去了。问题出在 Android 的 Bitmap 内存分配策略上——过去十几年里它经历了三次大的变迁,搞清楚这条演进线,很多诡异的内存问题就能对上号了。

第一阶段:Native 堆时代(Android 2.x 及以前)

早期 Android 中,Bitmap 的像素数据(pixel data)分配在 Native 堆 上,Java 层的 Bitmap 对象只是一个薄壳,持有指向 Native 内存的指针。

问题很直接:Native 内存不受 dalvik.vm.heapsize 限制,Dalvik GC 也感知不到这部分内存压力。一个 Bitmap 对象在 Java 堆上可能只占 50 字节,但它背后关联着 10MB 的 Native 像素缓冲区。GC 不知道该回收它,内存就这样悄悄漏掉了。

当时的应对方式是手动调用 Bitmap.recycle() 搭配 BitmapFactory.Options.inPurgeablerecycle() 本质上触发 Native 层 free(),而 inPurgeable 允许系统在内存紧张时丢弃像素数据、需要时重新解码。但这两个 API 都很脆弱——recycle 之后再访问 Bitmap 直接 crash,inPurgeable 的重解码时机不可控,实际用起来防御性代码写一堆。

第二阶段:回归 Java 堆(Android 3.0 - 7.1)

从 Android 3.0(Honeycomb)开始,Google 把像素数据搬到了 Java 堆(Dalvik/ART managed heap) 上。好处显而易见:GC 能感知 Bitmap 的真实内存占用,不再需要手动 recycle。

// Android 3.0+ 的 Bitmap 创建路径(简化)
// 像素数据直接分配在 Java byte[]
Bitmap bitmap = BitmapFactory.decodeResource(res, R.drawable.photo);
// bitmap 内部持有一个 byte[] 存放像素
// GC 可以直接追踪并回收

代价也很明显:像素数据开始挤占 Java 堆空间。一张 1920x1080 的 ARGB_8888 图片占 1920 * 1080 * 4 ≈ 7.9MB,而多数设备的 Java 堆上限只有 256-512MB。图片密集型应用很容易触顶 OOM。

这个阶段最有效的优化手段是 inBitmap——复用已有 Bitmap 的内存区域,避免反复分配和回收:

BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inMutable = true;
opts.inBitmap = reusableBitmap; // 复用已有 Bitmap 的 byte[]
Bitmap decoded = BitmapFactory.decodeFile(path, opts);

inBitmap 要求目标 Bitmap 与源 Bitmap 尺寸兼容(Android 4.4 之后放宽为目标 ≥ 源的字节数),Glide 和 Fresco 的 BitmapPool 就是基于这个机制实现的。

第三阶段:重返 Native 堆(Android 8.0+)

Android 8.0(Oreo)做了一个看似”开倒车”的决定:把像素数据重新搬回 Native 堆。但这次和 2.x 时代完全不同,关键区别在于 NativeAllocationRegistry 的引入。

// NativeAllocationRegistry 核心机制(简化)
// 在 Bitmap 构造时注册 Native 内存大小
NativeAllocationRegistry registry = new NativeAllocationRegistry(
    Bitmap.class.getClassLoader(),
    nativeFinalizer,    // Native 侧释放函数指针
    allocationSize       // 像素数据大小
);
registry.registerNativeAllocation(this, nativePtr);

这段注册逻辑告诉 ART 运行时:“这个 Java 对象关联了多少 Native 内存”。GC 计算内存压力时会把这部分 Native 内存纳入考量,达到阈值就主动触发回收。GC 能感知 Native 内存了,但像素数据不再挤占 Java 堆——既解决了 2.x 时代 GC 对 Native 内存的盲区,又把宝贵的 Java 堆空间腾了出来。

在实际项目中我观察到,升级到 Android 8.0 之后,图片密集型页面的 Java 堆占用能降低 30-50%,效果相当明显。

Hardware Bitmap:像素数据进 GPU

Android 8.0 同时引入了 Hardware Bitmap。它的像素数据既不在 Java 堆,也不在 Native 堆,而是存放在 GPU 显存(GraphicBuffer / HardwareBuffer) 中。

val opts = BitmapFactory.Options().apply {
    inPreferredConfig = Bitmap.Config.HARDWARE
}
val bitmap = BitmapFactory.decodeFile(path, opts)
// bitmap.config == Bitmap.Config.HARDWARE
// 像素数据位于 GPU GraphicBuffer 中

Hardware Bitmap 的核心优势在于省掉了一次 CPU → GPU 的纹理上传。普通 Bitmap 在绘制时,RenderThread 需要把像素数据从 CPU 内存上传为 GPU 纹理;Hardware Bitmap 的数据已经在 GPU 侧,直接作为纹理使用。在图片列表快速滑动的场景下,这对减少掉帧有明显帮助。

不过 Hardware Bitmap 有几个硬限制:

  • 不可变(immutable):无法调用 setPixel()getPixels() 或在 Canvas 上绘制修改
  • 读取像素代价高getPixel() 需要从 GPU 回读数据,涉及 GPU-CPU 同步,非常慢
  • 部分设备兼容性问题:一些低端 GPU 驱动对 GraphicBuffer 支持不完善

Glide 从 4.x 开始默认在 Android 8.0+ 设备上使用 Hardware Bitmap,Coil 同理。如果你需要对 Bitmap 做像素级操作(比如高斯模糊、色值采样),得先 copy()ARGB_8888

val softwareBitmap = hardwareBitmap.copy(Bitmap.Config.ARGB_8888, true)

各版本内存模型速查

Android 版本像素数据位置GC 可感知占用 Java 堆
2.x 及以前Native 堆
3.0 - 7.1Java 堆
8.0+(默认)Native 堆✅(NativeAllocationRegistry)
8.0+(Hardware)GPU 显存

实战中的 OOM 防治思路

搞清楚了内存模型的变迁,排查 OOM 就有章可循了。

Android 8.0+ 设备上,Java 堆 OOM 通常不再由 Bitmap 引起。如果还在 OOM,重点排查 Native 内存泄漏——用 Debug.getNativeHeapAllocatedSize() 监控 Native 堆水位,配合 malloc debug 或 ASan 定位泄漏点。

对于需要兼容低版本的场景inBitmap 复用仍然是最有效的手段。我在项目中的做法是维护一个按尺寸分桶的 BitmapPool,key 为 width * height * bytesPerPixel

class SimpleBitmapPool {
    private val pool = LruCache<Int, MutableList<Bitmap>>(maxSize)
    
    fun get(width: Int, height: Int, config: Bitmap.Config): Bitmap? {
        val key = width * height * config.bytesPerPixel()
        return pool.get(key)?.removeLastOrNull()
    }
    
    fun put(bitmap: Bitmap) {
        if (!bitmap.isMutable) return
        val key = bitmap.allocationByteCount
        pool.get(key)?.add(bitmap) 
            ?: pool.put(key, mutableListOf(bitmap))
    }
}

Hardware Bitmap 的使用策略也不复杂:图片展示场景(ImageView、列表头像)优先开启,需要像素操作的场景(截图合成、滤镜处理)回退到 Software Bitmap。不要无脑全局开启也不要全局关闭——Glide 中通过 disallowHardwareConfig() 可以按请求粒度控制。

最后一个容易忽略的点:Bitmap.Config.RGB_565 相比 ARGB_8888 内存减半(2 bytes vs 4 bytes per pixel),代价是丢失 Alpha 通道和色彩精度。对于不需要透明度的照片展示(比如相册、壁纸),RGB_565 投入产出比很高。但在带圆角、阴影的 UI 元素上使用会出现明显的色带(banding)。踩过这个坑之后,我的做法是只对确定不需要 Alpha 的全屏照片启用 565,其余一律 ARGB_8888。