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 垃圾回收算法
确定了垃圾对象,接下来怎么回收?常见算法四个:
- 标记-清除(Mark-Sweep):先标记出所有存活对象,然后清除未被标记的。缺点:产生大量内存碎片,后续大对象分配困难。
- 标记-复制(Mark-Copy):把内存分成两块,只使用其中一块。回收时将存活对象复制到另一块,然后清空已用块。优点:没有碎片,效率高。缺点:浪费一半内存。适用于新生代。
- 标记-整理(Mark-Compact):标记后将存活对象向一端移动,整理成连续空间。适合老年代,避免复制开销。
- 分代收集(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 触发的判断路径。
4. 移动端 GC 调优的几点个人体会
讲完了理论,来点实战的。在移动端(特别是 Android),GC 会直接导致掉帧和卡顿。我总结了几条经验:
- 减少对象分配:能复用的对象尽量复用,比如使用对象池(Object Pool)。我曾在列表滚动时频繁创建 EventBus 的 Message,改用池化后 GC 次数下降 60%。
- 避免在 UI 线程分配大对象:大对象直接进入老年代,触发 Full GC 的暂停时间更长。把图片解码、Bitmap 操作放到子线程。
- 弱引用和软引用的正确使用:比如缓存场景用 WeakHashMap 或者 LruCache(内部强引用),避免静态引用导致泄漏。
- 留意隐式持有:非静态内部类持有外部类的引用,很容易造成泄漏。我习惯匿名内部类改为静态内部类 + WeakReference。
一句话总结:内存模型决定了你的对象住在哪,GC 决定了它什么时候死。搞懂这两点,你就掌握了 Android 性能优化的半壁江山。
嗯,今天就聊到这里。下回咱们具体讲怎么用 Android Studio 的 Memory Profiler 抓内存泄漏,以及如何解读 GC 日志。这些工具用好了,排查问题就像开了透视眼。