Java 虚拟机(JVM)管理着 Java 对象的生命周期。一旦程序员创建了一个对象,就不需要担心其后续的生命周期。JVM 会自动找到那些不再使用的对象,并从堆中回收它们所占用的内存。
Java 垃圾回收是什么?
垃圾回收(GC)是 JVM 执行的一项主要操作,优化它能显著提升应用程序的性能。现代 JVM 提供了多种垃圾回收算法,我们需要了解应用的需求来决定使用哪种算法。
在 Java 中,你无法像在 C/C++ 这样的非垃圾回收语言中那样编程性地释放对象。因此,在 Java 中不会出现悬挂引用。但是,可能会存在空引用(引用指向一块 JVM 永远不会存储对象的内存区域)。每当使用空引用时,JVM 就会抛出 NullPointerException
。
尽管由于垃圾回收的存在使得 Java 程序中的内存泄漏较为罕见,但仍然可能发生。我们将在本章末尾创建一个内存泄漏的例子。
垃圾回收器的种类
现代 JVM 使用以下几种垃圾回收器:
以上每种算法都执行相同的工作——找出不再使用的对象并回收它们在堆中所占的内存。一种简单的但天真的方法是计算每个对象的引用计数并在引用计数变为零时释放它(这也被称为引用计数)。为什么说这是天真的?考虑一个循环链表。每个节点都有对其它节点的引用,但整个对象没有从任何地方被引用,理想情况下应该被释放。
内存合并
JVM 不仅释放内存,还会将小块内存合并成更大的一块。这样做是为了防止内存碎片化。
简单来说,典型的垃圾回收算法执行以下活动:
GC 运行时需要停止应用程序线程。这是因为当 GC 运行时,它会移动对象的位置,因此这些对象在此期间不能被使用。这样的暂停被称为“全局暂停”,减少这些暂停的频率和持续时间是我们优化 GC 的目标。
下面是一个简单的内存合并演示:
阴影部分是需要释放的对象。即使在所有空间都被重新分配后,我们也只能分配最大为 75Kb 的对象。即便如此,我们仍有 200Kb 的自由空间:
垃圾回收中的代
大多数 JVM 将堆分为三代——年轻代(YG)、老年代(OG)以及永久代(也称为持久代)。
让我们看一个简单的例子。Java 中的 String
类是不可变的。这意味着每次你需要更改 String
对象的内容时,都必须创建一个新的对象。假设你在循环中对字符串修改了 1000 次,如下所示:
String str = "G11 GC";
for(int i = 0 ; i < 1000; i++) {
str = str + String.valueOf(i);
}
在每次循环中,我们创建一个新的字符串对象,而上一次迭代创建的字符串变得无用(即没有引用)。该对象的生命周期仅为一次迭代——它们很快就会被 GC 收集。这种短寿命的对象会被保存在堆的年轻代区域中。从年轻代收集对象的过程被称为小垃圾回收(minor garbage collection),并且总是会导致全局暂停。
小垃圾回收
随着年轻代被填满,GC 会执行一次小垃圾回收。无用的对象被丢弃,存活的对象被移到老年代。这个过程会导致应用线程暂停。
这里我们可以看到这样的代设计带来的好处。年轻代只是堆的一小部分,并且很快就会被填满。但处理它的时间远远少于处理整个堆所需的时间。因此,这种情况下全局暂停的时间更短,尽管可能更频繁。我们应该始终争取更短的暂停时间,即使它们更频繁。
完整垃圾回收
年轻代被分为两个空间——伊甸园(eden)和幸存者空间。在伊甸园中幸存下来的对象会被移动到幸存者空间,而在幸存者空间中继续存活的对象会被移动到老年代。年轻代在收集过程中会被压缩。
随着对象被移动到老年代,它最终也会被填满,并需要被收集和压缩。不同的算法对此采取不同的方法。有些算法会停止应用线程(这会导致较长的全局暂停,因为老年代相比年轻代要大得多),而有些算法则会在应用线程持续运行的同时并发地执行。这一过程被称为完整垃圾回收(full GC)。两种这样的收集器是 CMS 和 G1。
垃圾回收器调优
我们可以根据需要调整垃圾回收器。以下是我们可以根据情况配置的一些领域:
让我们详细了解每一点及其影响。我们还将讨论基于可用内存、CPU 配置和其他相关因素的建议。
堆大小分配
堆大小是 Java 应用程序性能的一个重要因素。如果太小,它会频繁填满,导致 GC 频繁执行。另一方面,如果我们增加堆的大小,虽然它不需要那么频繁地被收集,但暂停的时间长度会增加。
此外,增加堆大小会对底层操作系统产生严重的开销。操作系统通过分页技术让我们的应用程序看到比实际可用更多的内存。操作系统通过使用磁盘上的交换空间来管理这一点,将程序的不活跃部分复制到其中。当需要这些部分时,操作系统再从磁盘复制回内存。
假设一台机器有 8G 的内存,而 JVM 可以看到 16G 的虚拟内存,JVM 并不知道实际上系统只有 8G 的内存。它只会向操作系统请求 16G 的内存,一旦获得这些内存,它将继续使用。操作系统将不得不频繁地在磁盘和内存之间交换数据,这对系统来说是一个巨大的性能开销。
接着是完整 GC 期间发生的暂停。由于 GC 需要对整个堆进行收集和压缩,它将不得不等待大量虚拟内存从磁盘中交换出来。对于并发收集器而言,后台线程将不得不等待大量数据从交换空间复制到内存中。
所以,如何确定最优的堆大小就成了一个问题。第一条规则是永远不要向操作系统请求超过实际存在的内存。这完全避免了频繁交换的问题。如果机器上有多个运行的 JVM,那么所有 JVM 请求的总内存应该小于系统中的实际物理内存。
你可以通过以下两个标志来控制 JVM 请求的内存大小:
这两个标志的默认值取决于底层的操作系统。例如,对于运行在 MacOS 上的 64 位 JVM,-XmsN
为 64M,-XmxN
为最小的 1G 或总物理内存的四分之一。
需要注意的是,JVM 可以在这两个值之间自动调整。例如,如果它注意到发生了太多的 GC,它会一直增加内存大小,直到达到 -XmxN
,并且满足预期的性能目标为止。
如果你确切知道你的应用需要多少内存,那么可以设置 -XmsN = -XmxN
。在这种情况下,JVM 不需要确定“最优”的堆值,从而使 GC 过程变得更高效。
Java 中的代大小分配
你可以决定想要分配给年轻代(YG)多少堆空间,以及想要分配给老年代(OG)多少堆空间。这两个值都会影响到我们应用程序的性能。
如果年轻代的大小非常大,那么它将被较少地收集。这将导致较少的对象晋升到老年代。另一方面,如果你过多地增加老年代的大小,那么收集和压缩它将花费太多时间,这将导致长时间的 STW(Stop-The-World)暂停。因此,用户必须在这两个值之间找到平衡。
以下是你可以用来设置这些值的标志:
-
-XX:NewRatio=N
: 年轻代与老年代的比例(默认值=2)
-
-
-XX:MaxNewSize=N
: 年轻代的最大大小
-
-XmnN
: 使用此标志将 NewSize 和 MaxNewSize 设置为相同的值
年轻代的初始大小由 NewRatio 的值通过以下公式确定:
(总堆大小)/(newRatio + 1)
由于 newRatio 的初始值为 2,上述公式给出的年轻代初始值为总堆大小的 1/3。你总是可以通过明确指定年轻代的大小来覆盖这个值。NewSize 标志没有任何默认值,如果没有明确设置,年轻代的大小将继续使用上述公式进行计算。
永久代和元空间配置
永久代和元空间是 JVM 存放类元数据的堆区。在 Java 7 中,这部分空间被称为“永久代”,而在 Java 8 及之后版本中,它被称为“元空间”。这些信息被编译器和运行时所使用。
你可以使用以下标志来控制永久代的大小:-XX:PermSize=N
和 -XX:MaxPermSize=N
。元空间的大小可以使用 -XX:MetaspaceSize=N
和 -XX:MaxMetaspaceSize=N
来控制。
当标志值未设置时,永久代和元空间的管理方式有所不同。默认情况下,两者都有一个默认的初始大小。但是,元空间可以占据所需的尽可能多的堆空间,而永久代占据的空间不能超过默认的初始值。例如,64 位 JVM 具有 82M 的最大永久代大小。
请注意,由于元空间可以占据未指定数量的内存,可能会导致内存溢出错误。每当这些区域正在调整大小时,就会发生完整的垃圾回收。因此,在启动时,如果有很多类被加载,元空间可能会不断调整大小,从而导致每次都会发生完整的垃圾回收。因此,对于大型应用程序来说,如果初始元空间大小太小,启动时间会很长。增加初始大小是个好主意,因为它减少了启动时间。
尽管永久代和元空间持有类元数据,但这并不是永久性的,并且空间也会被垃圾收集器回收,就像对象一样。这通常发生在服务器应用程序中。每当向服务器进行新的部署时,旧的元数据需要被清理,因为新的类加载器现在需要空间。这个空间由垃圾收集器释放。