Skip to content

Java的内存分配和回收过程

1. 内存分配和回收

“Java 的内存分配和回收过程”本质上是在问:

  1. JVM 把运行时内存分成了哪些区域。
  2. 一个对象从 new 出来开始,是怎么被分配到内存里的。
  3. 对象什么时候会进入 Survivor、老年代,甚至直接分配到老年代。
  4. JVM 如何判断对象已经没用了。
  5. 垃圾回收器是如何回收这些对象的。

所以这不是单纯讲 GC,也不是单纯讲 JVM 内存结构,而是要把对象从创建到死亡的完整生命周期串起来。

2. 先看 JVM 的运行时内存区域

2.1 线程私有区域

线程私有的区域主要有:

  • 程序计数器
  • Java 虚拟机栈
  • 本地方法栈

这些区域的特点是:

  • 生命周期和线程一致。
  • 一般不属于 GC 的重点回收区域。

其中最重要的是虚拟机栈,它存放:

  • 方法栈帧
  • 局部变量表
  • 操作数栈
  • 方法返回信息

局部变量里如果存放了对象引用,那么这个引用就可能成为 GC Roots 的一部分。

2.2 线程共享区域

线程共享区域主要有:

  • 堆(Heap)
  • 方法区(JDK 8 之后主要对应元空间 Metaspace)

其中:

  • 是对象实例分配的核心区域,也是垃圾回收最主要的区域。
  • 元空间主要存放类元数据、运行时常量池、方法信息等。

如果用户问“Java 内存分配和回收”,通常重点还是讲堆内存

3. 堆内存的典型分代结构

3.1 为什么要分代

HotSpot 的经典设计基于两个经验假说:

  1. 绝大多数对象朝生夕死
  2. 熬过越多次 GC 的对象越难死

因此堆不会被一视同仁地回收,而是按对象生命周期特征分区域治理。

3.2 经典分代结构

在经典分代模型里,堆通常分为:

  • 新生代(Young Generation)
  • 老年代(Old Generation)

其中新生代通常再拆成:

  • Eden
  • Survivor From
  • Survivor To

常见理解方式是:

  • 新对象大多数先进入 Eden。
  • 经过一次或多次 Minor GC 仍存活的对象,会在 Survivor 区之间复制。
  • 年龄达到阈值后,再晋升到老年代。

3.3 JDK 9 之后 G1 的视角

如果使用 G1,就不再是传统物理连续的新生代 / 老年代布局,而是 Region 模型。

但逻辑上仍然可以理解为:

  • 某些 Region 扮演年轻代角色。
  • 某些 Region 扮演老年代角色。

所以面试里讲“分代”仍然成立,只是底层组织方式更灵活。

4. 对象创建时的内存分配过程

4.1 类加载检查

当 JVM 执行到 new 指令时,第一步不是马上分配内存,而是先做类加载检查

JVM 会确认:

  • 这个类是否已经被加载、验证、解析和初始化。

如果没有,就先完成类加载流程。

4.2 计算对象大小

类元数据准备好后,对象需要多少内存其实就已经基本确定了。

对象大小通常由以下几部分构成:

  • 对象头
  • 实例字段
  • 对齐填充

这一步之后,JVM 才知道应该在堆里划出多大一块空间。

4.3 在堆上分配内存

JVM 在堆中为对象分配内存,常见有两种方式:

4.3.1 指针碰撞

如果堆空间规整,已使用和未使用内存边界清晰,那么只需要把分界指针向前移动即可。

这种方式分配很快。

常见于:

  • 使用复制、整理类回收算法后,堆空间比较规整的场景。

4.3.2 空闲列表

如果堆空间不规整,JVM 就要维护一张空闲列表,记录哪些内存块可用,分配时从中找合适的块。

这种方式更灵活,但分配成本更高。

4.4 解决并发分配问题:TLAB

多线程同时创建对象时,如果每次都去全局竞争堆指针,开销会很大。

所以 HotSpot 通常会使用:

  • TLAB(Thread Local Allocation Buffer)

也就是给每个线程在 Eden 中预留一小块私有分配区。

这样线程创建普通小对象时,通常只需要在自己的 TLAB 里移动指针,不需要全局加锁。

这也是为什么 Java 中“分配对象”在很多场景下其实非常快。

4.5 初始化零值和对象头

分配完内存后,JVM 会做两件事:

  1. 把对象实例字段所在内存置为零值。
  2. 设置对象头信息,例如:
  3. 哈希码相关信息
  4. GC 分代年龄
  5. 锁标记位
  6. 指向类元数据的指针

4.6 执行 <init>()

最后,JVM 才会执行构造方法对应的 <init>() 逻辑,对对象进行真正的业务初始化。

到这里,一个完整对象才真正创建完成。

5. 对象一般分配到哪里

5.1 大多数对象先分配在 Eden

绝大多数普通对象都会先分配在新生代的 Eden 区。

这是 Java 对象分配的默认路径。

原因是:

  • 大多数对象生命周期很短。
  • 放在年轻代统一快速回收最划算。

5.2 大对象可能直接进入老年代

如果对象非常大,例如:

  • 很大的数组
  • 大字符串缓冲区
  • 大批量数据对象

某些收集器或参数设置下,大对象可能会直接进入老年代。

这样做是为了避免:

  • 在 Eden / Survivor 之间来回复制大对象,造成巨大成本。

5.3 长期存活对象会晋升到老年代

对象如果多次经历 Minor GC 仍然存活,就会逐渐从新生代晋升到老年代。

这一步通常和对象年龄有关。

6. 对象在新生代中的流转过程

6.1 Eden 满了会触发 Minor GC

当 Eden 没有足够空间给新对象分配时,通常会触发一次 Minor GC

Minor GC 的核心处理逻辑是:

  1. 扫描 Eden 和一个 Survivor 区中的存活对象。
  2. 把存活对象复制到另一个 Survivor 区,或者直接晋升到老年代。
  3. 清空 Eden 和旧的 Survivor 区。

6.2 Survivor 的作用

Survivor 区的意义是:

  • 不让“刚活过一次 GC 的对象”立刻进入老年代。
  • 给这些对象一个中转和观察期。

这能有效降低老年代压力。

6.3 对象年龄

对象每熬过一次 Minor GC,年龄通常就会加 1。

当年龄达到阈值时,就会晋升到老年代。

这个阈值常见默认值是 15,但是否真的到 15 才晋升,还受其他策略影响。

6.4 动态年龄判定

JVM 不一定死板地等对象年龄到固定阈值才晋升。

如果某个年龄及以上对象总大小已经超过 Survivor 空间一半,那么这些对象可能会提前晋升到老年代。

这叫做动态年龄判定

其目的很简单:

  • 避免 Survivor 装不下。

6.5 空间分配担保

在 Minor GC 之前,JVM 通常还会评估老年代是否有足够空间容纳可能晋升的对象。

如果担保失败,就可能:

  • 直接触发 Full GC
  • 或发生分配失败、晋升失败

所以 Minor GC 并不是完全只看年轻代,它也会受老年代空间影响。

7. 老年代中的对象与回收

7.1 哪些对象会进入老年代

常见来源有三类:

  1. 年龄达到阈值的长期存活对象
  2. Survivor 放不下的对象
  3. 大对象直接分配进入老年代

7.2 老年代为什么回收更重

老年代和年轻代最大的差别在于:

  • 存活对象比例通常更高
  • 对象体积通常更大
  • 对象关系更复杂

因此老年代 GC 的成本通常更高,停顿也往往更长。

7.3 Major GC / Full GC

严格说:

  • Major GC 通常指老年代回收
  • Full GC 通常指整个堆甚至方法区相关区域的全量回收

但在很多面试和工程口语里,这两个词经常被混用。

你需要知道它们都意味着:

  • 成本更高
  • 停顿更长
  • 应尽量避免频繁发生

8. JVM 如何判断对象可以回收

8.1 引用计数不是主流方案

JVM 主流实现并不使用简单引用计数来判断对象是否存活。

因为引用计数有个致命问题:

  • 无法解决循环引用

例如 A 引用 B,B 引用 A,但它们和程序其他部分完全断开,此时单纯靠计数无法回收。

8.2 可达性分析才是主流方案

HotSpot 等主流 JVM 使用的是可达性分析(Reachability Analysis)

核心思想是:

  • 从一组固定的根对象 GC Roots 出发做图遍历。
  • 能够到达的对象是存活对象。
  • 到达不了的对象才是垃圾。

8.3 常见 GC Roots

常见 GC Roots 包括:

  • 虚拟机栈中局部变量表引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • JNI 引用的对象
  • 被同步锁持有的对象

所以一个对象能不能回收,本质上要看:

它是否还在某条从 GC Roots 出发的引用链上。

9. 四种引用类型和回收语义

Java 除了普通强引用,还定义了更细粒度的引用语义。

引用类型 典型类 回收特点 常见用途
强引用 普通对象引用 只要强引用在,就不会被 GC 回收 正常业务对象
软引用 SoftReference 内存不足时可能被回收 内存敏感缓存
弱引用 WeakReference 下一次 GC 通常就会回收 WeakHashMap、弱关联对象
虚引用 PhantomReference 不影响对象生命周期,主要用于接收回收通知 堆外资源清理

这部分更多是在说明:

  • 并不是所有“有引用”的对象都同等强。
  • JVM 回收时还要考虑引用语义。

10. 垃圾回收算法是怎么做回收的

10.1 标记-清除

步骤:

  1. 标记存活对象
  2. 清除垃圾对象

优点:

  • 实现简单

缺点:

  • 会产生内存碎片

10.2 标记-复制

步骤:

  1. 标记存活对象
  2. 把存活对象复制到另一块空间
  3. 直接清空原空间

优点:

  • 速度快
  • 没有碎片

缺点:

  • 需要额外复制空间

这非常适合新生代,因为新生代每次存活对象通常较少。

10.3 标记-整理

步骤:

  1. 标记存活对象
  2. 把存活对象向一端移动
  3. 清理边界外空间

优点:

  • 没有碎片

缺点:

  • 移动对象成本更高

这类算法常用于老年代。

11. 典型 GC 过程串起来看

11.1 Minor GC 过程

Minor GC 主要回收年轻代。

大致过程:

  1. Eden 满,触发 Minor GC。
  2. 从 GC Roots 出发,标记 Eden 和 From Survivor 中的存活对象。
  3. 将存活对象复制到 To Survivor 或老年代。
  4. 对象年龄增加。
  5. 清空 Eden 和旧 Survivor。

特点:

  • 频繁发生
  • 单次回收通常较快
  • 采用复制思路,成本主要与存活对象数量相关

11.2 Full GC 过程

Full GC 回收范围更大,通常涉及老年代,很多情况下也会波及整个堆。

常见触发原因:

  • 老年代空间不足
  • 晋升失败
  • 显式 System.gc()
  • 元空间不足

特点:

  • 频率低
  • 停顿长
  • 对系统影响大

线上调优通常重点就是减少 Full GC 的频率和耗时。

12. 常见垃圾回收器是如何参与这个过程的

12.1 Serial / Parallel

经典分代回收器。

特点:

  • 新生代和老年代仍是传统分区
  • Stop-The-World 明显
  • Parallel 更关注吞吐量

12.2 CMS

CMS 追求低停顿,主要针对老年代。

特点:

  • 并发标记、并发清理
  • 停顿比传统 Full GC 小
  • 但会产生碎片

12.3 G1

G1 以 Region 为基本单位回收。

特点:

  • 逻辑上仍有年轻代和老年代概念
  • 优先回收垃圾最多、收益最高的 Region
  • 兼顾吞吐量和停顿时间目标

12.4 ZGC / Shenandoah

目标是超低停顿。

特点:

  • 更大比例的并发回收
  • 停顿时间极低
  • 适合大堆和低延迟场景

13. 影响内存分配和回收的几个关键优化

13.1 TLAB

提升对象分配效率,减少多线程竞争。

13.2 逃逸分析

JIT 编译器可能分析出某些对象不会逃出方法或线程作用域,于是可能做:

  • 栈上分配
  • 标量替换
  • 锁消除

这意味着:

  • 并不是所有对象最终都真的进入堆。

不过面试时要注意说法:

  • 从规范角度看,对象通常认为分配在堆上
  • 从 HotSpot 优化角度看,部分对象可能因逃逸分析而不真正落到堆中

13.3 写屏障和记忆集

在现代收集器里,为了支持分代或并发回收,JVM 会使用:

  • 写屏障
  • 卡表
  • Remembered Set

它们的作用是:

  • 记录跨代引用
  • 减少每次 GC 都全堆扫描的成本

这部分属于更底层的 GC 实现细节,能讲出来会显得你理解更深入。

14. 常见线上问题和本质原因

14.1 为什么频繁 Minor GC

常见原因:

  • Eden 太小
  • 对象分配速率过高
  • 短生命周期对象暴增

14.2 为什么老年代很快被打满

常见原因:

  • 中生命周期对象太多
  • 晋升过早
  • 大对象直接进入老年代
  • 缓存、静态集合、线程池上下文导致对象长期存活

14.3 为什么 Full GC 后内存还是下不来

这通常说明:

  • 这些对象并不是垃圾
  • 它们仍然被某条 GC Roots 引用链挂住

本质上就是:

  • 内存泄漏
  • 缓存未淘汰
  • ThreadLocal 未清理
  • 静态变量持有