内存管理基础:Java/Kotlin内存模型、堆与栈、GC机制
各位同学好,我是你们的老朋友。今天咱们来聊聊内存管理的基础。说实话,这可能是整个性能优化课程里最“底层”的一章,但也是最重要的一章。你想想看,如果连内存怎么分配、怎么回收都不清楚,那优化就成了一句空话。
我刚开始做Android开发那会儿,也踩过不少坑。有一次线上版本频繁OOM,查了两天才发现是Bitmap没释放。从那以后,我就养成了一个习惯——每次写代码前,先想想这块内存到底该放哪儿。
Java/Kotlin内存模型:运行时数据区
先说说Java/Kotlin的内存模型。说白了,JVM在运行时会划分出几个区域,每个区域干不同的事。我习惯把它们分成两类:线程私有的和线程共享的。
| 区域 | 作用 | 是否线程私有 |
|---|---|---|
| 程序计数器 | 记录当前线程执行的字节码行号 | 是 |
| 虚拟机栈 | 存储局部变量、操作数栈、方法出口 | 是 |
| 本地方法栈 | 为Native方法服务 | 是 |
| 堆 | 存放对象实例、数组 | 否 |
| 方法区 | 存储类信息、常量、静态变量 | 否 |
嗯,这里要注意一点:Kotlin虽然运行在JVM上,但它的对象模型和Java基本一致。唯一的区别是Kotlin的object声明和companion object在底层会被编译成静态内部类或静态字段,本质上还是堆上的对象。
堆与栈:谁管分配,谁管引用
堆和栈的区别,我经常用一句话概括:栈管运行,堆管存储。
- 栈:每个线程一个,生命周期跟线程一致。存的是基本类型变量和对象引用。栈帧压入弹出,速度极快。
- 堆:所有线程共享,存的是真正的对象实例。GC主要在这儿干活。
举个例子:
fun loadImage() {
val bitmap = BitmapFactory.decodeResource(resources, R.drawable.big_image)
// bitmap引用在栈上,Bitmap对象在堆上
}
方法执行完,栈帧弹出,bitmap引用消失。但堆上的Bitmap对象还在,得等GC来收。这就是内存泄漏的根源——引用没了,对象还在。
核心原则:栈上引用指向堆上对象。引用消失不等于对象消失,只有所有引用都断了,对象才变成垃圾。
GC机制:三种核心算法
GC(垃圾回收)是JVM自动管理内存的机制。我见过不少开发者觉得“有GC就不用管内存了”,这是大错特错的。GC不是万能的,它只能回收“不可达”的对象。
1. 引用计数法
每个对象维护一个计数器,记录被引用的次数。次数为0就回收。听起来简单,但有个致命问题——循环引用。
class Node {
var next: Node? = null
}
fun test() {
val a = Node()
val b = Node()
a.next = b
b.next = a
// 两个对象互相引用,计数器永远不为0
}
Java和Kotlin都没有采用引用计数法,就是因为这个坑。我早期做C++时被循环引用坑过好几次,后来转到Android,发现JVM直接绕过了这个问题,舒服多了。
2. 可达性分析
这是JVM真正使用的算法。从一组“GC Roots”出发,遍历所有引用链。能到达的对象就是活的,到不了的就是垃圾。
GC Roots包括:
- 栈帧中的局部变量引用
- 静态变量引用
- JNI引用
- 活跃线程
避坑指南:我曾经在项目中遇到一个诡异的内存泄漏——一个Activity被静态集合持有,导致整个Activity及其View树都无法回收。这就是典型的GC Roots问题:静态变量是Root,它引用的对象永远不会被回收。
3. 分代收集
JVM把堆分成几个代:年轻代、老年代、元空间(替代永久代)。为什么要分代?因为大部分对象“朝生夕死”。
| 代 | 特点 | GC算法 |
|---|---|---|
| 年轻代 | 对象存活率低,回收频繁 | 复制算法 |
| 老年代 | 对象存活率高,回收少 | 标记-清除 / 标记-整理 |
| 元空间 | 存储类元数据 | 不参与常规GC |
年轻代又分为Eden区和两个Survivor区。新对象先放Eden,Minor GC时把存活对象复制到Survivor。每熬过一次GC,年龄加1,到阈值就晋升到老年代。
注意:Android的ART虚拟机虽然和标准JVM有差异,但分代思想是类似的。ART也有年轻代和老年代的划分,只是具体参数和回收策略不同。
Android中的内存分配策略
Android的内存分配和标准Java有些不同。我总结了几点关键差异:
- 堆大小有限:每个App有堆上限,由
largeHeap属性和设备决定。超出就OOM。 - 内存碎片:频繁创建和释放对象会导致碎片,ART有压缩机制,但仍有影响。
- 共享内存:Zygote进程预加载框架类,子进程通过写时拷贝共享内存。
举个例子,为什么Android推荐用SparseArray代替HashMap?因为HashMap的Entry对象太多,每个Entry都是一个堆对象,GC压力大。而SparseArray用两个数组存储,对象数量少得多。
// 不推荐
val map = HashMap<Int, String>()
map[1] = "a" // 创建了Integer和Entry对象
// 推荐
val array = SparseArray<String>()
array.put(1, "a") // 只有String对象
我个人习惯在列表页、图片加载等高频场景下,尽量用基本类型和数组,减少对象创建。你想想看,一个列表滑到底,如果每个item都创建一堆临时对象,GC就得频繁触发,卡顿就来了。
知识体系总览
下面这张图是我自己整理的,把本章的核心知识点串在了一起。你看一眼就能明白整个内存管理的脉络。
这张图把内存模型、堆栈、GC机制和Android策略串在了一起。你仔细看,其实所有问题都围绕一个核心:对象在哪里创建,什么时候回收。搞懂这个,内存优化就入门了。
我的建议:初学者可以先从堆和栈的区别入手,再理解GC Roots。别一上来就啃分代收集的细节,容易懵。先建立整体认知,再逐步深入。
好了,这一章的内容就到这里。内存管理是性能优化的地基,地基不稳,上层再花哨也没用。下一章我们会深入实战,看看具体怎么用工具定位内存问题。
公众号:蓝海资料掘金营,微信deep3321