Java/Kotlin 内存模型与 GC 基础:堆、栈、方法区及垃圾回收基本原理

大家好,我是老黄。Android 开发十多年,内存泄漏踩过的坑能绕地球半圈。今天咱们聊聊内存模型和 GC——说白了就是搞清楚你的 App 内存到底怎么分布的,垃圾回收又怎么帮我们擦屁股。

你可能会问:搞懂这些有什么用?我之前遇到过一个项目,每次跳转页面后内存涨十几 MB 下不去,GC 日志里全是 Full GC。排查后发现是静态变量持有了 Activity 引用。如果你不了解堆栈结构,根本想不到问题出在这。

1. 运行时内存区域:堆、栈、方法区

Java/Kotlin 程序在运行时,内存被划分为几个不同的区域。每个区域有各自的用途和生命周期。我习惯把它们看作一座工厂:

  • 堆(Heap):工厂的仓库。几乎所有对象实例和数组都在这里分配。GC 主要的工作区域。
  • 栈(Stack):工人的操作台。每个线程有自己的栈,存放局部变量、方法参数、操作数栈等。方法调用结束,栈帧弹出,内存自动释放。
  • 方法区(Method Area):工厂的图纸档案室。存放类的结构信息(类名、方法、字段)、常量、静态变量、JIT 编译后的代码。在 Java 8 之后改为元空间(Metaspace),使用本地内存。
  • 本地方法栈(Native Method Stack):给 native 方法用的栈,比如 Android 中 JNI 调用。
  • 程序计数器(PC Register):记录当前线程执行的字节码指令地址,很小,不会 OOM。

重点记忆:栈是线程私有的,自动回收;堆是线程共享的,GC 管理。你写的 new Object() 都在堆上,栈里只有对象的引用。

我做个对比表格帮你快速区分:

区域 共享范围 生命周期 是否触发 OOM 典型问题
堆 所有线程 程序运行期间 是(OutOfMemoryError) 内存泄漏,GC 频繁
栈 单线程 跟随方法 是(StackOverflowError 或 OOM) 递归过深,局部变量过多
方法区 所有线程 类卸载前 是(元空间 OOM) 动态生成类过多(如反射、代理)
本地方法栈 单线程 跟随 native 方法 可能 JNI 内存管理不当
程序计数器 单线程 线程生命期 否 无

2. 垃圾回收基本原理

GC 要解决的核心问题:哪些对象是垃圾?什么时候回收?怎么回收?

2.1 判断对象是否存活

主流的做法是可达性分析(Reachability Analysis)。从一组 GC Root 对象出发,沿着引用链向下遍历,凡是能到达的对象就是活的,否则就是垃圾。

你先记住常见的 GC Root:

  • 栈帧中的局部变量引用的对象(比如方法内的局部变量)
  • 静态变量引用的对象
  • JNI 全局引用(Global Reference)
  • 系统类加载器加载的类
  • 活跃线程

我曾经在一个 App 里看到数据库连接池的静态 HashMap 里存了 Activity 的引用,导致整个 Activity 及其视图树无法被回收。排查时用 MAT 抓到了 GC Root 是那个 HashMap,避坑指南就是:静态集合类务必注意生命周期,用完及时 remove。

顺带提一下引用计数法——每个对象记录被引用的次数,为 0 时回收。但它解决不了循环引用的问题,所以 Java 没有采用。嗯,别踩这个坑。

2.2 垃圾回收算法

确定了垃圾对象,接下来怎么回收?常见算法四个:

  1. 标记-清除(Mark-Sweep):先标记出所有存活对象,然后清除未被标记的。缺点:产生大量内存碎片,后续大对象分配困难。
  2. 标记-复制(Mark-Copy):把内存分成两块,只使用其中一块。回收时将存活对象复制到另一块,然后清空已用块。优点:没有碎片,效率高。缺点:浪费一半内存。适用于新生代。
  3. 标记-整理(Mark-Compact):标记后将存活对象向一端移动,整理成连续空间。适合老年代,避免复制开销。
  4. 分代收集(Generational Collection):结合上述算法,将堆分为新生代(年轻代)和老年代(Old Gen)。新生代用复制算法,老年代用标记-整理或标记-清除。

在 Android 的 ART 虚拟机上,其实也类似——新生代(Young Space)和老年代(Old Space),还有大对象区域(Large Object Space)。GC 触发时,通常会先做一次并发标记(Concurrent Marking),然后暂停用户线程进行清理(Pause)。

我个人习惯:在 Android Studio 的 Memory Profiler 里观察 GC 日志,看 GC 类型和暂停时间。如果出现大量 "Concurrent partial" 或 "Alloc" GC,说明内存压力大。这时候我会检查是否有频繁创建临时对象的循环,比如在 onDraw() 里 new Paint()——简直是自杀式写法。

3. 一张图搞懂内存模型与 GC 流程

下面的 SVGG 图展示了整个内存结构和 GC 的大致流程。你仔细看看,箭头表示引用方向和 GC 触发的判断路径。

Java/Kotlin 内存模型与 GC 流程 运行时内存区域 堆 对象实例、数组 栈 局部变量、引用 方法区 类信息、常量、静态变量 本地方法栈 JNI 调用 程序计数器 垃圾回收流程 GC Root 栈变量、静态变量等 可达性分析 引用链遍历 标记存活对象 判定为垃圾 回收算法选择 标记-清除 / 标记-复制 / 标记-整理 内存整理 / 空间释放 引用 实际 Android ART/Dalvik 采用分代收集,新生代用复制,老年代用标记-整理

4. 移动端 GC 调优的几点个人体会

讲完了理论,来点实战的。在移动端(特别是 Android),GC 会直接导致掉帧和卡顿。我总结了几条经验:

  • 减少对象分配:能复用的对象尽量复用,比如使用对象池(Object Pool)。我曾在列表滚动时频繁创建 EventBus 的 Message,改用池化后 GC 次数下降 60%。
  • 避免在 UI 线程分配大对象:大对象直接进入老年代,触发 Full GC 的暂停时间更长。把图片解码、Bitmap 操作放到子线程。
  • 弱引用和软引用的正确使用:比如缓存场景用 WeakHashMap 或者 LruCache(内部强引用),避免静态引用导致泄漏。
  • 留意隐式持有:非静态内部类持有外部类的引用,很容易造成泄漏。我习惯匿名内部类改为静态内部类 + WeakReference。

一句话总结:内存模型决定了你的对象住在哪,GC 决定了它什么时候死。搞懂这两点,你就掌握了 Android 性能优化的半壁江山。

嗯,今天就聊到这里。下回咱们具体讲怎么用 Android Studio 的 Memory Profiler 抓内存泄漏,以及如何解读 GC 日志。这些工具用好了,排查问题就像开了透视眼。