3、压缩对内存的影响:压缩率与内存占用、解压缓冲区大小设计、内存碎片问题

聊完CPU开销,咱们得看看内存这边。说实话,内存问题往往比CPU更隐蔽。CPU扛不住你还能看到帧率掉,内存出问题——嗯,可能直接闪退,或者隔几个小时突然崩一次。

我个人习惯把压缩对内存的影响拆成三个维度来看:压缩率怎么吃掉你的内存、解压缓冲区该开多大、以及内存碎片这个老冤家。一个一个说。

3.1 压缩率与内存占用的真实关系

很多人有个直觉:压缩率越高,内存占用越低。这个直觉在存储场景下是对的,但在双缓冲刷新场景下——不一定。

为什么?因为双缓冲里你至少有两份缓冲区:前台缓冲和后台缓冲。压缩后的资源虽然小了,但解压时需要一块临时空间。这块临时空间的大小,跟压缩率的关系不是线性的。

核心结论:压缩率越高,解压时需要的临时内存反而可能越大。尤其是高压缩比的算法(比如LZMA、Brotli),解压时需要维护一个较大的滑动窗口或字典。

我举个例子。假设你有一个16MB的纹理,用LZ4压缩后变成8MB,解压时只需要一个很小的临时缓冲区(大概几百KB)。但如果你用LZMA,压缩后可能只有4MB,解压时却需要分配一个64MB的字典——你想想看,这反而多占了内存。

所以我在项目中选压缩算法时,会先问一个问题:解压时的内存峰值是多少?而不是只看压缩率。

压缩算法 典型压缩率(纹理) 解压临时内存 适用场景
LZ4 50% ~ 60% 几KB ~ 几百KB 实时刷新、低延迟
zlib (Deflate) 40% ~ 50% 32KB ~ 128KB 通用资源
LZMA 30% ~ 40% 几MB ~ 64MB 安装包、非实时加载
Brotli 35% ~ 45% 4KB ~ 16MB(质量参数) 网络传输、离线打包

你看,LZMA压缩率确实漂亮,但解压时可能吃掉64MB。如果你的双缓冲系统总共才128MB显存/内存,那这64MB就是个大坑。

避坑提醒:我曾经在一个嵌入式项目里用了LZMA压缩UI图集,压缩率从55%降到了32%,心里美滋滋。结果一跑,解压时直接OOM——因为设备只有64MB内存,LZMA字典就占了32MB,加上双缓冲本身,直接爆了。后来换回zlib,虽然压缩率高了点,但系统稳了。

3.2 解压缓冲区大小设计

解压缓冲区的大小设计,说白了就是在内存占用和解压速度之间找平衡。

缓冲区太小,解压器要频繁暂停、等待数据搬走,性能直线下降。缓冲区太大,又浪费内存。我一般遵循几个原则:

  1. 按最大资源尺寸定:缓冲区至少能容纳解压后的最大单个资源。比如你的最大纹理是8MB,那缓冲区至少8MB。
  2. 考虑对齐:很多硬件解码器要求缓冲区地址对齐到16字节或64字节。不对齐的话,性能会掉20%~30%。
  3. 预留余量:我习惯在计算出的理论值上加10%~20%,防止极端情况。

我的做法:我会在资源打包时,把每个资源的压缩后大小和解压后大小都写入元数据。运行时根据这两个值动态分配缓冲区,而不是写死一个固定大小。这样既省内存,又不会爆。

代码上大概是这样:

// 伪代码:动态分配解压缓冲区
struct CompressedResource {
    uint32_t compressed_size;   // 压缩后大小
    uint32_t decompressed_size; // 解压后大小
    uint8_t* data;              // 压缩数据
};

// 解压时
uint8_t* decompress_buffer = malloc(resource->decompressed_size + SAFE_MARGIN);
if (!decompress_buffer) {
    // 处理OOM
    return ERROR_OUT_OF_MEMORY;
}
// 执行解压...

这里有个细节:SAFE_MARGIN我一般设成decompressed_size * 0.1,但不超过1MB。为什么?因为有些压缩算法在极端情况下会输出比原始数据稍大的结果(比如数据本身已经是高熵的,压缩后反而变大)。

3.3 内存碎片问题

内存碎片——这玩意儿我吃了不少亏。双缓冲刷新场景下,频繁分配和释放解压缓冲区,很容易把堆内存搞成筛子。

为什么会这样?你想想看:

  • 每一帧你可能要解压几个资源
  • 每个资源的大小不一样
  • 解压完释放缓冲区
  • 下一帧又分配新的

这么搞上几百帧,内存里就全是小洞。虽然总空闲内存还有,但连续的大块内存已经找不到了。这时候你突然要分配一个8MB的缓冲区——malloc返回NULL,系统崩了。

碎片化的本质:频繁分配和释放不同大小的内存块,导致空闲内存被分割成许多不连续的小块。双缓冲刷新场景尤其容易触发这个问题。

我总结了几种应对策略:

  1. 使用内存池:预分配一块大内存,自己管理分配和释放。比如固定分配几个8MB的槽位,循环使用。
  2. 统一缓冲区大小:把所有解压缓冲区都设成同一个大小(比如取最大值),避免大小不一导致的碎片。
  3. 延迟释放:解压完不立即释放,而是放回一个空闲列表,下一帧复用。
  4. 使用伙伴系统:如果自己实现内存管理,伙伴系统对碎片控制比较好。

我曾经踩过的坑:在一个双缓冲UI系统里,每帧解压3~5个图标,每个图标大小从几KB到几百KB不等。跑了半小时后,突然卡死——malloc失败。查了半天,发现堆里最大的连续空闲块只有128KB,而我要分配一个256KB的缓冲区。后来改成内存池,问题解决。

下面这张图展示了内存碎片化的过程,以及内存池如何避免这个问题:

内存碎片化 vs 内存池方案 频繁分配/释放 → 碎片化 初始: 连续空闲内存 (320KB) 分配: A 80K B 60K C 100K 80K 释放A,C: 空闲 80K B 60K 空闲 100K 80K 碎片化: 最大连续空闲: 100KB 需要 150KB → 分配失败! 内存池 → 无碎片 预分配: 内存池 220KB (固定槽位) 分配: 槽0 80K 槽1 60K 空闲 释放后: 空闲 槽1 60K 空闲 再分配: 槽0 80K 槽1 60K 槽2 70K 始终有连续空闲槽位 分配 150KB → 成功! 总结:内存管理策略对比 • 直接 malloc/free:简单但容易碎片化,适合一次性加载 • 固定大小内存池:无碎片,但可能浪费空间(按最大资源定) • 伙伴系统:碎片少,灵活,但实现复杂 • 延迟释放 + 空闲列表:适合帧率稳定的场景

最后说一句:内存问题在开发阶段往往不明显,因为你的测试机内存大、碎片少。但到了用户设备上,尤其是低端机,问题就全冒出来了。我个人建议在项目早期就把内存池方案加进去,别等到线上崩了再补。