岳阳市网站建设_网站建设公司_Redis_seo优化
2026/1/8 0:36:39 网站建设 项目流程

垃圾回收算法的思想

垃圾回收的基本思想是考察每一个对象的可触及性,即从根节点开始是否可以访问到这个对象,如果可以,则说明当前对象正在被使用,如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了,一般来说,此对象需要被回收。

1. 可达性分析

你可以把内存中的对象想象成一串葡萄

  • 根节点(GC Roots):就是你手里提着葡萄藤的位置。只要你的手抓着藤(Root),顺着藤连下来的葡萄(对象),都是“活”的,不能扔。
  • 可触及(Reachable):从手里的藤开始,顺着枝丫能找到的葡萄,都是可触及的。
  • 不可触及(Unreachable/Garbage):如果有几颗葡萄虽然彼此连在一起,但是和主藤断开了(掉在地上了),不管它们那一小簇葡萄内部连得有多紧密,因为手提不起来了,它们就是垃圾,需要被扫走。

核心逻辑:
JVM 在垃圾回收时,会暂时“冻结”世界(STW),从所有的 GC Roots 出发,给所有能连上的对象打个标记。等标记完了,剩下没被标记的对象,就是“孤魂野鬼”,会被清除。

为什么要用这种方法?
早期的算法是“引用计数法”(对象被引用一次计数+1)。但它有一个死穴:如果 A 引用 B,B 引用 A,但没人理它们俩,它们的计数器永远是 1,永远不会被回收。可达性分析完美解决了这个问题,只要断了根,内部循环引用照样死。


2. 什么是根节点(GC Roots)?附代码案例

“根节点”听起来很抽象,其实翻译成大白话就是:程序运行当前时刻,必须立刻、马上能用到的对象。

在 Java 中,最常见的GC Roots主要有以下几类。为了方便理解,我写了一个类来演示:

第一类:栈帧中的局部变量(最常见)

解释:你正在运行的方法中定义的变量。方法还没执行完,这个变量肯定是有用的,所以它是根。

publicclassStackRoot{publicstaticvoidmain(String[]args){// 场景:方法正在执行// 'user' 是一个局部变量,存放在栈内存中// 只要 main 方法没结束,'user' 指向的 new User() 对象就是被根节点引用的Useruser=newUser("张三");// 此时:user 就是一个 GC Rootuser=null;// 此时:刚才那个 User 对象失去了根节点的引用,下次 GC 就会被回收}}

第二类:方法区中的静态变量(Static)

解释:被static修饰的变量。它属于类,不属于具体的对象。类加载后通常长期存在,所以它引用的对象也是根。

publicclassStaticRoot{// 'cache' 是一个静态变量// 它随着类的加载而存在,生命周期很长// 所以 ArrayList 这个对象一直被 static 引用抓着,它是 GC RootpublicstaticList<String>cache=newArrayList<>();publicstaticvoidmain(String[]args){cache.add("数据1");// 这个字符串被 GC Root 引用,不会被回收}}

第三类:方法区中的常量(Final)

解释:被final修饰的常量,或者字符串常量池中的引用。

publicclassConstRoot{// 'GREETING' 是常量// 它指向的 "Hello World" 字符串对象,会被作为 GC Root 保护起来publicstaticfinalStringGREETING="Hello World";}

第四类:本地方法栈中引用的对象(JNI)

解释:如果你的 Java 代码调用了 C/C++ 写的 Native 方法(比如操作系统的 DLL 文件),这些 C 代码中引用的 Java 对象也是根。这个平时开发很少直接接触,知道即可。


3. 谁活谁死?

  1. Object A(Static 变量) ->Object B
  2. Object C(Main 方法内的局部变量) ->Object D
  3. Object E<->Object F(E 和 F 互相引用,但没有根连着它们)

判定结果:

  • A是根(Static),所以A活着,连带B也活着。
  • C是根(Stack),所以C活着,连带D也活着。
  • E 和 F虽然互相拉着手,但没人拉它们(没有 Root 引用),所以E 和 F 都是垃圾,会被回收。

总结

当你困惑“这玩意儿是不是 GC Root”时,问自己一个问题:
“代码运行到这一行时,我如果把这个对象删了,程序会报错吗?”

  • 如果是局部变量(正在用):删了肯定空指针,所以它是 Root。
  • 如果是静态变量(全局都能用):删了别人读不到,所以它是 Root。
  • 如果是孤零零的对象(没变量指向它):删了也没人知道,所以它不是 Root,可以回收。

引用计数法

1. 核心定义与基本原理

引用计数法是一种直接的内存管理技术。与可达性分析(追踪式 GC)不同,它不依赖于特定的“垃圾回收时刻”来扫描整个内存堆,而是将内存管理的职责下放到了每一个对象自身。

核心机制:
每个对象在创建时,会在其对象头(Object Header)或辅助结构中,分配一个整型字段(Integer Field),称为引用计数器(Reference Counter)。这个计数器实时记录着当前程序中有多少个指针(或引用)正在指向该对象。

2. 算法的逻辑流转

在引用计数算法下,内存管理变成了一个严格的算术问题。对象的状态仅由计数器的数值决定:

A. 计数器的正向变更(Increment)

当一个对象被引用时,其计数器立即加 1。这通常发生在以下技术场景:

  1. 赋值操作:将该对象的地址赋值给一个新的变量(Object a = b)。
  2. 传参操作:将该对象作为参数传递给一个方法(入栈,产生新的局部变量引用)。
  3. 容器存储:将该对象加入到数组、列表或映射等集合容器中(容器持有引用)。
B. 计数器的负向变更(Decrement)

当一个对象的引用失效时,其计数器立即减 1。这发生于:

  1. 引用销毁:某个指向该对象的局部变量超出了作用域(出栈)。
  2. 引用重置:指向该对象的变量被赋值为null,或被指向了另一个对象。
  3. 移出容器:该对象被从集合容器中移除。
C. 回收触发(Deallocation)

这是引用计数法最显著的特征:即时性
一旦某个对象的引用计数器变为0(即ref_count == 0),系统立即判定该对象为“不可用”,并同步触发内存回收操作。该对象所占用的内存空间会被立即释放,归还给空闲链表(Free List)。


3. 技术优势

从计算机科学的角度看,这种算法具有以下明确的特性:

  1. 内存回收的实时性(Real-time)
    垃圾回收不需要等到内存耗尽时才触发,也不需要暂停整个应用程序(Stop-The-World)。对象一旦成为垃圾,其占用的资源会立即被释放。这对内存敏感的系统非常重要。
  2. 执行平滑性
    垃圾回收的计算开销被均匀分散在每一次赋值操作中,而不是集中在某一时刻爆发。
  3. 判断逻辑简单
    不需要像可达性分析那样从 GC Roots 进行全局遍历,只需要关注对象局部的计数器状态。

4. 致命缺陷与技术代价

尽管逻辑简单,但引用计数法在现代高性能虚拟机(如 JVM)中被弃用,主要是因为以下两个无法忽视的技术硬伤:

A. 无法解决“循环引用”(Cyclic Reference)

这是引用计数法在图论层面的逻辑漏洞。

  • 现象:对象 A 内部持有对象 B 的引用,对象 B 内部持有对象 A 的引用。
  • 状态:此时,即使外部所有的引用都已断开(A 和 B 对外部程序不可见),A 和 B 的引用计数器依然都维持在1
  • 结果:依据算法逻辑,计数器不为 0 则不回收。这两个对象将形成一个“内存孤岛”,永久占用堆内存,造成内存泄漏
B. 运行时开销(Overhead)

引用计数看似分散了压力,但在高并发场景下,代价极高:

  1. 存储开销:每个对象都需要额外的内存空间来存储计数器。
  2. 计算开销:每一次简单的指针赋值操作(a = b),在底层都需要插入计数器的加减指令。
  3. 并发安全代价:在多线程环境下,修改计数器必须是原子操作(Atomic Operation)。频繁的原子更新(CAS操作或锁)会引发严重的 CPU 缓存同步问题,导致吞吐量大幅下降。

总结

引用计数法是一种基于局部状态的内存管理策略。

  • 它的信条是:“只要有人指向我,我就有存在的价值;一旦没人指向我,我立刻消失。”
  • 它的死穴是:无法处理“小团体内部互相吹捧(循环引用)”的情况,且在高并发下维护“账本(计数器)”的成本过于昂贵。

内存泄漏

1. 核心定义

内存泄漏是指:程序中已动态分配的堆内存,由于某种原因,程序未能释放,且垃圾回收器(GC)也无法回收,导致这块内存始终被占用,无法被再次利用的现象。

这里存在两个视角的矛盾:

  • 程序的视角(逻辑层面):该对象已经使用完毕,后续的业务逻辑中不再需要它了。
  • GC 的视角(技术层面):根据“可达性分析算法”,该对象依然存在一条从 GC Roots 到它的引用链。

结论:内存泄漏的本质是对象的生命周期被人为地(或无意地)延长了,超过了它实际被需要的时间。


2. 内存泄漏的发生机制

要理解内存泄漏,必须理解长生命周期对象短生命周期对象之间的持有关系。

正常情况

一个对象(如方法内的局部变量)使用完毕后,引用断开,它与 GC Roots 失去连接,GC 扫描时将其回收。

泄漏情况

当一个生命周期非常长的对象(例如static变量、单例对象),在内部持有了一个生命周期本该很短的对象(例如一次性的临时数据)的引用,且在使用完后没有手动断开。

结果
虽然那个短对象已经没用了,但因为它被长对象“抓”着,而长对象又是 GC Root(或连接着 GC Root),导致 GC 无法回收那个短对象。长此以往,堆内存中堆积的“无用但存活”的对象越来越多。


3. 典型的技术性场景归类

抛开具体业务,从代码模式上看,内存泄漏主要源于以下几种技术操作的不规范:

A. 静态集合类的膨胀 (Static Collections)
  • 机制static关键字修饰的成员变量,其生命周期与 JVM(或类加载器)一致,贯穿程序运行始终。
  • 泄漏点:如果将对象放入一个staticListMap等集合中,却只存不取(或只添加不删除),这些对象将永远滞留在堆内存中,直到程序结束。
B. 各种连接资源未关闭 (Unclosed Resources)
  • 机制:数据库连接(Connection)、网络连接(Socket)、IO 流。这些资源通常涉及到底层操作系统的句柄。
  • 泄漏点:虽然 Java 对象本身可能被回收,但如果未调用close()方法,底层的缓冲区域或句柄可能无法释放。更严格地说,这往往导致堆外内存或操作系统资源的泄漏。
C. 改变哈希值导致的“丢失” (Mutable Keys in Hash Structures)
  • 机制:当对象被存储在HashSetHashMap中时,是依据其hashCode放置的。
  • 泄漏点:如果对象存入集合后,你修改了该对象参与哈希计算的字段,导致其hashCode发生变化。此时,即使你调用remove()方法,集合也无法根据新的哈希值找到原来的对象。这个对象就“死”在了集合里,无法被删除,也无法被回收。
D. 内部类与外部类的隐式引用 (Inner Class References)
  • 机制:非静态内部类(Non-static Inner Class)在实例化时,会默认持有一个指向外部类实例的隐式引用。
  • 泄漏点:如果内部类的实例被一个长生命周期的对象持有(或者内部类开启了一个长线程),那么外部类的实例即便已经没用了,也无法被回收,因为内部类在引用它。

4. 内存泄漏的后果与演变

内存泄漏通常不是突发性的崩溃,而是一个累积的过程:

  1. 初期:几乎无感知,系统运行正常。
  2. 中期(性能下降)
  • 堆内存中可用空间越来越少。
  • GC 发现内存紧张,被迫频繁触发(尤其是 Full GC)。
  • 由于泄漏的对象是“可达的”,GC 扫描和标记它们需要消耗大量 CPU 资源,但又无法回收它们,导致“由于 GC 造成的应用暂停(Stop The World)”时间变长,系统响应变慢。
  1. 末期(OOM)
  • 堆内存被这些泄漏的对象填满。
  • 当程序试图申请新的内存空间时,GC 即使拼尽全力也无法腾出空间。
  • JVM 抛出java.lang.OutOfMemoryError: Java heap space,程序崩溃。

总结

逻辑上看,内存泄漏就是:持有(Reference)大于需求(Need)。

即:你依然持有该对象的强引用,但你的业务逻辑已经不再需要该对象了。解决内存泄漏的根本,就是确保引用的生命周期对象的业务生命周期保持一致,用完即断开(置为null或从集合中移除)。

标记清除法

1. 算法的前提条件

标记-清除算法依赖于可达性分析(Reachability Analysis)。即在执行回收前,系统必须能够暂停应用程序的执行(Stop The World),并从一组固定的GC Roots开始遍历对象图。

2. 第一阶段:标记阶段 (The Marking Phase)

此阶段的目标是识别所有存活的对象

  • 遍历过程
    垃圾回收器(GC)暂停应用程序,从所有的 GC Roots 出发,沿着引用链(Reference Chain)进行深度优先或广度优先遍历。
  • 标记动作
    对于遍历过程中遇到的每一个对象,GC 会在其对象头(Object Header)中设置一个特定的标记位(Mark Bit),将其标识为“可达”(Reachable)或“存活”。
  • 状态冻结
    为了保证标记的准确性,防止在标记过程中对象引用关系发生变化,通常需要冻结用户线程。
  • 结果
    当遍历结束时,堆内存中的对象被在逻辑上划分为两类:
  1. 被标记的对象:确认存活,需要保留。
  2. 未被标记的对象:被判定为不可达,确认为垃圾。

3. 第二阶段:清除阶段 (The Sweeping Phase)

此阶段的目标是回收未被标记对象占用的内存空间

  • 线性扫描
    GC 会对堆内存的某一块区域进行从头到尾的线性扫描(Linear Scan)。
  • 判定与回收
    在扫描过程中,GC 检查每个对象的标记位:
  1. 如果对象已被标记:说明该对象存活。GC 会清除该标记位(将其重置),以便下一次 GC 周期使用,然后跳过该对象。
  2. 如果对象未被标记:说明该对象是垃圾。GC 不会移动该对象,而是将其占用的内存地址和大小记录下来,放入一个**空闲列表(Free List)**中。
  • 逻辑删除
    注意,“清除”通常意味着逻辑上的释放。GC 并不一定会物理地将内存中的数据清零(Zero Out),而是简单地将这段地址标记为“可用”,下次分配新对象时可以直接覆盖这块区域。

4. 核心缺陷与技术代价

虽然标记-清除算法逻辑清晰且不需要移动对象,但它存在两个严重的工程化问题,导致现代高性能 JVM 很少单独使用它:

A. 效率问题 (Efficiency)
  • 标记效率:标记阶段需要遍历所有存活对象。如果堆中存活对象很多,递归遍历的开销很大。
  • 清除效率:清除阶段需要线性扫描整个堆空间。如果堆非常大,扫描耗时较长。
B. 内存碎片化 (Memory Fragmentation) —— 最核心的痛点

这是标记-清除算法最显著的特征。

  • 现象:由于垃圾对象在堆内存中的分布是随机的,清除这些对象后,会产生大量不连续的内存空隙。这些空隙就像奶酪上的孔洞一样。
  • 后果
  1. 大对象分配失败:当程序需要为一个个头较大的对象(如大数组)分配内存时,虽然堆中总的空闲内存足够,但无法找到一块连续的足够大的空间。这会迫使 JVM 提前触发下一次垃圾回收(Full GC)。
  2. 分配方式受限:由于内存不连续,JVM 无法使用高效的指针碰撞(Bump Pointer)方式分配内存,而必须维护一个复杂的空闲列表(Free List),记录哪些地址块是可用的。这增加了对象分配时的计算开销。

总结

标记-清除算法采用了“先盘点,后清理”的策略。

  • 它的贡献:利用可达性分析解决了循环引用问题,确立了现代 GC 的基本框架。
  • 它的局限“只管杀,不管埋”。它清除了垃圾,却留下了支离破碎的内存空间(碎片),这为后续的内存分配埋下了隐患。

为了解决碎片问题,后续演化出了标记-整理(压缩)算法(Mark-Compact)复制算法(Copying)

复制算法

复制算法(Copying Algorithm)是为了解决标记-清除算法中“内存碎片化”问题而提出的一种高性能回收策略。

它的核心理念是:通过牺牲一部分内存空间,换取内存整理的高效率和内存分配的低开销。

1. 内存分区策略 (Memory Partitioning)

复制算法不再将整个堆内存视为一个单一的连续空间,而是将其严格划分为两个大小相等的区域:

  1. 对象面(From-Space):当前正在使用的内存区域,负责对象的分配。
  2. 空闲面(To-Space):预留的内存区域,在 GC 开始前始终保持为空。

在任意时刻,只有其中一个区域(From-Space)在被应用程序使用,另一个区域(To-Space)处于闲置待命状态。

2. 算法执行流程

当 From-Space 的内存空间耗尽时,垃圾回收被触发(Stop The World),流程如下:

A. 标记与复制 (Mark and Copy)
  1. 根节点扫描:GC 从 GC Roots 出发,寻找所有存活的对象。
  2. 对象迁移:一旦发现某个对象是存活的,GC不只是标记它,而是立即将它从 From-Space复制到 To-Space 中。
  3. 紧凑排列:这是关键步骤。在向 To-Space 复制对象时,GC 会按顺序一个接一个地放置对象(从 To-Space 的起始地址开始往后排)。这意味着复制后的对象在物理内存上是连续存储的,中间没有任何空隙。
  4. 引用更新:由于对象在内存中的物理地址发生了改变,GC 会同时更新程序中所有指向该对象的引用,确保它们指向 To-Space 中的新地址。
B. 清除与交换 (Clear and Swap)
  1. 全量清除:当所有存活对象都复制到 To-Space 后,From-Space 中剩下的全是垃圾对象。GC 不需要逐个遍历回收,直接将 From-Space 的内存指针重置(清空整个区域)。
  2. 角色互换:From-Space 和 To-Space 交换身份。
  • 原来的 To-Space 变成新的 From-Space,继续为应用程序分配内存。
  • 原来的 From-Space 变成新的 To-Space,等待下一次 GC。

3. 技术优势

复制算法在工程实现上极其高效,主要体现在以下两点:

A. 彻底解决了内存碎片 (No Fragmentation)

由于存活对象在 To-Space 中是按顺序连续存放的,因此 GC 结束后,堆内存永远是规整的。这消除了“标记-清除”算法产生的内存空洞。

B. 极高的内存分配效率 (Pointer Bumping)

因为内存是规整的,当 JVM 需要为新对象分配内存时,不需要维护复杂的“空闲列表”来查找可用块。
JVM 只需要维护一个指针(Bump Pointer),指向 To-Space 中空闲内存的起始位置。分配内存时,只需将指针向后移动一段与对象大小相等的距离即可。这种操作在汇编层面极其快速。

4. 核心缺陷与代价

既然这么好,为什么不能完全取代其他算法?因为它有两个昂贵的代价:

A. 内存利用率低 (Low Memory Utilization)

这是最直观的缺点。为了运行该算法,你必须将内存一分为二,任何时刻都有一半的内存(To-Space)是闲置浪费的。这意味着可用内存直接缩水了50%

B. 存活率高时效率骤降

复制算法的效率取决于存活对象的数量

  • 如果大部分对象都是垃圾(存活率低),只需要复制极少数对象,效率极高。
  • 如果大部分对象都存活(存活率高),GC 不仅需要复制大量对象,还需要频繁更新引用地址,这会导致极其严重的性能开销。

5. 现代 JVM 的改良应用 (Appel 式回收)

由于 50% 的内存浪费在工业界是不可接受的,且根据统计,Java 对象中 98% 都是“朝生夕死”的短命对象。因此,现代 JVM(如 HotSpot)对复制算法进行了改良,用于**新生代(Young Generation)**的回收:

  • 分区调整:不再是 1:1 分区,而是分为一块较大的Eden 区(通常占 80%)和两块较小的Survivor 区(各占 10%)。
  • 运作方式:每次使用 Eden 和其中一块 Survivor。回收时,将存活对象复制到另一块闲置的 Survivor 中。
  • 空间利用率:这样只有 10% 的内存被闲置(Empty Survivor),内存利用率从 50% 提升到了90%

总结

复制算法采用了“空间换时间”的策略。

  • 逻辑:把活着的对象搬家到另一边,剩下的直接全部铲除。
  • 特点:分配极快,无碎片,但如果不加改良,内存浪费极其严重。
  • 适用场景:绝大多数对象都会死掉的场景(即 Java 堆的新生代)。

标记压缩法

标记-压缩算法(Mark-Compact),也称标记-整理算法,是结合了“标记-清除”和“复制”算法优点的一种综合性回收策略。

它旨在解决“标记-清除”算法带来的内存碎片问题,同时避免“复制”算法带来的内存空间浪费问题

1. 核心设计思路

该算法的执行过程分为两个截然不同的阶段。它的核心逻辑是:不仅要将垃圾清除,还要将剩余的存活对象“搬运”到一起,使它们在内存中紧凑排列。

2. 第一阶段:标记阶段 (The Marking Phase)

此阶段与“标记-清除”算法完全一致。

  • 过程:JVM 暂停应用程序(Stop The World),从 GC Roots 开始遍历对象引用图。
  • 动作:所有被引用的对象都会被识别,并在对象头中打上“存活”标记。
  • 结果:此时堆内存中呈现出存活对象与垃圾对象交替分布的离散状态。

3. 第二阶段:压缩(整理)阶段 (The Compacting Phase)

这是该算法最关键的步骤。与“标记-清除”算法直接回收垃圾不同,也与“复制”算法将对象移到另一块区域不同,标记-压缩算法是在当前区域内进行对象的移动。

  • 对象移动(Relocation)
    GC 将所有被标记为“存活”的对象,向内存空间的一端(通常是低地址端)移动。
  • 紧凑排列
    存活对象在移动后,会按顺序紧密靠拢,消除原来对象之间的空隙。
  • 引用修正(Reference Updating)
    由于对象的物理地址发生了改变,GC 必须同步更新所有指向这些对象的引用变量,将其指向新的物理地址。这是一个极其繁重且必须保证原子性的操作。
  • 边界清理
    当所有存活对象都移动并排列好之后,GC 会确定存活对象序列的边界(Boundary)。边界之后的所有内存空间(即原来的垃圾对象和空隙)被直接清理或标记为“空闲”。

4. 技术优势

标记-压缩算法在内存布局上达到了最优状态:

A. 消除了内存碎片 (No Fragmentation)

经过整理后,堆内存变为规整的连续块。这解决了“标记-清除”算法中大对象分配难的问题。

B. 内存利用率高 (High Utilization)

与“复制”算法相比,它不需要预留一半的 Survivor 区域(To-Space)。它可以在同一个区域内完成整理,因此内存的空间利用率可以达到 100%。

C. 支持高效分配 (Bump Pointer)

由于剩余的空闲内存是连续的,JVM 在分配新对象时,可以使用极快的**指针碰撞(Bump Pointer)**技术,只需移动空闲指针即可,无需维护空闲列表。

5. 核心缺陷与代价

虽然内存布局完美,但该算法的时间开销是最大的:

A. 极高的暂停时间 (Long Stop-The-World)
  • 移动成本:内存移动(Memory Copy)是 CPU 密集型操作。如果存活对象很多,搬运这些数据需要消耗大量时间。
  • 修正成本:更新引用地址需要遍历整个堆,进一步增加了暂停时间。
    在移动对象期间,应用程序必须全程暂停,无法运行。

6. 适用场景:老年代 (Old Generation)

基于上述特性,标记-压缩算法通常被用于 JVM 的老年代(Old Generation/Tenured)

  • 原因:老年代的对象存活率非常高(大部分对象长期存在)。

  • 如果用复制算法:需要复制大量对象,效率低,且没有额外的担保空间。

  • 如果用标记-清除:会产生大量碎片,不利于大对象(如长数组、大字符串)的存储。

  • 结论:虽然标记-压缩算法的暂停时间较长,但对于对象“死得少”且“不允许碎片”的老年代来说,它是最经济、最稳定的选择。

总结

标记-压缩算法采用了**“整理归纳”**的策略。

  • 逻辑:把活着的对象全部推到角落里排好队,剩下的空间一次性腾出来。
  • 特点:无碎片,无空间浪费,但对象移动的成本非常高(也就是 GC 停顿时间最长)。
  • 地位:它是以“时间换空间”的典型代表,是老年代垃圾回收的主流算法。

分代算法

分代收集算法(Generational Collection)严格来说并不是一种全新的垃圾回收算法,而是一种管理策略

它基于对程序运行特征的统计学分析,将堆内存划分成不同的区域,然后根据每个区域中对象的特性,选择最合适的回收算法(复制、标记-清除、标记-整理)。

这种策略的核心思想是:分而治之(Divide and Conquer)

1. 理论基石:弱分代假说 (Weak Generational Hypothesis)

分代算法的建立并非凭空想象,而是基于两个在绝大多数工业级程序中都成立的经验法则:

  1. 绝大多数对象都是“朝生夕死”的:统计表明,约 98% 的对象在创建后很快就会变得不可达(变成垃圾)。
  2. 熬过越多次垃圾回收的对象,越难消亡:如果一个对象已经存活了很久,那么它在未来大概率也会继续存活。

基于此,JVM 将堆内存划分为两个物理或逻辑上隔离的区域:新生代(Young Generation)老年代(Old Generation)

2. 内存分区与算法选择

A. 新生代 (Young Generation)
  • 对象特征
    这里是对象刚刚“出生”(分配内存)的地方。该区域的特点是对象存活率极低,垃圾回收频繁。
  • 采用算法复制算法(Copying)
  • 技术理由
    由于绝大多数对象都会死掉,存活下来的寥寥无几。
  1. 复制成本低:只需要将极少数存活对象复制到 Survivor 区,成本很小。
  2. 无碎片:复制算法天然保证内存规整,适合频繁的对象分配(TLAB, 指针碰撞)。
  3. 空间改良:为了解决复制算法浪费 50% 空间的问题,新生代通常被划分为Eden 区(80%)、Survivor 0 区(10%)和Survivor 1 区(10%)。每次只浪费 10% 的空间。
B. 老年代 (Old Generation)
  • 对象特征
    这里存放的是经过多次 GC 依然存活的“顽固”对象,或者是大对象。该区域的特点是对象存活率高,且没有额外的空间进行分配担保。
  • 采用算法标记-清除(Mark-Sweep)标记-整理(Mark-Compact)
  • 技术理由
  1. 无法复制:存活对象太多,如果用复制算法,复制成本过高,且没有第三块内存来分担风险。
  2. 必须紧凑:为了容纳更多长周期对象,必须保证空间利用率,因此主要使用标记-整理算法来压缩空间。

3. 对象的生命周期流转 (Object Promotion)

分代算法定义了对象在不同区域间的流动规则,这被称为对象晋升(Promotion)

  1. 分配(Allocation)
    绝大多数新对象在Eden 区分配。
  2. Minor GC(Young GC)
    当 Eden 区满时,触发 Minor GC。
  • Eden 和正在使用的 Survivor 区中,存活的对象会被复制到另一个空的 Survivor 区。
  • 对象的年龄(Age)加 1(记录在对象头中)。
  • Eden 区和原 Survivor 区被清空。
  1. 晋升(Tenuring)
    当对象的年龄达到阈值(默认为 15 岁),或者 Survivor 区空间不足以容纳所有存活对象时,这些对象会被移动到老年代
  2. Major GC / Full GC
    当老年代空间不足时,会触发 Major GC(或 Full GC)。此时会使用标记-整理/清除算法对老年代进行清理。如果清理后依然无法容纳新晋升的对象,就会抛出 OOM(Out Of Memory)错误。

4. 跨代引用问题与卡表 (Card Table)

这是一个为了实现分代算法必须解决的技术难题。

  • 问题:虽然我们将内存分成了两块,但老年代的对象完全可能引用新生代的对象。当进行 Minor GC(只回收新生代)时,为了确定新生代对象是否存活,是否需要扫描整个老年代来查找引用?这会极大地降低 Minor GC 的效率。
  • 解决方案卡表(Card Table)
    JVM 维护了一个数据结构叫卡表,将老年代内存划分为一个个小的内存块(Card)。
  • 如果老年代中某个对象引用了新生代对象,JVM 会通过**写屏障(Write Barrier)**技术,将该老年代对象对应的卡表标记为“脏(Dirty)”。
  • GC 时的优化:在进行 Minor GC 时,不需要扫描整个老年代,只需要扫描卡表中状态为“脏”的区域,就能快速找到跨代引用,作为 GC Roots 的一部分。

总结

分代收集算法并非一种单一的算法,而是一套组合拳

  • 新生代:利用“对象死得快”的特点,用复制算法以空间换时间,追求极致的回收速度。
  • 老年代:利用“对象死得慢”的特点,用标记-整理算法以时间换空间,追求存储效率和稳定性。

它是现代商业虚拟机(如 HotSpot)为了平衡吞吐量停顿时间而采用的标准解决方案。

分区算法

分区算法(Region-Based Collection),最为人熟知的实现是G1 (Garbage-First) 垃圾收集器,代表了垃圾回收技术从“连续分代”向“离散分区”的重大演进。

它彻底打破了传统分代算法中“新生代”和“老年代”在物理内存上必须连续隔离的限制,将堆内存重新布局。

1. 核心架构:棋盘式内存布局

在传统算法中,堆被物理划分为巨大的连续块(Eden, Survivor, Old)。而在分区算法中:

  • 离散化(Discretization)
    整个 Java 堆被划分为多个大小相等的独立区域,称为Region。JVM 启动时会自动设置 Region 的大小(通常为 1MB 到 32MB 之间,且为 2 的幂)。
  • 物理离散,逻辑分代
    虽然不再有物理隔离的大块区域,但每个 Region 依然会被动态地标记为属于不同的“逻辑代”:
  • Eden Region:存放新分配的对象。
  • Survivor Region:存放经过 GC 幸存的对象。
  • Old Region:存放老年代对象。
  • Humongous Region(巨型区域):这是分区算法特有的。如果一个对象的大小超过了 Region 容量的 50%,它会被直接存入巨型区域。如果对象特别大,可能会跨越多个连续的 Region。

2. 运作机制:化整为零

分区算法的核心不再是“一次回收整个新生代”或“一次回收整个老年代”,而是以 Region 为最小回收单元

A. 局部性回收 (Incremental Collection)

GC 不再必须扫描整个堆。它可以根据实际情况,只选择其中一部分最有回收价值的 Region 进行处理。这使得垃圾回收的停顿时间(Pause Time)变得可控。

B. 核心算法:复制与整理

在微观层面(Region 之间),该算法使用的是复制算法(Copying)

  • 过程:当回收某个 Region 时,将其中的存活对象复制到另一个空的 Region 中。
  • 结果:原 Region 被清空并回收。
  • 优势:这意味着在 Region 层面,它天然就是**压缩(Compact)**的,不会产生内存碎片。

3. 关键技术支撑

为了实现这种离散的回收,必须解决“跨 Region 引用”的问题(即回收 Region A 时,怎么知道 Region B 是否引用了它,而不用扫描 Region B?)。

A. Remembered Set (RSet) - 记忆集
  • 定义:每个 Region 都有一个对应的 RSet,用于记录“谁引用了我”。
  • 作用:当 Region A 中的对象引用了 Region B 中的对象时,JVM 会通过写屏障(Write Barrier)拦截该操作,并在 Region B 的 RSet 中记录下 Region A 的地址。
  • 价值:在回收 Region B 时,GC 只需要扫描 Region B 的 RSet,就能确定哪些对象是被外部引用的,而无需全堆扫描。
B. Collection Set (CSet) - 回收集合
  • 定义:在一次 GC 周期中,GC 会选定一组“需要被回收的 Region”,这组列表就是 CSet。
  • 策略:CSet 可以只包含 Eden Regions(Young GC),也可以包含 Eden + 部分 Old Regions(Mixed GC)。

4. 为什么叫“Garbage First” (G1)?

这是分区算法最核心的启发式调度策略

  • 价值评估
    JVM 会后台维护一个优先列表,跟踪每个 Region 中包含的垃圾数量(回收收益)以及回收该 Region 所需的时间(回收成本)。
  • 优先回收
    在给定的停顿时间限制下(例如用户设定 GC 停顿不能超过 200ms),GC 会优先选择那些垃圾占比最高、回收收益最大的 Region 加入 CSet。
  • 含义:即“垃圾优先”原则。这保证了在有限的时间内,获取尽可能高的收集效率。

5. 回收模式的演变

分区算法通常包含两种主要的回收模式:

  1. Young GC(年轻代回收)
  • 当 Eden Region 耗尽时触发。
  • 将所有 Eden Region 和 Survivor Region 中的存活对象,复制到新的 Survivor 或 Old Region 中。
  • 这是一个并行、独占式(Stop-The-World)的过程。
  1. Mixed GC(混合回收)
  • 这是分区算法的精髓。当老年代占用达到阈值时触发。
  • 回收范围包括:所有的 Young Regions+根据收益动态选取的“部分”Old Regions
  • 它不是 Full GC,它只是有计划地蚕食老年代的垃圾,避免堆内存被填满。

总结

**分区算法(G1)采用了“化整为零、按需调度”**的策略。

  • 逻辑:把堆切成小格子,每次只挑最脏的几个格子清理。
  • 特点
  1. 可预测的停顿:用户可以指定 GC 停顿时间,算法会自动调整回收的 Region 数量来满足。
  2. 无碎片:基于复制算法,内存天然规整。
  3. 内存占用较高:因为每个 Region 都要维护复杂的 RSet,会额外占用约 10%-20% 的堆内存空间。
  • 地位:它是对传统分代算法的颠覆性升级,是大内存(Large Heap)服务的标准选择。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询