FFmpeg 内存管理:一帧背后的引用计数

一帧图像不只是一块像素内存:在 FFmpeg 里,它背后可能挂着解码器、引用计数、buffer pool 和一串“谁还没松手”的生命周期。

很多人第一次写 FFmpeg 代码,都会被一个问题绊住:

“我拿到了一个 AVFrame,如果想保存一份,直接 memcpy 可以吗?”

答案是:可以,但经常不是最好的选择。

因为 FFmpeg 的很多对象不是“普通结构体 + 一块裸内存”。它更像一套引用计数系统:你看到的是 AVFrame,真正的大块数据可能在 AVBuffer 里;你以为拷贝了一帧,实际可能只是复制了几个指针;你以为释放了解码器,某个忘记释放的 frame 还可能把整个 buffer pool 顶住。

这篇文章就从“拷贝一帧”这个小问题出发,聊清楚 FFmpeg 的内存管理模型。

在这里插入图片描述

1. 想拷贝 AVFrame,先问:你要拷贝什么?

AVFrame 这个名字很容易误导人。它看起来像“一帧数据”,但更准确地说,它是一帧媒体数据的描述对象。

一个视频 AVFrame 里通常有这些东西:

成员 / 概念作用容易误解的点
data[]指向各个平面的数据地址,例如 Y、U、V它通常只是指针,不代表内存归 AVFrame 结构体本身所有
linesize[]每个平面的行跨度不一定等于图像宽度,可能有对齐 padding
buf[]保存数据 buffer 的引用这是释放大块像素内存的关键引用
extended_buf超过固定数组数量时的额外 buffer 引用音频多声道等场景常见
width/height/format描述图像尺寸和像素格式只描述数据,不负责管理底层内存
pts/pkt_dts时间戳信息拷贝像素时不一定要保留,做同步时必须关注

所以“拷贝 AVFrame”至少有两种含义:

需求典型做法结果
只想多持有一份引用av_frame_ref(dst, src)快,通常不复制像素;底层 buffer 引用计数 +1
想要独立像素副本av_frame_clone() 后必要时 av_frame_make_writable(),或新分配 frame 后拷贝数据更安全,但可能产生真实内存拷贝
只想临时传递所有权av_frame_move_ref(dst, src)不复制数据,把引用从 src 转移到 dst
只想拷贝元信息av_frame_copy_props(dst, src)只复制时间戳、色彩信息等属性,不复制像素

这里最重要的区别是:

av_frame_ref() 不是深拷贝像素,它是在共享底层 buffer 的前提下增加引用。

这正是 FFmpeg 高效的地方:高清视频一帧动辄几 MB,如果每个模块都深拷贝一次,内存和 CPU 很快就会爆炸。FFmpeg 默认更倾向于“共享数据 + 引用计数”。

2. xxx_ref / xxx_unref:FFmpeg 的“借书登记表”

理解 FFmpeg 内存管理,先抓住一组命名习惯:

API 形态含义你可以怎么理解
xxx_alloc()创建对象壳子或分配对象从仓库拿一个新对象
xxx_free()销毁对象,并通常把指针置空彻底归还对象
xxx_ref()增加一份引用多登记一个借阅人
xxx_unref()释放当前引用当前借阅人还书
xxx_move_ref()转移引用所有权把借书卡从 A 换到 B,不新增借阅人
xxx_clone()创建新对象并引用同一份底层数据新建一个壳子,但底层书可能还是同一本

拿 AVFrame 来说:

操作会发生什么注意点
av_frame_alloc()只分配 AVFrame 结构体不一定分配像素数据
av_frame_get_buffer()给 frame 分配底层数据 buffer数据通常挂在 buf[] 引用里
av_frame_ref(dst, src)dst 引用 src 的底层 buffer两个 frame 共享数据;引用计数增加
av_frame_unref(frame)释放 frame 当前持有的 buffer 引用frame 壳子还在,可以复用
av_frame_free(&frame)先 unref,再释放 frame 结构体,并置空指针最常见的收尾方式

这里的设计非常像“借书登记表”:

角色类比真实对象
书真正占空间的数据像素 buffer / packet data
借书卡一个引用AVBufferRef
借阅登记表引用计数AVBuffer 内部 refcount
读者持有引用的对象AVFrame、AVPacket、解码器内部结构
图书馆管理 buffer 的地方AVBufferPool 或普通 allocator

只有最后一个读者还书,书才真正能从系统里移走。

3. AVBuffer:真正管理大块内存的底座

在 FFmpeg 里,很多大块数据最终都会落到 AVBuffer / AVBufferRef 这套机制上。

简单说:

名称作用重点
AVBuffer真正管理底层 data 和引用计数的对象里面知道 data 怎么释放
AVBufferRef指向 AVBuffer 的引用句柄可以有多个 ref 指向同一块数据
av_buffer_ref()复制一个引用refcount +1
av_buffer_unref()释放一个引用refcount -1,归零时调用 free callback
av_buffer_alloc()分配一块带引用计数的内存常用于 frame/packet 底层数据
AVBufferPoolbuffer 池用于复用频繁申请释放的大块内存

普通 AVBuffer 的释放逻辑很好理解:

  1. 分配一块 data;
  2. 用 AVBuffer 包起来;
  3. 每多一个使用者,就多一个 AVBufferRef;
  4. 每个使用者结束时 av_buffer_unref();
  5. refcount 归零时调用释放回调,真正释放 data。

但 AVBufferPool 又多了一层“复用”。

在这里插入图片描述

当解码器需要一帧 buffer 时,它可能不是每次都直接向系统 malloc,而是:

阶段行为结果
取 bufferav_buffer_pool_get(pool)从池里拿一个可用 buffer;没有空闲才新分配
frame 使用中AVFrame::buf[] 持有 AVBufferRefbuffer 不能被回收
frame 释放av_frame_unref/free()引用释放,buffer 可能回到 pool
pool 销毁pool 自身引用也释放,且没有外部 buffer ref池里缓存的底层内存才真正 free

这就是很多 FFmpeg 内存问题难查的地方:

av_frame_free() 不一定等于“立刻把像素内存还给系统”,它可能只是把 buffer 还给池。真正释放要看 pool 是否销毁,以及所有引用是否都归零。

4. 为什么 FFmpeg 要这么设计?

因为视频处理太吃内存,也太吃拷贝成本。

一帧 1080p YUV420P 8bit 大概 3MB;如果是 10bit,接近 6MB。4K、10bit、多线程解码时,这个数字还会继续上升。

如果每个环节都做深拷贝,典型链路会一路膨胀:

环节如果深拷贝直接后果
解码输出产出第一份像素数据基础帧内存已经产生
滤镜处理再拷贝一份给滤镜CPU 多一次大块内存读写
缩放处理再拷贝一份给缩放器临时峰值继续抬高
渲染显示再拷贝一份给渲染层帧率和功耗都受影响
缓存复用再拷贝一份给缓存多路播放、批量缩略图时更容易爆内存

这条链路真正的问题可以概括成两类:

问题后果典型表现
CPU 拷贝成本高帧率下降、耗电上升高清视频、实时滤镜更明显
瞬时内存膨胀多份大 frame 同时存在多路播放、缩略图批处理时尤其明显

所以 FFmpeg 更偏向“共享引用,而不是层层复制”的模型:

设计选择做法收益
数据只放一份像素数据放在底层 buffer 里避免重复存储大块内存
对象通过 ref 共享AVFrame / AVPacket 持有引用模块间传递更轻量
用完主动 unref谁增加引用,谁负责释放引用生命周期可追踪
最后一个引用释放refcount 归零后释放底层内存保证没人使用时再回收

这套模型很强,但也有代价:你必须认真对待每一个 ref 和 unref。

5. 常见对象的 ref / unref 心智模型

下面这张表可以作为写 FFmpeg 代码时的速查。

对象常见创建增加引用 / 复制引用释放引用销毁对象典型坑
AVFrameav_frame_alloc()av_frame_ref() / av_frame_clone()av_frame_unref()av_frame_free()只 free 了壳子思维,忘了 buf[] 才是大头
AVPacketav_packet_alloc()av_packet_ref() / av_packet_clone()av_packet_unref()av_packet_free()循环读包时忘记 av_packet_unref()
AVBufferRefav_buffer_alloc() / av_buffer_ref()av_buffer_ref()av_buffer_unref()通常没有单独 free,靠 unref多一个 ref 就多一个释放责任
AVCodecContextavcodec_alloc_context3()通常不 ref不适用avcodec_free_context()释放 context 不代表外部 frame 引用也会消失
AVFormatContextavformat_open_input()通常不 ref不适用avformat_close_input()demux 和 decode 是两套生命周期
SwsContextsws_getContext()不适用不适用sws_freeContext()尺寸/格式变化时旧 context 忘释放

一个很实用的习惯是:谁拿到 ownership,谁负责释放;谁调用了 ref,谁就必须配一个 unref。

6. 忘记释放一个 AVFrame,为什么可能不只漏“一帧”?

现在回到最开头的问题:如果一个 AVFrame 忘记释放,会发生什么?

直觉上,很多人会以为只是漏了一个结构体:

sizeof(AVFrame) 几百字节?小问题。

真实情况完全不是这样。

AVFrame 自己只是壳。它可能通过 buf[] 挂着几 MB 的像素内存;如果这些 buffer 来自 AVBufferPool,漏掉一个 frame 引用,还可能让整个 pool 没法销毁。

在这里插入图片描述

后果可以分三层:

层级泄漏内容影响
第一层AVFrame 结构体本身很小,通常不是主因
第二层AVFrame::buf[] 持有的像素 buffer可能是几 MB 到几十 MB
第三层AVBufferPool 因引用未归零无法销毁可能拖住一批已归还 pool 的大块 buffer

这也是为什么某些问题看起来只是“漏了一帧”,最后却表现成几百 MB 甚至 GB 级 native 内存增长。

尤其在这些场景里,放大效应更明显:

场景为什么更危险
批量缩略图每个视频都要解一帧,次数多,容易线性累积
10bit / 4K 视频单帧内存更大
多线程解码每个线程可能持有 delayed frame / reference frame
H.264 / HEVC解码器内部有参考帧队列和 DPB
异常 fallback正常路径释放了,错误路径反而容易漏

所以 FFmpeg 内存排查里,一个非常重要的原则是:

不要只看“我最后有没有 free codec context”,还要看中间所有 frame / packet 的引用有没有归零。

avcodec_free_context() 能释放解码器自己持有的资源,但它不能替你释放已经丢到外面的 AVFrame。如果某个 AVFrame 指针被覆盖、丢失、或者异常路径漏掉,里面的 AVBufferRef 仍然可能让底层 buffer 活着。

7. 写 FFmpeg 代码的几个自检问题

最后给一组很实用的检查清单。每次写到 AVFrame / AVPacket 生命周期时,可以顺手过一遍。

自检问题为什么要问
每个 av_frame_alloc() 是否都有对应 av_frame_free()?frame 壳子和其中的 buffer 引用都要释放
每次循环复用 AVFrame* 前,旧值是否已经 unref/free?防止旧指针被覆盖后永久失联
每个 av_frame_ref() 是否都有对应 av_frame_unref()?引用计数不归零,底层 buffer 就不能释放
错误路径、continue、break 前是否清理了 frame?内存泄漏最常出现在异常分支
成功码和失败码是否可能冲突?成功被误判失败时,资源释放路径可能被绕过
AVPacket 循环读包后是否及时 av_packet_unref()?packet 也常持有引用计数 buffer
释放 codec context 前,外部是否还持有输出 frame?context 释放不了外部 frame 的引用
是否把 shallow ref 当成 deep copy?会导致意外共享或释放时机错误

如果只能记住一句话,我建议记这句:

在 FFmpeg 里,真正的大内存经常不在你手里的结构体里,而在它引用的 buffer 里;忘记释放一个引用,可能拖住一整片池化内存。

结尾

FFmpeg 的内存管理并不神秘,它只是把“谁拥有数据、谁还在使用数据、什么时候能释放数据”这件事做得非常细。

AVFrame、AVPacket、AVBufferRef、AVBufferPool 看起来是一堆 API,背后其实是一套统一的思路:

核心问题FFmpeg 的回答
大块数据要不要反复拷贝?尽量不要,优先共享引用
多个对象共享数据怎么管理?引用计数
频繁申请释放大块内存怎么办?buffer pool 复用
什么时候真正释放?最后一个引用释放时
最容易出问题在哪里?异常路径、覆盖旧指针、成功/失败语义混乱

写 FFmpeg,最怕的不是少写一个 free(),而是少想清楚一次“这个引用现在归谁”。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐