Java的内存分配和回收过程
1. 内存分配和回收
“Java 的内存分配和回收过程”本质上是在问:
- JVM 把运行时内存分成了哪些区域。
- 一个对象从
new出来开始,是怎么被分配到内存里的。 - 对象什么时候会进入 Survivor、老年代,甚至直接分配到老年代。
- JVM 如何判断对象已经没用了。
- 垃圾回收器是如何回收这些对象的。
所以这不是单纯讲 GC,也不是单纯讲 JVM 内存结构,而是要把对象从创建到死亡的完整生命周期串起来。
2. 先看 JVM 的运行时内存区域
2.1 线程私有区域
线程私有的区域主要有:
- 程序计数器
- Java 虚拟机栈
- 本地方法栈
这些区域的特点是:
- 生命周期和线程一致。
- 一般不属于 GC 的重点回收区域。
其中最重要的是虚拟机栈,它存放:
- 方法栈帧
- 局部变量表
- 操作数栈
- 方法返回信息
局部变量里如果存放了对象引用,那么这个引用就可能成为 GC Roots 的一部分。
2.2 线程共享区域
线程共享区域主要有:
- 堆(Heap)
- 方法区(JDK 8 之后主要对应元空间 Metaspace)
其中:
- 堆是对象实例分配的核心区域,也是垃圾回收最主要的区域。
- 元空间主要存放类元数据、运行时常量池、方法信息等。
如果用户问“Java 内存分配和回收”,通常重点还是讲堆内存。
3. 堆内存的典型分代结构
3.1 为什么要分代
HotSpot 的经典设计基于两个经验假说:
- 绝大多数对象朝生夕死
- 熬过越多次 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 会做两件事:
- 把对象实例字段所在内存置为零值。
- 设置对象头信息,例如:
- 哈希码相关信息
- GC 分代年龄
- 锁标记位
- 指向类元数据的指针
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 的核心处理逻辑是:
- 扫描 Eden 和一个 Survivor 区中的存活对象。
- 把存活对象复制到另一个 Survivor 区,或者直接晋升到老年代。
- 清空 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 哪些对象会进入老年代
常见来源有三类:
- 年龄达到阈值的长期存活对象
- Survivor 放不下的对象
- 大对象直接分配进入老年代
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 标记-清除
步骤:
- 标记存活对象
- 清除垃圾对象
优点:
- 实现简单
缺点:
- 会产生内存碎片
10.2 标记-复制
步骤:
- 标记存活对象
- 把存活对象复制到另一块空间
- 直接清空原空间
优点:
- 速度快
- 没有碎片
缺点:
- 需要额外复制空间
这非常适合新生代,因为新生代每次存活对象通常较少。
10.3 标记-整理
步骤:
- 标记存活对象
- 把存活对象向一端移动
- 清理边界外空间
优点:
- 没有碎片
缺点:
- 移动对象成本更高
这类算法常用于老年代。
11. 典型 GC 过程串起来看
11.1 Minor GC 过程
Minor GC 主要回收年轻代。
大致过程:
- Eden 满,触发 Minor GC。
- 从 GC Roots 出发,标记 Eden 和 From Survivor 中的存活对象。
- 将存活对象复制到 To Survivor 或老年代。
- 对象年龄增加。
- 清空 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未清理- 静态变量持有