![[Kv9ichJ.gif]]
这是”内存管理”系列的一部分
- [[揭开现代编程语言中内存管理的神秘面纱]]
- [[在JVM中可视化内存管理(Java、Kotlin、Scala、Groovy、Clojure)]]
- [[在V8引擎中可视化内存管理 (Javascript、NodeJS、Deno、WebAssembly)]]
- [[在Go语言中可视化内存管理]]
- [[在Rust中可视化内存管理]]
- [[避免NodeJS中的内存泄露:性能最佳实践]]
在这个多个部分的系列中,我旨在揭开内存管理背后的概念的神秘面纱,并深入了解一些现代编程语言中的内存管理。我希望这个系列能让您对这些语言在内存管理方面发生的事情有所了解。
在本章中,我们将了解 Java、Kotlin、Scala、Clojure、Groovy 等语言使用的 Java 虚拟机 (JVM) 的内存管理。
如果您还没有阅读本系列的第一部分,请先阅读它,因为我解释了堆栈和堆内存之间的区别,这将有助于理解本章。
JVM内存结构
首先,让我们看看JVM的内存结构是什么。这是基于 JDK 11 及更高版本的。以下是 JVM 进程可用的内存,由操作系统(OS)分配。
![[Pasted image 20220718163145.png]]
这是操作系统分配的本机内存,其数量取决于操作系统、处理器和 JRE。让我们看看不同区域的用途:
堆内存(Heap Memory)
这是JVM存储对象或动态数据的地方。 这是内存区域中最大的一块,也是垃圾收集(GC)发生的地方。 可以使用Xms(Initial)和Xmx(Max)标志控制堆内存的大小。 整个堆内存没有提交给虚拟机(VM),因为其中一些内存被保留为虚拟空间,堆可以增长以使用这些内存。 Heap又进一步划分为“Young”和“Old”代空间。
– 年轻代:年轻代或“新空间”是新对象生存的地方,并进一步分为“伊甸园空间”和“幸存者空间”。该空间由“Minor GC”管理,有时也称为“Young GC”
– 伊甸园空间:这是创建新对象的地方。当我们创建一个新对象时,这里分配了内存。
– 幸存者空间:这是存储在次要 GC 中幸存的对象的地方。这分为两半,S0 和 S1。
– 老年代:老年代或“Tenured Space”是在 Minor GC 期间达到最大期限阈值的对象存活的地方。这个空间由“Major GC”管理。
线程栈(Thread Stacks)
这是栈内存区域,进程中的每个线程都有一个栈内存。这是存储特定于线程的静态数据的地方,包括方法/函数帧和指向对象的指针。可以使用 Xss 标志设置堆栈内存限制。
元空间(Meta Space)
这是本机内存的一部分,默认情况下没有上限。这就是早期 JVM 版本中的永久代(PermGen)空间。类加载器使用这个空间来存储类定义。如果这个空间不断增长,操作系统可能会将存储在这里的数据从 RAM 移动到虚拟内存,这可能会降低应用程序的速度。为避免这种情况,可以对使用 XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 标志的元空间设置限制,在这种情况下,应用程序可能会抛出内存不足错误。
代码缓存(Code Cache)
这是 Just In Time (JIT) 编译器存储经常访问的已编译代码块的地方。通常,JVM 必须将字节码解释为本机机器码,而 JIT 编译的代码不需要解释,因为它已经是本机格式并缓存在这里。
共享库(Shared LIbraries)
这是存储使用的任何共享库的本机代码的地方。操作系统每个进程只加载一次。
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);
}
}
以下图片展示上述程序如何执行以及如何使用堆栈和堆内存:
![[Pasted image 20220718181752.png]]
![[Pasted image 20220718181801.png]]
![[Pasted image 20220718181807.png]]
![[Pasted image 20220718181813.png]]
![[Pasted image 20220718181819.png]]
![[Pasted image 20220718181826.png]]
![[Pasted image 20220718181832.png]]
![[Pasted image 20220718181838.png]]
![[Pasted image 20220718181845.png]]
![[Pasted image 20220718181852.png]]
![[Pasted image 20220718181858.png]]
![[Pasted image 20220718181903.png]]
![[Pasted image 20220718181909.png]]
![[Pasted image 20220718181915.png]]
![[Pasted image 20220718181921.png]]
![[Pasted image 20220718181927.png]]
![[Pasted image 20220718181934.png]]
![[Pasted image 20220718181940.png]]
如你所见:
- 每个函数调用都作为帧块添加到线程的堆栈内存中
- 所有局部变量,包括参数和返回值都保存在堆栈上的函数框架块中
- 所有原始类型(如 int)都直接存储在 Stack 中
- 所有对象类型,如 Employee、Integer、String 都是在堆上创建的,并使用堆栈指针从堆栈中引用。这也适用于静态字段
- 从当前函数调用的函数被压入栈顶
- 当一个函数返回时,它的帧被从堆栈中删除
- 主进程完成后,堆上的对象不再有来自堆栈的指针,并成为孤立对象
- 除非您显式地进行复制,否则其他对象中的所有对象引用都是使用指针完成的
如您所见,栈是由操作系统而不是 JVM 本身自动管理的。因此,我们不必太担心栈。另一方面,堆不是由操作系统自动管理的,因为它是最大的内存空间并保存动态数据,它可能会呈指数增长,导致我们的程序随着时间的推移耗尽内存。随着时间的推移,它也会变得支离破碎,从而减慢应用程序的速度。这就是 JVM 提供帮助的地方。它使用垃圾收集过程自动管理堆。
JVM 内存管理:垃圾回收
现在我们知道了 JVM 是如何分配内存的,让我们看看它是如何自动管理堆内存的,这对应用程序的性能非常重要。当一个程序试图在堆上分配比免费可用的更多的内存(取决于 Xmx 配置)时,我们会遇到内存不足的错误。
JVM 通过垃圾回收来管理堆内存。简单来说,它释放了孤儿对象使用的内存,即(i.e,) 不再直接或间接(通过另一个对象中的引用)从栈中引用的对象,以为创建新对象腾出空间。
![[Pasted image 20220718184505.png]]
JVM中的垃圾收集器负责:
– 从操作系统分配内存并返回到操作系统。
– 在应用程序请求时将分配的内存分发给应用程序。
– 确定已分配内存的哪些部分仍在由应用程序使用。
– 回收未使用的内存以供应用程序重用。
JVM 垃圾收集器是分代的(堆中的对象按其年龄分组并在不同阶段清除)。有许多不同的算法可用于垃圾收集,但 Mark & Sweep 是最常用的一种。
标记和清扫垃圾收集
JVM 使用在后台运行的单独守护线程进行垃圾收集,并且在满足某些条件时运行该进程。 Mark & Sweep GC 通常涉及两个阶段,有时根据所使用的算法还有一个可选的第三阶段。
![[AZaR0LP (1).gif]]
– Marking:垃圾收集器识别哪些对象正在使用和哪些未使用的第一步。正在使用或可从 GC 根(栈指针)递归访问的对象被标记为活动的。
– Sweeping:垃圾收集器遍历堆并删除任何未标记为活动的对象。这个空间现在被标记为空闲。
– Compacting:删除未使用的对象后,所有幸存的对象将被移动到一起。这将减少碎片并提高将内存分配给较新对象的性能
这种类型的 GC 也称为 stop-the-world GC,因为它们在执行 GC 时会在应用程序中引入暂停时间。
当涉及到 GC 时,JVM 提供了几种不同的算法可供选择,并且根据您使用的 JDK 供应商,可能有更多的选项可供选择(如 Shenandoah GC,可在 OpenJDK 上使用)。不同的实现侧重于不同的目标,例如:
– Throughput: 收集垃圾所花费的时间而不是应用程序时间会影响吞吐量。理想情况下,吞吐量应该很高(即当 GC 时间很短时)。
– Pause-time: GC 停止应用程序执行的持续时间。理想情况下,暂停时间应该非常短。
– Footprint: 使用的堆大小。理想情况下,这应该保持在较低水平。
自 JDK 11 起可用的收集器
从 JDK 11(即当前的 LTE 版本)开始,以下垃圾收集器可用,并且使用的默认值由 JVM 根据所使用的硬件和操作系统选择。我们也可以随时指定要与 -XX 开关一起使用的 GC。
- Serial Collector: 它使用单线程进行 GC,对于小数据集的应用程序效率很高,最适合单处理器机器。这可以使用
-XX:+UseSerialGC
开关启用。 - Parallel Collector: 这一个专注于高吞吐量,并使用多个线程来加速 GC 过程。这适用于在多线程/多处理器硬件上运行的中型到大型数据集的应用程序。这可以使用
-XX:+UseParallelGC
开关启用。 - Garbage-First(G1) Collector: G1 收集器大部分是并发的(意味着只有昂贵的工作同时完成)。这适用于具有大量内存的多处理器机器,并且在大多数现代机器和操作系统上默认启用。它专注于低暂停时间和高吞吐量。这可以使用
-XX:+UseG1GC
开关启用。 - Z Garbage Collector: 这是JDK11中引入的新的实验性GC。它是一个可扩展的低延迟收集器。它是并发的,不会停止应用程序线程的执行,因此不会停止世界。它适用于需要低延迟和/或使用非常大的堆(数 TB)的应用程序。这可以使用
-XX:+UseZGC
开关启用。
GC 进程
无论使用哪种收集器,JVM 都有两种类型的 GC 进程,具体取决于执行时间和地点,即次要 GC 和主要 GC。
次要 GC
这种类型的 GC 保持年轻代空间紧凑和干净。当满足以下条件时触发:
– JVM 无法从 Eden 空间中获取分配新对象所需的内存
最初,堆空间的所有区域都是空的。伊甸园内存是第一个被填充的,然后是幸存者空间,最后是终身空间。
让我们看一下minor GC过程:
![[Pasted image 20220718190125.png]]
![[Pasted image 20220718190133.png]]
![[Pasted image 20220718190141.png]]
![[Pasted image 20220718190149.png]]
![[Pasted image 20220718190156.png]]
![[Pasted image 20220718190203.png]]
![[Pasted image 20220718190211.png]]
![[Pasted image 20220718190219.png]]
![[Pasted image 20220718190234.png]]
![[Pasted image 20220718190243.png]]
![[Pasted image 20220718190251.png]]
![[Pasted image 20220718190302.png]]
- 让我们假设当我们开始的时候伊甸园空间(Eden space)已经有对象了(block 01到06标记为已使用内存)
- 应用程序创建一个新对象(07)
3. JVM试图从伊甸园空间获取所需的内存,但是伊甸园中没有空闲空间来容纳我们的对象,因此JVM触发了小型GC - GC递归地从堆栈指针开始遍历对象图,将使用的对象标记为活动的(已用内存),剩余的对象标记为垃圾(孤儿)
- JVM从S0和S1中随机选择一个块作为“To Space”,我们假设它是S0。 GC现在将所有活着的对象移动到“To Space”S0中,当我们开始时S0是空的,并将它们的年龄增加1。
- GC现在清空伊甸园空间,并在伊甸园空间中为新对象分配内存
- 让我们假设一段时间过去了,现在伊甸园空间上有更多的对象(block 07 – 13标记为已使用内存)
- 应用程序创建一个新对象(14)
- JVM试图从伊甸园空间获得所需的内存,但是伊甸园中没有空闲空间来容纳我们的对象,因此JVM触发了第二次次要GC
- 标记阶段是重复的,活着的/孤儿的物体被标记包括幸存者空间中的物体” To space ”
- 现在,JVM选择空闲的S1作为“To Space”,S0成为“From Space”。 GC现在将所有活着的对象从Eden空间和“from space”S0移动到“To space”S1中,S1在我们开始时是空的,并将它们的年龄增加1。 因为有些对象不适合这里,它们被移到“终身空间”,因为幸存者空间不能增长,这个过程被称为过早提升。 即使其中一个幸存者空间是空闲的,这种情况也会发生
- GC现在清空伊甸园空间和“From space”S0,新对象在伊甸园空间中分配内存
- 这种情况在每个小GC中持续重复,幸存者在S0和S1之间移动,他们的年龄增加。 一旦年龄达到“最大年龄阈值”(默认为15),对象将被移动到“保留区空间”。
所以我们看到了 Minor GC 如何从年轻代中回收空间。这是一个停止世界的过程,但它是如此之快,以至于在大多数情况下都可以忽略不计。
主要GC
这种类型的 GC 保持老年代(Tenured)空间紧凑和干净。当满足以下条件时触发:
– 开发者从程序中调用’ System.gc() ‘或’ Runtime.getRunTime().gc() ‘
– JVM决定没有足够的永久空间,因为它被小的GC周期填满了。
– 在次要GC期间,如果JVM不能从伊甸园或幸存者空间回收足够的内存。
– 如果我们为JVM设置了“MaxMetaspaceSize”选项,并且没有足够的空间来加载新类。
让我们看一下major GC过程,它没有minor GC那么复杂:
1. 让我们假设已经通过了许多次要GC周期,并且保留区空间几乎已满,JVM决定触发“主要GC”。
2. GC从堆栈指针开始递归地遍历对象图,将使用的对象标记为活动的(已用内存),并将剩余的对象标记为保留区空间中的垃圾(孤儿)。 如果主要GC在次要GC期间触发,则进程包括年轻(Eden & Survivor)和保留区空间
3. GC现在删除所有孤立对象并回收内存
4. 在一个主要的GC事件期间,如果堆中没有更多的对象,JVM也通过从元空间中删除加载的类从元空间回收内存,这也被称为完全GC
结论
这篇文章应该给你一个 JVM 内存结构和内存管理的概述。这并不是详尽无遗的,有许多更高级的概念和可用于特定用例的调整选项,您可以从 https://docs.oracle.com 了解它们。但是对于大多数 JVM(Java、Kotlin、Scala、Clojure、JRuby、Jython)开发人员来说,这一级别的信息就足够了,我希望它可以帮助您编写更好的代码,考虑到这些,对于更高性能的应用程序并牢记这些会帮助您避免可能遇到的下一个内存泄漏问题。 我希望您在学习 JVM 内部时玩得开心,请继续关注本系列的下一篇文章。
发表回复