揭秘 JVM 内存管理(转载) weir 2021-08-18 10:11:05.0 java,jvm 329 在这个由多个部分组成的系列中,我的目标是揭开内存管理背后的概念的神秘面纱,并深入研究一些现代编程语言中的内存管理,特别是 Java、Kotlin、Scala、Groovy 和 Clojure。 我希望这个系列能让你深入了解这些语言在内存管理方面发生了什么。在本章中,我们将了解Java、Kotlin、Scala、Clojure、Groovy 等语言使用的Java 虚拟机 (JVM)的内存管理。 如果您还没有阅读本系列的第一部分,请先阅读,因为我在那里解释了堆栈和堆内存之间的区别,这对理解本章很有帮助。 JVM内存结构 首先,让我们看看JVM的内存结构是什么。这是基于JDK 11以后的。以下是 JVM 进程可用的内存,由操作系统 (OS) 分配。 这是操作系统分配的本机内存,数量取决于操作系统、处理器和 JRE。让我们看看不同区域的用途: 堆内存 这是 JVM 存储对象或动态数据的地方。这是最大的内存块,也是垃圾收集(GC)发生的地方。可以使用Xms(Initial) 和Xmx(Max) 标志控制堆内存的大小。整个堆内存不会提交给虚拟机 (VM),因为其中一些被保留为虚拟空间,并且堆可以增长以使用它。堆进一步分为“年轻”和“老”代空间。 年轻代:年轻代或“新空间”是新对象居住的地方,并进一步分为“伊甸园空间”和“幸存者空间”。这个空间由“Minor GC”管理,有时也称为“Young GC” 伊甸空间:这是创建新对象的地方。当我们创建一个新对象时,内存是在这里分配的。 Survivor Space:这是存储在次要 GC 中幸存下来的对象的地方。这分为两半,S0和S1。 老年代:老年代或“Tenured Space”是在minor GC期间达到最大任期阈值的对象所在的地方。这个空间由“Major GC”管理。 线程栈 这是堆栈内存区域,进程中的每个线程都有一个堆栈内存。这是存储特定于线程的静态数据的地方,包括方法/函数框架和指向对象的指针。可以使用Xss标志设置堆栈内存限制。 元空间 这是本机内存的一部分,默认情况下没有上限。这就是早期 JVM 版本中的永久代(PermGen)空间。类加载器使用此空间来存储类定义。如果此空间不断增长,操作系统可能会将存储在此处的数据从 RAM 移动到虚拟内存,这可能会降低应用程序的速度。为了避免这种情况,可以对与XX:MetaspaceSize和-XX:MaxMetaspaceSize标志一起使用的元空间设置限制,在这种情况下,应用程序可能会抛出内存不足错误。 代码缓存 这是Just In Time (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); } } 单击幻灯片的标题或此处直接在 SpeakerDeck 中打开它。 如你看到的: 每个函数调用都作为帧块添加到线程的堆栈内存中 包括参数和返回值在内的所有局部变量都保存在堆栈上的函数框架块中 所有原始类型int都直接存储在堆栈中 所有对象类型,如Employee、Integer、String都是在堆上创建的,并使用堆栈指针从堆栈中引用。这也适用于静态字段 从当前函数调用的函数被压入栈顶 当函数返回时,它的帧从堆栈中移除 一旦主进程完成,堆上的对象就不再有来自堆栈的指针并成为孤儿 除非您明确进行复制,否则其他对象中的所有对象引用都是使用指针完成的 如您所见,堆栈是自动管理的,由操作系统而非 JVM 本身完成。因此我们不必太担心堆栈。另一方面,堆不是由操作系统自动管理的,因为它是最大的内存空间并保存动态数据,它可能会呈指数增长,导致我们的程序随着时间的推移耗尽内存。随着时间的推移,它也会变得碎片化,从而减慢应用程序的速度。这就是 JVM 的帮助所在。它使用垃圾收集过程自动管理堆。 JVM 内存管理:垃圾收集 现在我们知道了 JVM 是如何分配内存的,让我们看看它是如何自动管理堆内存的,这对应用程序的性能非常重要。当程序试图在堆上分配比免费可用的更多内存(取决于Xmx配置)时,我们会遇到内存不足错误。 JVM 通过垃圾回收来管理堆内存。简单来说,它释放孤儿对象使用的内存,即不再直接或间接(通过另一个对象中的引用)从堆栈引用的对象,为新对象的创建腾出空间。 JVM 中的垃圾收集器负责: 从操作系统分配内存并返回给操作系统。 在应用程序请求时将分配的内存分发给它。 确定应用程序仍在使用已分配内存的哪些部分。 回收未使用的内存以供应用程序重用。 JVM 垃圾收集器是分代的(堆中的对象按年龄分组并在不同阶段清除)。有许多不同的垃圾收集算法可用,但Mark & Sweep是最常用的一种。 标记和清除垃圾收集 JVM 使用在后台运行的单独守护线程进行垃圾收集,并且该进程在满足某些条件时运行。Mark & Sweep GC 通常涉及两个阶段,有时根据所使用的算法还有可选的第三阶段。 标记:第一步,垃圾收集器识别哪些对象正在使用中,哪些对象未使用。正在使用或可从 GC 根(堆栈指针)递归访问的对象被标记为活动的。 Sweeping:垃圾收集器遍历堆并删除任何未标记为活动的对象。这个空间现在被标记为空闲。 压缩:删除未使用的对象后,所有幸存的对象将被移动到一起。这将减少碎片并提高向新对象分配内存的性能 这种类型的 GC 也称为 stop-the-world GC,因为它们在执行 GC 时会在应用程序中引入暂停时间。 当涉及到 GC 时,JVM 提供了几种不同的算法可供选择,并且根据您使用的 JDK 供应商,可能还有更多可用的选项(例如Shenandoah GC,可在 OpenJDK 上使用)。不同的实现侧重于不同的目标,例如: 吞吐量:用于收集垃圾的时间而不是应用程序时间会影响吞吐量。理想情况下,吞吐量应该很高(即当 GC 时间很短时)。 暂停时间:GC 停止应用程序执行的持续时间。理想情况下,暂停时间应该非常低。 占用空间:使用的堆的大小。理想情况下,这应该保持在较低水平。 从 JDK 11 开始可用的收集器 从当前的 LTE 版本 JDK 11 开始,可以使用以下垃圾收集器,JVM 根据使用的硬件和操作系统选择默认使用的垃圾收集器。我们也可以始终指定要与-XX开关一起使用的 GC 。 Serial Collector:它使用单线程进行GC,对于小数据集的应用程序效率很高,最适合单处理器机器。这可以使用-XX:+UseSerialGC开关启用。 Parallel Collector:这个专注于高吞吐量并使用多个线程来加速 GC 过程。这适用于在多线程/多处理器硬件上运行的具有大中型数据集的应用程序。这可以使用-XX:+UseParallelGC开关启用。 垃圾优先(G1)收集器:G1 收集器主要是并发的(意味着只有昂贵的工作是并发完成的)。这适用于具有大量内存的多处理器机器,并且在大多数现代机器和操作系统上默认启用。它专注于低暂停时间和高吞吐量。这可以使用-XX:+UseG1GC开关启用。 Z 垃圾收集器:这是 JDK11 中引入的新的实验性 GC。它是一个可扩展的低延迟收集器。它是并发的,不会停止应用程序线程的执行,因此不会停止世界。它适用于需要低延迟和/或使用非常大的堆(数 TB)的应用程序。这可以使用-XX:+UseZGC开关启用。 GC过程 无论使用何种收集器,JVM 都有两种类型的 GC 过程,具体取决于其执行的时间和地点,即次要 GC 和主要 GC。 次要GC 这种类型的 GC 使年轻代空间保持紧凑和干净。当满足以下条件时触发: JVM 无法从 Eden 空间获取所需的内存来分配新对象 最初,堆空间的所有区域都是空的。伊甸记忆是第一个被填充的,其次是幸存者空间,最后是终身空间。 让我们看看minor GC过程: 单击幻灯片并使用箭头键向前/向后移动以查看过程: 请单击幻灯片的标题或此处直接在 SpeakerDeck 中打开它。 让我们假设我们开始时 Eden 空间上已经有对象(块 01 到 06 标记为已用内存) 应用程序创建一个新对象(07) JVM 尝试从 Eden 空间获取所需的内存,但 Eden 中没有可用空间来容纳我们的对象,因此 JVM 触发了次要 GC GC从堆栈指针开始递归遍历对象图,以标记用作活动的对象(已用内存)和剩余的对象为垃圾(孤儿) JVM 从 S0 和 S1 中随机选择一个块作为“To Space”,假设它是 S0。GC 现在将所有活着的对象移动到“To Space”,S0,当我们开始时它是空的,并将它们的年龄增加 1。 GC 现在清空 Eden 空间,新对象在 Eden 空间中分配内存 让我们假设一段时间过去了,现在 Eden 空间上有更多的对象(块 07 到 13 标记为已用内存) 应用程序创建一个新对象(14) JVM 尝试从 Eden 空间获取所需的内存,但 Eden 中没有可用空间来容纳我们的对象,因此 JVM 触发了第二次小 GC 重复标记阶段并标记存活/孤儿对象,包括幸存者空间“To Space”中的对象 JVM 现在选择空闲的 S1 作为“To Space”,S0 变成“From Space”。GC 现在将所有活动对象从 Eden 空间和“From Space”S0 移动到“To Space”S1,当我们开始时它是空的,并将它们的年龄增加 1。由于一些对象不适合这里,它们被移动到“Tenured Space”,因为幸存者空间不能增长,这个过程称为过早提升。即使幸存者空间之一是空闲的,这也可能发生 GC 现在清空 Eden 空间和“From Space”,S0,并且新对象在 Eden 空间中分配内存 对于每个小 GC,这会不断重复,并且幸存者在 S0 和 S1 之间移动,并且他们的年龄增加。一旦年龄达到“max-age阈值”,默认为15,对象就会被移动到“Tenured space” 所以我们看到了 Minor GC 如何从年轻代回收空间。这是一个停止世界的过程,但速度如此之快,以至于在大多数情况下可以忽略不计。 主要GC 这种类型的 GC 使老年代(Tenured)空间保持紧凑和干净。当满足以下条件时触发: 开发人员调用System.gc(),或Runtime.getRunTime().gc()从程序中调用。 JVM 决定没有足够的永久空间,因为它被次要 GC 周期填满。 在次要 GC 期间,如果 JVM 无法从 Eden 或幸存者空间回收足够的内存。 如果我们MaxMetaspaceSize为 JVM设置了一个选项并且没有足够的空间来加载新类。 让我们看看major GC的过程,它没有minor GC那么复杂: 让我们假设许多次要 GC 周期已经过去,tenured 空间几乎已满,JVM 决定触发“主要 GC” GC 从堆栈指针开始递归遍历对象图,以将使用的对象标记为活动(已用内存)并将剩余对象标记为终身空间中的垃圾(孤儿)。如果在次要 GC 期间触发了主要 GC,则该过程包括年轻的(伊甸园和幸存者)和终身的空间 GC 现在删除所有孤立对象并回收内存 在主要 GC 事件期间,如果堆中没有更多对象,JVM 也会通过从中删除加载的类从元空间回收内存,这也称为完全 GC 结论 这篇文章应该为您概述 JVM 内存结构和内存管理。这并不是详尽无遗的,有很多更高级的概念和调整选项可用于特定用例,您可以从https://docs.oracle.com了解它们。 但是对于大多数 JVM(Java、Kotlin、Scala、Clojure、JRuby、Jython)开发人员来说,这个级别的信息就足够了,我希望它可以帮助您编写更好的代码,考虑到这些,对于更高性能的应用程序并牢记这些帮助您避免下一个您可能会遇到的内存泄漏问题。 我希望您在学习 JVM 内部结构时玩得开心,请继续关注本系列的下一篇文章。 本文转自:https://foojay.io/today/demystifying-jvm-memory-management/