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 解压缓冲区大小设计
解压缓冲区的大小设计,说白了就是在内存占用和解压速度之间找平衡。
缓冲区太小,解压器要频繁暂停、等待数据搬走,性能直线下降。缓冲区太大,又浪费内存。我一般遵循几个原则:
- 按最大资源尺寸定:缓冲区至少能容纳解压后的最大单个资源。比如你的最大纹理是8MB,那缓冲区至少8MB。
- 考虑对齐:很多硬件解码器要求缓冲区地址对齐到16字节或64字节。不对齐的话,性能会掉20%~30%。
- 预留余量:我习惯在计算出的理论值上加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,系统崩了。
碎片化的本质:频繁分配和释放不同大小的内存块,导致空闲内存被分割成许多不连续的小块。双缓冲刷新场景尤其容易触发这个问题。
我总结了几种应对策略:
- 使用内存池:预分配一块大内存,自己管理分配和释放。比如固定分配几个8MB的槽位,循环使用。
- 统一缓冲区大小:把所有解压缓冲区都设成同一个大小(比如取最大值),避免大小不一导致的碎片。
- 延迟释放:解压完不立即释放,而是放回一个空闲列表,下一帧复用。
- 使用伙伴系统:如果自己实现内存管理,伙伴系统对碎片控制比较好。
我曾经踩过的坑:在一个双缓冲UI系统里,每帧解压3~5个图标,每个图标大小从几KB到几百KB不等。跑了半小时后,突然卡死——malloc失败。查了半天,发现堆里最大的连续空闲块只有128KB,而我要分配一个256KB的缓冲区。后来改成内存池,问题解决。
下面这张图展示了内存碎片化的过程,以及内存池如何避免这个问题:
最后说一句:内存问题在开发阶段往往不明显,因为你的测试机内存大、碎片少。但到了用户设备上,尤其是低端机,问题就全冒出来了。我个人建议在项目早期就把内存池方案加进去,别等到线上崩了再补。