一、内存管理基础: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分配内存不是随便扔的,它有一套策略:

  1. 优先在Eden区分配:大多数对象“朝生夕死”,在Eden区分配最合适。
  2. 大对象直接进入老年代:如果对象大小超过-XX:PretenureSizeThreshold(默认3MB),直接进老年代。避免在Eden和Survivor之间来回复制。
  3. 长期存活的对象进入老年代:每熬过一次Minor GC,对象年龄+1。年龄超过-XX:MaxTenuringThreshold(默认15),晋升到老年代。
  4. 动态年龄判定:如果Survivor区中相同年龄的所有对象大小总和超过Survivor区的一半,年龄大于等于该值的对象直接进入老年代。

这里我画了一张图,帮你理清整个流程:

Java堆内存分配与GC流程 年轻代 (Young Generation) Eden区 新对象分配 Survivor From 存活对象 Survivor To 空闲 老年代 (Old Generation) 大对象直接分配 / 长期存活对象晋升 晋升 元空间 (Metaspace) 类元数据、常量、静态变量 Minor GC: Eden满 → 复制存活对象到Survivor Full GC: 老年代满 → 整个堆+元空间GC

你看这张图,新对象先到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