🚀 JVM 内存管理可视化(Java、Kotlin、Scala、Groovy、Clojure)JVM 内存结构 JVM 内存使用情况(栈 vs 堆)JVM 内存管理:垃圾回收 结论 参考

2025-05-24

🚀 可视化 JVM 中的内存管理(Java、Kotlin、Scala、Groovy、Clojure)

JVM内存结构

JVM 内存使用情况(堆栈与堆)

JVM内存管理:垃圾收集

结论

参考

最初发表于deepu.tech

在这个由多部分组成的系列文章中,我旨在揭开内存管理背后的概念,并深入探讨一些现代编程语言中的内存管理。我希望本系列文章能让您深入了解这些语言在内存管理方面的底层原理。在本章中,我们将探讨Java、Kotlin、Scala、Clojure、Groovy 等语言所使用的Java 虚拟机 (JVM)的内存管理。

如果您还没有阅读本系列的第一部分,请先阅读它,因为我在那里解释了堆栈和堆内存之间的区别,这将有助于理解本章。

JVM内存结构

首先,我们来看看 JVM 的内存结构。这是基于JDK 11及更高版本的。以下是 JVM 进程可用的内存,由操作系统 (OS) 分配。

JVM内存结构

这是操作系统分配的本机内存,其大小取决于操作系统、处理器和 JRE。让我们看看不同区域的用途:

堆内存

JVM 存储对象或动态数据的地方。这是最大的内存块,也是垃圾收集 (GC)Xms发生的地方。可以使用(Initial) 和(Max) 标志控制堆内存的大小Xmx。并非所有堆内存都分配给虚拟机 (VM),因为其中一部分被保留为虚拟空间,堆可以增长以利用这些空间。堆进一步分为“年轻代”“老年代”空间。

  • 年轻代:年轻代,又称“新生代空间”,是新生对象存放的地方,又分为“伊甸园空间”和“幸存者空间”。年轻代空间由“小型垃圾回收”(Minor GC)管理,有时也称为“年轻代垃圾回收”(Young GC)。
    • Eden 空间:这是新对象被创建的地方。当我们创建一个新对象时,会在这里分配内存。
    • 幸存者空间:这是存储在 Minor GC 中幸存的对象的地方。它被分为两部分,分别为 S0S1
  • 老生代:老生代,又称“永久空间”,是指在 Minor GC 期间达到最大永久空间阈值的对象所居住的空间。该空间由Major GC进行管理。

线程堆栈

这是堆栈内存区域,进程中每个线程都有一个堆栈内存。线程专有的静态数据(包括方法/函数框架和指向对象的指针)都存储在此。可以使用Xss标志设置堆栈内存限制。

元空间

这是本机内存的一部分,默认情况下没有上限。在早期版本的 JVM 中,这部分空间被称为永久代 (PermGen) 空间。类加载器使用此空间来存储类定义。如果此空间持续增长,操作系统可能会将存储在此处的数据从 RAM 移动到虚拟内存,从而降低应用程序的速度。为了避免这种情况,可以使用XX:MetaspaceSize-XX:MaxMetaspaceSize标志对元空间的使用设置限制,在这种情况下,应用程序可能会抛出内存不足的错误。

代码缓存

这是即时 (JIT)编译器存储经常访问的已编译代码块的地方。通常,JVM 需要将字节码解释为本机机器码,而 JIT 编译的代码无需解释,因为它已经是本机格式并缓存在这里。

共享库

这里存储了所有用到的共享库的本机代码。操作系统每个进程只加载一次。


JVM 内存使用情况(堆栈与堆)

现在我们已经清楚了内存是如何组织的,让我们看看在执行程序时如何使用其中最重要的部分。

让我们使用下面的 Java 程序,该代码未针对正确性进行优化,因此忽略不必要的中间变量、不正确的修饰符等问题,重点是可视化堆栈和堆内存使用情况。

class Employee {
    String name;
    Integer salary;
    Integer sales;
    Integer bonus;

    public Employee(String name, Integer salary, Integer sales) {
        this.name = name;
        this.salary = salary;
        this.sales = sales;
    }
}

public class Test {
    static int BONUS_PERCENTAGE = 10;

    static int getBonusPercentage(int salary) {
        int percentage = salary * BONUS_PERCENTAGE / 100;
        return percentage;
    }

    static int findEmployeeBonus(int salary, int noOfSales) {
        int bonusPercentage = getBonusPercentage(salary);
        int bonus = bonusPercentage * noOfSales;
        return bonus;
    }

    public static void main(String[] args) {
        Employee john = new Employee("John", 5000, 5);
        john.bonus = findEmployeeBonus(john.salary, john.sales);
        System.out.println(john.bonus);
    }
}
Enter fullscreen mode Exit fullscreen mode

单击幻灯片并使用箭头键向前/向后移动,查看上述程序如何执行以及如何使用堆栈和堆内存:

注意:如果幻灯片的边缘看起来被切断了,请单击幻灯片的标题或此处直接在 SpeakerDeck 中打开它。

如你看到的:

  • 每个函数调用都作为帧块添加到线程的堆栈内存中
  • 所有局部变量(包括参数和返回值)都保存在堆栈上的函数框架块内
  • 所有原始类型int都直接存储在堆栈中
  • 所有对象类型(例如EmployeeInteger)都是String在堆上创建的,并使用栈指针从栈中引用。这也适用于静态字段
  • 当前函数调用的函数被压入栈顶
  • 当函数返回时,其框架将从堆栈中删除
  • 一旦主进程完成,堆上的对象就不再有来自栈的指针,并变为孤儿
  • 除非明确进行复制,否则其他对象中的所有对象引用都是使用指针完成的

如您所见,堆栈是自动管理的,由操作系统而非 JVM 本身完成。因此,我们无需过多担心堆栈。另一方面,堆并非由操作系统自动管理,而且由于它是最大的内存空间,并且存储动态数据,因此它可能会呈指数级增长,导致程序随着时间的推移耗尽内存。随着时间的推移,堆还会变得碎片化,从而降低应用程序的运行速度。这时,JVM 就派上用场了。它使用垃圾回收机制自动管理堆。


JVM内存管理:垃圾收集

现在我们知道了 JVM 如何分配内存,让我们看看它是如何自动管理堆内存的,这对于应用程序的性能至关重要。当程序尝试在堆上分配超过可用内存(取决于Xmx配置)的内存时,就会遇到内存不足的错误

JVM 通过垃圾回收来管理堆内存。简单来说,它会释放孤立对象(即不再被栈直接或间接(通过其他对象中的引用)引用的对象)占用的内存,以便为新对象的创建腾出空间。

GC 根

JVM中的垃圾收集器负责:

  • 从操作系统分配内存并返回操作系统。
  • 根据应用程序的请求,将分配的内存分发给应用程序。
  • 确定分配的内存中哪些部分仍在被应用程序使用。
  • 回收未使用的内存以供应用程序重复使用。

JVM 垃圾收集器是分代的(堆中的对象按其年龄分组,并在不同阶段清除)。垃圾收集有许多不同的算法,但标记和清除是最常用的算法。

标记与清除垃圾收集

JVM 使用一个单独的守护线程在后台运行,用于垃圾收集,该进程在满足特定条件时运行。标记和清除 GC 通常包含两个阶段,有时还会根据所使用的算法可选地包含第三个阶段。

标记 & 清除 GC

  • 标记:第一步,垃圾收集器会识别哪些对象正在使用,哪些对象尚未使用。正在使用或可从 GC 根(堆栈指针)递归访问的对象将被标记为“活动”。
  • 清除:垃圾收集器遍历堆并删除所有未标记为活动的对象。该空间现在被标记为空闲。
  • 压缩:删除未使用的对象后,所有剩余的对象将被移动到一起。这将减少碎片,并提高向新对象分配内存的性能。

这种类型的 GC 也称为“停止世界 GC”,因为它们在执行 GC 时在应用程序中引入暂停时间。

JVM 提供了几种不同的 GC 算法可供选择,根据您使用的 JDK 供应商的不同,可能还会有更多可用的算法(例如OpenJDK 上的Shenandoah GC)。不同的实现侧重于不同的目标,例如:

  • 吞吐量:垃圾收集时间(而非应用程序时间)会影响吞吐量。理想情况下,吞吐量应该较高(即 GC 时间较短时)。
  • 暂停时间:GC 暂停应用程序执行的时长。理想情况下,暂停时间应该非常短。
  • 占用空间:使用的堆大小。理想情况下,该值应保持在较低水平。

JDK 11 中可用的收集器

从 JDK 11(即当前的 LTE 版本)开始,以下垃圾收集器可用,JVM 根据所使用的硬件和操作系统选择默认使用的 GC。我们也可以通过开关指定要使用的 GC -XX

  • 串行收集器:它使用单线程进行 GC,对于数据集较小的应用程序来说非常高效,并且最适合单处理器计算机。可以使用-XX:+UseSerialGC开关启用此功能。
  • 并行收集器:该收集器专注于高吞吐量,并使用多线程来加速 GC 进程。它适用于在多线程/多处理器硬件上运行的中大型数据集应用程序。可以使用开关启用该-XX:+UseParallelGC收集器。
  • 垃圾优先 (G1) 收集器:G1 收集器主要支持并发(意味着仅并发执行开销较大的工作)。该收集器适用于拥有大量内存的多处理器计算机,并且在大多数现代计算机和操作系统上默认启用。它专注于降低暂停时间和提高吞吐量。可以使用-XX:+UseG1GC开关启用此功能。
  • Z 垃圾收集器:这是 JDK11 中引入的全新实验性 GC。它是一款可扩展的低延迟收集器。它支持并发,并且不会停止应用程序线程的执行,因此不会造成 Stop-the-world 故障。它适用于需要低延迟和/或使用大量堆(数 TB)的应用程序。您可以使用-XX:+UseZGC开关启用此功能。

气相色谱过程

无论使用哪种收集器,JVM 都有两种类型的 GC 过程(取决于执行的时间和位置),即次要 GC 和主要 GC。

Minor GC

这种类型的 GC 使年轻代空间保持紧凑和干净。当满足以下条件时会触发此 GC:

  • JVM 无法从 Eden 空间获取分配新对象所需的内存

最初,堆空间的所有区域都是空的。Eden 区内存最先被填充,然后是 Survivor 区,最后是 Tenured 区。

我们来看一下minor GC的流程:

单击幻灯片并使用箭头键向前/向后移动以查看流程:

注意:如果幻灯片的边缘看起来被切断了,请单击幻灯片的标题或此处直接在 SpeakerDeck 中打开它。

  1. 让我们假设当我们启动时,Eden 空间上已经有对象(块 01 到 06 标记为已用内存)
  2. 应用程序创建一个新对象(07)
  3. JVM 尝试从 Eden 空间获取所需的内存,但 Eden 中没有可用空间来容纳我们的对象,因此 JVM 触发 minor GC
  4. GC 从堆栈指针开始递归遍历对象图,将使用的对象标记为活动对象(已用内存),将剩余的对象标记为垃圾(孤儿)
  5. JVM 从 S0 和 S1 中随机选择一个块作为“目标空间”,我们假设是 S0。GC 会将所有存活的对象移动到“目标空间”S0(启动时该空间为空),并将这些对象的年龄加 1。
  6. GC 现在清空 Eden 空间,并在 Eden 空间中为新对象分配内存
  7. 假设一段时间过去了,现在 Eden 空间中有更多的对象(块 07 到 13 标记为已用内存)
  8. 应用程序创建一个新对象(14)
  9. JVM 尝试从 Eden 空间获取所需的内存,但 Eden 中没有可用空间来容纳我们的对象,因此 JVM 触发第二次 Minor GC
  10. 重复标记阶段,并将存活/孤立对象(包括幸存者空间中的对象)标记为“至空间”
  11. JVM 现在选择空闲的 S1 作为“目标空间”,S0 变为“源空间”。GC 会将所有存活的对象从 Eden 区和“源空间”S0 移到“目标空间”S1(启动时为空),并将它们的年龄加一。由于有些对象无法容纳,它们会被移到“终身空间”,因为 Survivor 空间无法增长,这个过程被称为过早提升。即使 Survivor 空间中有一个空闲,这种情况也可能发生。
  12. GC 现在清空 Eden 空间和“From Space”S0,并在 Eden 空间中为新对象分配内存
  13. 每次 Minor GC 都会重复此过程,幸存者对象会在 S0 和 S1 之间移动,其年龄也会随之增加。一旦年龄达到“最大年龄阈值”(默认为 15),对象就会被移至“老年代空间”。

以上我们了解了 Minor GC 是如何从年轻代回收空间的。这是一个 Stop-the-world 的过程,但它非常快,大多数情况下可以忽略不计。

主要 GC

这种类型的 GC 使老生代(Tenured)空间保持紧凑和清洁。当满足以下条件时,会触发此操作:

  • 开发人员调用System.gc(),或者Runtime.getRunTime().gc()从程序中。
  • JVM 认为没有足够的永久空间,因为它已被小型 GC 循环填满。
  • 在次要 GC 期间,如果 JVM 无法从 Eden 或幸存者空间回收足够的内存。
  • 如果我们MaxMetaspaceSize为 JVM 设置一个选项,但没有足够的空间来加载新类。

我们来看看Major GC的流程,它不像Minor GC那么复杂:

  1. 假设已经经过了许多次要 GC 周期,并且老年代空间几乎已满,JVM 决定触发“主要 GC”
  2. GC 会从堆栈指针开始递归遍历对象图,将仍在使用的对象标记为“已用内存”,并将剩余的对象标记为“孤儿”对象,这些对象位于老年代空间中。如果主 GC 是在次要 GC 期间触发的,则该过程会同时处理年轻代(伊甸园和幸存者)和老年代空间。
  3. GC 现在删除了所有孤立对象并回收内存
  4. 在主要 GC 事件期间,如果堆中没有其他对象,JVM 也会从元空间中回收内存,方法是从中删除已加载的类,这也称为完整 GC

结论

本文旨在概述 JVM 内存结构和内存管理。但并非详尽无遗,针对特定用例,还有很多更高级的概念和调优选项,您可以从https://docs.oracle.com了解。但对于大多数 JVM(Java、Kotlin、Scala、Clojure、JR​​uby、Jython)开发人员来说,这种程度的信息已经足够了。我希望它能帮助您编写更好的代码,牢记这些内容,从而构建性能更高的应用程序。牢记这些内容将有助于您避免下次可能遇到的内存泄漏问题。

我希望您在学习 JVM 内部结构的过程中能获得乐趣,请继续关注本系列的下一篇文章。


参考


如果您喜欢这篇文章,请点赞或留言。

您可以在TwitterLinkedIn上关注我。

文章来源:https://dev.to/deepu105/visualizing-memory-management-in-jvm-java-kotlin-scala-groovy-clojure-19le
PREV
🚀 可视化 V8 引擎中的内存管理(JavaScript、NodeJS、Deno、WebAssembly)V8 内存结构 V8 内存使用情况(堆栈与堆)V8 内存管理:垃圾收集 结论 参考
NEXT
我的 VS Code 设置 - 充分利用 VS Code 插件终端设置结论