Appearance
JVM / ART / Dart / Flutter 内存模型总览
一句话定义
- JVM / ART:线程拥有私有执行栈,进程内的托管堆通常由多个线程共享;GC 负责回收不可达对象,JMM / 锁 /
volatile/ 原子类负责协调共享状态的可见性、顺序性与并发安全 - Dart / Flutter:默认以 isolate 为边界,每个 isolate 拥有独立堆和事件循环;Flutter 进程还包含 Engine、Native、图片缓冲和 GPU 等非 Dart 堆资源
本页讲的是运行时内存布局、对象可达性、GC、生命周期和跨端边界的总览。happens-before、指令重排序、volatile 和安全发布属于 JMM 专题,不等同于“对象保存在堆还是栈”。
代码索引
本主题当前以机制收口为主,相关运行时入口主要分布在并发模块;内存专题实验仍在补齐,已落地的生命周期实验见模块 README。
| 主题 | 说明 | 入口 |
|---|---|---|
| JVM 共享堆与线程私有栈 | 对照线程共享状态与局部执行现场 | Java 并发模型总览 |
| Kotlin 挂起恢复与执行现场 | 理解对象/状态为何跨挂起点继续存活 | 运行时承载模型入口 |
| Dart event loop / isolate | 对照独立堆、消息传递、主 isolate 卡顿 | Dart 并发总览:Future / event loop / isolate |
| JVM JMM 可见性与安全发布 | 解释“对象在哪里”之外,线程何时能看见修改 | JMM:happens-before / 安全发布 |
| JVM / ART GC | GC Roots、可达性、分代、并发收集与 Android 卡顿 | JVM / ART 垃圾回收算法与 GC 演进深度剖析 |
| Java / Android 引用类型 | 强/软/弱/虚引用、ReferenceQueue 与缓存边界 | Java / Android 引用类型与 Dart 弱引用对照 |
| Android 泄漏定位 | 引用链、LeakCanary、Hprof 与 Profiler | Android / Java 内存泄漏定位、LeakCanary 原理与 Profiler 实战 |
| Dart GC 与分配 | 新生代、老年代、对象分配与主 isolate 卡顿 | Dart GC 机制、对象分配模式与主 Isolate 卡顿归因 |
| Android / Flutter 图像内存 | Bitmap、Native/外部内存、解码尺寸与 GPU 边界 | ART GC 机制、Bitmap 内存演进与大图优化 |
本页目录与阅读顺序
这篇总览按“先建立坐标,再进入机制,最后排障”的顺序组织。不要一开始就把 JVM、ART、Dart 和 Flutter 的所有名词横向混在一张表里;先把每个运行时自己的结构讲清楚,再做有边界的对比。
text
1. 四个概念的边界
└── 运行时布局 / GC 与生命周期 / JMM / 系统约束
2. JVM 经典运行时数据区
└── PC / JVM 栈 / 本地方法栈 / 堆 / 方法区 / 常量池
3. 跨端运行时映射
├── Android ART:JVM 概念的移动端实现
├── Dart VM:isolate + Dart Heap
└── Flutter:Dart Runtime + Engine + 平台 / GPU
4. 进程级内存排障地图
└── 托管堆之外的 Native / Bitmap / Surface / GPU
5. 核心机制与工程判断
├── 对象、引用和 GC Roots
├── JMM 与共享状态
├── Dart 对象与外部资源
└── GC、生命周期、泄漏和卡顿
6. 跨运行时对比、场景、陷阱与复习题先分清四个概念
很多“内存模型”问题其实混用了四个不同层次:
text
┌──────────────────────────────────────────────────────────────┐
│ 进程级内存:这块内存属于谁? │
│ Java/Kotlin Heap · Dart Heap · Native Heap · Thread Stack │
│ Code/Metadata · Bitmap/Decode Buffer · GPU/BufferQueue │
└──────────────────────────────┬───────────────────────────────┘
│ 对象是否仍然可达?
▼
┌──────────────────────────────────────────────────────────────┐
│ GC / 生命周期:这块内存什么时候可以回收? │
│ GC Roots · 强引用链 · 弱引用 · dispose/onDestroy · 缓存 │
└──────────────────────────────┬───────────────────────────────┘
│ 多线程如何看见共享状态?
▼
┌──────────────────────────────────────────────────────────────┐
│ JMM / 并发:另一个执行单元什么时候能看到修改? │
│ happens-before · volatile · synchronized · CAS · Future │
└──────────────────────────────┬───────────────────────────────┘
│ 页面离开后系统还允许做什么?
▼
┌──────────────────────────────────────────────────────────────┐
│ 生命周期 / 系统约束:任务是否还能继续? │
│ Activity · Flutter State · AppLifecycleState · 后台调度窗口 │
└──────────────────────────────────────────────────────────────┘这张图不是四套互相独立的“内存”,而是四个排障问题:先定位资源属于哪个区域,再判断引用是否断开;如果有多线程共享,再判断可见性;最后才判断页面和操作系统是否还允许任务继续。
JVM 经典运行时数据区与跨端映射
这一节先把 Java 面试和排障中最经典的“栈、堆、方法区、程序计数器、本地方法栈”讲完整,再说明它们如何映射到 Android ART、Dart VM 和 Flutter。这里讲的是运行时结构,不是 JMM 的可见性规则。
1. JVM 规范中的运行时数据区
经典 JVM 运行时结构可以先记成两类:线程私有区域和多个线程共享区域。
text
JVM / 一个 Java 进程
│
├── 每个线程私有
│ ├── 程序计数器 PC
│ │ └── 当前线程下一条要执行的字节码位置
│ ├── JVM 虚拟机栈
│ │ └── 每次方法调用创建一个栈帧
│ │ ├── 局部变量表
│ │ ├── 操作数栈
│ │ ├── 动态链接
│ │ └── 方法返回地址
│ └── 本地方法栈
│ └── 执行 JNI / C / C++ 本地方法时使用
│
├── 多个线程共享
│ ├── 堆 Heap
│ │ ├── 对象实例
│ │ ├── 数组
│ │ └── GC 管理的主要区域
│ └── 方法区 Method Area
│ ├── 类元数据
│ ├── 字段和方法信息
│ └── 运行时常量池
│
└── JVM 实现扩展,不属于上述规范分区的简单同义词
├── JIT / AOT 编译代码
├── Code Cache
├── Direct Memory
└── Native Heap| 区域 | 线程关系 | 主要保存什么 | 典型排障问题 |
|---|---|---|---|
| 程序计数器 PC | 每线程私有 | 当前执行位置 | 线程暂停和恢复时,运行时如何知道从哪里继续 |
| JVM 虚拟机栈 | 每线程私有 | 栈帧、局部变量、操作数栈、返回地址 | 递归过深、栈空间不足、调用链排查 |
| 本地方法栈 | 每线程私有 | JNI / Native 方法的执行现场 | Native 崩溃、JNI 调用栈、线程栈占用 |
| 堆 Heap | 多线程共享 | 对象实例、数组、绝大部分业务状态 | GC、泄漏、堆 OOM、对象生命周期 |
| 方法区 Method Area | 多线程共享 | 类元数据、方法信息、运行时常量池 | 类加载、类元数据增长、类加载器泄漏 |
| Direct / Native / Code 等 | 取决于实现和分配者 | 堆外缓冲、编译代码、Native 对象 | Java Heap 没满但进程仍然 OOM |
几个容易混淆的点要单独记住:
- 程序计数器不是“存放 Java 对象的内存区”,它只是每个线程的执行位置记录。
- 栈帧里通常保存的是对象引用,不是对象本身。
User user是栈帧中的引用,new User()创建的实例通常在堆中。 - 方法区是 JVM 规范层概念。HotSpot 的
Metaspace是一种具体实现,不能把所有 JVM、ART 和所有版本都直接画成同一个物理区域。 - Direct Memory、Native Heap、Bitmap 和 GPU 内存不属于传统 JVM 五大运行时数据区,但 Android 工程排查内存时不能忽略它们。
2. 从一次方法调用看 PC、栈帧和堆对象
java
void load() {
int count = 1;
User user = new User("A");
cache.put(user.id(), user);
}执行到 cache.put 时,可以用下面的概念模型理解:
text
当前线程
│
├── PC
│ └── 指向 load() 当前执行到的字节码位置
│
├── JVM 栈
│ └── load() 栈帧
│ ├── count = 1
│ ├── user = ───────────────┐
│ └── 返回地址 │
│ ▼
└── 堆 User("A") 对象
▲
│
cache / Map当 load() 返回时,load() 的栈帧和局部引用会退出当前调用栈;但 cache 如果仍然持有 User,对象仍然从 GC Root 可达,不能因为创建它的方法返回就自动回收。这也解释了“局部引用消失”和“对象被 GC 回收”为什么不是同一件事。
“局部变量在栈、对象在堆”是非常有用的概念模型,不是每个对象都必须以完整对象的形式待在堆中。JIT 可能通过逃逸分析、标量替换或其他优化消除对象分配;排查时先用经典模型定位,再用 Profiler、Heap Dump 和编译优化解释例外。
3. Android 中的 ART:类似 JVM,但不是桌面 JVM
Android 平台不是 JVM;Android 上负责执行 Java / Kotlin 代码的运行时是 ART(Android Runtime) ,更早的 Android 版本使用过 Dalvik。Java / Kotlin 通常先编译为 DEX,随后由 ART 解释执行、JIT 编译或 AOT 编译。
text
Java / Kotlin 源码
↓
DEX 字节码
↓
ART
├── 线程执行状态 / ART 栈 / Native 调用栈
├── ART Managed Heap
├── 类元数据、DEX 运行时结构
├── JIT / AOT 代码
└── ART GCART 与 JVM 的概念映射如下:
| JVM 经典概念 | Android / ART 中的近似对应 | 需要注意的边界 |
|---|---|---|
| 程序计数器 | ART 线程的当前执行位置 | 是运行时执行状态,不是业务对象存放区 |
| JVM 虚拟机栈 | ART 线程的 Java / Dex 调用栈 | 具体栈帧由 ART 的解释器、JIT 和编译代码共同决定 |
| 本地方法栈 | JNI / C / C++ 调用使用的 Native 栈 | 进入 Native 后要同时看 Native 调用栈和 Native Heap |
| 堆 | ART Managed Heap | Java / Kotlin 对象由 ART GC 管理 |
| 方法区 | ART 的类元数据、DEX 相关结构和运行时结构 | 不能直接等同于 HotSpot Metaspace |
| Code Cache | ART 的 JIT / AOT 代码区域 | 属于运行时或进程级代码内存,不是普通 Java 对象堆 |
因此,Android 中可以使用 JVM 的“线程私有栈 + 共享托管堆”直觉,但不能把桌面 JVM 的实现细节原样套到 ART。一个 Android 进程的内存问题还可能来自 Native Heap、Bitmap、线程栈、内存映射文件、Surface 和 GPU。
4. Dart VM:有运行时结构,但核心边界是 isolate
Dart 的执行运行时可以类比 JVM,叫 Dart VM。不过 Dart 规范没有规定一套与 JVM 五大区域完全对应的“程序计数器、方法区、虚拟机栈、堆、本地方法栈”布局。Dart 最重要的内存和并发边界是 isolate。
text
Dart VM / 一个进程
│
├── Isolate A
│ ├── 自己的 Dart 调用栈
│ ├── 自己的 Dart Heap
│ ├── 自己的事件循环
│ └── 自己的 GC 视角
│
├── Isolate B
│ ├── 自己的 Dart 调用栈
│ ├── 自己的 Dart Heap
│ ├── 自己的事件循环
│ └── 自己的 GC 视角
│
└── VM / Runtime 级资源
├── 类、函数和代码元数据
├── JIT / AOT 代码
└── Native / VM 内部资源| JVM 关注点 | Dart 中的近似概念 | Dart 的实际重点 |
|---|---|---|
| JVM 栈 | isolate 的 Dart 调用栈 | await 后的可恢复状态可能由异步执行状态承载 |
| 堆 | isolate 自己的 Dart Heap | 普通 Dart 对象不能像 JVM 线程那样直接跨 isolate 共享 |
| GC | Dart Heap 的垃圾回收 | GC 只负责 Dart 堆对象,不自动负责所有 Native / GPU 资源 |
| 方法区 | VM 内部的类、函数、代码元数据 | 不是 Dart 规范中固定命名的独立区域 |
| 本地方法栈 | FFI / Native 调用时的 Native 栈 | Pointer<T> 指向的资源通常需要 Native 侧显式释放 |
所以 Dart 的核心记忆方式不是“多个线程共享一个堆”,而是:
text
isolate A 的 Dart Heap isolate B 的 Dart Heap
│ │
└────── 消息传递 / 数据转移 ──┘
不是普通对象引用共享5. Flutter:不是另一个 JVM,而是 Dart Runtime + Engine + 平台资源
Flutter 没有一个可以单独称为“Flutter Heap”的统一内存区域。Flutter 应用至少由 Dart / Framework、Dart Runtime、Flutter Engine、平台 Embedder 和 GPU 资源几层共同组成。
text
Flutter App Process
│
├── Dart / Flutter Framework
│ ├── Widget、Element、State
│ ├── RenderObject
│ ├── Controller、Future、Stream
│ └── 业务对象
│
├── Dart Runtime / Isolate
│ ├── Dart Heap
│ ├── Dart 调用栈
│ ├── Event Loop
│ └── Dart GC
│
├── Flutter Engine
│ ├── C++ 对象和 Native Heap
│ ├── Platform Channel
│ ├── 图片解码和缓存路径
│ ├── Skia / Impeller
│ └── Engine 线程与渲染资源
│
├── Platform Embedder
│ ├── Android ART / View / Surface
│ ├── iOS ARC / UIView / CALayer
│ └── 平台插件与系统资源
│
└── GPU / OS
├── Texture
├── Surface
├── PlatformView
└── Render Buffer| Flutter 资源 | 首先看哪里 | 释放还要继续看什么 |
|---|---|---|
Widget、Element、State | 当前 isolate 的 Dart Heap | 引用链、Element 树、Route 和 dispose() |
Future、Stream、Controller | Dart Heap 与事件源持有关系 | cancel()、dispose()、回调是否解绑 |
Uint8List、JSON、图片字节 | Dart Heap,部分路径可能有外部缓冲 | 副本数量、缓存淘汰、解码路径 |
| FFI 指针、插件对象 | Native Heap / 外部资源 | Native API 显式释放,Finalizer 只能作为兜底 |
| 图片纹理、PlatformView、外置纹理 | Engine / GPU / Surface | ImageCache、纹理、宿主和平台生命周期 |
必须分开记住三件事:
text
Dart 对象被 GC
≠ Engine 图片资源已经释放
≠ GPU Texture 已经释放
≠ Android / iOS PlatformView 已经销毁这就是为什么 Flutter 的内存排障不能只看 DevTools 的 Dart Heap,还要结合图片缓存、插件资源、Engine、宿主平台和 GPU 资源。
6. 四种运行时结构的总对照
| 维度 | JVM | Android ART | Dart VM | Flutter |
|---|---|---|---|---|
| 最核心的执行单元 | Thread | Android Thread / ART Thread | Isolate | Dart Isolate + Engine 线程 |
| 普通托管对象的堆 | 多线程共享 Heap | ART Managed Heap | 每个 isolate 独立 Dart Heap | Dart Heap,加 Engine / Native / GPU |
| 经典栈模型 | JVM 栈 + 本地方法栈 | ART 栈 + JNI / Native 栈 | isolate 调用栈 + Native 调用栈 | Dart 栈、Engine 线程栈、平台栈 |
| 类似方法区的区域 | Method Area / 实现相关 Metaspace | 类元数据、DEX、ART 运行时结构 | VM 类、函数和代码元数据 | Dart Runtime + Engine / AOT 代码 |
| GC 管理范围 | JVM Heap | ART Managed Heap | isolate Dart Heap | 主要是 Dart Heap,Engine 资源另行管理 |
| 共享状态问题 | JMM、锁、volatile、CAS | Java/Kotlin 共享堆仍受并发规则约束 | 普通对象默认不跨 isolate 共享 | Dart 状态之外还要处理平台和 Engine 资源 |
这张表的阅读顺序是:先看谁拥有堆,再看谁拥有执行栈,最后看哪些资源不在托管堆里。不要把“Dart 有 GC”理解成“Flutter 的所有内存都由 Dart GC 管理”。
Android / Flutter 进程级内存排障地图
上面的章节回答“运行时结构是什么”;下面这张地图回答“一个 Android / Flutter 进程的总内存从哪里来”。两者不是重复:前者偏规范和运行时,后者偏线上排障。
Android / JVM / ART
text
Android App Process
├── ART managed heap
│ ├── Java/Kotlin 对象、数组、集合
│ ├── Activity / ViewModel / Controller
│ └── GC Roots 可达对象
├── Thread stacks
│ └── 调用栈帧、局部变量、对象引用
├── Class metadata / Code cache
│ └── 类元数据、JIT/AOT 代码等
├── Native heap
│ └── JNI、C/C++、FFI、部分框架与第三方库分配
├── Bitmap / decode buffers
│ └── 具体归属和统计口径随 Android 版本与实现变化
└── Graphics / GPU / BufferQueue
└── 纹理、Surface、渲染缓冲,不等同于普通 Java heap因此,“Java Heap 没满”不等于“进程整体内存安全”。大图、Native 分配、GPU 纹理和线程栈都可能造成进程内存压力。Bitmap 的解码尺寸和像素内存见 ART / Bitmap 内存专题。
Flutter / Dart
text
Flutter App Process
├── Dart isolate heap
│ ├── Widget / Element / State
│ ├── Controller、Future、Stream、业务对象
│ └── 每个 isolate 默认独立管理
├── Dart isolate stack / execution state
│ └── 当前调用栈,以及 await 后保存的状态
├── Flutter Engine / Native heap
│ ├── Skia / Impeller
│ ├── Platform Channel / FFI 数据
│ └── Native 解码与插件资源
└── GPU / external texture / image buffers
└── 图片纹理、PlatformView、外置纹理等isolate 隔离的是普通 Dart 对象和 Dart 执行状态,不代表整个进程的 Native 或 GPU 内存也被完全隔离。使用 dart:ffi、外置纹理、图片解码或 PlatformView 时,必须同时考虑 Dart Heap 之外的资源生命周期。
dart
Future<User> loadUser() async {
final raw = await api.fetch();
final user = User.fromJson(raw);
return user;
}执行 await 前后的局部状态可能需要被运行时保存到可恢复的异步执行状态中;它不是“永远占着一条原生线程栈”。user 是 Dart 对象引用,User 实例通常位于当前 isolate 的堆;如果外部缓存、Stream、闭包或全局服务继续持有它,即使页面已经离开,也不会因为 Future 返回就自动消失。
为什么需要
内存模型不是“面试八股”,而是直接决定这些问题能不能说清:
- 为什么 Android 会 OOM、泄漏、GC 卡顿
- 为什么某个对象明明置空了还没释放
- 为什么多线程共享缓存时会出现脏读、竞态、可见性问题
- 一句话答:JVM 多线程共享堆并不自动建立可见性和顺序保证;
happens-before、volatile、锁和原子类的详细规则见JMM 专题。
- 一句话答:JVM 多线程共享堆并不自动建立可见性和顺序保证;
- 为什么 Dart isolate 默认不共享对象
- 一句话答:每个 isolate 默认拥有自己的 Dart 堆和事件循环,普通对象通过消息传递或传输机制跨 isolate 交互;详细边界见Dart Isolate 消息传递。
- 为什么 Flutter 页面销毁了,但某些 controller / subscription 还活着
- 一句话答:Widget/Route 离开树不等于所有异步源和缓存都断开,必须在
dispose()中取消订阅、Timer、Controller,并检查是否仍有回调或缓存持有 State;见Flutter 生命周期。
- 一句话答:Widget/Route 离开树不等于所有异步源和缓存都断开,必须在
- 为什么大 JSON 解析、图片处理、列表重建会把主线程 / 主 isolate 卡到掉帧
- 一句话答:CPU 计算、短命对象分配、Dart GC 和图片/Native/GPU 资源都可能占用帧预算;Dart 对象分配见Dart GC 专题,图片解码见图片内存专题。
对 Android 背景转 Flutter 的工程师,这一篇最重要的价值是:
- 保住你对 堆、栈、引用、GC、泄漏 的 JVM 直觉
- 同时建立 Dart 的 isolate 独立堆 + 消息传递 新直觉
- 知道哪些经验可以迁移,哪些不能生搬硬套
核心机制与工程判断
前面的章节回答“每个运行时有哪些区域、边界和资源”;这一节回答“对象如何存活、何时回收、共享状态如何同步,以及出现 OOM / 泄漏 / 卡顿时如何判断”。这里会复用前面的结构图,但不再把它当成新的分区清单。
1. 对象如何从栈帧进入堆,并沿引用链存活
经典拆法:
- 线程栈:局部变量、调用栈帧、方法执行现场
- 堆:对象实例、数组、绝大部分共享状态
- 类元数据区 / Metaspace:类信息、方法元数据、常量池等
直觉理解:
- 局部变量大多在线程自己的栈里,天然线程私有
new出来的对象通常在堆里,更容易被多个线程共享访问- 共享状态一多,就会引出可见性、竞态、发布安全、锁竞争等问题
这就是为什么:
- 局部计算一般不用加锁
- 全局缓存、单例、共享状态要考虑同步与可见性
- Activity / Fragment / ViewModel 被跨线程、单例、回调持有时特别容易泄漏
这里的“局部变量在栈、对象在堆”是帮助工程判断的概念模型,不是 JVM 规范规定的绝对物理位置。JIT 可能通过逃逸分析、标量替换或其他优化消除对象分配,甚至不再保留一个完整的对象形态;排查问题时先用经典模型定位,再结合 Profiler、Heap Dump 和编译优化解释例外。
一个最小例子:
java
void load() {
int count = 1; // 栈帧中的局部值(概念模型)
User user = new User("A"); // user 是引用;User 对象通常在托管堆
cache.put(user.id(), user); // cache 可能成为更长生命周期的持有者
}load() 返回后,count 和局部引用 user 不再属于当前调用栈;但 cache 如果仍然持有 User,对象就仍然可达。真正决定对象能否回收的不是“创建它的方法已经返回”,而是从 GC Roots 出发是否还存在强引用路径。
2. 共享堆之后的并发语义:JMM
JVM 真正难的地方不只是对象存放位置,而是:
- 线程 A 改了字段,线程 B 何时可见
- 指令能否重排
- 对象是否被安全发布
- 双重检查锁为什么必须配合
volatile
也就是说,JVM 内存模型复习时至少要同时记住三件事:
- 存放位置:栈、堆、元数据区
- 共享边界:哪些状态会被多线程共同访问
- 可见性与顺序性:什么时候需要
synchronized/volatile/ CAS / 原子类
要把两个问题分开:
| 问题 | 所属概念 | 典型问题 |
|---|---|---|
| 对象、栈帧、元数据和 Native 资源放在哪里? | JVM / ART 运行时内存布局 | 为什么 Java Heap 没满但进程仍然 OOM? |
| 线程 A 的写入什么时候对线程 B 可见? | JMM | 为什么没有 volatile 的停止标志可能读不到更新? |
详细的 happens-before、安全发布和 DCL 见 JMM 专题。JMM 不等于“Java 对象都放在堆里”,它描述的是共享内存访问的可见性与顺序关系。
3. Dart / Flutter 的对象与外部资源生命周期
Dart 默认不是“多个线程一起摸一个堆”,而是:
- 每个 isolate 有自己的事件循环
- 每个 isolate 有自己的堆
- isolate 之间通过消息传递通信
- 普通对象引用不能像 JVM 多线程那样直接共享
这带来的直接好处是:
- 少了大量共享内存锁竞争
- 少了“这个对象被另一个线程改了”的复杂度
- UI 线程思维更清晰:主 isolate 负责界面与大部分异步调度
代价是:
- 不能像 JVM 那样随便把对象引用传给另一条执行流
- 真正跨 isolate 时,要有复制 / 序列化 / 消息设计成本
- isolate 不是廉价线程池,拉起与通信都有代价
Flutter 的内存问题还要继续往 Dart Heap 之外看:
| 对象/资源 | 通常关注的区域 | 典型释放边界 |
|---|---|---|
Widget、Element、State、业务对象 | Dart isolate heap | 引用链断开后由 Dart GC 回收 |
TextEditingController、AnimationController、订阅 | Dart heap + 框架/事件源持有关系 | dispose() / cancel() 与引用链同时收口 |
Uint8List、JSON、图片字节 | Dart heap,可能产生临时副本 | 对象不可达、缓存淘汰或处理完成 |
| FFI 指针、Native 插件对象 | Native heap / 外部资源 | 显式释放、Finalizer 兜底但不保证及时 |
| 图片纹理、PlatformView、外置纹理 | Engine / GPU / Surface 相关资源 | ImageCache、纹理和宿主生命周期共同决定 |
所以“Dart 对象被 GC”不等于“整张图片、纹理或 Native 缓冲已经释放”。这也是为什么 Flutter 的内存排障需要同时看 DevTools Memory、图片缓存、插件资源和宿主侧内存。
4. GC 与分配压力:为什么会卡顿或 OOM
JVM
你会遇到:
- Young GC / Old GC
- Stop-The-World
- 大对象、碎片、晋升压力
- 频繁分配导致的回收抖动
在 Android 里,GC 问题常表现为:
- 掉帧
- 卡顿
- 内存抖动
- OOM 前的频繁回收
- 页面来回切换时峰值内存上升后回不去
Dart / Flutter
Dart 也有垃圾回收,但工程体感常和下面几件事绑在一起:
- Widget 重建频率
- 短命对象分配量
- 大图/大列表/解析任务
- 主 isolate 上的 CPU 密集计算
- isolate 是否真的把重任务隔离出去
所以 Flutter 里很多“像生命周期问题”的卡顿,根上其实是:
- 分配太多临时对象
- 把大计算留在主 isolate
- 在
build链路里做了不该做的工作
GC 的总流程可以先记成四步,具体算法分别下沉到专题页:
text
分配对象
↓
对象仍被 GC Root / isolate 根集合可达?
├─ 是:继续存活,可能晋升或进入长生命周期缓存
└─ 否:等待对应 GC 周期回收
↓
堆空间下降,但不代表 Native / GPU 资源同步下降- JVM / ART 的 GC Roots、可达性和收集器见 JVM / ART GC 专题;
- Dart 新生代、老年代和分配模式见 Dart GC 专题;
- 引用强度和
ReferenceQueue见 引用类型专题; - 真实引用链定位见 内存泄漏检测专题。
5. 对象生命周期的本质是“引用链还在不在”
内存层最常见误判就是:
- “我已经把页面关了,为什么对象还在?”
- “我把变量置空了,为什么内存没立刻降?”
要点是:
- 只要还有强引用链,GC 就不会回收
- 页面结束、
dispose、onDestroy都不等于对象立刻被收走 - GC 是时机性回收,不是
free()式立刻释放
常见持有链包括:
- 单例持有 Activity / Context / View
- Handler / Runnable / 延迟任务持有页面实例
- Observer / callback / Flow / StreamSubscription 未解绑
- 静态缓存 / LruCache / 图片缓存仍持有对象
- Flutter 中
TextEditingController/AnimationController/StreamSubscription没有dispose/cancel
6. 执行顺序:遇到“卡顿 / 泄漏 / OOM”时先怎么想
场景 A:怀疑 Android 泄漏
- 先确认页面是否真的退出或销毁
- 再确认是否仍有单例、静态变量、listener、协程作用域持有页面
- 再区分是“GC 还没来得及回收”,还是“引用链根本没断”
- 最后再看缓存与图片等大对象是否是主因
场景 B:怀疑 Flutter 页面释放不及时
- 先看
dispose是否触发 - 再看 controller / subscription / timer 是否在
dispose中清理 - 再看回调是否在页面销毁后继续触发
- 最后看对象是否只是暂时还未被 GC 回收,而不是引用泄漏
场景 C:怀疑主线程 / 主 isolate 卡顿
- 先确认是 IO 等待还是 CPU 密集计算
- 再看是否在 UI 主执行上下文分配了大量短命对象
- 再判断是否需要拆到后台线程 / isolate
- 最后评估拆分成本是否值得
跨运行时对比
只有在 JVM、ART、Dart VM 和 Flutter 的自身结构都讲清楚之后,下面的横向对比才有意义。这里的对比用于建立迁移直觉,不表示这些运行时的内部实现完全相同。
| 维度 | JVM / Android | Dart / Flutter | Web / JS | Backend |
|---|---|---|---|---|
| 默认共享内存 | 多线程共享堆 | isolate 独立堆 | 单线程主上下文 | 依语言而定 |
| 执行现场 | 线程栈帧 | isolate 调用栈 | 调用栈 | 线程/协程栈 |
| 并发风险重点 | 可见性、锁、竞态、发布安全 | isolate 间通信、主 isolate 卡顿 | 主线程阻塞 | 数据竞争、线程池/协程治理 |
| 回收问题表现 | GC 卡顿 / OOM / 泄漏 | UI 卡顿 / 分配抖动 / isolate 边界 | 主线程卡顿 / 闭包滞留 | 吞吐下降 / 堆膨胀 |
| 主线复习重点 | 共享状态治理 | 主 isolate 压力与对象生命周期 | 必要对照即可 | 仅作迁移辅助 |
推荐阅读路径
不要把所有专题强行当成一条线性章节。先读本页建立共同坐标,再按问题选择分支:
Android / JVM 分支
text
本页总览
↓
JVM / ART GC:GC Roots、可达性、分代、停顿
↓
引用类型:强/软/弱/虚引用与 ReferenceQueue
↓
泄漏检测:LeakCanary、Hprof、Profiler、实际持有链
↓
Android 生命周期:Activity / Fragment / ViewModel 的页面边界Dart / Flutter 分支
text
本页总览
↓
Dart GC:对象分配、新生代、老年代、主 isolate 卡顿
↓
Flutter 生命周期:Widget / Element / State / dispose
↓
图片、FFI、Engine、GPU:Dart Heap 之外的资源边界JMM 专题分支
当问题是“线程 A 的写入什么时候对线程 B 可见”时,转到 JMM:happens-before / 安全发布,不要继续在 GC 或生命周期文档里寻找答案。
常见场景
1. Android 页面退出了但对象还没释放
常见原因:
- 单例持有 Activity
- Handler / Runnable 持有页面引用
- Observer / callback 没解绑
- 大集合缓存未清
- ViewModel / repository 上游仍持有页面级对象
这里真正要分开的,是“页面回调走完了”与“对象已经被 GC 回收了”两件事。页面离开只说明宿主不再可见,不代表引用链已经断干净;如果上游还有 callback、协程、缓存或单例继续持有页面对象,内存就不会按你期待的时机下降。
java
// 最小观察:全局单例强引用 Activity → 页面销毁后引用链不断
public class AppCache {
static final AppCache INSTANCE = new AppCache();
Activity lastActivity; // 泄漏点:长生命周期单例持有短生命周期页面
}- 预期现象:页面离开后,页面级资源应逐步脱离引用链;即使内存不是立刻下降,也不该再有页面级回调持续命中。
- 观察重点:是否还有日志、回调、网络响应、协程继续引用页面对象;如果有,就先追引用链,而不是先怪 GC。
2. Flutter 页面没了,但 controller / subscription 还活着
典型问题:
TextEditingController没disposeAnimationController没disposeStreamSubscription没cancel- 长任务结果回调晚于页面销毁
这里的关键不是“页面看不见了”,而是页面对应的 State 是否已经真正 dispose,以及绑定在它上面的 controller / subscription 是否跟着收尾。Flutter 里很多资源泄漏不是因为 UI 没消失,而是因为页面级资源和异步回调还沿着旧 State 继续存活。
dart
// 最小观察:controller 未在 dispose 清理 → 随旧 State 继续存活
class _MyPageState extends State<MyPage> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose(); // 漏掉这行:controller 仍被底层持有
super.dispose();
}
}- 预期现象:页面最终
dispose后,页面绑定资源不应继续产生回调,也不应再驱动旧页面状态。 - 观察重点:是否出现
setState() called after dispose()、日志仍持续追加、动画仍占用资源;这些都比“内存数值有没有立刻下降”更能说明释放链路是否正确。
3. 大 JSON / 大图解析导致卡顿
这类问题常被误以为“异步就好了”。实际更关键的是:
- 任务是否还在主执行上下文
- 是否制造了过多短命对象
- 是否该拆到 isolate
- 是否需要流式解析 / 分块处理
4. Android 背景转 Flutter 时误把 isolate 当线程池
典型误判:
- 觉得有了 isolate 就能像线程池一样随便并发共享对象
- 觉得把任务扔到 isolate 就天然解决页面销毁与后台执行问题
正确理解:
- isolate 主要解决计算隔离与主 isolate 卡顿
- 不解决页面作用域、系统后台授权、业务状态保存
常见坑
| 坑 | 现象 | 修法 |
|---|---|---|
以为对象置 null 就一定立刻释放 | 内存没马上降 | GC 是时机性回收,不是立刻销毁 |
| 共享对象不做可见性控制 | 偶现脏读/竞态 | JVM 下用同步原语;Dart 下优先隔离通信 |
| Flutter 里过度制造临时对象 | 掉帧、GC 抖动 | 减少不必要分配、重任务移出主 isolate |
| 把 isolate 当便宜线程池 | 通信复杂、过度设计 | 只给 CPU 密集任务上 isolate |
以为 dispose / onDestroy 就等于对象已经释放 | 排障判断错层 | 把页面回调与 GC 分开看 |
问题边界对比
| 概念 | 关注点 | 更像解决什么问题 |
|---|---|---|
| JMM | 多线程共享内存可见性 | 为什么另一个线程能不能看见修改 |
| GC | 对象何时释放 | 为什么内存不立刻下降 |
| isolate | 内存隔离与消息通信 | 如何减少共享内存复杂度 |
| 生命周期 | 对象/页面该活多久 | 为什么页面走了对象还活着 |
| 后台任务 | 系统是否允许继续执行 | 页面走后任务还能不能可靠跑 |
对应实验
当前本主题先以理论文档为主;相关对照入口:
| 入口 | 说明 |
|---|---|
| Java 并发模型总览 | JVM 共享状态、并发与可见性入口 |
| 运行时承载模型入口 | Kotlin Continuation / 挂起恢复入口 |
| Dart 并发总览:Future / event loop / isolate | Dart event loop / isolate 入口 |
计划补齐的实验:
labs/dart/host/topics/memory-lifecycle/dart-gc-allocation-patterns/— 验证大量短命对象分配与主 isolate 卡顿labs/android/host/topics/memory-lifecycle/android-memory-leak-holding-chain/— 验证单例/回调持有 Activity 的泄漏链
复习检查题
为什么说 JVM 的共享内存复杂度通常高于 Dart isolate?
答:因为 JVM 多线程默认共享堆,对象可被多个线程同时读写,需要额外处理可见性、顺序性与同步;Dart isolate 默认独立堆,不共享普通对象,天然减少了共享内存竞态。
为什么页面销毁后对象还可能活着?
答:因为只要还有引用链,GC 就不会回收;页面结束并不等于对象自动失去所有引用,
onDestroy/dispose也不等于对象立刻释放。什么情况下应优先考虑 isolate?
答:CPU 密集任务、大 JSON 解析、图片处理等主 isolate 容易卡顿的场景,而不是所有异步任务,更不是把 isolate 当后台保活或通用线程池。
为什么 Android 工程师迁移到 Flutter 时,最容易在内存模型上做错类比?
答:因为会下意识沿用“多线程共享堆”的直觉,把 Dart 的 isolate 当成线程池;但 Dart 更强调独立堆与消息通信,很多共享状态问题的写法和成本模型都不同。
速记
- JVM:共享堆 + 线程栈 + 可见性问题
- Dart:isolate 独立堆 + 消息传递
- 回收是时机性,不是立刻销毁
- 泄漏本质是“引用链没断”
- isolate 解决隔离与卡顿,不解决后台授权
高频面试题还原
以下为大厂面试中真实出现的问题场景还原,与上方「复习检查题」互补。
面试还原:「如果内存泄漏发生在一个短命的 Activity 上,但被一个全局单例持有,GC 会回收它吗?原理是什么?」
答:不会回收。JVM/ART 使用 可达性分析算法(Reachability Analysis) 判断对象死活。GC Root 包含静态变量(单例持有的引用)、活线程栈等。只要全局单例(GC Root)对 Activity 存在强引用链,就算 Activity 已经生命周期结束(
onDestroy),GC 也不会回收该 Activity 及其引用的整个 View 树,从而产生严重内存泄漏。面试还原:「Dart 是单线程事件循环机制,为什么还会产生内存泄漏?」
答:虽然 Dart 的 isolate 拥有独立堆且无并发锁竞争,但内存泄漏的本质是 长生命周期对象持有了短生命周期对象的引用。例如在 Dart 中:① 注册了
Stream.listen或全局 EventBus 监听器,但在组件 dispose 时未调用cancel();② 全局静态 Map 缓存了 Widget/State 引用。这都会导致垃圾回收器(Dart GC 标记擦除算法)无法释放被引用的对象。面试还原:「JVM 中的 WeakReference(弱引用)在 GC 时具体的回收时机是什么?与 SoftReference(软引用)有何区别?」
答:弱引用(WeakReference) 不阻止对象在 GC 中被清除,但具体清除时机由 GC 决定;软引用(SoftReference) 也由 JVM/GC 根据内存压力和策略处理,不能当成稳定缓存容量控制。工程上用显式 LRU 管缓存,用取消任务、解绑回调和生命周期作用域解决泄漏。
面试还原:「Android 应用在后台被系统杀死(Process Killed)和正常在内存中被 GC 回收对象,在生命周期与数据恢复上有什么区别?」
答:GC 回收对象发生在进程存活期,仅清理失去引用的堆内存对象;而系统杀进程会销毁整个 VM 实例与堆空间。恢复机制的区别:普通的内存数据在 ViewModel 中无法抵御进程被杀;只有写入
SavedStateHandle/onSaveInstanceState的数据会被系统 OS 进程通过 Binder 暂存 Bundle,在进程重建时恢复。面试还原:「Dart 3.x 垃圾回收器(Garbage Collector)针对 Flutter 渲染特性做了哪些专门的优化?」
答:Flutter 界面更新会产生大量短命 Dart 对象,Dart VM 的分代策略会优先处理这类对象:
- 新生代收集:常见实现使用复制/半空间思路,主要成本与存活对象和分配压力有关;
- 老年代收集:处理长期存活对象,可能涉及标记、清除、整理和不同程度的暂停;具体行为需要结合 Dart/Flutter 版本与 DevTools Timeline 判断,不能把某个固定毫秒数当作保证。