Skip to content

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 GCGC Roots、可达性、分代、并发收集与 Android 卡顿JVM / ART 垃圾回收算法与 GC 演进深度剖析
Java / Android 引用类型强/软/弱/虚引用、ReferenceQueue 与缓存边界Java / Android 引用类型与 Dart 弱引用对照
Android 泄漏定位引用链、LeakCanary、Hprof 与 ProfilerAndroid / 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 GC

ART 与 JVM 的概念映射如下:

JVM 经典概念Android / ART 中的近似对应需要注意的边界
程序计数器ART 线程的当前执行位置是运行时执行状态,不是业务对象存放区
JVM 虚拟机栈ART 线程的 Java / Dex 调用栈具体栈帧由 ART 的解释器、JIT 和编译代码共同决定
本地方法栈JNI / C / C++ 调用使用的 Native 栈进入 Native 后要同时看 Native 调用栈和 Native Heap
堆ART Managed HeapJava / Kotlin 对象由 ART GC 管理
方法区ART 的类元数据、DEX 相关结构和运行时结构不能直接等同于 HotSpot Metaspace
Code CacheART 的 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 共享
GCDart 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、ControllerDart Heap 与事件源持有关系cancel()、dispose()、回调是否解绑
Uint8List、JSON、图片字节Dart Heap,部分路径可能有外部缓冲副本数量、缓存淘汰、解码路径
FFI 指针、插件对象Native Heap / 外部资源Native API 显式释放,Finalizer 只能作为兜底
图片纹理、PlatformView、外置纹理Engine / GPU / SurfaceImageCache、纹理、宿主和平台生命周期

必须分开记住三件事:

text
Dart 对象被 GC
       ≠ Engine 图片资源已经释放
       ≠ GPU Texture 已经释放
       ≠ Android / iOS PlatformView 已经销毁

这就是为什么 Flutter 的内存排障不能只看 DevTools 的 Dart Heap,还要结合图片缓存、插件资源、Engine、宿主平台和 GPU 资源。

6. 四种运行时结构的总对照 ​

维度JVMAndroid ARTDart VMFlutter
最核心的执行单元ThreadAndroid Thread / ART ThreadIsolateDart Isolate + Engine 线程
普通托管对象的堆多线程共享 HeapART Managed Heap每个 isolate 独立 Dart HeapDart Heap,加 Engine / Native / GPU
经典栈模型JVM 栈 + 本地方法栈ART 栈 + JNI / Native 栈isolate 调用栈 + Native 调用栈Dart 栈、Engine 线程栈、平台栈
类似方法区的区域Method Area / 实现相关 Metaspace类元数据、DEX、ART 运行时结构VM 类、函数和代码元数据Dart Runtime + Engine / AOT 代码
GC 管理范围JVM HeapART Managed Heapisolate Dart Heap主要是 Dart Heap,Engine 资源另行管理
共享状态问题JMM、锁、volatile、CASJava/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 卡顿
    • 一句话答:要先区分托管堆泄漏、GC/分配抖动、Bitmap/Native/GPU 外部内存和进程限制;引用链见泄漏检测专题,GC 见GC 专题。
  • 为什么某个对象明明置空了还没释放
    • 一句话答:变量置空只断开这一条引用,只要还有 GC Root → 缓存/回调/单例 → 对象的强引用链,GC 就不会回收;详细判断见引用类型和泄漏检测。
  • 为什么多线程共享缓存时会出现脏读、竞态、可见性问题
    • 一句话答:JVM 多线程共享堆并不自动建立可见性和顺序保证;happens-before、volatile、锁和原子类的详细规则见JMM 专题。
  • 为什么 Dart isolate 默认不共享对象
    • 一句话答:每个 isolate 默认拥有自己的 Dart 堆和事件循环,普通对象通过消息传递或传输机制跨 isolate 交互;详细边界见Dart Isolate 消息传递。
  • 为什么 Flutter 页面销毁了,但某些 controller / subscription 还活着
    • 一句话答:Widget/Route 离开树不等于所有异步源和缓存都断开,必须在 dispose() 中取消订阅、Timer、Controller,并检查是否仍有回调或缓存持有 State;见Flutter 生命周期。
  • 为什么大 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 内存模型复习时至少要同时记住三件事:

  1. 存放位置:栈、堆、元数据区
  2. 共享边界:哪些状态会被多线程共同访问
  3. 可见性与顺序性:什么时候需要 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 资源同步下降

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 泄漏 ​

  1. 先确认页面是否真的退出或销毁
  2. 再确认是否仍有单例、静态变量、listener、协程作用域持有页面
  3. 再区分是“GC 还没来得及回收”,还是“引用链根本没断”
  4. 最后再看缓存与图片等大对象是否是主因

场景 B:怀疑 Flutter 页面释放不及时 ​

  1. 先看 dispose 是否触发
  2. 再看 controller / subscription / timer 是否在 dispose 中清理
  3. 再看回调是否在页面销毁后继续触发
  4. 最后看对象是否只是暂时还未被 GC 回收,而不是引用泄漏

场景 C:怀疑主线程 / 主 isolate 卡顿 ​

  1. 先确认是 IO 等待还是 CPU 密集计算
  2. 再看是否在 UI 主执行上下文分配了大量短命对象
  3. 再判断是否需要拆到后台线程 / isolate
  4. 最后评估拆分成本是否值得

跨运行时对比 ​

只有在 JVM、ART、Dart VM 和 Flutter 的自身结构都讲清楚之后,下面的横向对比才有意义。这里的对比用于建立迁移直觉,不表示这些运行时的内部实现完全相同。

维度JVM / AndroidDart / FlutterWeb / JSBackend
默认共享内存多线程共享堆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 没 dispose
  • AnimationController 没 dispose
  • StreamSubscription 没 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 / isolateDart 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 的泄漏链

复习检查题 ​

  1. 为什么说 JVM 的共享内存复杂度通常高于 Dart isolate?

    答:因为 JVM 多线程默认共享堆,对象可被多个线程同时读写,需要额外处理可见性、顺序性与同步;Dart isolate 默认独立堆,不共享普通对象,天然减少了共享内存竞态。

  2. 为什么页面销毁后对象还可能活着?

    答:因为只要还有引用链,GC 就不会回收;页面结束并不等于对象自动失去所有引用,onDestroy / dispose 也不等于对象立刻释放。

  3. 什么情况下应优先考虑 isolate?

    答:CPU 密集任务、大 JSON 解析、图片处理等主 isolate 容易卡顿的场景,而不是所有异步任务,更不是把 isolate 当后台保活或通用线程池。

  4. 为什么 Android 工程师迁移到 Flutter 时,最容易在内存模型上做错类比?

    答:因为会下意识沿用“多线程共享堆”的直觉,把 Dart 的 isolate 当成线程池;但 Dart 更强调独立堆与消息通信,很多共享状态问题的写法和成本模型都不同。

速记 ​

  • JVM:共享堆 + 线程栈 + 可见性问题
  • Dart:isolate 独立堆 + 消息传递
  • 回收是时机性,不是立刻销毁
  • 泄漏本质是“引用链没断”
  • isolate 解决隔离与卡顿,不解决后台授权

高频面试题还原 ​

以下为大厂面试中真实出现的问题场景还原,与上方「复习检查题」互补。

  1. 面试还原:「如果内存泄漏发生在一个短命的 Activity 上,但被一个全局单例持有,GC 会回收它吗?原理是什么?」

    答:不会回收。JVM/ART 使用 可达性分析算法(Reachability Analysis) 判断对象死活。GC Root 包含静态变量(单例持有的引用)、活线程栈等。只要全局单例(GC Root)对 Activity 存在强引用链,就算 Activity 已经生命周期结束(onDestroy),GC 也不会回收该 Activity 及其引用的整个 View 树,从而产生严重内存泄漏。

  2. 面试还原:「Dart 是单线程事件循环机制,为什么还会产生内存泄漏?」

    答:虽然 Dart 的 isolate 拥有独立堆且无并发锁竞争,但内存泄漏的本质是 长生命周期对象持有了短生命周期对象的引用。例如在 Dart 中:① 注册了 Stream.listen 或全局 EventBus 监听器,但在组件 dispose 时未调用 cancel();② 全局静态 Map 缓存了 Widget/State 引用。这都会导致垃圾回收器(Dart GC 标记擦除算法)无法释放被引用的对象。

  3. 面试还原:「JVM 中的 WeakReference(弱引用)在 GC 时具体的回收时机是什么?与 SoftReference(软引用)有何区别?」

    答:弱引用(WeakReference) 不阻止对象在 GC 中被清除,但具体清除时机由 GC 决定;软引用(SoftReference) 也由 JVM/GC 根据内存压力和策略处理,不能当成稳定缓存容量控制。工程上用显式 LRU 管缓存,用取消任务、解绑回调和生命周期作用域解决泄漏。

  4. 面试还原:「Android 应用在后台被系统杀死(Process Killed)和正常在内存中被 GC 回收对象,在生命周期与数据恢复上有什么区别?」

    答:GC 回收对象发生在进程存活期,仅清理失去引用的堆内存对象;而系统杀进程会销毁整个 VM 实例与堆空间。恢复机制的区别:普通的内存数据在 ViewModel 中无法抵御进程被杀;只有写入 SavedStateHandle / onSaveInstanceState 的数据会被系统 OS 进程通过 Binder 暂存 Bundle,在进程重建时恢复。

  5. 面试还原:「Dart 3.x 垃圾回收器(Garbage Collector)针对 Flutter 渲染特性做了哪些专门的优化?」

    答:Flutter 界面更新会产生大量短命 Dart 对象,Dart VM 的分代策略会优先处理这类对象:

    • 新生代收集:常见实现使用复制/半空间思路,主要成本与存活对象和分配压力有关;
    • 老年代收集:处理长期存活对象,可能涉及标记、清除、整理和不同程度的暂停;具体行为需要结合 Dart/Flutter 版本与 DevTools Timeline 判断,不能把某个固定毫秒数当作保证。

站点构建时间:2026/8/24 23:43:17