Java虚拟机实例的内存分配与垃圾回收,本质上就是“堆上对象的一生”如何被管理你写的每一个new,都在堆里占一块地,而GC就是那个定期来收地的管家,具体例子能让你一眼看穿这个过程。
通过一个真实对象看透JVM内存分配流程
假设你在写一个电商下单接口,代码里有一行Order order = new Order();,这行代码在Java虚拟机实例里触发的动作,远比你想象得复杂。
这个Order对象首先会被分配到堆内存中的Eden区,几乎所有的新生对象都先在这里落户,因为绝大多数对象“朝生夕死”,放在一起方便批量回收,JVM会为它创建一个对象头,里面存着哈希码、GC分代年龄、锁状态等信息,对象头之后是实例数据,也就是你定义的字段:订单号、金额、用户ID等,最后还有对齐填充,目的是让对象大小凑成8字节的整数倍。
这里有个关键细节:如果对象太大,比如一个包含几万条明细的大订单对象,Eden区放不下,它会直接被扔进老年代,这不是什么奇怪的决策,而是JVM的分配策略大对象直接进老年代,避免在新生代频繁复制导致性能损耗。
当这个Order对象被创建后,虚拟机栈的局部变量表里会有一个引用指向它,栈管引用,堆管实例,这就是Java内存分配最基本的格局。对象的“出生地”永远是堆,但“户口本”在栈上。
小对象先到Eden区,大对象直接进老年代,方法区的常量池里还可能存着字符串字面量,你需要记住这条路径,因为垃圾回收就是沿着这个路径反向操作的。
用“新生代Minor GC”的例子理解对象晋升过程
现在你的下单接口跑了一晚上,Eden区塞满了对象,JVM发现新对象没地方放了,于是触发一次Minor GC。
这次GC只清理新生代,垃圾收集器从GC Roots开始扫描,哪些对象还活着?比如你正在处理中的订单,它的引用还在栈上,那就是活的,那些已经处理完的订单对象,没有引用指向它们,就会被标记为垃圾,在Minor GC中,活着的对象会被移动到Survivor区,也就是S0或S1。
新生代的内存被分成Eden、S0、S1三块,比例默认是8:1:1,每一次Minor GC后,存活下来的对象年龄加一岁,并且从Eden区挪到Survivor区,当对象年龄达到15岁(可通过-XX:MaxTenuringThreshold参数调整),它就会从Survivor区晋升到老年代。
听起来很简单对吧?但实际场景里有个常见问题:Survivor区空间不足怎么办,比如你的接口正好碰到秒杀活动,大量订单对象在GC后依然存活,S1区装不下了,JVM的应对策略是“分配担保”直接把多余的对象提前晋升到老年代,这不算什么错误,而是保护机制,防止对象被错误回收。
通过这个例子你能直观理解:对象不是一步登天进入老年代的,而是在一次次GC中“幸存”下来的,这就像职场晋升,每年考核一次,连续15次考核通过才能升到高级岗,老年代里装着的,都是经历过多次GC仍然存活的长生命周期对象,比如缓存、单例、数据库连接池。
一个Full GC实例:老年代空间紧张时发生了什么
老年代对象过多,最终会引发Full GC,这是所有Java开发人员最不想看到的场景之一。
让我们构建一个具体的例子:你的系统里有一个静态的LinkedHashMap做缓存,存放着最近下单用户的信息,这个Map被静态引用持有着,所以它里面的对象永远不会被当作垃圾回收,随着时间推移,老年代的空间逐渐被这些缓存对象占满,当老年代空间不足时,JVM会触发Full GC。
Full GC会同时清理新生代、老年代和方法区,它会停止整个应用程序线程,也就是著名的Stop-The-World,在这个停顿期间,你的接口请求全部卡住,用户端表现为“转圈”或“超时”,GC线程完成回收后,应用线程才恢复运行。
这个过程中你可以观察到两个核心指标:
- Full GC的频率,如果频繁发生,说明老年代空间持续紧张,要么是缓存对象过多,要么是大量短期对象被错误晋升到了老年代。
- 单次Full GC的停顿时间,如果超过1秒,在交易系统里就是灾难,可能导致大量请求超时。
常用JVM监控命令示例:
jstat -gcutil <pid> 1000 // 每秒输出一次GC情况
jmap -heap <pid> // 查看堆内存分配情况
业内专家指出,Full GC的停顿时间与堆大小直接相关,堆越大,需要扫描的对象越多,停顿时间越长,所以很多系统采用G1收集器,就是为了把停顿时间控制在可预测的范围内,这不是玄学,而是垃圾收集器设计思路的根本转变从追求吞吐量转向追求可控延迟。
内存泄漏与垃圾回收:一个常见的对象无法回收例子
有时候对象明明没用了,但GC就是回收不了它,这就是内存泄漏,理解这个概念需要对照垃圾回收的判断规则。
垃圾回收器判断对象是否死亡的依据是可达性分析,从GC Roots出发,沿着引用链向下搜索,任何不在引用链上的对象都可以被回收,GC Roots包括栈帧中的引用、静态属性引用的对象、常量引用的对象、JNI引用的对象等。
举个具体例子:你在一个工具类里写了个静态的List,用来暂存登录用户操作日志,你只是想让日志在内存里攒一攒,定期写库,但你的代码里没有提供清空这个List的机制,当用户反复登录,这个List不断变大,可问题在于,这个List作为静态变量,本身就是GC Roots的一部分,它里面所有对象永远可达,即使用户已经下线,那些登录日志对象也无法被垃圾回收。
内存泄漏的常见原因还有:
- 连接未关闭,数据库连接、网络连接、IO流如果没有close,会导致底层资源对象一直被持有。
- 监听器未移除,注册到某个组件上的监听器,如果忘记在适当时候注销,那么监听器对象也会被一直引用。
- 不正确使用
ThreadLocal,当线程池中的线程复用很长时间时,ThreadLocal中存储的对象可能会一直无法释放。
优化方法很简单:用完就清,不用的引用置null,静态集合需要提供清理入口,在JDK 8及以后版本中,你没有借口把责任推到语言身上这是代码设计的问题。
通过堆内存分区理解各个代的作用,彻底搞懂JVM内存模型
要彻底搞懂垃圾回收,必须先把JVM内存分区图刻在脑子里,堆内存被划分为不同代,每代有自己的职责:
| 分区 | 回收频率 | 回收算法 | |
|---|---|---|---|
| Eden区 | 新创建对象 | 极高 | 复制算法 |
| S0/S1 | Minor GC后存活对象 | 高 | 复制算法 |
| 老年代 | 长期存活对象 | 低 | 标记整理或标记清除 |
| 元空间 | 类元数据、常量池 | 视情况 | 无特定回收机制 |
为什么这样分区?因为绝大多数对象存活时间极短,据统计,接近90%的对象在第一次GC时就会被回收,如果所有对象都混在一起,每次GC都要扫描全部内存,代价太高,分代收集可以利用不同区域对象的存活特性,采用最合适的算法。
新生代用复制算法是因为存活率低,只需复制少量对象,浪费一点内存换取效率,老年代对象存活率高,复制算法不适合,所以用标记-整理或标记-清除,标记-清除会产生碎片,标记-整理会在清除后移动对象,让内存变连续,但移动对象会花更多时间。
在当前主流的HotSpot虚拟机中,G1收集器打破了这种物理分代的严格边界,G1把堆划分成多个大小相等的Region,每个Region在逻辑上分代,这样做的好处是,回收时不用整个堆扫描,而是优先回收垃圾最多的Region,也就是Garbage First这个名字的由来,不同Java虚拟机实例的垃圾回收器选择,取决于你的应用是多线程高吞吐优先,还是低延迟优先,或者是对堆内存大小有明确要求。
Java虚拟机实例中常问的垃圾回收问题
垃圾回收会回收方法区的类吗?
会,但条件苛刻,方法区主要存放类元信息、常量池等,类的卸载条件包括:该类所有的实例都已被回收,加载该类的ClassLoader已被回收,该类对应的Class对象没有任何地方被引用,在老版本中方法区存放的是永久代,在JDK 8之后改为元空间,使用本地内存,这减少了内存溢出概率。
怎样判断一个对象是否应该被回收?
通过可达性分析算法,从GC Roots出发,不可达的对象第一次被标记,之后需要经过一个筛选过程,也就是对象的finalize()方法是否有必要执行,如果对象没有覆盖finalize(),或者已经被调用过,虚拟机不会执行它,一个对象可以被形容为“自救”一次,也就是在finalize()里把自己重新与引用链上的对象关联,但这已经被视为不推荐的做法,因为它增加了不确定性。
Full GC频繁是不是只能加内存解决?
不是,加内存是缓解措施,但不是根治办法,Full GC频繁通常是因为老年代空间不足或元空间不足,你应该先通过jstat查看GC日志,分析老年代是否真的满了,再检查是否有内存泄漏,如果是大量短生命周期对象晋升到老年代,可能需要调整Survivor区大小或晋升阈值,如果元空间不足,需要检查是不是动态生成类过多,每次调整参数前,请先确认自己的业务特征和瓶颈所在,单纯加大堆内存可能会让单次GC停顿更长,问题反而恶化。
回到最初的例子,那个Order对象从Eden区出生,经历Minor GC的筛选,或晋升老年代,或最终被垃圾收集器回收,整个过程就是一个Java虚拟机实例中内存管理的微观缩影,理解它不需要背诵晦涩的规范,只需要把“对象的一生”这条线走通,你会发现,内存分配与垃圾回收这件事,本质上就是一问一答:对象在哪出生,何时被回收,谁来决定它的生死。