JVM 中对象进入老年代的时机

JVM 中对象进入老年代的时机

老年对象进入老年代

首先介绍几个核心概念:

  • 对象晋升:指 JVM 堆内存中,原本分配在新生代(Eden 区 / Survivor 区)的对象,满足特定条件后从新生代转移至老年代的内存流转行为,是 JVM 内存管理的核心环节。
  • 对象年龄计数器:JVM 为每个新生代对象维护的一个内存标记值,用于记录该对象在新生代垃圾回收中存活的次数(老年代的垃圾回收不会影响新生代对象的年龄),是判断对象是否晋升的核心依据,对象初始年龄为 0。
  • 新生代的垃圾回收:涵盖不同垃圾回收器针对新生代的回收行为(如串行回收器的 Minor GC、并行回收器的 PSYoungGC等),是触发新生代对象年龄增长的唯一场景。

新生代对象进入老年代的具体规则:

一、固定年龄阈值晋升(基础逻辑)

Eden 区是新生代对象的初始分配区域,对象从 Eden 区开始的完整晋升逻辑如下:

  1. 新对象在 Eden 区创建,初始年龄为 0;
  2. 当发生新生代的垃圾回收时,Eden 区中未被回收的存活对象会被复制到新生代的 Survivor 区(如 S0 区),此时对象年龄自增 1;
  3. 后续每发生一次新生代的垃圾回收,若该对象仍未被回收且能在 Survivor 区之间(S0↔S1)复制,年龄则逐次 + 1;
  4. 当对象年龄等于 -XX:MaxTenuringThreshold 设定的值时,该对象会在本次新生代垃圾回收完成后,从 Survivor 区直接晋升至老年代。

二、动态年龄判断(自动调节,提前晋升)

JVM 不会机械遵循达到阈值才晋升的规则,而是通过动态年龄判断机制优化内存分配,避免 Survivor 区空间不足,该机制的执行逻辑为:

  1. 在新生代的垃圾回收过程中,JVM 会统计当前 Survivor 区中同一年龄的所有对象的总内存占用;
  2. 若某一年龄 N 对应的对象总大小 ≥ Survivor 区总容量的 50%,则所有年龄 ≥N 的对象会直接晋升至老年代,无需等待达到 MaxTenuringThreshold 设定的阈值。

核心调优参数

-XX:MaxTenuringThreshold

  • 规范定义:
    -XX:MaxTenuringThreshold 是 JVM 针对新生代对象晋升老年代的核心年龄阈值调优参数,适用于支持对象晋升阈值自动调节的垃圾回收器(如 ParNew、Parallel Scavenge、G1 等),用于设定新生代对象晋升至老年代的最大年龄阈值。
  • 取值范围与默认值:
    整数型参数,取值范围为 1~15;JDK 8 及以上版本默认值为 15,不同垃圾回收器对该参数的支持逻辑一致。
  • 核心作用(不受回收器类型影响):
    • 上限约束:为对象在新生代的存活周期设定 "绝对上限"------ 无论是否触发动态年龄判断,只要对象年龄达到该参数值,本次新生代 GC 完成后必然晋升至老年代;
    • 边界约束:动态年龄判断仅能让对象 "提前晋升",但无法突破该参数的上限,即对象年龄绝不可能超过该值仍停留在新生代。

-XX:TargetSurvivorRatio

  • 规范定义:-XX:TargetSurvivorRatio 是 JVM 动态年龄判断机制的核心调优参数,为整数型百分比参数,用于设定新生代 GC 后 Survivor 区的目标使用率。
  • 取值范围与默认值:
    整数型参数(表示百分比),取值范围通常为 10~90,JVM 默认值为 50(即 50%)。
  • 执行逻辑:
    • JVM 统计 Survivor 区中同一年龄的所有对象总内存占用,若 Survivor 区实际使用率超过 TargetSurvivorRatio 设定值,则触发动态年龄判断规则;
    • 目的是避免 Survivor 区因使用率过高导致溢出,本质是 JVM 对 Survivor 区内存使用的自适应优化策略。

代码示例:通过下面两组对比代码,验证 JVM 中MaxTenuringThreshold参数的作用机制,以及固定年龄阈值晋升和动态年龄判断规则的实际表现,明确 MaxTenuringThreshold 是对象晋升老年代的充分非必要条件。

java 复制代码
public class AllocEden{
    public static final int _1K=1024;
    public static void main (String args[]) {
        for(int i=0;i<5* _1K; i++) {
            byte[] b=new byte[_1K] ;
        }
    }
}

循环创建 5000 个 1KB 的字节数组(总大小 5MB),且对象创建后无持续引用。

使用该参数运行:-Xmx1024M -Xms1024M -XX:+PrintGCDetails

运行全程无 GC 发生,堆日志显示 Eden 区占用约 25MB 空间,Survivor 区(From/To)、老年代(Tenured)均无使用。

原理:新创建的小对象默认优先分配在 Eden 区,Eden 区剩余空间足够容纳 5MB 对象,JVM 无需执行新生代垃圾回收;只有发生新生代垃圾回收时,存活对象才会复制到 Survivor 区,无 GC 则对象始终停留在 Eden 区,最终结果显示对象未进入 Survivor 和老年代。

java 复制代码
import java.util.HashMap;
import java.util.Map;

public class MaxTenuringThreshold {
    public static final int _1M = 1024 * 1024;
    public static final int _1K = 1024;

    public static void main(String args[]) {
        Map<Integer, byte[]> map = new HashMap<Integer, byte[]>();
        for (int i = 0; i < 5 * _1K; i++) {
            byte[] b = new byte[_1K];
            map.put(i, b);
        }
        for (int k = 0; k < 17; k++) {
            for (int i = 0; i < 270; i++) {
                byte[] g = new byte[_1M];
            }
        }
    }
}

第一阶段:创建 5000 个 1KB 字节数组并将所有数组对象的引用存入 HashMap 中,由于 HashMap 持有这些对象的持续强引用,对象始终处于可达状态,不会被垃圾回收器回收;

第二阶段:通过双层循环创建大量大对象 ------ 外层循环执行 17 轮,内层每轮创建 270 个 1MB 字节数组(单次内层循环总计分配 270MB 内存)。该操作会快速耗尽 Eden 区空间,迫使 JVM 频繁触发新生代垃圾回收,每次新生代 GC 执行时,HashMap 中存活的 5MB 对象年龄会自增 1,以此推动这些对象的年龄持续增长,为后续晋升老年代创造条件。

用下面参数运行

-Xmx1024M -Xms1024M -XX:+PrintGCDetails -XX:MaxTenuringThreshold=5 -XX:+PrintHeapAtGC

其中:-XX:MaxTenuringThreshold=5 用于设定对象晋升老年代的最大年龄为 5;-XX:+PrintHeapAtGC 用于每次 GC 前后输出堆各区域的使用情况

新生代 GC 的触发逻辑:

第二阶段循环创建 1MB 大对象,单次循环分配 270MB 内存,远超 Eden 区默认容量,JVM 会频繁触发新生代垃圾回收,每次 GC 都会让map中存活的 5MB 对象年龄 + 1。

对象晋升的两种可能路径:

固定年龄阈值:当对象年龄增长至 5(达到MaxTenuringThreshold=5),在第 5 次新生代 GC 后直接晋升至老年代;

动态年龄判断:若 Survivor 区中某一年龄 N 的对象总大小超过TargetSurvivorRatio(默认 50%),则年龄≥N 的对象提前晋升。

大对象进入老年代

精准定义大对象:

JVM 中的大对象是指需要连续内存空间的大尺寸对象,典型代表包括超大字节数组、超长字符串,包含大量元素的连续存储集合,其他需要整块连续内存的自定义大对象。

大对象的核心问题:

大对象的复制成本高,新生代 GC 的核心机制是复制算法,大对象在 Eden 区和 Survivor 区之间频繁复制会消耗大量 CPU 和内存带宽,严重降低 GC 效率,因此 JVM 设计了 "大对象优先进入老年代" 的规则。

大对象进入老年代的两类触发场景

  • 场景 1:通过参数主动控制(显式阈值触发)
    通过 -XX:PretenureSizeThreshold 参数设定阈值,只要大小超过设定阈值的对象,不管新生代有没有空间装下,都会主动跳过新生代直接在老年代分配内存。
  • 场景 2:新生代空间不足被动触发(隐式空间约束)
    若未设置 PretenureSizeThreshold 或参数不生效,但大对象所需的连续内存空间超过新生代某区域的最大可用容量(如 Eden 区剩余空间不足,或 Survivor 区总容量小于对象大小),JVM 会被动将该大对象分配至老年代。

-XX:PretenureSizeThreshold 参数解析

  • 规范定义:
    -XX:PretenureSizeThreshold 是 JVM 新生代对象分配的核心调优参数,单位为字节,用于设定大对象直接进入老年代的体积阈值。
  • 取值规则:
    取值范围为非负整数(0 ≤ 值 ≤ 新生代最大可用空间),JVM 按 对象大小≥阈值 判定大对象并直接分配至老年代。
  • 生效范围:
    Serial GC 单线程串行回收器,设计上兼容传统的对象分配规则,无特殊的吞吐量优化逻辑;
    ParNew GC 新生代并行回收器,多线程版本的 Serial GC,核心分配逻辑与 Serial GC 一致,仅回收线程并行化。
  • 默认值为 0:
    禁用按对象大小直接进入老年代的规则,JVM 不再判断对象体积,所有对象都严格遵循 "Eden 区→Survivor 区→老年代" 的常规分配流程,只有当新生代真的装不下,大对象才会被动进入老年代。

不同垃圾回收器对大对象的差异化处理:

  • 传统回收器(Serial/ParNew/Parallel GC)
    Serial / ParNew:遵循阈值与空间得双规则,符合阈值或新生代空间不足的大对象直接进老年代;
    Parallel GC:Parallel GC 的设计目标是最大化吞吐量,它对对象分配做了专属优化,优先保证新生代 GC 效率,直接忽略了 PretenureSizeThreshold 参数,仅遵循空间不足规则,大对象先进入 Eden 区,若 Eden 区空间不足则触发新生代 GC,GC 后仍无法容纳则被动进老年代。
  • 新型回收器(G1/ZGC/Shenandoah)
    这类回收器无物理上的新生代与老年代的分区,采用 Region 作为内存管理单元,对大对象的处理逻辑如下:
    对象大小超过单个 Region 容量的 50%,即判定为超大对象(Humongous Object),超大对象会被分配至 Humongous Region,该区域逻辑上归属老年代范畴,且不会参与新生代 GC;避免超大对象跨 Region 分配,减少内存碎片,同时降低新生代 GC 的压力。

大对象直接分配到老年代的核心价值:

  • 减少新生代 GC 频率:大对象不会快速占满 Eden 区,避免短时间内频繁触发新生代 GC(即使新生代 GC 耗时短,频繁触发仍会消耗 CPU 资源);
  • 降低 GC 耗时:避免大对象在 Eden 区和 Survivor 区之间的复制操作,减少单次新生代 GC 的执行时间;
  • 提升程序稳定性:减少因新生代频繁 GC 导致的 CPU 波动,尤其适合对延迟敏感的业务(如金融交易、实时计算)。

代码示例:

java 复制代码
import java.util.HashMap;
import java.util.Map;

public class PretenureSizeThreshold {
    public static final int _1K = 1024;

    public static void main(String args[]) {
        Map<Integer, byte[]> map = new HashMap<Integer, byte[]>();
        for (int i = 0; i < 5 * _1K; i++) {
            byte[] b = new byte[_1K];
            map.put(i, b);
        }
    }
}

使用下面参数运行:-Xmx32m -Xms32m -XX:+UseSerialGC -XX:+PrintGCDetails

可以看到,所有的对象均分配在新生代,老年代的使用率为0。

接着附加参数 PretenureSizeThreshold,令PretenureSizeThreshold=1000,则大小为1024字节的byte数组理应被分配在老年代,参数如 -Xmx32m -Xms32m -XX:+UseSerialGC -XX:+PrintGCDetails -XX:PretenureSizeThreshold=1000

基于 PretenureSizeThreshold 参数的设计语义,-XX:PretenureSizeThreshold=1000 表示所有大小超过 1000 字节的对象直接在老年代分配。代码中创建的 byte[] b = new byte[1024] 对象满足超过阈值的条件,5000 个该对象总大小约 5MB,理论上老年代应观测到约 5MB 的内存占用。

但实际运行日志显示:

新生代 Eden 区:eden space 8704K, 90% used(7875KB),包含全部 5000 个 1KB 数组;

老年代:tenured generation total 21888K, used 35K,仅占用 35KB;

直观来看 PretenureSizeThreshold 参数似乎未生效。

底层根源是 TLAB 的高优先级分配逻辑干扰全局阈值检查

TLAB(Thread Local Allocation Buffer)是 JVM 在新生代 Eden 区为每个线程划分的线程私有内存缓冲区,其核心设计目标是规避多线程分配对象时的全局内存锁竞争,大幅提升小对象的分配效率。TLAB 的默认大小通常为几十 KB 至几百 KB。

JVM 分配对象时遵循线程私有优先于全局的原则,仅当TLAB 剩余空间不足以容纳当前对象或显式禁用 TLAB(-XX:-UseTLAB),JVM 才会触发全局分配逻辑,而 PretenureSizeThreshold 的阈值检查仅在全局分配逻辑中执行,TLAB 分配阶段完全绕过该检查。

示例中 TLAB 的具体干扰过程:

主线程启动后,JVM 为其在 Eden 区分配一块私有 TLAB,每个 1KB 数组对象分配时,优先填充至 TLAB 中(单块 TLAB 可容纳数十个至数百个 1KB 对象),循环创建 5000 个 1KB 数组时,JVM 会持续复用与扩容 TLAB,所有对象均在 TLAB 内完成分配,全程未触发全局分配逻辑,因此 JVM 从未执行 PretenureSizeThreshold 的阈值检查,最终所有数组均留在新生代,参数看似失效。

日志数据拆解:

eden space 8704K, 90% used(7875KB),即 5000 个 1KB 目标数组与 JVM 新生代基础开销,完全符合内存分配逻辑。

老年代的 35KB 占用与目标数组无关,仅为 JVM 的基础运行开销,包括主线程的栈结构信息、HashMap 的内部哈希表元数据、类元信息的少量全局存储、JVM 内存管理模块的基础数据。

下面禁用 TLAB 再次执行

使用参数:-Xmx32m -Xms32m -XX:+UseSerialGC -XX:+PrintGCDetails -XX:PretenureSizeThreshold=1000 -XX:-UseTLAB

新生代仅这 1156KB 的使用量完全是 JVM 的基础开销,5000 个 1024 字节的 byte 数组,已经完全不在新生代分配了,这正是关闭 TLAB 后, PretenureSizeThreshold=1000 生效的体现。老年代 6052KB 的使用量,刚好对应5000 个 1024 字节的数组和 JVM 自身在老年代的基础开销。关闭 TLAB 后,JVM 在分配对象时会走全局分配逻辑,此时会检查 PretenureSizeThreshold 阈值,超过 1000 字节的对象直接分配到老年代。

相关推荐
鱼很腾apoc1 小时前
【实战篇】 第13期 算法竞赛_数据结构超详解(上)
c语言·开发语言·数据结构·学习·算法·青少年编程
派大鑫wink2 小时前
【Day37】MVC 设计模式:原理与手动实现简易 MVC 框架
java·设计模式·mvc
毕设源码-赖学姐2 小时前
【开题答辩全过程】以 基于java的医院床位管理系统的设计与开发 为例,包含答辩的问题和答案
java·开发语言
lly2024062 小时前
AJAX PHP 实践指南
开发语言
Never_Satisfied2 小时前
在JavaScript / HTML中,cloneNode()方法详细指南
开发语言·javascript·html
曹轲恒2 小时前
SpringBoot的热部署
java·spring boot·后端
huwei8532 小时前
python设计通用表格类 带右键菜单
开发语言·windows·python
Remember_9932 小时前
深入理解 Java String 类:从基础原理到高级应用
java·开发语言·spring·spring cloud·eclipse·tomcat
—Qeyser2 小时前
Flutter组件 - BottomNavigationBar 底部导航栏
开发语言·javascript·flutter