1内存分代模型和分配策略
垃圾收集器与内存分配策略
对象存活的条件
- 对象的存活条件取决于其是否被可达性分析(Reachability Analysis)判定为“可达”
- JVM通过GC Roots作为起点,遍历对象引用链。若某个对象能被GC Roots直接或间接引用,则视为存活;否则,将被标记为可回收。
引用的概念
- 如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。引用不等同于对象本身,根据虚拟机种类的不同,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置。
引用计数算法
- 给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减一;任何时刻计数器为0的对象就是不可能在被使用的。
- 对象之间相互循环引用的问题无法解决
引用的分类
- 1.2后对引用的概念进行了扩充
强引用(Strong Reference)
- 类似(Object object = new Object()),只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用(Soft Reference)
- 用来描述还有用但并非必需对象,在系统见要发生内存溢出之前,将会把这些对象列进回收范围之中进行二次回收。进行二次回收之后还没有足够的内存,才会抛出内存溢出异常。
- Srping配置文件,只有在启动时加载,所以使用软引用
弱引用(Weak Reference)
- 用来描述非必需对象的,被弱引用关联的对象只能存活到下一次垃圾收集发生之前。当垃圾收集器工作时,无论内存是否够用,都会回收掉只被弱引用关联的对象。
虚引用(Phanton Reference)
- 幽灵应用或幻影引用,一个对象是否有虚引用,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。为一个对象设置虚引用关联的唯一作用就是能在这个对象被收集器回收时收到一个系统通知。
- 虚引用通常用来跟踪对象被垃圾回收的活动,虚引用的存在意义就是监控对象是否存活。
可达性分析算法
- 通过一系列的称为”GC Roots”的对象作为起始点。从这些结点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,则此对象为不可用
- GC Roots举例:
- 线程栈帧中的局部变量(Java方法栈、Native方法栈中的对象引用)。
- 类的静态变量(如
public static Object obj;)。 - JNI(Java Native Interface)引用(本地代码持有的对象)。
- 已启动且未终止的线程对象(
Thread实例)。 - 系统类加载器加载的类(如
java.lang.Class对象)。 - 被同步锁(Monitor)持有的对象(如
synchronized锁对象)。
可作为GC Roots对象的条件
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一般说的Native方法)引用的对象
宣告对象死亡的两次标记
- 两次标记和一次筛选

finalize()方法
- 被放在F-Queue队列的对象的finalize(),虚拟机只会触发这个方法,但并不承诺会等待它运行结束。
- 任何一个对象的finalize方法只会被系统自动调用一次。
- 该方法不建议使用,不确定性太大。
永生代的无用的类的回收
- 回收条件
- 该类所有的实例已经被回收,Java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
- 虚拟机可以对满足上述三个条件的类进行回收,但不是必然的。
JVM内存分代模型
- 内存分代模型是基于对象生命周期差异设计的一种内存管理策略,旨在优化垃圾回收(GC)效率
- JVM堆内存主要划分为年轻代(Young Generation)、老年代(Old Generation),以及元空间(Metaspace,方法区的实现)
年轻代
- 新创建的对象都会被分配到Eden区(如果该对象占用内存非常大,则直接分配到老年代区), 当Eden区内存不够的时候就会触发MinorGC(Survivor满不会引发MinorGC,而是将对象移动到老年代中)
- 新生代:1/3的堆内存空间
- eden区: 8/10 的新生代空间
- survivor0:1/10的新生代空间
- servivor1:1/10的新生代空间
- 线程本地分配TLAB
- TLAB(Thread-Local Allocation Buffer,线程本地分配缓冲区)是 JVM 在堆内存(尤其是新生代的 Eden 区)中为每个线程分配的一块私有内存区域。它的核心目的是显著提升对象分配的速度并减少同步开销。
- 如果空间足够,直接在 TLAB 的当前指针位置“撞针”分配(移动指针),整个过程完全无锁,因为 TLAB 是线程私有的。
- 占用eden,默认1%
- 线程私有的,多线程的时候不用竞争eden就可以申请空间,提高效率
- 小对象
- 无需调整
- 一般使用复制算法
老年代
- 一般使用标记清除(回收)算法或者标记压缩算法
- 对象在Survivor区中每熬过一次Minor GC,年龄就会+1岁,当年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置,默认是15)的对象会被移动到年老代中
- 虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
- 如果该对象占用内存非常大,则直接分配到老年代区
元空间(Metaspace,方法区)
- 元空间(Metaspace) 是用于存储类元数据的内存区域,取代了早期的永久代(PermGen)
- 存储内容:
- 类结构信息(如类名、方法、字段、访问修饰符)
- 方法字节码
- 常量池(Constant Pool)
- 注解信息
- 类加载器引用等
- 使用本地内存(Native Memory)而非JVM堆内存,其大小仅受操作系统可用内存限制。
特性 永久代(PermGen) 元空间(Metaspace) 内存位置 JVM堆内存 本地内存(Native Memory) 大小限制 固定(需手动设置 -XX:MaxPermSize)默认无上限(可设 -XX:MaxMetaspaceSize)垃圾回收机制 Full GC时回收,效率低 独立GC周期,更高效 内存碎片问题 容易产生碎片 分块(Chunk)管理,减少碎片 类卸载 困难,易内存泄漏 支持高效卸载未使用的类 - 元空间的应用场景
- 动态类加载:
如OSGi框架、热部署(HotSwap)场景,需频繁加载/卸载类。 - 反射与动态代理:
生成代理类(如Spring AOP、Java动态代理)时,元空间高效管理类元数据。 - 大型应用:
微服务架构中多个应用共享JVM时,合理分配元空间防止冲突。
- 动态类加载:
对象分配机制
逃逸分析
- 在编译期间(主要是 JIT 编译器,如 C1/C2)分析一个新建对象的作用域和生命周期,判断它是否会“逃逸”出创建它的方法或线程
- 分析维度
- 方法逃逸(Method Escape): 对象被作为参数传递给其他方法,或者作为方法的返回值返回。这意味着对象在创建它的方法执行结束后,可能还被其他方法引用。
- 对象被赋值给类变量(静态变量)或可以在其他线程中访问的实例变量(例如,放入
ConcurrentHashMap)。这意味着对象可能被其他线程访问。
- 如果一个对象被分析为没有逃逸(No Escape),即它仅在创建它的方法内部使用,且不会被其他线程访问,那么这个对象就成为了进行栈上分配和标量替换的候选者。
- 标量替换
- 对于被判定为没有逃逸的对象,JIT 编译器会尝试不真正地在堆或栈上分配这个完整的对象实例。相反,它会将这个对象的成员变量(字段)分解成一个个独立的标量(Scalar)(即基本数据类型
int,long,char,boolean等 或 对象引用reference),并将这些标量直接存储在方法的局部变量表(Local Variable Table) 或 CPU 寄存器(CPU Registers) 中。也就是说这个对象不会占用堆空间 - 工作步骤
- 分析对象: 编译器识别出一个没有逃逸的临时小对象。
- 分解字段: 将这个对象的所有字段视为独立的局部变量。
- 替换访问: 将代码中所有通过对象引用访问这些字段的地方(如
obj.fieldA,obj.fieldB),直接替换为访问对应的局部变量或寄存器。 - 消除分配: 完全避免为该对象分配内存(无论是在堆上还是栈上)。主要是消除了对象创建所带来的成本
- 优点
- 零分配开销: 彻底避免了在堆上分配内存(
new操作)和在栈上分配空间的开销。 - 零GC开销: 因为对象根本不存在,自然不需要垃圾回收器来回收它。
- 提升执行效率: 访问局部变量/寄存器比访问堆内存或栈帧中的对象快得多,减少了内存访问延迟。寄存器分配还能让编译器做更多的优化(如循环展开、常量传播)。
- 减少内存占用: 避免了对象头的开销(Mark Word, Klass Pointer)。
- 零分配开销: 彻底避免了在堆上分配内存(
- 对于被判定为没有逃逸的对象,JIT 编译器会尝试不真正地在堆或栈上分配这个完整的对象实例。相反,它会将这个对象的成员变量(字段)分解成一个个独立的标量(Scalar)(即基本数据类型
- 栈上分配
- 对于被判定为没有逃逸的对象,JVM 可以选择在当前线程的Java虚拟机栈(Java Virtual Machine Stack) 上为该对象分配内存,而不是在共享的堆(Heap)上分配
- 工作步骤
- 分析对象: 编译器识别出一个没有逃逸的对象。
- 栈帧分配: 在创建该对象的方法对应的栈帧内部,开辟一块空间来存储这个对象的实例数据(包括对象头和数据字段)。
- 生命周期绑定: 该对象的内存空间的生命周期与栈帧完全绑定。当方法调用结束,栈帧被弹出销毁时,这块内存空间也自动、立即被释放,不需要垃圾回收器介入。
- 优点
- 快速分配/释放: 栈上分配(移动栈指针)通常比在堆上分配(需要处理空闲列表、同步等)快得多。释放更是瞬间完成(栈帧弹出)。
- 零GC开销: 对象随栈帧销毁而自动回收,完全避开了垃圾回收机制(Minor GC / Full GC),避免了GC停顿。
- 局部性好: 对象内存和方法调用的局部变量在同一个栈帧内,访问速度快。
- 与标量替换的关系
- 在 HotSpot JVM 的实现中,栈上分配并不是一个独立于标量替换的常用优化
- 标量替换是更彻底的优化: 它完全消除了对象分配。栈上分配虽然比堆分配快,但依然需要在栈帧上分配空间、初始化对象头、写入字段等操作
- HotSpot JVM 的逃逸分析优化会优先尝试标量替换。只有当对象的结构无法被有效分解(例如,对象需要作为一个整体被传递,尽管它没有逃逸出方法,比如被同步块锁定
synchronized(obj),或者其字段被以对象引用的形式使用),或者标量替换的收益不高时,才会退而求其次选择栈上分配。
TLAB
- 当应用程序线程需要分配一个新对象时并且TLAB已经启用,默认是启用的:
- JVM 首先尝试在该线程的 TLAB 内进行分配。
- TLAB 主要针对小型、短生命周期对象的分配进行优化。
- JVM 会首先判断对象的大小是否超过了某个阈值(通常与 TLAB 的最大可能大小、Eden 区剩余空间或特定的大对象阈值
-XX:PretenureSizeThreshold相关)。 - 如果对象非常大(“Humongous Object”,在 G1 收集器中尤其有明确定义), JVM 会绕过 TLAB 和 Eden 区,尝试直接在老年代分配(可能需要同步)。这种对象根本不适合在 TLAB 中分配。决策结束。
- 如果对象不是“巨大”对象, 进入下一步。
- 当前线程是否有有效的 TLAB?
- 每个线程在创建时或需要时会被分配一个 TLAB。线程会记录其当前使用的 TLAB 的起始地址、当前分配指针和结束地址。
- 如果当前线程还没有 TLAB 或者之前的 TLAB 已经完全用尽(或因为其他原因被废弃):
- 线程会向内存管理器申请一个新的 TLAB。这个申请过程需要同步(因为要从共享的 Eden 区划一块连续内存出来)。
- 如果申请成功,线程就有了一个新的、空的 TLAB。
- 对象将分配到这个新申请的 TLAB 中(无锁)。
- 如果当前线程已经有一个有效的 TLAB, 进入下一步。
- 当前 TLAB 的剩余空间是否足够容纳该对象?
- 线程检查其当前 TLAB 的剩余空间(
current_ptr到end的距离)。 - 如果剩余空间 >= 对象大小 + 可能的填充(对齐):
- 对象直接在 TLAB 内分配(无锁)。 这是最快、最高效的路径。线程只需将 TLAB 的当前指针
bump(推进)对象大小的距离。
- 对象直接在 TLAB 内分配(无锁)。 这是最快、最高效的路径。线程只需将 TLAB 的当前指针
- 如果剩余空间 < 对象大小:
- 当前 TLAB 的剩余空间不足以容纳该对象。线程有两个选择:
- a) 废弃当前 TLAB,申请一个新 TLAB:
- 线程将当前 TLAB 标记为“已满”或废弃(其剩余空间将被浪费,直到下次 GC 回收整个 Eden 区)。
- 线程申请一个新的 TLAB(需要同步)。
- 然后尝试将对象分配到新申请的 TLAB 中。
- 如果新 TLAB 足够大(通常新申请的 TLAB 大小是动态计算的,大概率能容纳该对象),则分配成功(无锁)。
- b) 直接在 Eden 区的共享区域分配(慢路径):
- 线程可能会选择不申请新 TLAB,而是直接将对象分配到 Eden 区的共享部分。这通常发生在:
- 对象大小虽然大于当前 TLAB 剩余空间,但小于某个“浪费阈值”(
-XX:TLABRefillWasteFraction控制)。申请一个全新的 TLAB 来放这个不大的对象,会导致当前 TLAB 的剩余空间被浪费,而新 TLAB 可能只用了一小部分。JVM 认为直接去 Eden 共享区分配(需要同步)可能比浪费一个 TLAB 的大部分空间更划算。 - JVM 的实现策略或特定启发式算法认为此时直接分配更合适。
- 对象大小虽然大于当前 TLAB 剩余空间,但小于某个“浪费阈值”(
- 这种分配需要同步(锁),因为多个线程可能同时竞争更新 Eden 区的全局分配指针 (
bump-the-pointer)。
- 线程可能会选择不申请新 TLAB,而是直接将对象分配到 Eden 区的共享部分。这通常发生在:
- a) 废弃当前 TLAB,申请一个新 TLAB:
- 当前 TLAB 的剩余空间不足以容纳该对象。线程有两个选择:
- 线程检查其当前 TLAB 的剩余空间(
从eden到survivor
- 绝大多数新创建的对象都在新生代的 Eden 区分配,当 Eden 区被填满时,会触发一次 Minor GC(也称为 Young GC)
- STW
- 标记 Eden 区和 其中一个 Survivor 区(假设是 SurvivorFrom,比如 Survivor0)中的存活对象。
- 将 Eden 区和 SurvivorFrom 区中所有存活的对象,复制到 另一个 Survivor 区(SurvivorTo,比如 Survivor1)。
- 清理 Eden 区和刚刚使用过的 SurvivorFrom 区(Survivor0),使其变为空闲状态
- 对象每在新生代中“熬过”一次 Minor GC(即被复制一次),它的“年龄”(Age)就会增加 1
从新生代到老年代
- 年龄阈值达到 (
MaxTenuringThreshold)- JVM 有一个参数
-XX:MaxTenuringThreshold=<n>(默认值在 JVM 8 中是 15)来控制这个阈值
- JVM 有一个参数
- Survivor 空间不足
- 在 Minor GC 过程中,当尝试将 Eden 区和 SurvivorFrom 区的存活对象复制到 SurvivorTo 区时,如果 SurvivorTo 区的剩余空间不足以容纳所有存活对象,那些无法放入 SurvivorTo 区的存活对象(无论年龄大小)会被提前晋升(Premature Promotion) 到老年代
- 动态年龄判断
- 这是 HotSpot JVM 的一个优化策略,目的是避免固定阈值导致 Survivor 区空间浪费
- JVM 会计算 Survivor 区中相同年龄的所有对象大小的总和,如果发现某个年龄的所有对象大小的总和超过了 Survivor 区空间的一半 (
TargetSurvivorRatio,默认 50%,可通过-XX:TargetSurvivorRatio=<percent>调整),那么所有年龄大于或等于这个年龄的对象,都会在下一次 Minor GC 中被晋升到老年代,而无需等待达到MaxTenuringThreshold
- 大对象直接进入老年代
- 非常大的对象(比如长数组、大字符串)可能会直接在老年代分配,而不是在 Eden 区
- 由参数
-XX:PretenureSizeThreshold=<size>控制(如-XX:PretenureSizeThreshold=3M)。大于等于指定大小的对象直接在老年代分配
- 空间分配担保
- JVM 会检查一个关键条件:老年代的剩余连续空间是否大于 *历次* 晋升到老年代对象的平均大小,如果大于,则认为这次 Minor GC 是安全的,可以尝试进行。如果小于,此时,JVM 可能会先进行一次 Full GC 来腾出足够的老年代空间,然后再进行 Minor GC。Full GC 后如果老年代空间仍然不足,则抛出
OutOfMemoryError。
- JVM 会检查一个关键条件:老年代的剩余连续空间是否大于 *历次* 晋升到老年代对象的平均大小,如果大于,则认为这次 Minor GC 是安全的,可以尝试进行。如果小于,此时,JVM 可能会先进行一次 Full GC 来腾出足够的老年代空间,然后再进行 Minor GC。Full GC 后如果老年代空间仍然不足,则抛出
垃圾收集算法
标记-清除算法(Mark-Sweep)
一般作用于老年代
首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
不足
- 效率问题
- 空间问题:会产生大量不连续的空间碎片。分配较大对象时,可能无法找到连续的内存而不得不提前触发另一次垃圾收集
复制算法(Copying)
一般作用于年轻代
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面。然后再把已使用过的内存空间一次清理掉。
将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和刚才使用过的Survivor还存活的对象一次性复制到另外一个Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间。
标记整理算法(Mark-Compact)
一般作用于老年代
HotSpot
- 最知名的JVM实现,核心设计思想:通过动态监控识别代码中的“热点”(频繁执行的代码段)并对其进行深度优化
枚举根节点
- 枚举根节点(Enumeration of GC Roots)是垃圾回收(Garbage Collection, GC)过程中可达性分析(Reachability Analysis)的关键步骤,其目的是快速、准确地找出所有GC Roots对象。
- 枚举根节点时,会发生Stop The Word
- HotSpot通过OopMap的数据结构来确定哪些地方存放着对象引用,在类加载完成的时候,HotSpot就把对象内什么偏移量时什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置(安全点)记录下栈和寄存器中那些地方是引用。
- OopMap是一种空间换时间的策略,当JVM到达安全点时,会更新自己的OopMap,记录哪些栈中代表着内存引用,枚举根节点时,从OopMap中拿取引用,这比查找内存先判断是否是一个引用要快很多
- 枚举根节点是垃圾回收的基石,通过OopMap和安全点机制,实现了对GC Roots的高效定位,确保可达性分析的准确性与性能。这一过程的优化直接关系到GC的停顿时间,是JVM低延迟设计的关键环节。理解其原理有助于开发者在代码中避免创建不必要的根引用,从而提升垃圾回收效率。
安全点
- 程序执行时并非在所有地方都能停顿进行GC,只有到达安全点才可以
- 安全点的选定标准
- 是否可以让程序长时间执行的特征。
- 长时间执行的特征:方法调用、循环跳转、异常跳转。
- 所有线程都跑到安全点的方案
- 抢先式中断
- 在GC发生时,先把所有线程中断。当线程中断的地方不在安全点上时,恢复线程,让它跑到安全点上
- 主动式中断
- GC需要中断线程时,不直接对线程进行操作,而是设置一个标志。各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,再加上创建对象需要分配内存的地方。
- 抢先式中断
安全区域
- 安全区域指在一段代码片段中,引用关系不会发生变化。在这个区域的任意地方开始GC都是安全的。
- safePoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的SafePoint,但是程序不执行的情况没有考虑(程序不执行就是没有分配CPU时间,典型例子就是线程处于Sleep状态或者Blocked状态)
- 当线程执行到Safe Region中的代码时,首先标识自己已经进入到了sqfe Region,当在这段时间里JVM要发起GC时,就可以忽略SafePoint的标识状态。当线程要离开SafeRegion时,要检查系统是否已经完成了根节点枚举(或者整个GC过程),直到收到可以安全离开的信号才可以离开。
1内存分代模型和分配策略
https://x-leonidas.github.io/2025/10/26/04Java/JVM/1内存分代模型和分配策略/
