内存管理基础: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就得频繁触发,卡顿就来了。

知识体系总览

下面这张图是我自己整理的,把本章的核心知识点串在了一起。你看一眼就能明白整个内存管理的脉络。

内存管理基础:知识体系 Java/Kotlin内存模型 程序计数器 | 虚拟机栈 本地方法栈 | 堆 | 方法区 堆与栈 栈:线程私有,存引用 堆:线程共享,存对象 GC机制 引用计数(未采用) 可达性分析(GC Roots) 分代收集(年轻/老年代) Android内存分配策略 堆大小限制 内存碎片与压缩 Zygote共享内存

这张图把内存模型、堆栈、GC机制和Android策略串在了一起。你仔细看,其实所有问题都围绕一个核心:对象在哪里创建,什么时候回收。搞懂这个,内存优化就入门了。

我的建议:初学者可以先从堆和栈的区别入手,再理解GC Roots。别一上来就啃分代收集的细节,容易懵。先建立整体认知,再逐步深入。

好了,这一章的内容就到这里。内存管理是性能优化的地基,地基不稳,上层再花哨也没用。下一章我们会深入实战,看看具体怎么用工具定位内存问题。


公众号:蓝海资料掘金营,微信deep3321