Dart - 内存管理与垃圾回收(GC)深度解析

Flutter 性能底层(一):内存世界的版图 ------ Dart 分代模型

引言:透过现象看本质

作为 Flutter 开发者,我们每天都在与 Widget 打交道。无论是简单的 Text 还是复杂的 CustomPaint,我们习惯了通过不断地 new 对象来构建界面。

但在这些代码背后,你是否曾有过一丝隐忧:Dart 虚拟机(VM)到底把这些对象存在了哪里?它是怎么管理这一大堆数据的?

要回答这个问题,解决我们对"频繁创建对象"的恐惧,我们不能一上来就谈枯燥的 GC 算法。我们首先需要拿出一张地图,看懂 Dart 内存世界的版图结构

因为 Dart 所有的内存策略,都建立在两个核心设计之上:Isolate 的独立性分代假说


一、 宏观格局:Isolate 的分户式管理

在 Java 或 C++ 等传统语言中,多线程通常共享同一块堆内存(Shared Heap)。这就像一群人住在一个大通铺里:

  • 优点:我想给你个东西,直接放在桌子上就行。
  • 缺点:打扫卫生(GC)时非常麻烦。为了防止"我刚扫完地,你又扔了纸团",GC 往往需要暂停所有人的活动(Stop The World),给整个屋子上锁。

Dart 选择了完全不同的路线:Isolate(隔离区)

在 Dart 中,每个线程(Isolate)都有自己独立的堆内存(Heap)。Isolate A 的内存,Isolate B 根本看不见,更摸不着。

这种"分户式管理"带来了两个巨大的优势:

  1. 无锁分配:因为只有我自己用这块内存,创建对象时不需要加锁,速度极快。
  2. 局部 GC:当主 UI 线程需要扫垃圾时,完全不影响后台做繁重计算的 Isolate。App 不会因为后台任务的 GC 而卡顿。

二、 核心理论:分代假说 (Generational Hypothesis)

搞清楚了"谁管谁家"的问题,接下来我们看单各 Isolate 内部,这块内存(Heap)是如何规划的。

Dart 的内存规划并非拍脑门决定的,而是基于计算机科学中一个著名的观察结论 ------ 分代假说 (The Generational Hypothesis)

研究人员发现,在几乎所有的面向对象程序(特别是像 Flutter 这样构建 UI 的程序)中,对象的生命周期呈现出极端的两极分化

  1. 绝大多数对象是"朝生夕死"的
    比如我们在 build() 方法里创建的 Container, Padding, EdgeInsets,它们存在的意义就是为了渲染这一帧画面。下一帧来了,它们就没用了。这些对象占了总量的 90% 以上。
  2. 极少数对象是"长命百岁"的
    比如全局的 UserSession, HttpClient, 图片缓存。它们一旦创建,可能会伴随 App 运行很久。

基于这个事实,Dart 决定不再"一视同仁"地管理所有内存,而是将堆内存物理划分为两个世界:新生代老年代


三、 版图划分:两室一厅的智慧

Dart VM 将堆内存(Heap)切分成了两块截然不同的区域,每一块都有自己的"性格"和"规则"。

1. 新生代 (Young Generation / New Space)

  • 定位:对象的"生产室"与"育儿所"。
  • 住户所有 刚被 new 出来的对象,必须先在这里出生。没有例外。
  • 特点
    • 空间较小:因为大部分对象马上就会死,不需要太大空间。
    • 内存连续:为了追求极致的分配速度(下一章会讲的指针碰撞)。
    • 流转极快:这里的 GC 频率非常高,策略是"快进快出"。
    • 双空间:方便快速复制整理,涉及Cheney 算法内容(下一章节会讲)。

2. 老年代 (Old Generation / Old Space)

  • 定位:对象的"退休所"或"档案馆"。
  • 住户:只有在新生代经历过"大风大浪"(GC)还没死的对象,才有资格拿到这里的"绿卡"。
  • 特点
    • 空间巨大:用于存放长期数据,甚至可以动态扩容。
    • 管理沉稳:这里的 GC 频率很低,因为大家都很稳定,但这并不意味着它不重要(涉及内存碎片整理)。

四、 为什么这么设计?

试想一下,如果没有分代,所有对象都在一个大池子里:

每次 GC,我们都要遍历几万个 Widget 和几个全局单例。为了清理那 90% 的垃圾 Widget,我们不得不把 10% 的长寿对象也扫描一遍。这就像为了扔掉门口的垃圾袋,却把家里的保险柜也翻了一遍,效率极低。

有了分代模型,Dart 就可以因材施教:

  • 新生代(满地垃圾):采用一种"只捡宝贝,剩下全扔"的激进算法。
  • 老年代(满地宝贝):采用一种"小心核对,整理归档"的保守算法。

小结

这一章我们建立了两条核心认知:

  1. Isolate 让我们拥有了独立的内存空间,互不干扰。
  2. 分代模型 将内存划分为"快"与"慢"两个区域,完美契合了 Flutter UI "高频刷新"的特性。

这就解释了那个经典问题:"为什么 Flutter 官方建议我们将大 Widget 拆分成小 Widget?"

因为拆分出来的小 Widget 都在 新生代。只要我们能证明新生代的创建和销毁是"免费"的,那么拆分 Widget 就只有好处(代码清晰、局部刷新),没有坏处(性能损耗)。

那么,新生代到底凭什么做到"创建极速"且"销毁免费"呢?

下一章,我们将拿上放大镜,深入 新生代 的内部,揭秘那个反直觉的 Scavenge 算法指针碰撞 技术。


Flutter 性能底层(二):新生代的极速魔法 ------ 为什么创建对象不需要勇气?

引言:被误解的 new

在上一章的地图中,我们要聚焦在左边那个繁忙的区域------新生代 (New Space)

还记得我们在 build() 方法里随手写下的 new Container(), new Padding() 吗?

很多直觉告诉我们:"申请内存"应该是一个很重的操作。就像去图书馆占座,你得先找管理员,查查哪里有空位,登记名字,然后坐下。如果频繁这么做,肯定会累死。

但在 Dart 的新生代里,**"占座"**这件事被简化到了极致。它不需要查找,甚至不需要管理员介入。

今天,我们将揭秘两项核心技术:让分配快如闪电的 "指针碰撞" ,以及让销毁成本归零的 "Scavenge 算法"


一、 分配的艺术:指针碰撞 (Bump Pointer)

还记得我们在第一章图中看到的新生代 吗?它的内存空间不仅小,还有一个关键特性:连续

正因为内存是连续的,Dart VM 不需要像老式内存管理那样,去维护一个复杂的"空闲列表(Free List)"来记录哪里有缝隙。

Dart 只需要维护一个简单的指针:Top Pointer(顶部指针)。这个指针永远指向当前内存使用量的末尾。

当你执行 new Widget() 时,VM 内部的对话是这样的:

  1. Widget: "我需要 32 字节的空间。"
  2. VM: "Top 指针现在的地址是 1000。1000 + 32 = 1032。没超出边界。给,你拿去用吧。"
  3. VM: (默默把 Top 指针移到 1032)。

结束。

没有查找,没有遍历,没有碎片整理。整个过程仅仅是几条 CPU 指令(一次加法运算)。

这就像是老式的磁带录音。

你想录一段新歌,根本不需要去整盘磁带里寻找哪里有空白片段。你只需要在当前磁头的位置,按下录音键,顺着往下录就行了。

所以,在 Dart 新生代里分配内存,在时间复杂度上几乎等同于赋值操作。 这就是你敢在 Flutter 每一帧里疯狂 new 对象的底气。


二、 布局的秘密:两个半区 (Semi-Space)

但是,"指针碰撞"有一个致命问题:只能往后录,不能回头。

如果内存填满了(Top 指针撞墙了)怎么办?这时候,GC (垃圾回收) 就必须登场了。

为了解决这个问题,Dart 采用了 Cheney 算法 的变种。它把新生代物理切分成了两个大小完全一样的半区:

  1. 活跃半区 (Active / To Space):当前的"工作车间",所有新对象都在这里分配。
  2. 空闲半区 (Inactive / From Space):完全是空的,留作备用。

这就解释了第一章那张图中,为什么新生代里总有一半是"灰白色"的空闲状态。这是为了接下来的"乾坤大挪移"做准备。


三、 清理的艺术:Scavenge (搬家算法)

当活跃半区被填满时,GC 哨声吹响。Dart VM 开始执行 Scavenge 操作。

这里的核心逻辑极其反直觉:
传统的清洁工是"找垃圾 -> 扔掉"。
Dart 的 Scavenge 是"找活人 -> 救走"。

过程如下:

  1. 标记存活:GC 从根节点(栈变量)出发,瞬间找出那些还活着的对象(比如还在用的 State,还没销毁的 Widget)。
  2. 复制 (Copying) :把这些活着的"幸存者",从 活跃半区 复制到 空闲半区
  • 注意:复制过去的时候,它们是紧紧挨着排队的。这意味着,原来可能存在的内存碎片,在复制的一瞬间被自动整理好了。
  1. 无视垃圾 :那些没人引用的垃圾对象呢?GC 看都不看它们一眼。

四、 最大的反直觉:O(0) 的销毁成本

现在,活对象都搬到了另一边。

原来的活跃半区里,剩下了成吨的垃圾。Dart 需要一个个去销毁它们吗?

不需要。

Dart 只需要做一个动作:身份互换 (Swap)。

  • 原来的空闲半区 (现在装满了整齐的活对象) 变成新的 活跃半区
  • 原来的活跃半区 (满地垃圾) 变成新的 空闲半区

变成空闲半区意味着什么?意味着 指针归零 (Reset Top Pointer)

那块内存里的数据(垃圾)还在吗?在。

但在逻辑上,它们已经被视为"空白"了。下次分配新对象时,直接覆盖在它们尸体上。

这就是 O(0) 销毁的奥义。

我们可以得出一个震撼的结论:
Dart 清理新生代的耗时,只取决于"活对象"的数量,而与"垃圾"的数量完全无关。

  • 如果你产生了 10 个垃圾,GC 耗时为 T。
  • 如果你产生了 10,000 个垃圾,只要活下来的还是那几个,GC 耗时依然是 T。

这就是为什么 Flutter 官方敢建议我们将大 Widget 拆分成小 Widget。因为那些中间产生的临时小 Widget,对 GC 来说,清理它们的成本是零。


小结

这一章我们见证了新生代的两大魔法:

  1. 进得快:指针碰撞,无需查表。
  2. 死得快:半区复制,无视垃圾。

Dart 的新生代就像一个高效的**"一次性用品流水线"**,完美契合了 Flutter 声明式 UI "用完即扔"的特性。

但是,如果一个对象命很大,在这一轮"大清洗"中活下来了,甚至活了好几轮,该怎么办?

它不能永远在两个半区之间搬来搬去,那样太累了。

下一章,我们将讲述这些幸存者的故事------晋升 (Promotion) ,以及它们在 老年代 (Old Generation) 将面临的残酷生存法则。


Flutter 性能底层(三):老年代的沉稳智慧 ------ 晋升与并发的艺术

引言:幸存者的烦恼

在上一章,我们见识了新生代 Scavenge 算法的"神速"。通过把活对象在两个半区之间搬来搬去,Dart 实现了 O(0) 的垃圾清理。

但是,请设想这样一个场景:

你有一个全局的 UserSession 对象,或者一张缓存的大图。它们在第 1 帧被创建,并且会一直存活到 App 关闭。

如果它们一直留在新生代,就会出现一个很滑稽的现象:
GC 每跑一次,这些长寿对象就要被"搬运"一次。 它们像钉子户一样,在两个半区之间反复横跳,不仅浪费了宝贵的 CPU 搬运时间,还长期占据了新生代本就不大的地盘。

为了解决这个问题,Dart 引入了 晋升机制 (Promotion)


一、 晋升:从幼儿园到社会

Dart 的规则很简单:"新生代是留给短命鬼的,只有经历过风浪的对象才配去老年代。"

当一次 Scavenge (新生代 GC) 发生时,Dart 会检查那些幸存的对象:

  • 规则 :如果一个对象在上一轮 GC 中已经活下来过(或者其生命周期显式地被标记为长),它就不再会被复制到新生代的另一个半区,而是直接被打包,晋升(Promote)老年代 (Old Generation)

一旦进入老年代,它就安全了。它不会再被频繁地搬来搬去,而是安稳地定居下来。

但是,随着越来越多的对象晋升进来,老年代终究也会满。这时候,老年代的 GC 就必须出手了。


二、 策略突变:标记-清除 (Mark-Sweep)

在老年代,Dart 放弃了新生代那种高效的"半区复制"算法。为什么?

  1. 空间浪费:复制算法需要把内存一分为二。老年代通常很大(几百 MB),如果为了 GC 浪费一半内存,手机内存根本不够吃。
  2. 复制太慢 :老年代里 90% 可能都是活对象。要把几百 MB 的数据搬一遍,CPU 绝对会发烧,App 也会卡死。

所以,老年代采用了更成熟、更节省空间的 标记-清除 (Mark-Sweep) 策略。

  • Mark (标记):找出所有活的对象。
  • Sweep (清除):回收死掉对象的内存地址,记录在"空闲列表"里,供下次使用。

其中最核心的难点在于:如何在几百兆内存中快速找出活对象,而且不让 UI 卡顿?


三、 并发标记:三色魔法 (Tri-Color Marking)

如果 Dart 像传统 GC 那样,在扫描老年代时暂停整个 App(Stop The World),用户就会感觉到明显的 掉帧(Jank)

为了解决这个问题,Dart 采用了源自 Dijkstra 的经典理论 ------ 三色标记算法 (Tri-Color Marking)。这允许 GC 线程在后台默默工作,而 UI 线程可以同时继续运行。

Dart 将内存中的对象逻辑上分为三种颜色:

  1. 白色 (White)未访问。初始状态下所有对象都是白色。如果扫描结束还是白色,说明是垃圾。
  2. 灰色 (Grey)进行中。对象本身被访问了(活着),但它引用的子对象还没扫描完。这是扫描的"波浪前沿"。
  3. 黑色 (Black)已完成。对象本身和它引用的所有子对象都已扫描完毕。黑色对象是绝对安全的。

魔法过程(边跑边标):

  1. 根节点扫描 :GC 把从栈上直接能访问到的对象瞬间染成 灰色
  2. 并发推进:GC 线程从灰色对象出发,把它的子对象染灰,把自己染黑。灰色像一道波浪,推着白色(未知区域)向黑色(安全区域)转化。

如果 UI 捣乱怎么办?(写屏障 Write Barrier)

既然是并发,就可能出事。比如 GC 刚把一个对象标记为黑色(扫完了),UI 线程突然把一个白色的新对象塞给了这个黑色对象。GC 会以为黑色对象已经没事了,结果漏掉了那个白色对象。

Dart 的对策是 写屏障

UI 线程 : "大哥(GC),我对这个黑色对象做了修改,给它指了个新引用。"
GC : "收到。把你修改的这个黑色对象重新标灰,我稍后会重新扫描它。"

正是这套机制,让 Flutter 在进行大规模内存扫描时,依然能保持流畅。


四、 整理:治愈"瑞士奶酪" (Compaction)

当标记完成后,所有白色的对象就是垃圾。Dart 会把它们的地址回收。

但是,随着时间推移,老年代会变成一块 "瑞士奶酪" ------ 到处都是小的空洞(内存碎片)。

如果你突然想存一张高清大图(需要连续的大块内存),虽然空洞加起来总和够大,但没有一块连续区域能放得下。这就叫 OOM (Out Of Memory)

这时候,Dart 会祭出最后的大招:整理 (Compaction)

动作

GC 会暂停世界,把所有活着的(黑色)对象,强行往内存的一端推移,把它们挤在一起,把所有的空隙都挤到另一端去。

代价

这是一个非常昂贵的操作。所以,Dart 并不是每次 GC 都做整理,只有在碎片化非常严重,或者内存即将耗尽时才会触发。


小结

老年代的世界没有"唯快不破"的热血,只有"精打细算"的权衡。

  • 晋升:避免了长寿对象的无意义搬运。
  • 并发标记:利用三色模型,让 GC 和 UI 线程和谐共处。
  • 整理:解决内存碎片,防止 OOM。

至此,我们已经看懂了 Dart 内存管理的空间维度

但还有一个时间维度 的谜题没有解开:
Flutter 每一帧只有 16ms,Dart 到底是在这 16ms 的哪个时间缝隙里偷偷执行这些 GC 操作的?

下一章(终章),我们将揭秘 Flutter Engine 的调度机制 ------ 看它如何利用 Idle Time(空闲时间) 做到"在用户眨眼的瞬间打扫完房间"。


Flutter 性能底层(四):终极调度 ------ 时间缝隙里的生存游戏

引言:16 毫秒的生死线

做过动画或游戏开发的都知道,16.6ms 是一条不可逾越的生死线。

为了保持 60fps 的流畅体验,系统每隔 16.6ms 就会发出一个 VSync(垂直同步)信号。

Flutter 的渲染管线(Build -> Layout -> Paint)必须在这段时间内跑完。

  • 跑完了,屏幕刷新,画面丝般顺滑。
  • 跑不完,新的一帧无法生成,屏幕重复显示上一帧。这就是用户眼中的 "掉帧" (Jank)

这时候,一个终极矛盾出现了:
GC(垃圾回收)也是要消耗 CPU 的。如果我的业务代码已经很重了,GC 还要横插一杠,岂不是必然导致卡顿?

Dart 的回答是:我会看脸色,也会在绝境中求生。


一、 顺风局:引擎与 VM 的"空闲密谋" (Idle-Time GC)

在大多数正常的场景下,Flutter 的每一帧并不会填满 16ms。也许你的代码很高效,只用了 10ms 就完成了所有渲染工作。

那么,剩下的 6.6ms 去哪了?

这段时间,CPU 处于空闲状态,等待下一个 VSync 信号。这就是 "空闲时间 (Idle Time)"

Flutter Engine(渲染管家)极其聪明,它利用这段时间与 Dart VM(内存管家)进行了一次完美的配合:

  1. 计算余额:Engine 发现:"当前帧已提交,距离下一帧还有 6ms。"
  2. 发送信号:Engine 通知 VM:"嘿,我有 6ms 空档,你要不要打扫卫生?"
  3. 见缝插针 :VM 评估发现新生代 GC 只需要 2ms,于是果断执行 Scavenge

结果:GC 在用户感知的"死角"里完成了。用户觉得 App 极其流畅,完全感觉不到内存被清理了。这是 Dart 最理想的工作状态。


二、 逆风局:当没有空闲时间时 (Forced GC & Jank)

但是(关键的转折来了),现实并不总是完美的。

假设你写了一个极其复杂的列表,或者正在进行大量的 3D 变换计算,导致 BuildLayout 阶段就已经消耗了 15ms

这时候,Engine 根本没有多余的时间留给 VM。

更糟糕的是,在这个高强度的计算过程中,你的代码还在疯狂地 new 对象(比如在循环里创建临时变量)。

突然,新生代(New Space)满了。 指针指到了尽头,物理上无法再分配下一个对象的内存了。

这时候,Dart VM 别无选择,必须启动 "应急预案" 。这个过程不再是协商,而是强制执行

强制 GC (Stop The World)

  1. 立即挂起:VM 必须强制暂停当前的 UI 线程。哪怕现在正画到一半,也得停下来,因为没有内存可用了。
  2. 原地清理:VM 立即执行 Scavenge 算法,清理新生代。
  3. 惨痛代价
  • 业务代码耗时:15ms。
  • 强制 GC 耗时:3ms。
  • 总耗时:18ms

结局 :总时间超过了 16.6ms 的红线。VSync 信号来了,但新画面没准备好。用户看到了明显的 卡顿


三、 绝境局:老年代的"内存换时间"策略

如果不是新生代满了,而是 老年代(Old Gen) 满了呢?

老年代的 GC(标记-整理)非常慢,可能耗时 100ms 以上。如果在 UI 运行过程中触发这个,App 会直接卡死一瞬间。

为了避免这种灾难性的体验,Dart 采用了一种 "能拖就拖" 的策略:

  1. 优先扩容 :如果老年代满了,但当前没有空闲时间,Dart 通常不会立即触发老年代 GC。相反,它会向操作系统申请更多的物理内存,临时扩大老年代的容量(Hard Limit)。
  2. 扛过高峰:先把新晋升的对象塞进去,硬扛过这一波高负载的动画或计算。
  3. 秋后算账:等到后面终于有空闲时间了,或者内存占用实在太大触碰到了 OOM(内存溢出)的红线,才会强制执行大规模 GC。

这也解释了为什么 Flutter App 有时候内存占用会飙升:

因为它在用 空间(内存) 换取 时间(流畅度)。它宁愿多吃点内存,也不想让用户感到卡顿。


四、 总结:开发者的责任

看完这整个系列,我们可以得出一个最终结论:

Dart 的内存管理机制是"为 UI 而生"的,它构筑了三道防线:

  1. 极速分配(微观):指针碰撞,快如闪电。
  2. 并发标记(宏观):三色算法,减少阻塞。
  3. 空闲调度(终极):见缝插针,隐藏成本。

但是,Dart 救不了"作死"的代码。

如果你的代码在每一帧里都塞满了繁重的计算,并且毫无节制地分配内存,导致"空闲时间"消失,那么 强制 GC掉帧 就是必然的物理结果。

所以,作为 Flutter 开发者,我们在享受便利的同时,依然要遵守基本法:

  1. **多用 const**const 对象不占新生代,不给 GC 添堵。
  2. **避免循环内 new**:这会瞬间填满新生代,触发强制 GC。
  3. 拆分耗时任务:别让 UI 线程过载,给 Engine 留一点喘息(和 GC)的时间。
相关推荐
一起养小猫3 小时前
Flutter for OpenHarmony 实战:记忆棋游戏完整开发指南
flutter·游戏·harmonyos
Betelgeuse765 小时前
【Flutter For OpenHarmony】TechHub技术资讯界面开发
flutter·ui·华为·交互·harmonyos
铅笔侠_小龙虾5 小时前
Flutter 安装&配置
flutter
mocoding6 小时前
使用已经完成鸿蒙化适配的Flutter本地持久化存储三方库shared_preferences让你的应用能够保存用户偏好设置、缓存数据等
flutter·华为·harmonyos·鸿蒙
无熵~8 小时前
Flutter入门
flutter
hudawei9968 小时前
要控制动画的widget为什么要with SingleTickerProviderStateMixin
flutter·mixin·with·ticker·动画控制
jian1105810 小时前
flutter dio 依赖,dependencies 和 dev_dependencies的区别
flutter
王码码203510 小时前
Flutter for OpenHarmony 实战之基础组件:第十七篇 滚动进阶 ScrollController 与 Scrollbar
flutter·harmonyos
小哥Mark10 小时前
Flutter开发鸿蒙年味 + 实用实战应用|春节祝福:列表选卡 + 贴纸拖动 + 截图分享
flutter·harmonyos·鸿蒙