一、内存管理基础:Java/Kotlin内存模型、堆与栈的区别、GC机制、内存分配策略
大家好,我是你们的老朋友。今天咱们聊聊内存管理的基础。说实话,这话题看着基础,但很多人在项目里栽跟头,往往就是基础没打牢。
我见过不少开发者,一上来就追各种黑科技优化方案,结果连堆和栈都分不清。嗯,这就像盖楼不打地基,迟早要塌。咱们今天就把这些基础掰开揉碎了讲清楚。
1.1 Java/Kotlin内存模型:程序员的“地盘划分”
先说说内存模型。说白了,就是JVM在运行你的代码时,怎么划分它的“地盘”。
我个人习惯把内存模型想象成一个大型写字楼。每个楼层、每个房间都有不同的用途。Java内存模型主要分为这么几块:
- 程序计数器:记录当前线程执行到哪一行了。很小的一块空间,不会OOM。
- 虚拟机栈:每个方法调用都会创建一个栈帧,存局部变量、操作数栈、方法出口等。
- 本地方法栈:跟虚拟机栈类似,但服务于native方法。
- 堆:所有对象实例和数组都在这里分配。GC的主要战场。
- 方法区:存类信息、常量、静态变量、JIT编译后的代码等。
这里有个坑,我提醒一下:方法区在Java 8之后被元空间(Metaspace)取代了。以前的方法区在堆里,有大小限制,很容易OOM。元空间直接使用本地内存,理论上只受物理内存限制。
核心要点:线程私有的区域(程序计数器、虚拟机栈、本地方法栈)不会发生垃圾回收。线程共享的区域(堆、方法区/元空间)才是GC的重点关注对象。
1.2 堆与栈的区别:一个管“住”,一个管“活”
堆和栈的区别,我经常用一个比喻来解释:栈管运行,堆管存储。
栈是线程私有的,每个方法调用都会压栈。栈里存的是基本类型的变量和对象的引用。栈的生命周期跟方法调用同步——方法结束,栈帧弹出,局部变量自动销毁。
堆是线程共享的,所有new出来的对象都在堆里。对象的生命周期由GC决定,不受方法调用结束的影响。
举个例子:
public void doSomething() {
int count = 10; // count存在栈里
User user = new User(); // user引用在栈里,User对象在堆里
user.setName("老王");
// 方法结束,count和user引用从栈弹出
// 但User对象还在堆里,等待GC回收
}
我在项目中遇到过一个问题:有个同事在循环里不断new大对象,结果栈内存没爆,堆内存先爆了。为什么?因为栈里只存了引用,真正的对象都在堆里。你new一万个对象,栈里就一万个引用,每个引用才几个字节。但堆里那一万个对象,可能几百兆就没了。
避坑指南:我曾经以为栈溢出只会发生在递归调用太深时。后来发现,如果你在方法里声明了超大局部变量(比如byte[10_000_000]),栈也会爆。因为局部变量表放不下这么大的数据。嗯,这个坑我替你们踩过了。
1.3 GC机制:Young/Old/Full GC
GC,说白了就是JVM的“保洁阿姨”。但这位阿姨不是所有垃圾都扫,她有自己的工作流程。
Java堆被分成了几个区域:
- 年轻代(Young Generation):新对象都出生在这里。又分为Eden区和两个Survivor区(From和To)。
- 老年代(Old Generation):熬过多次GC还没死的对象,晋升到这里。
- 元空间(Metaspace):存类元数据,不在堆里。
GC有三种类型:
| GC类型 | 触发条件 | 影响范围 | STW时间 |
|---|---|---|---|
| Young GC(Minor GC) | Eden区满了 | 年轻代 | 很短(几毫秒) |
| Old GC(Major GC) | 老年代满了 | 老年代 | 较长(几十毫秒) |
| Full GC | 老年代满了 + 年轻代也满了 / 元空间满了 / System.gc() | 整个堆 + 元空间 | 很长(几百毫秒甚至秒级) |
你想想看,Full GC的时候,所有线程都要暂停(Stop The World)。如果你的App在用户滑动列表时突然卡住几百毫秒,那体验得多糟糕?
我优化过一个项目,每次Full GC要卡1.2秒。查了半天,发现是某个图片库在后台线程里频繁创建大Bitmap,而且没有复用。这些大对象直接进了老年代,老年代一满就触发Full GC。后来改成对象池复用,Full GC频率从每分钟一次降到了每半小时一次。
警告:不要在Android主线程里调用System.gc()!我曾经见过有人这么干,说是“手动释放内存”。结果每次调用都触发Full GC,UI线程直接卡成PPT。GC是JVM自己的事,你强行干预只会帮倒忙。
1.4 内存分配策略:对象是怎么“住”进堆里的
JVM分配内存不是随便扔的,它有一套策略:
- 优先在Eden区分配:大多数对象“朝生夕死”,在Eden区分配最合适。
- 大对象直接进入老年代:如果对象大小超过-XX:PretenureSizeThreshold(默认3MB),直接进老年代。避免在Eden和Survivor之间来回复制。
- 长期存活的对象进入老年代:每熬过一次Minor GC,对象年龄+1。年龄超过-XX:MaxTenuringThreshold(默认15),晋升到老年代。
- 动态年龄判定:如果Survivor区中相同年龄的所有对象大小总和超过Survivor区的一半,年龄大于等于该值的对象直接进入老年代。
这里我画了一张图,帮你理清整个流程:
你看这张图,新对象先到Eden区。Eden满了触发Minor GC,存活对象复制到Survivor From。下次Minor GC时,From和To角色互换,存活对象在Survivor之间来回复制。年龄够了就晋升到老年代。
大对象呢?直接走绿色箭头进老年代。为什么?你想想看,一个大对象在Eden和Survivor之间复制来复制去,多浪费性能。还不如直接扔老年代省事。
个人经验:我调优过一个内存泄漏问题,发现是某个单例持有了Activity的引用。这个Activity本来应该被回收,但因为单例一直引用着它,它从年轻代一路“活”到了老年代。最后老年代满了触发Full GC,但GC发现它还被引用着,没法回收。这就是典型的内存泄漏。所以,检查老年代里那些不该长期存活的对象,往往是定位内存泄漏的捷径。
1.5 总结一下
好了,今天的内容就到这里。咱们把内存模型、堆栈区别、GC机制、分配策略都过了一遍。这些东西看着基础,但你真的理解透了,后面讲内存泄漏、性能优化的时候,才能跟得上。
记住几个关键点:
- 栈管运行,堆管存储
- 年轻代GC快,老年代GC慢,Full GC要命
- 大对象直接进老年代,别让它在年轻代折腾
- 别手动调System.gc(),让JVM自己管
下一章咱们聊聊Android的内存管理特点,看看为什么Android的内存问题比普通Java应用更棘手。到时候见。
公众号:蓝海资料掘金营,微信deep3321