Java-内存管理-全-

Java 内存管理(全)

原文:zh.annas-archive.org/md5/3f85b308931766d8fe35b94d3ba2c698

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

理解 Java 内存的工作原理对你的 Java 编码和应用管理有很大的好处。它使得可视化对象组合中发生的事情以及对象分配和释放的情况变得更容易,结合对象组合。正如你可能已经知道的,对象组合是对象包含其他对象的地方。例如,Person类指定了一个类型为Address的属性,而Address也是一个类。了解这些在内存中是如何工作的,使得了解如何到达某个特定数据字段所需的步骤变得更容易。

此外,当你理解 Java 内存的工作原理时,static的概念以及使用this关键字访问实例将更容易可视化并完全理解。如果不理解 Java 内存的工作原理,就无法真正掌握staticthis的概念。

另一个理解 Java 内存的好处是,使用原始类型或类引用作为参数之间的区别突然变得更有意义。这也帮助理解对象的不可变性和可变性。

更复杂的话题也会更容易理解,例如并发,这是我个人最喜欢的一个。这是指在你的应用程序中同时发生多件事情(多线程)。你可能还没有与之合作过,但作为一个 Java 开发者,你总有一天会。理解 Java 内存使得理解并发的某些方面变得更容易,特别是数据访问。另一个将更容易理解的话题是垃圾回收。这对于性能至关重要,因为它是一个非常昂贵的进程,你希望尽可能少地需要它,并尽可能多地优化它。

当你更好地理解 Java 内存的工作原理时,你每天可能已经在使用的所有东西都会变得更加清晰。

本书面向的对象

这本书面向所有类型的 Java 专业人士。无论是初级还是高级开发者,DevOps 工程师,测试人员,还是 Java 应用程序的系统管理员,这都不重要。如果你目前对 Java 内存、垃圾回收和/或 JVM 调优没有深入的了解,这本书将帮助你将 Java 技能提升到新的水平。

本书涵盖的内容

第一章Java 内存的不同部分,涵盖了 Java 内存的不同部分:栈、堆和元空间。我们将从栈内存以及变量如何在栈上存储开始。然后,我们将继续处理对象以及它们如何在堆上存储。接下来,我们将简要讨论访问原始类型和对象。最后,我们将描述元空间及其用途。

第二章Java 内存中的基本类型和对象,聚焦于 Java 内存中的基本类型和对象。我们将更详细地处理堆和栈。通过使用可视化,我们将展示 Java 程序执行过程中栈和堆内存发生的情况。一旦内存管理的基本原理清晰,我们将更详细地处理对象引用。我们解释了当 Java 的按值调用机制应用于引用时,如何导致一个称为逃逸引用的安全问题。我们讨论了如何解决这个问题。

第三章聚焦堆空间,关注堆空间的不同部分。它有两个主要区域:年轻代空间和持久代空间。年轻代空间包含两个独立区域:eden 空间和幸存者空间。本章不会深入探讨垃圾收集过程,但我们会简要提及它以及它是如何解释对象在空间之间晋升的。我们将添加堆和不同区域的可视化,以提供有关堆空间详细信息的清晰度。本章的内容对于理解下一章将要讨论的垃圾收集算法是必要的。

第四章通过垃圾收集释放内存,深入探讨堆上对象的释放。为了使应用程序能够继续运行,释放内存是必要的。如果没有释放内存的能力,我们只能分配一次,最终会耗尽内存。在本章中,我们处理堆空间中的对象何时适合进行垃圾收集以及垃圾收集器经过哪些阶段。我们将以尽可能直观的方式结束对垃圾收集器不同实现的讨论。

第五章聚焦元空间,涉及元空间,它是 JVM 用于类元数据和例如静态变量的。这些元数据在类加载时被存储。我们将描述类加载过程以及内存的分配。释放元空间内存与释放堆内存略有不同。这个过程也将在此处描述。

第六章配置和监控 JVM 的内存管理,解释了如何开始 JVM 调优。首先,我们将描述 JVM 调优是什么以及谁需要它。有几个指标与 JVM 内存管理调优相关。我们将检查这些指标以及如何获取它们。最后,我们将结束实际的 JVM 配置调整和如何使用分析来深入了解调优效果。

第七章避免内存泄漏,讨论了如何有效地使用内存以及如何发现和解决内存泄漏。当内存中保留不再需要的对象时,我们就会得到内存泄漏。一开始,这可能看似无害,但久而久之,它将减慢应用程序的速度,并且应用程序需要重新启动才能正常工作。在本章中,我们将确保读者理解内存泄漏并知道如何发现它们。我们将以导致内存泄漏的非常常见的错误以及如何避免它们结束。

为了充分利用本书

本书假定使用 Java 8 或更高版本。不需要特定的操作系统或 IDE。如果您计划亲自运行示例,那么 VisualVM(用于监控 Java 应用程序内存的视觉工具)将很有用。

如果您目前系统上没有安装任何东西,以下设置就足够了:

  • JDK 8 或更高版本(Oracle 的 JDK 或 OpenJDK)

  • IntelliJ IDEA(社区版就足够了)或 Eclipse

  • VisualVM

本书涵盖的软件/硬件 操作系统要求
Java 8+ Windows、macOS 或 Linux

如果您正在使用这本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/B18762_Java-Memory-Management。如果代码有更新,它将在 GitHub 仓库中更新。

我们还从丰富的图书和视频目录中提供了其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/OeQqF

使用约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“例如,int x; 定义(创建)了一个原始变量 x,其类型为(原始类型)int。”

代码块设置如下:

Object o = new Object();
System.out.println(o);
o = null;

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

Object o = new Object();
System.out.println(o);
o = null;

任何命令行输入或输出都如下所示:

java.lang.Object@4617c264

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以粗体显示。以下是一个示例:“在创建按钮旁边有一个创建另一个选项。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果你对本书的任何方面有疑问,请通过电子邮件发送给我们,邮箱地址为 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这个错误。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果你在互联网上发现我们作品的任何形式的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送给我们,邮箱地址为 copyright@packt.com,并附上材料的链接。

如果你有兴趣成为作者:如果你在某个领域有专业知识,并且有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享你的想法

一旦你阅读了《Java 内存管理》,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。

你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

你喜欢在路上阅读,但无法携带你的印刷书籍到处走?

你的电子书购买是否与您选择的设备不兼容?

不要担心,现在,每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。

优惠不会就此停止,你还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取福利:

  1. 扫描下面的二维码或访问以下链接

https://packt.link/free-ebook/9781801812856

  1. 提交你的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件地址。

第一章:Java 内存的不同部分

你知道需要重启应用程序以提升该应用程序性能的现象吗?如果是的话,你可能已经体验到了糟糕的内存管理的结果:内存满载,应用程序变慢。但这并不总是应用程序变慢的原因——其他原因,如从服务器处理数据或网络瓶颈等,也起着作用——但内存管理问题是导致应用程序性能下降的常见嫌疑。

你可能之前在计算机科学领域听说过内存。这是有道理的,因为计算机有内存,它们在运行程序时使用这种内存来存储和访问数据(而这些程序本身也是数据!)。

那么,应用程序何时会使用内存呢?例如,假设你想运行一个将要处理大型视频文件的应用程序。如果你在打开活动监视应用程序(例如,macOS 上的活动监视器或 Windows 上的任务管理器)时这样做,你会发现一旦打开应用程序并加载视频,使用的内存就会增加。内存是计算机上的有限资源,一旦你的计算机内存耗尽,它就会变慢。

提高应用程序性能的方法有很多。深入了解这种内存究竟是如何工作的,是帮助你提高应用程序性能的途径之一。通过在编码中采用良好的实践来高效使用内存,将提升你应用程序的性能。因此,编写良好的代码并关注内存的工作方式,在内存管理方面实现高性能时,始终应该是首要的方法。还有另一种方式可以影响 Java 内存管理,那就是通过配置Java 虚拟机JVM),它负责管理Java 内存。当准备好时,我们将在第六章中介绍这一点。

高效管理 Java 内存对于 Java 应用程序的性能至关重要。在 Java 中,这尤其重要,因为它包含昂贵的进程,如垃圾回收,我们将在获得足够的基本知识来理解它之后稍后看到。

在并发环境中,内存管理对于数据完整性也很重要。不用担心,现在这听起来可能非常复杂。到这本书的结尾,你会明白这里的含义。

因此,为了优化我们应用程序的 Java 内存使用,我们首先需要了解这种内存的外观,并掌握与内存相关的基本过程。在本章中,我们将这样做。我们将探讨 Java 内存的不同部分以及我们如何在日常编码中使用它们。你将获得 Java 内存的良好概述,并为下一章即将到来的深入探讨做好准备。为此,我们将涵盖以下主题:

  • 理解计算机内存和 Java 内存

  • 在 Java 中创建变量

  • 在栈上存储变量

  • 在 Java 中创建对象

  • 在堆上存储对象

  • 探索元空间

技术要求

本章的代码可以在 GitHub 上找到,地址是 PacktPublishing/B18762_Java-Memory-Management。

理解计算机内存和 Java 内存

首先——运行应用程序,无论是 Java 还是其他,都需要计算机内存。应用程序的内存是计算机的物理内存。对计算机内存有更多的了解将有助于我们理解 Java 内存。因此,让我们更详细地讨论内存和 Java 内存的概念。

计算机内存

很可能你已经知道这一点,但为了重申:计算机有内存。这是计算机用于存储执行过程所需信息的部分。我们也将这称为主内存或有时称为主要存储。在这里要强调的一个重要观点是,这与计算机存储不同,在计算机存储中存储的是长期信息。这种存储是长期的,因为硬盘存储以磁性方式存储信息,而固态硬盘(SDD)可以被认为是电可擦可编程只读存储器EEPROM)。它们不需要持续供电来保持数据。另一方面,一种常见的主内存类型,随机存取存储器RAM),需要持续供电来保持数据。

这可以与我们的人类大脑相比较,至少部分如此。我们拥有长期和短期记忆。我们用长期记忆来存储我们的记忆——例如,一个珍贵的童年记忆,你的父亲推着你在一个手推车里嬉戏,而你的母亲在你穿着你三岁时最喜欢的衣服时引用了你最喜爱的故事书(神奇,其余的留给我自己的回忆录或治疗师吧)。然后是短期记忆,当你想要记住用于两步验证过程的六个数字时,它就非常棒了,如果你几分钟内无法回忆起来,那就更好了。

访问主内存

计算机或更确切地说,计算机的 CPU 可以比访问永久存储空间更快地访问主内存。在主内存中,当前打开的程序和它们正在使用的数据被存储。

你可能还记得你第一次启动计算机并打开你当天首次使用的应用程序,意识到它需要几秒钟才能启动。如果你关闭它,也许是不小心,然后立即再次打开它,它就快得多。主内存就像某种缓存或缓冲区,这解释了第二次加载时间更短的现象。第二次,它可以从主内存而不是从存储中打开,这证明了,至少支持了这样一个观点,即主内存更快。

好消息是,你不需要了解计算机内存的每一个最细微的细节,但一个大致的概述会有所帮助。

主存储器概述

主存储器中最常见的一部分是 RAM。RAM 是决定计算机性能的一个重要因素。正在运行或活跃的应用程序需要 RAM 来存储和访问数据。这种内存可以被应用程序和进程非常快速地访问。如果可用足够的 RAM,并且操作系统(OS)在管理 RAM 方面做得很好,那么你的应用程序将实现其性能潜力。

你可以通过查看你的监控应用程序来查看可用的 RAM 数量。对我来说,那就是活动监视器。如图所示,我的计算机目前正在使用相当多的内存:

图 1.1 – macOS 12.5 上活动监视器的截图

图 1.1 – macOS 12.5 上活动监视器的截图

我已经按照从高内存到低内存的顺序对进程进行了排序。在底部,你可以看到可用内存和已用内存的摘要。说实话,这看起来有点高,我可能应该在写完这一章后调查一下。

为什么我还要调查这个,如果我还剩下很多内存可用呢?嗯,如果 RAM 太满,正在运行的应用程序只能非常缓慢地执行。这可能是你已经体验过的事情,当你运行了比你的计算机规格允许的更多或更重的应用程序时。

RAM 是易失性的。这意味着当你关闭电源时,信息就会消失。主存储器不仅包括 RAM。只读存储器ROM)也是主存储器的一部分,但它不是易失性的。它包含计算机启动所需的指令,所以幸运的是,当我们关闭电源时,这些指令不会消失!

有趣的事实

我们把主存储器称为 RAM,这是一个非常常见的术语,但现在你知道它在技术上是不正确的!这确实是一个有趣的事实。

Java 内存和 JVM

你可能会想知道我们是否还会涵盖 Java 内存 – 是的,我们会!Java 内存与计算机的内存模型有些相似,但也不同。然而,在我们谈论 Java 内存之前,我需要解释 JVM 是什么。我必须说,我真的很感激你的耐心。

JVM

JVM 执行 Java 应用程序。这意味着 JVM 理解 Java 吗?不,完全不是!它理解字节码 – .class 文件。这意味着编译后的 Java 程序。一些其他语言的代码,例如 Kotlin,也被编译成 JVM 字节码,因此也可以被 JVM 解释。这就是为什么它们有时被称为 JVM 语言,例如 Java、Kotlin 和 Scala 等。

步骤可以在 图 1.2 中看到:

图 1.2 – 编译一次,到处运行

图 1.2 – 编译一次,到处运行

源代码(在图中,我们假设这是 Java 源代码)由 Java 编译器编译。结果是包含字节码的.class文件。这种字节码可以被 JVM 解释。每个平台,无论是 macOS、Windows 还是 Linux,都有自己的 JVM 版本来执行字节码。这意味着应用程序不需要修改就可以在不同的环境中运行,因为特定平台的 JVM 会处理这一点。

JVM 实际上是 Java 曾经因其一次编写,到处运行原则而闻名和受到喜爱的原因。Java 不再因此闻名的原因是,如今对于语言来说,以这种方式工作似乎是正常的。任何安装了 JVM 的平台都可以运行 Java,因为 JVM 负责将其转换为运行平台上的机器代码。

我通常将此比作旅行插头适配器。插头无法全球通用,因为不同地区使用不同的插座。当你拥有正确的旅行适配器插头时,无论你在哪里,都可以使用自己的适配器。在这种情况下,旅行适配器就是 JVM。“无论你在哪里”就是你试图在上面运行 Java 的平台,而你的适配器就是你的 Java 程序。

让我们看看 JVM 处理内存管理的基本方法。

内存管理和 JVM

Java 内存存储了运行 Java 应用程序所需的数据。在 Java 应用程序中存在的所有类的实例都存储在 Java 内存中。这也适用于原始值。那么常量呢?它们也存储在 Java 内存中!至于方法代码、本地方法、字段数据、方法数据以及方法的执行顺序呢?你可能猜得到,它们都存储在 Java 内存中!

JVM 的一项任务是管理 Java 内存。没有这种内存管理,就无法分配内存,也无法存储对象。即使这部分已经到位,它也永远不会被清理。因此,清理内存,也称为对象的释放分配,对于运行 Java 代码非常重要。没有它,代码无法运行,或者如果只是分配了,它将填满并导致程序内存不足。具体是如何工作的,我们将在讨论释放过程——称为垃圾回收时学习,这将在第四章中介绍。

简而言之:内存管理很重要。它是 JVM 非常重要的任务之一。实际上,如今,我们似乎理所当然地认为自动内存管理是理所当然的,但在它的早期,这非常新颖且特别。让我们看看如果 JVM 没有为我们管理内存会发生什么。

Java 之前的内存管理

automatic garbage collection is and in no way a complete guide to how to do memory allocation in C – there’s a lot more to it:
int* x;
x = (int*)malloc(4 * sizeof(int));

int*表示x持有指向内存块基址的指针的值。

malloc,代表内存分配,是一个用于分配指定大小的内存块的函数。在这种情况下,指定的大小是int大小的四倍。该函数返回基本地址。

如果我们想要分配一个值给内存分配,我们需要通过使用*x来这样做——否则,我们将覆盖位置:

*x = 5;
printf("Our value: %d\n", *x);
5 to the memory location that x is pointing to. So, if we then go ahead and print the value stored in that location (*x), we will see the value of 5. It’s just x, not *x, that is the memory location.

当我们不再需要*x来持有内存位置时,我们需要手动释放内存。如果我们不这样做,内存将变得不可用,并且不必要地被消耗。以下是我们如何释放内存的方法:

free(x);
x = NULL;

我们使用free函数使内存再次可用,以便在请求另一块内存时可以重新分配。那么我们就完成了吗?不,我们还没有。我们仍然持有该内存位置的指针。由于内存位置现在已被释放,它可能被其他内容覆盖,而我们不知道在那个点存储了什么。因此,我们将我们的指针设置为NULL

那么,在释放内存后,x将指向什么?嗯,正是同一个地址——但里面是什么?这是不确定的。根据释放的方式,它可能是空的,或者如果尚未被覆盖,则是旧值,但一旦被覆盖,它将变成被覆盖的内容。换句话说:一个巨大的惊喜!当然,我通常喜欢惊喜,但通常不是在代码中变量的值方面。

在手动进行内存管理时,这里有一些常见的问题:

  • 释放内存地址后的NULL

  • 内存泄漏:这是当你没有释放不再需要的内存时发生的情况。它不会再次变得可用,并且不必要地保持阻塞。最终,你可能因为保留所有不需要的值而耗尽内存。

  • 样板代码:在代码库中,你有很多处理分配和释放的代码,但与你的业务逻辑关系不大。所有这些代码都需要维护。

  • 例如,NULL

还有其他常见的陷阱,但我相信这已经足够让我们欣赏 JVM 及其垃圾收集器和自动分配了。让我们看看 JVM 是如何实现所有这些功能的。

理解 JVM 的内存管理组件

为了能够执行应用程序,JVM 大致有三个组件。一个是用来加载所有类的类加载器。这实际上是一个复杂的过程;类被加载,字节码被验证。类的加载和字节码的执行需要内存。这部分内存用于存储类数据、内存分配以及正在执行的指令。这就是运行时数据区组件的作用。这是本书的主要内容:Java 内存。当类被加载时,文件需要被执行。在主内存中使用前两个组件加载字节码后执行的字节码通常被称为执行引擎。执行引擎与Java 本地接口JNI)交互,以使用执行字节码所需的本地库。这些过程以及它们之间的步骤在图 1.3中展示:

图 1.3 – 应用程序执行时 JVM 组件的概述

图 1.3 – 应用程序执行时 JVM 组件的概述

现在我们已经知道了内存大致由哪些元素组成,让我们更详细地探讨内存管理最重要的组成部分:运行时数据区。

运行时数据区

这里是 JVM 的运行时数据区:

  • 方法区/元空间

  • 运行时常量池

  • 程序计数器寄存器

  • 本地方法栈

Java 内存的不同部分在图 1.4中展示:

图 1.4 – 不同运行时区域的概述

图 1.4 – 不同运行时区域的概述

内存由图中所示的不同部分组成。所有这些部分都是 Java 应用程序运行所需的。让我们详细地查看这些内存部分的每一个。

当 JVM 启动时,它会为 Java 应用程序在 RAM 中预留一块用于动态内存分配的空间。这块内存被称为堆。这是存储运行时数据的地方。类实例可以在堆上找到。JVM 负责为堆分配空间,并通过垃圾回收过程清理它。分配空间也称为分配,再次释放这块空间也称为释放。堆上对象的释放由 JVM 的垃圾回收过程处理。垃圾回收与堆的不同区域协同工作。这些不同的区域和垃圾回收是非常有趣的话题,我们将在第三章第四章中更详细地讨论。

栈,或者更准确地说,JVM 栈,是存储原语和堆指针的地方。对于每个被调用的方法,栈上都会创建一个帧,这个帧也持有这个方法的价值,例如部分结果和返回值。

不仅仅有一个栈。应用程序中的每个线程都有自己的线程。这可以在 图 1.5 中看到:

图 1.5 – 包含每个线程栈的栈区域

图 1.5 – 包含每个线程栈的栈区域

线程是执行路径。当一个应用程序有多个线程时,这意味着同时发生了多件事情。在应用程序中同时发生的事情是一个非常重要的概念,称为 并发

这意味着内存的栈区域实际上包含了很多栈——每个线程一个。线程只能访问自己的栈,栈之间不能有链接。

因此,栈存储了方法执行所需的所有值,每个线程都有自己的栈。我们将要查看的下一个运行时数据区域是方法区。

方法区(Metaspace)

方法区是存储类运行时表示的地方。方法区包含运行时代码、静态变量、常量池和构造函数代码。总结一下:这是存储类元数据的地方。所有线程共享这个方法区。JVM 只指定了方法区,但我们自 Java 8 以来所处理的是称为 Metaspace 的实现。这个区域的老名字是 PermGen(即,永久生成空间)。实际上,PermGen 和 Metaspace 之间也有一些差异,但这些有趣的细节留待以后讨论。难道我们不喜欢一个好的悬念吗?

PC 寄存器

程序计数器PC)寄存器通过持有正在执行的指令的地址来知道正在执行什么代码。在 图 1.6 中,你可以看到这个的描述:

图 1.6 – 包含每个线程寄存器的 PC 寄存器

图 1.6 – 包含每个线程寄存器的 PC 寄存器

每个线程都有自己的 PC 寄存器,有时也称为调用栈。它知道需要执行的语句序列以及当前正在执行的语句。这就是为什么我们需要为每个线程单独一个的原因——只有一个 PC 寄存器,我们无法同时执行多个线程!

这与栈区域类似,正如你在比较 图 1.5 和 图 1.6 时可以看到的那样。

本地方法栈

此外,还有一个本地方法堆栈,也称为C 堆栈。它用于执行本地代码。本地代码是未用 Java 编写的实现的一部分,例如 C。这些堆栈存储本地代码的值,就像 JVM 堆栈为 Java 代码做的那样。同样,每个线程都有自己的。这些是如何实现的取决于 JVM 的具体实现。一些 JVM 不支持本地代码;显然,它们也不需要本地堆栈。这可以在你使用的 JVM 的文档中找到。

通过这种方式,我们已经更详细地了解了 Java 运行时数据区的不同部分。很可能在这一点上,大量的新信息被抛给了你,这可能会很困难!在我们继续之前,让我解释一下我们为什么要了解这些。

到目前为止,你可能想知道我还在等待什么,迫不及待地想要开始——你是对的!在本章中,我们将更详细地讨论内存管理的基础、堆栈和堆内存以及元空间,但首先,我们需要看看如何在 Java 中创建变量。

在 Java 中创建变量

在 Java 中创建变量意味着我们必须声明一个变量。如果我们还想使用它,我们必须初始化它。正如你很可能知道的,声明是将类型和名称分配给变量的过程。初始化是给变量赋予实际值:

int number = 3;
char letter = 'z';

在这里,我们声明变量并在同一行上初始化它。我们使用类型和名称来声明它。这里的类型是intchar,变量名是numberletter。这也可以像以下这样分多行进行:

double percentage;
percentage = 8.6;

JVM 不再检查类型了——这是在运行应用程序之前由编译器完成的。实际上,原始类型和引用类型的存储之间是有区别的。这正是我们现在要探讨的。

原始类型和引用类型

JVM 处理两种类型的变量:原始类型和引用类型。Java 中有八种原始类型:

  • int

  • byte

  • short

  • long

  • float

  • double

  • boolean

  • char

原始类型仅存储值,并且限于八种类型。还有引用类型。引用类型是类的实例。你可以创建自己的类。因此,引用类型的数量实际上是没有限制的。

当你创建变量时,它们可以存储两种类型的值:原始值和引用值。原始值具有原始类型之一。引用值持有对象位置的指针。

参考文献有四种类型:

  • 类引用

  • 数组引用

  • 接口引用

  • null

类引用类型持有(动态)创建的类对象。数组引用类型有一个组件类型。这是数组的类型。如果组件类型不是数组类型,则称为元素类型。数组引用始终具有单个维度,但组件类型可以是另一个数组,从而创建多维数组。数组的维度有多少并不重要;最后一个组件类型不是数组类型,因此是元素类型。这种元素类型可以是以下三种类型之一:原始类型、类或接口。

null是引用没有指向任何内容的特殊情况。此时,引用的值是null

这些变量是如何存储的?原始类型和引用变量存储在堆栈上。实际对象存储在堆上。让我们首先看看如何在堆栈上存储变量。

在堆栈上存储变量

在方法中使用的变量存储在堆栈上。堆栈内存是用于执行方法的内存。在图 1.7中,我们展示了三个线程的堆栈区域,每个区域包含多个帧。

图 1.7 – 三线程堆栈区域的帧概述

图 1.7 – 三线程堆栈区域的帧概述

在方法内部,存在原始类型和引用。应用程序中的每个线程都有自己的堆栈。堆栈由帧组成。每个被调用的方法都会在堆栈上带来一个新的帧。当方法执行完成后,帧会被移除。

如果堆栈内存太小,无法存储帧所需的内容,则会抛出StackOverFlowError。当为新线程分配新的堆栈空间不足时,会抛出OutOfMemoryError。当前线程正在执行的方法称为当前方法,其数据存储在当前帧中。

当前帧和当前方法

堆栈之所以被称为堆栈,是因为它只能访问堆栈的顶部帧。你可以将其比作盘子堆,你只能(安全地)从顶部取盘子。顶部帧称为当前帧,因为它属于当前方法——当时正在执行的方法。

如果正在执行的方法调用另一个方法,则会在帧的顶部放置一个新的帧。这个新帧成为当前帧,因为新调用的方法是当前正在执行的方法。

图 1.7中,有三个当前帧,因为有三个线程。当前帧位于顶部。所以,让我们看看以下内容:

  • 方法 y 的帧是为线程 1准备的

  • 方法 c 的帧是为线程 2准备的

  • 方法 k 的帧是为线程 3准备的

当方法被执行时,它被移除。然后,之前的框架再次成为当前框架,因为调用其他方法的方法是暂时获得控制权的方法,也是当时正在执行的方法(当前方法)。

框架元素

一个框架包含许多元素。这些元素用于存储方法执行所需的所有必要数据。所有元素的概述可以在 图 1**.8 中看到:

图 1.8 – 栈帧的示意图

图 1.8 – 栈帧的示意图

如您所见,一个框架包含局部变量数组、操作数栈和框架数据。让我们更详细地探讨框架的各个单独元素。

局部变量数组

框架的局部变量存储在一个数组中。这个数组的长度在编译时设置。该数组有单倍和双倍位置。单倍位置用于 intshortcharfloatbytebooleanreference 类型。双倍位置用于 longdouble(它们的大小为 64 位)。

可以通过索引访问局部变量。有两种类型的方法:static 方法(类方法)和 instance 方法。对于这些 instance 方法,局部变量数组的第一元素始终是它们存在的对象的引用,也称为 this。传递给方法的参数从局部变量数组的索引 1 开始。

对于 static 方法,不需要向框架提供实例,因此它们从索引 0 处开始使用调用它们的参数。

操作数栈

这个概念可能有点粗糙,请耐心听我说。每个栈帧都有一个操作数栈——一个位于栈元素(框架)上的栈(操作数栈)——这个操作数栈用于写入操作数,以便它们可以被操作。这就是所有值飞来飞去的地方。

它需要举一个例子,所以让我们看看一个例子。当框架被新创建时,操作数栈上没有任何东西,但让我们假设创建框架的方法将要执行一个基本的数学运算,比如添加 xy

xy 是局部变量,它们的值在前面提到的局部变量数组中。为了进行操作,它们的值需要推送到操作数栈——所以,x 的值将被首先推送,y 的值将被其次推送。

操作数栈是一个栈,所以当它需要访问变量时,它只能从栈顶获取它们。它首先弹出 y,然后弹出 x。之后,操作数栈再次为空。正在执行的操作知道弹出变量的顺序。操作完成后,结果被推送到操作数栈,并可以从那里弹出。

操作数栈也用于其他重要操作,例如准备需要作为输入发送到方法中的参数,以及接收方法返回的结果。

帧数据

帧数据由执行方法所需的各种数据组成。一些例子包括对常量池的引用、如何正常返回方法以及突然完成的方法(或异常)。

其中第一个,对常量池的引用,需要特别注意。类文件包含所有需要在运行时常量池中解析的符号引用。这个池包含运行类所需的所有常量,并且由编译器生成。它包含类中标识符的名称,JVM 在运行时使用这个文件将类链接到其他类。

每个帧在运行时都有一个对当前方法常量池的引用。由于这是一个具有符号引用的运行时常量池,链接需要动态发生。

让我们看看我们的愚蠢的Example类的常量池是什么样的。以下是我们的Example类的代码:

package chapter1;
public class Example {
    public static void main(String[] args) {
        int number = 3;
        char letter = 'z';
        double percentage;
        percentage = 8.6;
    }
}

通过运行以下命令(在我们用javac Example.java编译之后),我们可以看到常量池:

javap -v Example.class

在这里,你可以看到输出结果:

Classfile /Users/maaikevanputten/Documents/packt/memorymanagement/src/main/java/chapter1/Example.class
  Last modified 12 Jun 2022; size 298 bytes
  SHA-256 checksum b2a6321e598c50c5d97ba053ca0faf689197df18c5141b727603 eaec0fecac3e
  Compiled from "Example.java"
public class chapter1.Example
  minor version: 0
  major version: 61
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #9                          // chapter1/Example
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Double             8.6d
   #9 = Class              #10            // chapter1/Example
  #10 = Utf8               chapter1/Example
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               main
  #14 = Utf8               ([Ljava/lang/String;)V
  #15 = Utf8               SourceFile
  #16 = Utf8               Example.java
{
  public chapter1.Example();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=5, args_size=1
         0: iconst_3
         1: istore_1
         2: bipush        122
         4: istore_2
         5: ldc2_w        #7                  // double 8.6d
         8: dstore_3
         9: return
      LineNumberTable:
        line 5: 0
        line 6: 2
        line 8: 5
        line 9: 9
}
SourceFile: "Example.java"

如你所见,常量池有 16 个条目。这些是我们创建的,但也有一些是 Java 创建的。它们是执行程序所需的,因此程序名称、方法等都在常量池中创建以运行程序。

栈上的值

原始类型的局部变量值直接存储在栈上——更准确地说,在局部变量所在的方法帧的数组上。对象不存储在栈上。相反,对象引用存储在栈上。对象引用是在堆上找到对象的地址。

原始类型和包装类

请注意不要混淆原始类型及其对象包装类。它们很容易通过类型是否为大写字母来识别。包装类对象不生活在栈上,仅仅因为它们是对象。每当一个方法被执行,相关的原始值的栈就会被清理,它们将永远消失。

一些包装类比其他包装类更容易识别。让我们看看一个代码片段:

int primitiveInt = 2;
Integer wrapperInt = 2;
char primitiveChar = 'A';
Character wrapperChar = 'A';

如你所见,包装器以大写字母开头且更长。然而,对于许多类型,单词本身完全相同,唯一的区别是它以大写字母开头。我个人最常被Booleanboolean(我为此责怪 C#,因为 C#中 Java boolean原语等价的是bool)所迷惑。

在这里,你可以看到其他原始类型及其引用类型之间的区别:

short primitiveShort = 15;
Short wrapperShort = 15;
long primitiveLong = 8L;
Long wrapperLong = 8L;
double primitiveDouble = 3.4;
Double wrapperDouble = 3.4;
float primitiveFloat = 5.6f;
Float wrapperFloat = 5.6f;
boolean primitiveBoolean = true;
Boolean wrapperBoolean = true;
byte primitiveByte = 0;
Byte wrapperByte = 0;

请注意,它们的名称完全相同。我们需要查看第一个字母来区分包装类和原始类型。包装类是对象,它们的创建方式不同。让我们找出如何创建它们。

在 Java 中创建对象

对象是一组值的集合。在 Java 中,它们可以通过使用new关键字实例化类来创建。

这里有一个非常基础的Person类:

public class Person {
    private String name;
    private String hobby;
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public String getHobby() {
        return hobby;
    }
    public void setHobby(String hobby) {
        this.hobby = hobby;
    }
}

如果我们要实例化它,我们将使用以下方式:

Person p = new Person();

这将创建一个新的Person对象并将其存储在堆上。在堆上存储需要更多的解释。这正是我们现在要深入探讨的!

在堆上存储对象

在堆上存储对象与在栈上存储值非常不同。正如我们刚才看到的,堆上位置的引用存储在栈上。这些引用是内存地址,这些内存地址转换到堆上的某个位置,对象就是存储在那里。没有这个对象引用,我们就无法访问堆上的对象。

对象引用具有特定的类型。Java 中有非常多的内置类型我们可以使用,例如ArrayListString、所有包装类等,但我们也可以创建自己的对象,这些对象也会存储在堆上。

堆内存持有应用程序中存在的所有对象。堆上的对象可以通过对象的地址、对象引用从应用程序的任何地方访问。对象包含与栈上的块相同的内容:原始值直接存储,以及堆上其他对象的地址。

图 1.9中,你可以看到栈和堆的概述以及这对于以下 Java 代码的简化视图:

public static void main(String[] args) {
    int x = 5;
    Person p = new Person();
    p.setName("maaike");
    p.setHobby("coding");
}

图 1.9 – 栈和堆之间连接的概述

图 1.9 – 栈和堆之间连接的概述

它非常简化——例如,Person对象中的String对象本身也是独立的对象。我们将在第三章中关注堆,以获得对堆区域的更准确理解。

那么,当我们耗尽堆内存时会发生什么?如果应用程序需要的堆空间超过了可用空间,就会抛出OutOfMemoryError异常。

好的,我们已经看到了栈和堆。这里还有一个我们需要讨论的内存区域,那就是 Metaspace。

探索 Metaspace

Metaspace 是存储运行时必要的类元数据的内存空间。它是 JVM 规范中的方法区,在 Java SE 7 之后的多数流行 Java 实现中,这个区域被称为 Metaspace。

如果你了解 PermGen,或者遇到它,只需知道这是一个旧的内存区域,其中存储了所有类的元数据。它有一些限制,并且已经被 Metaspace 所取代。

那么,回到这个类metadata。那究竟是什么呢?类metadata是运行时表示 Java 程序运行所需的 Java 类的表示。它实际上包含很多东西,例如以下内容:

  • Klass结构(我们将在第五章中深入了解元空间时了解更多!)

  • 方法的字节码

  • 常量池

  • 注解和更多内容

就这些!这是 Java 内存管理的基础知识。还有很多关于具体部分的内容要讲。我们将在下一章更详细地探讨堆上的原始类型和对象,但首先,让我们回顾一下我们已经做了什么。

概述

在本章中,我们概述了 Java 内存。我们从计算机内存和辅助存储开始学习,了解到计算机有主内存和辅助存储。对于我们来说,主内存是最重要的,因为这是运行程序(包括 Java 程序)所使用的。

主内存由 RAM 和 ROM 组成。Java 应用程序使用 RAM 来运行。Java 应用程序由 JVM 执行。这个 JVM 执行 Java 应用程序,为了执行这些应用程序,它有三个组件:类加载器、运行时数据区域和执行引擎。

我们关注了运行时数据区的不同组件:堆、栈、方法区、PC 寄存器和本地方法栈。

栈是用于在帧中存储变量和方法值的内存区域。堆用于存储对象。栈持有对堆上对象的引用。堆在应用程序的任何地方都可以访问,任何拥有堆上对象地址的人都可以访问该对象。栈只能由创建此栈的线程访问。

元空间是存储运行时所需的类元数据的内存区域。

在下一章中,我们将可视化并更详细地了解堆和栈内存是如何结合在一起的。

第二章:Java 内存中的基本类型和对象

第一章中,我们看到了基本类型、对象和引用之间的区别。我们了解到基本类型是 Java 语言自带的数据类型;换句话说,我们不需要定义基本类型,我们只需使用它们。例如,int x;定义(创建)了一个基本变量x,它是(基本)类型int。这意味着x只能存储整数,例如,-5、0、12 等等。

我们还了解到对象是通过new关键字创建的对象实例。例如,假设存在一个Person类,new Person();实例化(创建)了一个Person类型的对象。这个对象将存储在堆上。

我们看到引用使我们能够操作对象,并且引用有四种不同类型:classarrayinterfacenull。当你创建一个对象时,你得到的是对象的引用。例如,在代码Person p = new Person();中,引用是p,它是Person类型。引用是放在栈上还是堆上取决于上下文——稍后会有更多介绍。

理解引用和对象之间的区别非常重要,并且可以极大地简化核心ClassCastException错误。了解 Java 的按值调用机制,特别是它与引用的关系,可以防止称为逃逸引用的微妙封装问题。

在本章中,我们将更深入地探讨以下主题:

  • 理解栈和堆上的基本类型

  • 在堆上存储对象

  • 管理对象引用和安全

技术要求

本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/B18762_Java-Memory-Management

理解栈和堆上的基本类型

Java 自带一组预定义的基本数据类型。基本数据类型始终为小写,例如,double。将基本数据类型与其关联的包装器对应物进行对比,这些对应物是 API 中的类,有方法(基本数据类型没有),并且包装器以大写字母开头,例如,Double

基本数据类型可以分为整型(整数),即byteshortintlongchar,以及浮点型(小数),即floatdoublebooleantruefalse)。

基本类型也可以存储在栈和堆上。当它们是方法的局部变量时,存储在栈上,换句话说,是方法的参数或方法内部声明的变量。当它们是类的成员时,即实例变量,基本类型存储在堆上。实例变量在类的作用域内声明,换句话说,在所有方法之外。因此,在方法内部声明的原始变量存储在栈上,而实例变量存储在堆上(在对象内部)。

现在我们已经了解了原始类型存储的位置,让我们将注意力转向对象的存储。

在堆上存储对象

在本节中,我们将探讨在堆上存储对象。要全面理解这一领域,需要讨论比较引用和对象。我们将检查它们的类型、存储位置以及关键的区别。一个带有相关图表的代码示例将结束本节。

引用

引用指向对象,并使我们能够访问它们。如果我们访问的是对象实例成员,那么我们使用引用。如果我们访问的是静态(类)成员,我们使用类名。

引用可以存储在栈和堆上。如果引用是方法中的局部变量,那么引用就存储在栈上(在该方法帧的局部方法数组中)。如果引用是实例变量,那么引用存储在对象内部,在堆上。

通过与对象的比较,我们可以有一个抽象类的引用,但不能有抽象类的对象。同样适用于接口——我们可以有一个接口引用类型,但不能实例化接口;也就是说,不能创建接口类型的对象。这两种情况都在图 2.1中得到了演示:

图 2.1 – 对象实例化错误

图 2.1 – 对象实例化错误

图 2.1中,第 10 行和第 13 行声明的引用,分别是抽象类和接口引用,没有问题。然而,尝试在第 11 行和第 14 行创建这些类型的对象会导致错误。您可以自由尝试这段代码,它位于此处的ch2文件夹中:github.com/PacktPublishing/B18762_Java-Memory-Management/tree/main/ch2。编译错误的原因是您不能基于抽象类或接口创建对象。我们将在下一节中解决这些错误。

现在我们已经讨论了引用,让我们来考察对象。

对象

所有对象都存储在堆上。为了理解对象,我们首先必须理解面向对象编程中的一个基本结构,即类。类类似于房子的设计图。有了房子的设计图,你可以查看并讨论它,但你不能打开任何门,放水壶,等等。这就是面向对象编程中的类 – 它们是对象在内存中外观的视图。当房子建成时,你现在可以打开门,喝杯茶,等等。当对象创建时,你就有了一个类的内存表示。使用引用,我们可以使用点符号语法访问实例成员。

让我们解决 图 2.1 中的编译器问题,并展示点符号语法在实际操作中的使用:

图 2.2 – 接口和抽象类引用已固定

图 2.2 – 接口和抽象类引用已固定

图 2.2 中,由于第 11 行和第 15 行编译时没有出现任何错误,这表明在基于该类创建对象之前,该类必须是非抽象(具体)类。第 12 行和第 16 行演示了点符号语法。

让我们更详细地考察对象的创建。

如何创建对象

对象是通过使用 new 关键字实例化的(创建)。new 的目的是在堆上创建一个对象并返回其地址,我们将它存储在一个引用变量中。图 2.2 中的第 11 行有如下代码:

h = new Person();

引用位于赋值运算符的左侧 – 我们正在初始化一个 Human 类型的 h 引用。

要实例化的对象位于赋值运算符的右侧 – 我们正在创建一个 Person 类型的对象,并执行默认的 Person 构造函数。这个默认构造函数是由编译器合成的(因为代码中没有显式的 Person 构造函数)。

现在我们已经了解了对象和引用,让我们扩展这个例子,并使用图表来查看栈和堆的表示。

理解引用和对象之间的区别

为了对比栈和堆,Person 类和 main() 方法都进行了更改:

图 2.3 – 栈和堆代码

图 2.3 – 栈和堆代码

图 2.3 详细说明了包含两个实例变量、接受两个参数的构造函数和 toString() 实例方法的 Person 类。第二个类 StackAndHeap 是驱动类(它包含 main() 方法)。在 main() 中,我们初始化一个局部原始变量 x 并实例化一个 Person 实例。

图 2.4 展示了执行第 27 行之后的栈和堆表示:

图 2.4 – 图 2.3 中的代码的栈和堆表示

图 2.4 – 图 2.3 中代码的栈和堆表示

参考图 2.3,首先执行的是第 23 行的main()方法。这导致main()的帧被压入栈中。在这个帧中,局部变量argsx被存储在局部变量数组中。在第 25 行,我们创建了一个Person实例,传递了String字面量Joe Bloggs和整数字面量23。任何String字面量本身就是一个String对象,并且存储在堆上。此外,由于它是一个String字面量,这个String对象存储在堆的一个特殊区域,称为字符串池(也称为字符串常量池)。

Person对象内部的实例变量name位于堆上,是String类型;也就是说,它是一个引用变量,它引用了Person对象,而age是一个原始类型,其值为23直接存储在堆上的对象内部。然而,Person对象的引用joeBloggs存储在栈上,在main()方法的帧中。

图 2.3 的第 26 行,我们输出局部变量x,它将0输出到标准输出设备(通常是屏幕)。第 27 行随后被执行,如图 2.4 所示。首先,PrintStream中的println()方法(outPrintStream类型)导致一个帧被压入栈中。为了简化图示,我们没有深入到那个栈帧的细节。在println()完成执行之前,必须首先执行joeBloggs.toString()

由于Person中的toString()方法已经被调用,一个新的toString()帧被压入栈中,位于println()帧之上。接下来,toString()使用String字面量和实例变量构建一个名为decoratedName的局部String变量。

如你所知,如果你在+运算符的左边或右边有一个String实例,整个操作就变成了String连接,最终得到一个String结果。

这些String字面量存储在字符串池中。最终的String结果是My name is Joe Bloggs and I am 23 years old,它被分配给局部变量decoratedName。这个StringtoString()返回到调用它的第 27 行的println()语句。然后,返回的String被回显到屏幕上。

这就结束了我们在堆上存储对象的章节。现在我们将注意力转向可能导致代码中微妙问题的区域。然而,由于我们已经将引用与对象分离,这些问题将更容易理解和修复。

管理对象引用和安全

在本节中,我们将检查对象引用和一个可能出现的微妙安全问题,如果引用没有得到适当的关注,这个问题就会发生。这个安全问题被称为逃逸引用,我们将通过一个示例来解释它何时以及如何发生。此外,我们将在示例中修复这个问题,展示如何解决这个安全问题。

检查逃逸引用问题

在本节中,我们将讨论并提供一个 Java 值调用参数传递机制的示例。一旦我们理解了值调用,这将使我们能够演示在传递(或返回)引用时发生的问题。让我们从 Java 的值调用机制开始。

值调用

Java 在向方法传递参数和从方法返回结果时使用值调用。简单来说,这意味着 Java 会复制某个东西。换句话说,当你向方法传递一个参数时,会复制该参数,当你从方法返回一个结果时,会复制该结果。我们为什么要关心这个呢?因为你正在复制的 – 一个原始值或引用 – 可能会有重大影响(特别是对于可变类型,如 StringBuilderArrayList)。这正是我们想要进一步探讨的。我们将使用一个示例程序和相关图表来帮助。图 2.5 展示了示例代码:

图 2.5 – 值调用代码示例

图 2.5 – 值调用代码示例

图 2.5 详细说明了有一个简单 Person 类的程序,该类有两个属性:一个 String 类型的名称和一个 int(原始类型)的年龄。构造函数使我们能够初始化对象状态,我们为实例变量提供了访问器/修改器方法。

CallByValue 类是驱动类。在 main() 函数的第 27 行,声明并初始化了一个局部原始 int 变量,即 age,其值为 20。在第 28 行,我们创建了一个 Person 类型的对象,传递了字符串字面量 John 和原始变量 age。基于这些参数,我们初始化了对象状态。引用,即 john,是用于在堆上存储 Person 对象引用的局部变量。图 2.6 展示了第 28 行执行完毕后内存的状态。为了清晰起见,我们省略了 args 数组对象。

图 2.6 – 栈和堆的初始状态

图 2.6 – 栈和堆的初始状态

图 2.6 所示,main() 方法的框架是当前栈上的框架。它包含两个局部变量:值为 20int 原始类型年龄和指向堆上 Person 对象的 Person 引用 johnPerson 对象有两个实例变量被初始化:age 原始类型变量被设置为 20,名称 String 实例变量指向字符串池中的 John String 对象(因为 John 是一个 String 字面量,Java 会将其存储在那里)。

现在,我们在 图 2**.5 中执行第 29 行,change(john, age);。这里很有趣。我们调用change()方法,传递john引用和age原始值。由于 Java 是按值调用,每个参数都会被复制。图 2**.7 展示了我们进入change()方法并即将执行第 34 行的第一条指令时的栈和堆:

  图 2.7 – 进入 change() 方法时的栈和堆

图 2.7 – 进入 change() 方法时的栈和堆

在前面的图中,我们可以看到为change()方法压入栈中的一个帧。由于 Java 是按值调用,方法中的两个参数都被复制到局部变量中,即ageadult。这里的区别是至关重要的,因此需要分节来解释。

复制一个原始值

复制一个原始值类似于复印一张纸。如果你把复印件交给别人,他们可以随意处理那张纸 – 你仍然有原件。在这个程序中将要发生的就是这样;被调用的change()方法将改变原始age变量,但main()中的age的副本将保持不变。

复制一个引用

复制一个引用类似于复制电视遥控器。如果你把第二个/复制的遥控器交给别人,他们可以改变你正在观看的频道。在这个程序中将要发生的就是这样;被调用的change()方法将使用adult引用,改变Person对象中的name实例变量,而main()中的john引用将看到这个变化。

返回到 图 2**.5 的代码示例。图 2**.8 展示了在执行完第 34 和 35 行之后,但 change()方法返回到main()之前 的栈和堆:

  图 2.8 – change() 方法退出时的栈和堆

图 2.8 – change() 方法退出时的栈和堆

如所示,change()方法帧中的age原始值已被更改为90。此外,在字符串池中为Michael创建了一个新的String字面量对象,并且Person对象中的name实例变量正在引用它。这是因为String对象是不可变的;也就是说,一旦初始化,就不能更改String对象的内容。请注意,字符串池中的John String对象现在可以回收垃圾,因为没有引用指向它。

图 2**.9 展示了change()方法执行完成后并控制返回到main()方法后的栈和堆状态:

  图 2.9 – change() 方法完成后栈和堆的状态

图 2.9 – change() 方法完成后栈和堆的状态

图 2**.9中,change()方法的栈帧已经被弹出。现在,main()方法的栈帧再次成为当前帧。你可以看到age原始值没有改变,即它仍然是20。引用也是相同的。然而,change()方法能够改变john正在查看的实例变量。第 30 行System.out.println(john.getName() + " " + age);通过输出Michael 20证明了所发生的事情。

现在我们已经了解了 Java 的按值调用机制,我们将通过一个示例来讨论逃逸引用。

问题

面向对象编程中的封装原则是,一个类的数据是private的,并且可以通过其public API 供外部类访问。然而,由于逃逸引用,在某些情况下,这不足以保护你的private数据。图 2**.10是一个受逃逸引用影响的类的例子:

图 2.10 – 具有逃逸引用的代码

图 2.10 – 具有逃逸引用的代码

前面的图中包含一个Person类,它有一个名为nameprivate实例变量。Person构造函数根据传入的参数初始化实例变量。该类还提供了一个public getName()访问方法,以便外部类可以检索private实例变量。

在这里,驱动类是EscapingReferences。在main()函数中,第 16 行创建了一个局部StringBuilder对象,包含字符串Dan,而sb是该局部引用的名称。这个引用被传递到Person构造函数中,以便初始化Person对象中的name实例变量。图 2**.11显示了此时的栈和堆,即第 17 行执行完毕后。为了清晰起见,省略了字符串池。

图 2.11 – 逃逸引用的输入过程

图 2.11 – 逃逸引用的输入过程

在这一点上,逃逸引用的问题开始显现。在执行Person构造函数时,传递了一个sb引用的副本,它被存储在name实例变量中。现在,如图 2**.11所示,name实例变量和局部main()变量sb都引用了同一个StringBuilder对象!

现在,当main()函数中的第 18 行执行时,即sb.append("Dan");,对象变为DanDan,对于本地sb引用和name实例变量都是如此。当我们第 19 行输出实例变量时,它输出DanDan,反映了这种变化。

因此,这是进入过程中一个问题:将我们的实例变量初始化为传入的(副本)引用。我们将在稍后解决如何修复它。然而,在输出过程中,我们也有一个问题。图 2**.12展示了这个问题:

图 2.12 – 逃逸引用的输出过程

图 2.12 – 逃逸引用的输出过程

“图 2.12”显示了执行第 21 行StringBuilder sb2 = p.getName();之后的栈和堆。再次,我们有一个局部引用,这次称为sb2,它指向堆中Person对象的name实例变量所指向的同一对象。因此,当我们使用sb2引用将Dan追加到StringBuilder对象,然后输出实例变量时,我们得到DanDanDan

到目前为止,很明显,仅仅将数据private是不够的。问题在于StringBuilder是一个可变类型,这意味着在任何时候,你都可以更改(原始)对象。这与不可变的String对象(例如:DoubleIntegerFloatCharacter的包装类型)形成对比。

不可变性

Java 保护String对象,因为对String对象的任何更改都会导致创建一个完全新的对象(更改反映在其中)。因此,请求更改的代码将看到请求的更改(只是它是一个全新的对象)。其他人可能看到的原始String对象仍然未受影响。

现在我们已经讨论了逃逸引用的问题,让我们看看如何解决这些问题。

寻找解决方案

实质上,解决方案围绕一种称为防御性复制的实践。在这种情况下,我们不想为任何可变对象存储引用的副本。同样,对于我们在访问器方法中返回的private可变数据的引用也适用 – 我们不想返回引用的副本给调用代码。

因此,我们在进入和退出时都需要小心。解决方案是在这两种情况下完全复制对象内容。这被称为深度复制(而只复制引用则称为浅度复制)。因此,在进入时,我们将对象的内容复制到一个新对象中,并存储对新对象的引用。在退出时,我们再次复制内容,并返回对新对象的引用。我们已经在这两种情况下保护了我们的代码。“图 2.13”显示了从“图 2.10”中解决问题的方案:

图 2.13 – 逃逸引用代码修复

图 2.13 – 逃逸引用代码修复

第 7 行显示了在进入时(构造函数)创建的副本对象。第 10 行显示了在退出时(访问器方法)创建的副本对象。第 19 行和第 23 行都输出了Dan,正如它们应该做的那样。图 2.14 表示程序即将退出时的栈和堆:

图 2.14 – 逃逸引用代码修复的栈和堆

图 2.14 – 逃逸引用代码修复的栈和堆

为了清晰起见,我们省略了字符串池。我们已将StringBuilder对象编号为 1 到 5。我们可以按照以下方式将对象与代码匹配:

  • 第 16 行创建了对象 1。

  • 第 17 行调用了第 7 行,创建了对象 2。Person实例变量name引用了这个对象。

  • 第 18 行修改了对象 1,将其改为DanDan(然而,请注意,由name实例变量引用的对象,即对象 2,未受到影响)。

  • 第 19 行创建了对象 3。引用被传回main()函数,但从未被存储。由于输出了Dan,这证明了在输入过程中的防御性复制正在起作用。

  • 第 21 行创建了对象 4。局部main()引用sb2指向它。

  • 第 22 行将对象 4 修改为DanDan(未修改实例变量所引用的对象)。

  • 第 23 行创建了对象 5。由于输出了Dan,这证明了在输出过程中的防御性复制正在起作用。

图 2**.14显示,由name实例变量引用的StringBuilder对象从未从Dan改变。这正是我们想要的。

本章到此结束。我们涵盖了很多内容,所以让我们回顾一下主要观点。

摘要

在本章中,我们首先检查了原始数据类型在内存中的存储方式。原始数据类型是语言自带预定义的类型,可以存储在栈上(局部变量)和堆上(实例变量)。由于它们全部都是小写字母,因此很容易识别原始数据类型。

相比之下,对象只存储在堆上。在讨论对象时,有必要区分引用和对象本身。我们发现,虽然引用可以是任何类型(接口、抽象类和类),但对象本身只能属于正确的、具体的类,这意味着该类不能是抽象的

小心管理对象引用。如果没有正确管理,你可能会遇到逃逸引用。Java 使用按值调用,这意味着传递或返回的参数会创建一个副本。根据参数是原始数据类型还是引用类型,这可能会产生重大影响。如果它是可变类型的引用副本,那么调用代码可以更改你所谓的private数据。这不是适当的封装。

我们检查了存在此问题的代码以及栈和堆的关联图。解决方案是使用防御性复制,即在输入和输出过程中复制对象内容。因此,引用及其所引用的对象保持私有。最后,我们详细介绍了代码解决方案以及栈和堆的关联图。

在下一章中,我们将更深入地探讨堆,这是对象居住的内存区域。

第三章:放大堆空间

第二章 中,我们讨论了内存中引用和对象之间的区别。引用及其引用的对象密切相关。我们发现,Java 的按值调用机制可能导致一个名为 逃逸引用 的安全问题,除了可变对象之外。借助示例代码和图表,我们探讨了这些问题以及如何通过防御性复制来解决它们。

我们知道原始类型和引用可以存在于栈和堆上,而对象仅存在于堆上。现在,我们准备更仔细地查看堆,为下一章关于 垃圾收集GC)的内容做准备。在本章中,我们将涵盖以下主题:

  • 探索堆上的不同代

  • 学习如何使用这些空间

探索堆上的不同代

堆空间由两个不同的内存区域组成:

  • 年轻代空间

  • 老年代(持久)空间。

虽然我们不会在本章深入探讨 GC 过程,但我们需要解释什么是 对象。活对象是从 GC 根可达的对象。

垃圾收集根

GC 根是一种特殊的活对象,因此不符合 GC 条件。所有从 GC 根可达的对象也是活的,因此也不符合 GC 条件。GC 根在 GC 中充当起始点,即从这些根开始,标记所有可达的对象为 。最常见的 GC 根如下:

  • 栈上的局部变量

  • 所有活跃的 Java 线程

  • 静态变量(因为这些可以由它们的类引用)

  • Java 本地接口JNI)引用 – 作为 JNI 调用一部分由本地代码创建的对象。这是 GC 根的一个非常特殊的情况,因为 JVM 无法知道对象是否被本地代码引用。

让我们检查这些空间在内存中的表现,如图 图 3**.1 所示:

图 3.1 – 堆代

图 3.1 – 堆代

在这一点上,我们需要简要定义,以便我们可以讨论如何使用这些空间:

  • 年轻代空间 – 年轻代空间,有时称为 苗床 空间,包含两个独立的区域:伊甸 空间和 幸存 空间。这两个区域都服务于不同的功能,总体目标是提高内存效率。我们将依次讨论它们:

    • 伊甸空间对象在伊甸空间中分配。当伊甸空间满了,没有空间再分配新对象时,年轻代(次要)垃圾收集器启动。

    • 幸存空间:有两个等分的幸存空间,即 S0 和 S1。次要垃圾收集器交替使用这些区域。我们将在稍后更详细地探讨这一点。

  • 老年代空间 – 这也被称为晋升空间。这是长期存在的对象所在的地方。换句话说,垃圾收集器将经过一定数量 GC 的存活对象移动到这里。当晋升空间满了,这会触发主要GC。

现在我们对空间有了简要的了解,让我们来看看它们是如何被使用的。

学习如何使用这些空间

要了解这些不同空间的使用方式,我们将分两个不同阶段来解释。最初,我们将检查在小型 GC 算法中如何使用这些空间。随后,通过一个示例,我们将展示算法的实际运行。

理解小型垃圾回收算法

让我们从小型 GC 算法开始。图 3**.2是小型 GC 过程的高级伪代码:

图 3.2 – 小型垃圾回收算法的伪代码

图 3.2 – 小型垃圾回收算法的伪代码

让我们通过一个给定-当-然后场景来检查前面图中概述的过程。

  • 给定S0作为目标幸存者空间和S1作为初始源幸存者空间。

  • :运行小型垃圾回收器。换句话说,eden 空间没有足够的空间来分配 JVM 希望分配的对象。

  • 1,因为它们刚刚度过了它们的第一轮 GC 周期。

  • S1被检查,并且任何年龄达到给定阈值(晋升阈值)的存活对象被复制到老年代空间,这意味着它们已经晋升。换句话说,这是一个长期存在的对象,所以将其复制到老年代区域,那里有更长时间存在的对象。这使得未来的小型 GC 运行更加高效,因为它确保这些相同的对象不会被重新检查。

  • 剩余的存活S1对象(那些没有晋升的对象)被复制到S0,它们的年龄增加一个,因为它们刚刚通过了另一个 GC 周期。

注意,使用 JVM 参数-XX:MaxTenuringThreshold可以配置晋升阈值。实际上,这个标志允许您自定义对象在最终晋升到老年代之前将在幸存者空间中停留多少次 GC 周期。然而,在使用此参数时必须谨慎,因为大于 15 的值指定对象永远不会晋升,从而无限期地用旧对象填满幸存者空间。

图 3**.3显示了刚刚讨论的过程:

图 3.3 – 以 S0 为目标空间的小型垃圾回收

图 3.3 – 以 S0 为目标空间的小型垃圾回收

这里是一个总结:

  • 将实时 eden 对象复制到1

  • 将旧的存活S1对象复制到长生存空间

  • 将年轻的存活S1对象复制到S0(年龄增加)

现在 eden 和S1中的存活对象已经被复制(保存),现在 eden 和S1都可以被回收。

当小回收器再次运行时,每个对象有1个集合。由于S1现在是目标空间,S0成为源空间。垃圾收集器检查S0并将长寿对象复制到持久代空间,将短寿对象复制到S1图 3.4显示了此过程:

图 3.4 – 以 S1 为目标空间的小型垃圾收集

图 3.4 – 以 S1 为目标空间的小型垃圾收集

这里是一个总结:

  • 所有的活动 eden 对象都被复制到1

  • 复制老、活动的S0对象到长生存空间

  • 复制年轻、活动的S0对象到S1(年龄增加)

由于 eden 和S0中的活动对象已被复制,因此 eden 和S0都可以被回收。

现在我们已经讨论了空间的使用方式,我们将通过一个例子来增强我们的解释。

展示小垃圾收集算法的实际操作

图 3.5显示了在第一次小垃圾收集器运行之前内存中的情况:

图 3.5 – 小型垃圾收集第 1 次之前的初始堆状态 #1

图 3.5 – 小型垃圾收集第 1 次之前的初始堆状态

在前面的图中,对象H代表 JVM 试图在 eden 空间为其分配内存的对象。eden 空间包括以下内容:

  • 红色对象没有来自 GC 根的引用。它们符合 GC 的条件。

  • 绿色对象是活动对象,意味着它们是 GC 根或者可以通过 GC 根访问。这些对象符合 GC 的条件。

  • 白色空间是 eden 空间中的间隙。如果有足够的连续空间来分配对象,则对象存储在 eden 中,并返回其引用。然而,如果由于内存碎片化,没有足够的连续空间来分配对象,则会触发小(年轻代)GC。

存活空间包括以下内容:

  • S0 – 初始为空;我们将假设 JVM 最初使用它作为目标存活空间

  • S1 – 初始也为空;由于S0是目标空间,S1成为源空间(由于S1最初没有内容,这第一次没有影响)

持久代(老年代)空间包括长寿对象。长寿对象是经过一定预定义数量的次要 GC 后存活的对象。这是一个可以通过-XX:MaxTenuringThreshold JVM 参数自定义的阈值值。

图 3.5所示,JVM 需要分配对象H,但由于 eden 空间不足,这触发了小(年轻代)GC。对象ADG可以从 eden 中移除,而对象BCEF可以移动到S0。eden 空间被回收,对象H被分配。

图 3.6显示了第一次小 GC 完成后的堆状态:

图 3.6 – 小型垃圾收集第 1 次之后的堆状态

图 3.6 – 小型垃圾回收后的堆状态 #1

在前面的图中,对象 H 被分配在 eden 中,而对象 BCEFS0 中。注意,S0 中的每个对象年龄都是 1,因为这是它们第一次在小型垃圾回收后幸存。

图 3**.7 展示了第二次小型垃圾回收运行前的堆状态:

图 3.7 – 小型垃圾回收前的堆状态 #2

图 3.7 – 小型垃圾回收前的堆状态 #2

图 3**.7 中,JVM 正在尝试分配对象 N,但在 eden 中没有为其留出空间。这将触发小型垃圾收集器运行(第二次)。在 eden 空间中,对象 HLM 符合垃圾收集条件,而对象 IJK 是活动的。在幸存空间 S0 中,对象 B 现在符合垃圾收集条件,而对象 CEF 是活动的。

图 3**.8 展示了第二次小型垃圾回收运行后的堆状态:

图 3.8 – 小型垃圾回收后的堆状态 #2

图 3.8 – 小型垃圾回收后的堆状态 #2

图 3**.8 中,S1 现在是目标幸存空间,因此 S0 是源。垃圾收集器将活动对象 CEFS0 移动到 S1,将它们的年龄值从 1 增加到 2。然后垃圾收集器回收 S0 空间。

对象 IJK 被从 eden 移动到 S1,年龄值为 1,因为这是它们第一次在小型垃圾回收后幸存。eden 空间被回收,并分配了对象 N

最后要展示的是对象移动到持久空间。这正是 3**.9 所展示的:

图 3.9 – 对象移动到持久空间

图 3.9 – 对象移动到持久空间

图 3**.9 代表经过 15 次小型垃圾回收后的堆状态。对象 EF 移动到持久空间,因为它们的年龄值 15 达到了阈值(默认阈值是 15)。下一次小型垃圾收集器运行时,这两个对象将不会出现,从而使得垃圾收集器运行得更有效率。

对象 X 是触发小型垃圾收集器的对象,对于这次迭代,S1 是源,S0 是目标幸存空间。对象 JPS 仍然是活动的,分别以 1483 的年龄计数从 S1 移动到 S0

在我们结束这一章之前,值得提一下一些其他相关的 JVM 标志:

  • -Xms-Xmx 分别指定堆的最小和最大大小。

  • -XX:NewSize-XX:MaxNewSize 分别指定年轻代的最小和最大大小。

  • -XX:SurvivorRatio 指定两个幸存空间相对于 eden 空间的大小比例。例如,-XX:SurvivorRatio=6 将 eden 和一个幸存空间之间的比例设置为 1:6。换句话说,每个幸存空间将是 eden 的六分之一大小,因此是年轻代(不是七分之一,因为有两个幸存空间)的八分之一。

  • -XX:NewRatio 表示新年代相对于老年代的大小比例。例如,-XX:NewRatio=3 将新年代和老年代之间的比例设置为 1:3。这意味着新年代(包括 eden 和两个幸存空间)占堆的 25%,而老年代占剩余的 75%。

  • -XX:PretenureSizeThreshold – 如果一个对象的大小大于此标志指定的值,则该对象将立即晋升到老年代,这意味着对象直接分配到老年代空间。默认值是 0,这意味着没有对象将直接分配到堆的老年代。

通常,保持年轻代空间在总堆大小的 25%到 33%之间。这确保了老年代空间始终更大。这是可取的,因为完全垃圾回收(full GCs)比部分垃圾回收(minor ones)更昂贵。

本章内容到此结束。让我们回顾一下主要要点。

概述

在本章中,我们聚焦于堆空间。我们首先检查堆上的不同代,即年轻代空间和老年代(晋升)空间。

年轻代空间分为两个空间:eden 空间和幸存空间。eden 空间是新对象分配的地方。幸存空间由两个大小相等的空间组成,即 S0 和 S1。在回收内存时,小(年轻代)垃圾回收器使用这些幸存空间。当 eden 空间中没有足够的连续空间来分配对象时,会触发小垃圾回收。我们使用伪代码和图表来检查小垃圾回收器如何利用代和空间。然后,我们使用具有几个用例场景的示例来加强这些概念。

晋升空间是长期存活对象所在的地方。我们看到了如果一个对象在几个垃圾回收周期中存活下来,该对象将移动到晋升空间,以使后续的小垃圾回收周期更高效。最后,我们查看相关的 JVM 标志。

现在我们已经了解了堆,并对小垃圾回收器有了高级概述,我们准备深入探讨垃圾回收(GC),这是下一章的主题。

第四章:使用垃圾回收释放内存

当不再需要分配的内存时,需要释放它。在某些语言中,开发者需要负责这一点。在其他一些语言中,例如 Java,这会自动发生。对于 Java,垃圾收集器会这样做。内存的释放对于应用程序保持运行是必要的。如果没有在不再需要时释放内存的能力,我们只能分配一次内存,最终我们会耗尽内存。在本章中,我们将学习如何使用垃圾收集器在堆上释放内存。

这是一个棘手的话题!在你准备好这一章之前,你需要对堆空间有一个清晰的理解。再次强调,我们将尽可能地可视化这些概念,以增加你的理解。

这里将讨论以下主题:

  • 对象的垃圾回收GC)资格

  • 垃圾收集器的标记

  • 垃圾收集器的清除

  • 不同的 GC 实现

技术要求

本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/B18762_Java-Memory-Management

有资格进行 GC

我们已经知道,当堆上的对象不再需要时,它们会被移除。那么,接下来要问的正确问题是,对象何时不再需要

那个问题很容易回答,但同时也引出了一个复杂的问题。让我们首先看看答案:当对象不再与 有连接时,堆上的对象就不再需要了

当栈不存储对象的引用变量时,对象与栈没有连接。这里有一个简单的例子:

Object o = new Object();
System.out.println(o);
o = null;

在第一行,我们创建了对象,它在堆上被创建。o变量持有对栈上Object类型对象的引用。我们使用对象是因为我们存储了引用。在这种情况下,我们在示例的第二行打印它,这显然是一个相当愚蠢的输出,因为ObjecttoString()方法只会返回以下输出到控制台:

java.lang.Object@4617c264

在下一行,我们将变量设置为null。这覆盖了对对象的引用,并且简单地指向了某个地方,因为没有对象存储在o中。我们的应用程序中没有其他东西持有对创建的Object的引用。因此,它变得有资格进行 GC。

这个例子相当简单。为了展示这个问题实际上有多复杂,让我们看看一个稍微复杂一些的问题,并用一些图表来展示。我们需要回答的问题是,在每一行中,哪些对象有资格进行 GC

Person p1 = new Person(); // 1
Person p2 = new Person(); // 2
Person p3 = new Person(); // 3
List<Person> personList = Arrays.asList(p1, p2, p3); // 4
p1 = null; // 5
personList = null; // 6
Figure 4*.1* after the first four lines.

图 4.1 – 资格示例的栈和堆概述

图 4.1 – 资格示例的栈和堆概述

在第五行,我们将 p1 设置为 null。这意味着 p1 有资格进行 GC 吗?快速提醒:堆上的对象一旦不再与栈有连接,就有资格进行 GC。但是,让我们看看执行第 5 行之后会发生什么:

图 4.2 – 执行第 5 行后的堆和栈概述

图 4.2 – 执行第 5 行后的堆和栈概述

如我们所见,p1 与栈的连接已经消失。但这并不意味着没有与栈的连接。仍然存在间接连接。我们可以从栈到 Person 对象的列表,然后我们可以继续访问 p1 持有引用的对象,因为列表仍然持有对该对象的引用。因此,在执行第 5 行之后,堆上的所有对象都没有资格进行 GC。

这在执行第 6 行后发生变化。在第 6 行,我们将持有列表的变量设置为 null。这意味着 p1 在此行之后不再与栈有连接,正如你在 图 4.3 中可以看到的那样。

图 4.3 – 代码结束时的堆和栈概述

图 4.3 – 代码结束时的堆和栈概述

列表和栈之间没有连接,现在 List 对象和 Person 对象的第一个实例都有资格进行 GC。同时,p2p3 变量仍然持有对堆上对象的引用,因此这些对象没有资格进行 GC。

一旦你理解了从堆到栈的直接和间接连接,就不难判断哪些对象已准备好进行 GC。然而,确定哪些对象与栈有连接将需要一些时间,这将减慢应用程序的其他部分。有几种方法可以做到这一点,但每种方法都有其自身的缺点,无论是关于准确性还是性能。

这个复杂的问题与语言无关:我们如何确定一个对象是否仍然与栈有连接?我们将讨论的解决方案当然是 Java 特定的。在标记阶段,通过垃圾回收器找到不再需要的对象。标记阶段由一个特殊的算法组成,该算法确定哪些对象有资格进行 GC。

垃圾回收器的标记

标记会标记任何活动对象以及那些未标记为准备垃圾回收的对象。对象保留一个特殊的位来决定它们是否被标记。在创建时,该位是 0。在标记阶段,如果一个对象仍在使用且不应被移除,则将其设置为 1

堆和栈都在不断变化。堆上没有与步骤连接的对象有资格进行 GC。它们是不可达的,应用程序没有可能使用这些对象。那些尚未准备好移除的对象被标记;未标记的对象将被移除。

这究竟是如何实现的,取决于 Java 的实现以及你使用的特定垃圾回收器。但从高层次来看,这个过程从栈开始。栈上所有对象引用都会被跟踪,对象会被标记。

如果我们回顾之前的例子,这就是它们的标记方式。我们使用以下代码示例,其中我们没有将personList的引用设置为null

Person p1 = new Person(); // 1
Person p2 = new Person(); // 2
Person p3 = new Person(); // 3
List<Person> personList = Arrays.asList(p1, p2, p3); // 4
p1 = null; // 5

在垃圾回收开始之前,所有对象都是未标记的。这意味着特殊位是 0,这是它们在创建时得到的值。

图 4.4 – 在垃圾回收开始之前,没有任何对象被标记

图 4.4 – 在垃圾回收开始之前,没有任何对象被标记

因此,首先,所有对象都是未标记的,正如我们可以从对象后面的所有 0 来判断。下一步是将与栈有连接的对象标记,通过将 0 改为 1

图 4.5 – 标记步骤一:与栈的直接连接

图 4.5 – 标记步骤一:与栈的直接连接

但仅仅标记与栈有直接连接的对象是不够的。目前,由Person p1引用的对象即使可达,也有资格进行垃圾回收。这也是为什么每个对象的引用也会被遍历并标记,直到没有更多的嵌套对象。图 4.6 展示了标记阶段之后我们的例子看起来是什么样子。

图 4.6 – 标记后

图 4.6 – 标记后

我们堆上的所有对象都被标记了,我们可以从每个对象后面的 1 来判断。所以,在我们的例子中,没有任何对象有资格进行垃圾回收,因为它们仍然都是可达的。

在标记阶段,有不同算法发挥着重要作用。我们将首先探讨的是停止世界方法。

停止世界

想想这该如何实现。当你检查栈上的所有变量并标记它们及其嵌套对象时,新对象可能在此期间被创建。有可能你错过了栈的那部分。这会导致未标记的对象(记住,对象在创建时最初是未标记的)应该被标记,结果它们会被移除。这将非常有问题。

解决这个问题会影响性能,因为垃圾回收器需要暂停主应用程序的执行,以确保在标记阶段不会创建新的对象。这种策略被称为停止世界,尽管听起来很戏剧化,但它是一个 Java 术语。在计算机科学领域还有其他策略,其中之一是引用计数,我们将在下一节中探讨。

引用计数和隔离孤岛

另一种实现方法是计数一个对象上的引用数量。所有对象都会包含一个计数,表示它们被引用的次数作为某种属性。这样,执行 GC 仅仅是移除所有引用次数为 0 的对象。

你可能会想,这比暂停应用程序要好得多,那么为什么我们不使用它呢?答案是孤岛。这不是某种现代社会现象;孤岛是那些仅仅相互引用而没有与堆栈连接的对象。

让我们探索以下代码示例的栈和堆。在这个例子中,我们有一个 Nest 类:

class Nest {
    private Nest;
    public Nest getNest() {
        return nest;
    }
    public void setNest(Nest nest) {
        this.nest = nest;
    }
}

我们创建了两个 Nest 实例,并将它们设置为彼此的 nest 属性:

public class IslandOfIsolation {
    public static void main(String[] args) {
        Nest n1 = new Nest(); // 1
        Nest n2 = new Nest(); // 2
        n1.setNest(n2); // 3
        n2.setNest(n1); // 4
        n1 = null; // 5
        n2 = null; // 6
    }
}

让我们看看在第四行之后想要计数引用并暂停的情况。

图 4.7 – 创建两个 Nest 对象并将它们分配给彼此的字段后的概览

图 4.7 – 创建两个 Nest 对象并将它们分配给彼此的字段后的概览

在第四行之后,两个计数器都是 2。对象被另一个对象和堆栈所引用。这会在第五行和第六行之后改变,因为堆栈的引用被移除了。

图 4.8 – 设置堆栈引用为 null 后的概览

图 4.8 – 设置堆栈引用为 null 后的概览

如代码所示,在执行末尾注释 6 的行之后,两个对象都无法从堆栈中访问。然而,如果我们使用引用计数,它们仍然都会有一个 1,因为它们都相互引用。

由于这些对象没有 0 的计数,但也没有与堆栈的连接。它们是孤岛:孤立的孤岛。它们应该被垃圾收集,但简单的计数垃圾收集器无法检测到它们,因为它们没有 0 的引用计数。更高级的垃圾收集器,它会标记所有与堆栈有连接且需要暂停应用程序的元素,会将它们垃圾收集,因为它们没有与堆栈的连接。

因此,Java 使用更精确的标记阶段来暂停应用程序。如果没有标记垃圾收集器,孤岛会导致内存泄漏:本可以释放但从未再次提供给应用程序使用的内存。

接下来,让我们谈谈标记之后如何释放内存。

垃圾收集器清扫

一旦需要保留的对象被标记,就到了开始下一阶段以实际释放内存的时候了。在 GC 术语中,这种对象的删除称为清扫。为了使其更有趣,我们有三种清扫方式:

  • 正常清扫

  • 压缩清扫

  • 复制清扫

我们将通过插图详细讨论所有这些,以帮助您理解正在发生的事情。

正常清除

正常清除是移除未标记的对象。图 4.9 显示了内存中的五个对象。其中两个,带有 x 的那些,将被移除。

图 4.9 – 带有标记对象的内存示意图

图 4.9 – 带有标记对象的内存示意图

内存块的大小并不相等;其中一些较小,而另一些较大。在清除不可达的对象之后,内存看起来如下:

图 4.10 – 清除后的内存示意图

图 4.10 – 清除后的内存示意图

通过清除和内存块之间的间隙,内存已被释放,可以再次分配。然而,只有适合间隙的块才能存储在那里。现在内存是碎片化的,这可能导致存储较大内存块时出现问题。

碎片化

在首先存储内存然后从中移除块之后,会发生内存碎片化。在内存块之间,可以分配新的内存。这如图 图 4.11 所示。

图 4.11 – 分散内存中新对象分配

图 4.11 – 分散内存中新对象分配

新的内存块存储在间隙中。在特定显示的情况下,这种方法效果很好,因为内存块适合间隙。如果内存块不适合间隙之间(或在此概述的末尾),我们就会遇到问题。让我们看看我们想要存储一个新块的情况。

图 4.12 – 尝试存储小于可用内存的大块内存

图 4.12 – 尝试存储小于可用内存的大块内存

如果我们查看总的可用内存,前面图中的块是适合的。然而,我们无法在碎片化内存中存储它,因为没有足够的连续内存来存储新块。

图 4.13 – 示意概述显示新内存块不合适

图 4.13 – 示意概述显示新内存块不合适

如果请求的内存块无法容纳,将导致错误:OutOfMemoryError。尽管我们没有用完内存,并且从技术上讲有足够的内存来存储新的块,但它无法容纳,因为可用的内存是碎片化的。这是正常清除操作的问题。这是一个非常高效且简单的过程,但它会导致内存碎片化。如果内存充足,并且应用程序只需要快速释放内存,这个过程可能是可取的。当内存更紧张时,最好使用其他清除操作的选项之一:压缩后的清除操作或复制后的清除操作。让我们首先看看压缩后的清除操作。

压缩后的清除操作

压缩后的清除操作是一个两步过程。就像在正常清除操作中一样,内存块被删除。这次,我们不接受碎片化的内存作为最终结果,而是执行一个额外的步骤,称为压缩。这会将内存块移动以确保它们之间没有间隙。过程如图 4.14 所示。我们假设与图 4.9 所示相同的内存块已准备好删除。

图 4.14 – 压缩后的清除操作

图 4.14 – 压缩后的清除操作

如您所见,这次我们没有得到碎片化的内存。因此,我们不会得到 OutOfMemoryError。这听起来很棒,但就像往常一样,魔法是有代价的。在这种情况下,代价是性能。从性能的角度来看,内存的压缩是一个代价高昂的过程,因为所有内存块都需要移动(这通常需要按顺序进行)。

这种代价高昂的压缩过程有一个替代方案,那就是复制后的清除操作。别忘了压缩后的清除操作,因为复制后的清除操作也有其自身的成本。

复制后的清除操作

复制后的清除操作是一个巧妙的过程。为此我们需要两个内存区域。我们不是删除不再需要的内存块,而是删除所有内存块!但在删除之前,我们需要将我们仍然需要的内存块复制到第二个内存区域(参见图 4.15)。

图 4.15 – 实际清除操作前的复制操作

图 4.15 – 实际清除操作前的复制操作

首先,我们有我们的内存区域,其中包含不再需要的对象,然后我们有一个尚未分配的第二个内存区域。

在下一步中,我们将所有需要的对象复制到第二个内存区域。

图 4.16 – 复制后的清除操作

图 4.16 – 复制后的清除操作

到目前为止,我们只进行了复制,还没有进行清除。这正是下一步将要做的:清除第一个内存区域,因为所有我们仍然需要的对象都保存在第二个内存区域中。结果如图 4.17 所示。

图 4.17 – 清除操作后的内存示意图

图 4.17 – 使用复制清扫后的内存示意图

在清扫第一个内存区域之后,我们所有的对象都仍然可以在第二个内存区域中访问。这在性能上比带有压缩的清扫更好,但正如你可以想象的那样,这需要更多的可用空闲内存。

使用的清扫类型取决于垃圾回收器的选择实现。现在有很多实现。我们将在下一节中探讨最常见的一些。

探索 GC 实现

标准 JVM 有五种 GC 实现。其他 Java 实现可以有其他的 GC 实现,例如 IBM 和 Azul 的垃圾回收器。在理解了标准 JVM 附带的以下五种实现之后,这些工作方式相对容易理解:

  • 序列 GC

  • 并行 GC

  • CMS(并发标记清除)GC

  • G1 GC

  • ZGC(Z 垃圾回收器)

我们将在稍后详细检查这些实现是如何工作的(然而,我们不会讨论每个的不同的命令行选项)。但在我们讨论这些特定的垃圾回收器是如何工作的之前,另一个概念需要被解决:代式 GC 的概念。

代式 GC

如果你有一个大型 Java 应用程序正在运行,为了等待垃圾回收器标记每个活动的对象而暂停整个程序,将会是一个性能噩梦。幸运的是,他们通过利用堆上的不同代想出了一个更聪明的办法。并非所有即将解释的垃圾回收器都使用这种策略,但其中一些确实如此。

与一次性运行完整的垃圾回收器不同,代式垃圾回收器专注于内存的某个部分,例如,年轻代。这种方法对于大多数对象都年轻死亡的应用程序来说效果很好。它节省了很多标记。

代式垃圾回收器通常与记忆集一起工作。这是一个包含所有从老年代到年轻代对象引用的集合。这样,老年代就不需要被扫描,因为指向年轻代的引用已经在记忆集中了。

对于大多数对象都在持久代的应用程序,使用 GC 专注于年轻代的方法不会繁荣。因为在这种情况下,堆特别重的是老年代,只收集年轻代不会释放高比例的内存。

通常,代垃圾收集器必须为不同的内存区域使用不同的策略。例如,年轻代可以使用停止世界垃圾收集器进行垃圾收集,将整个可到达对象集复制到老年代,然后删除年轻代。同时,老年代可以使用压缩,也许还有停止世界的替代方案,如 CMS 垃圾收集器,我们将在查看不同实现时看到。

现在我们已经讨论了清扫的不同选项以及停止世界和代垃圾收集器,我们现在处于更好的位置来理解我们之前列出的五种实现。(所以,坚持住,你几乎已经通过了这个艰难的章节!)

Serial GC

串行 GC 在单个线程上运行并使用停止世界策略。这意味着当垃圾收集器运行时,应用程序不会执行其主要任务。这是垃圾收集的最简单选项。

对于年轻代,它使用标记策略来识别哪些对象有资格进行 GC,并使用复制方法进行实际的内存释放。对于老年代,它使用标记和压缩的清除方法。

串行垃圾收集器适用于小型程序,但对于像SpringQuarkus应用程序这样的大型程序,有更好的选择。

Parallel GC

并行垃圾收集器 是 Java 8 的默认垃圾收集器。它对年轻代使用标记-复制方法,对老年代使用标记-清除-压缩方法,就像串行垃圾收集器一样。然而,这可能是个惊喜,它是以并行的方式做到这一点的。在这种情况下,并行意味着使用多个线程来清理堆空间。所以,不是只有一个线程负责标记、复制和压缩阶段,而是多个线程。尽管它仍然是停止世界,但由于世界需要停止的时间更短,所以它的性能比串行垃圾收集器更好。

并行垃圾收集器在多核机器上运行良好。在(较少见的)单核机器上,由于管理多个线程的成本以及单核上实际上并没有并行处理,串行垃圾收集器可能是一个更好的选择。

CMS GC

并发标记清除垃圾收集器CMS GC)有一个改进的标记-清除算法。它通过多个线程实现这一点,并大大减少了暂停时间。这是 CMS GC 与并行垃圾收集器之间的主要区别。

尽管并非所有系统都能处理主应用程序和垃圾收集器之间的资源共享,但如果它们可以,与并行垃圾收集器相比,这将是一个性能上的巨大提升。

CMS GC 也是一个代式垃圾回收器。它为年轻代和老年代分别设置了不同的周期。对于年轻代,它使用带有停止世界的标记和复制。因此,在年轻代的垃圾回收期间,主要的应用线程会被暂停。

老年代使用 主要并发 标记和清除进行垃圾回收。这里的“主要并发”意味着它大部分的垃圾回收是并发的,但在垃圾回收周期中仍会使用两次停止世界的操作。它第一次在垃圾回收周期的开始时暂停所有主要的应用线程,然后在标记阶段非常短暂地暂停,然后在垃圾回收周期中间的某个时候(通常)稍微长一点的时间进行最终标记。

这些暂停通常非常短,因为 CMS GC 尝试在并发于主应用线程的同时收集足够的旧代内存,从而防止它填满。有时,这是不可能的。如果 CMS GC 在旧代内存填满时无法释放足够的内存,或者应用程序无法分配对象,CMS GC 会暂停所有应用线程,主要关注点转向垃圾回收。这种垃圾回收器无法主要并发进行垃圾回收的情况被称为 并发 模式失败

如果收集器仍然无法释放足够的内存,则会抛出 OutOfMemoryError。这种情况发生在应用程序的 98% 的时间都花在垃圾回收上,而堆中恢复的内存不到 2%。

这与其他我们讨论过的垃圾回收器并没有太多不同。CMS GC 的非常短的暂停时间听起来已经相当不错了,但还有更晚的升级可用。让我们来看看 G1 GC。

G1 GC

G1(垃圾优先)垃圾回收器随着 Java 7(次要版本 4)推出,是 CMS GC 的升级。它以巧妙的方式结合了不同的算法。G1 收集器是并行、并发,并旨在缩短应用程序的暂停时间。它采用了一种称为增量压缩的技术。

G1 垃圾回收器将堆分成更小的区域:比代式垃圾回收器小得多。它与这些较小的内存段一起工作,对它们进行标记和清除。它跟踪每个内存区域中可达和不可达对象的数量。具有最多不可达对象的区域首先进行垃圾回收,因为这样可以释放最多的内存。这就是为什么它被称为垃圾优先垃圾回收器。具有最多垃圾的区域首先被收集。

它在将对象从一个区域复制到另一个区域的同时完成所有这些操作。这将导致第一个区域完全释放。这样,G1 GC 一石二鸟:同时实现垃圾回收和压缩。这就是为什么它相对于之前提到的垃圾回收器来说是一个如此大的升级。

G1 GC 是一个优秀的垃圾收集器。你可能想知道这个垃圾收集器是否能够不停止世界来工作。不,压缩仍然需要以这种方式发生。但由于区域较小,暂停时间要短得多。

G1 GC 垃圾收集器的另一个新特性是字符串去重。这实际上就是你所想的:垃圾收集器运行一个进程来检查String对象。当它找到包含相同内容但引用堆上不同char数组的String对象时,它们将被更新为都指向同一个char数组。这使得另一个char数组有资格进行 GC,从而优化了内存使用。更令人兴奋的是,这一切都是完全并发发生的!此选项需要使用以下命令启用:-XX:+UseStringDeduplication

就像 CMS GC 一样,G1 GC 试图并发地做很多事情。因此,应用程序线程大多数时候不需要暂停。然而,如果 G1 GC 无法释放足够的内存,并且应用程序分配的内存超过了可以并发释放的内存,那么应用程序线程需要暂停。

G1 垃圾收集器是针对性能强大且内存空间大的系统的首选 GC。但这并不是最近添加的垃圾收集器。让我们看看 ZGC。

Z GC

Java 15 为我们提供了垃圾收集器的另一个生产就绪实现,即Z 垃圾收集器ZGC)。它并发地执行所有垃圾回收,并且不需要在每次暂停时使应用程序停止超过 10 毫秒。

它通过首先标记活动对象来实现这一点。它不保留映射,而是使用引用着色。引用着色意味着引用的活动状态存储为构成引用的位。这需要一些额外的位,这就是为什么 ZGC 只能在 64 位系统上运行,而不能在 32 位系统上运行。

通过使用重新定位来避免碎片化。这个过程与应用程序并行发生,以避免超过 10 毫秒的暂停,但这是在应用程序执行的同时发生的。

没有额外的测量,这可能会导致不愉快的惊喜。想象一下,我们正在尝试使用参考访问某个对象,但在做这个过程中,它被重新定位并且有了新的参考。旧的内存位置可能已经被覆盖或清除。在这种情况下,调试将是一场噩梦。

当然,Java 团队不会将存在那样问题的垃圾收集器推向生产。他们引入了加载屏障来处理这个问题。加载屏障在从堆加载引用时运行。它检查引用的元数据位,并根据结果,在检索结果之前可能或可能不会进行一些处理。这种魔法被称为重映射。

我们刚才讨论的五个垃圾回收器是目前写作本书时可以选择的主要选项。你的选择取决于你使用的 Java 版本、系统配置以及应用程序的类型。为了确保垃圾回收器表现良好,需要实施监控。这正是我们将在下一节中探讨的内容。

监控 GC

为了决定合适的垃圾回收器,你需要了解你的应用程序。有几个指标对于 GC 特别重要:

  • 分配率:应用程序在内存中分配对象的速度。

  • 堆内存占用:堆上存活的对象的数量和大小。

  • 突变率:内存中引用更新的频率。

  • 平均对象存活时间:对象平均存活的时间。一个应用程序可能有存活时间短的对象,而另一个应用程序可能有存活时间长的对象。

监控垃圾回收器的性能需要不同的指标。其中最重要的包括标记时间、压缩时间和 GC 周期时间。标记时间是指垃圾回收器在堆上找到所有存活对象所需的时间。压缩时间是指垃圾回收器释放所有空间并重新定位对象所需的时间。GC 周期时间是指垃圾回收器执行完整 GC 所需的时间。

每当可用的堆空间很少时,你会看到垃圾回收(GC)的 CPU 使用率增加。选择合适的内存量可以提高你应用程序的性能。可用的内存量越大,垃圾回收器的工作就越容易。

复制并压缩收集器需要足够的可用空间来进行复制和重新定位。当可用内存有限时,这是一个成本更高的过程。只能复制一小段内存来释放更多空间,希望下次能复制更多,依此类推。垃圾回收器的 CPU 使用率在低内存时最高。在另一端,在假设我们有无限内存的情况下,我们实际上根本不需要进行垃圾回收。

第六章中,我们将探讨如何使用 JVM 调优来管理内存,以改善 JVM 内存的功能。在那里,我们还将了解如何调整垃圾回收器。

摘要

在本章中,我们更深入地了解了堆的 GC 工作原理。当堆上的对象不再与栈有直接或间接的连接时,它们就有资格进行 GC。

垃圾回收器在标记阶段确定哪些对象有资格进行 GC。与栈有连接的对象会被标记。有资格进行 GC 的对象不会被标记。

在标记阶段之后,实际的删除操作发生在清除阶段。我们讨论了三种清除方式,即正常清除、压缩清除和复制清除。

然后,我们讨论了垃圾收集器的不同实现方式。其中之一是代际垃圾收集器。这些垃圾收集器专注于堆内存中的一代,因此,在标记阶段不需要扫描堆内存中的所有对象。之后,我们讨论了垃圾收集器的五种常见实现。

在下一章中,我们将聚焦于元空间(Metaspace)。

第五章:专注于元空间

第四章中,我们详细研究了垃圾回收。我们发现没有引用的对象有资格进行垃圾回收。实际上,垃圾收集器标记了那些有回溯到栈的对象,将它们标记为活动对象。垃圾收集器的清除阶段随后回收了未标记的对象(即已死亡的对象)的内存。

我们还研究了各种垃圾收集实现。根据您的具体标准,需要对每个实现进行评估。

本章重点介绍一个称为元空间的区域。我们将以下标题下检查元空间:

  • JVM 对元空间的利用

  • 类加载

  • 释放元空间内存

让我们从 JVM 对元空间的利用开始。

JVM 对元空间的利用

元空间是堆之外的本机内存的一个特殊区域。本机内存是操作系统提供给应用程序用于其自身使用的内存。JVM 使用元空间来存储与类相关的信息,即类的运行时表示。这是类的元数据;因此,元数据存储在元空间中。

元数据

元数据是关于数据的信息。例如,数据库中的列是关于列中数据的元数据。因此,如果列名为Name,而特定行的值是John,那么Name是关于John的元数据。

这项元数据包括以下内容:

  • 类文件

  • 类的结构和方法

  • 常量

  • 注解

  • 优化

因此,在元数据中,JVM 拥有与类一起工作所需的一切。

PermGen

在 Java 8 之前,元数据存储在一个称为PermGen永久代的区域(与堆连续)。PermGen 存储类元数据、内部字符串和类的静态变量。从 Java 8 开始,类元数据现在存储在元空间中,内部字符串和类/静态变量存储在堆上。

现在我们来检查类加载。

类加载

当第一次访问类时(例如,当创建类的对象时),类加载器定位类文件并在元空间中为其分配元数据。类加载器拥有分配的元空间,并且类加载器实例本身被加载到堆上。一旦加载,后续的引用将重用该类相同的元数据。

在此阶段,有两个类加载器值得提及:引导类加载器(负责加载类加载器本身)和应用类加载器。这两个类加载器的元数据永久存储在元空间中,因此永远不会被垃圾回收。动态类加载器(以及它们加载的类)另一方面,有资格进行垃圾回收。

这导致我们从元空间中释放内存。

释放元空间内存

从 PermGen(Java 8 之前)到 Metaspace(Java 8 及以后)的一个主要变化是,Metaspace 现在可以按需增长大小。默认情况下,分配给 Metaspace 的内存量是无界的,因为它属于本地内存的一部分。Metaspace 的大小可以使用 JVM 的 –XX:MetaspaceSize 标志进行自定义。

Metaspace 只能在两种情况下触发垃圾回收:

  • Metaspace 内存耗尽

  • Metaspace 的大小超过了 JVM 设置的阈值

让我们逐一检查这些内容。

Metaspace 内存耗尽

如前所述,默认情况下,Metaspace 可用的本地内存是无限的。如果你耗尽内存,你会收到一个 OutOfMemoryError 信息,这将触发垃圾回收器的运行。你可以使用 JVM 的 –XX:MaxMetaspaceSize 标志来限制 Metaspace 的大小。如果你达到这个限制,也会触发垃圾回收器的运行。

Metaspace 的大小超过了 JVM 设置的阈值

我们可以配置 JVM,当 Metaspace 达到某个特定阈值时触发垃圾回收,这个阈值被称为 -XX:MetaspaceSize 标志。我们使用 –XX:MinMetaspaceFreeRatio–XX:MaxMetaspaceFreeRatio 标志来分别提高或降低高水位线。

既然我们已经知道了垃圾回收在 Metaspace 中运行的时间,让我们来检查垃圾回收在 Metaspace 中的工作方式。

Metaspace 的垃圾回收

由于类加载器拥有类的元数据,垃圾回收器只能在类加载器本身死亡时回收这些元数据。类加载器只有在没有由该加载器加载的任何类的实例时才会死亡。

让我们通过一个例子来进一步解释这一点。该例子假设了一个动态类加载器,并使用简化的图表来便于解释。

图 5.1 详细说明了我们在创建了两个 O 类型的对象和一个 P 类型的对象后内存中的情况。

图 5.1 – Metaspace 分配

图 5.1 – Metaspace 分配

在前面的图中,最初,JVM 在堆上创建了类加载器对象(深蓝色),两个 O 类型的对象(浅蓝色)和一个 P 类型的对象(黄色)。OP 引用在栈上。在创建第一个 OP 实例时,类加载器在 Metaspace 中加载了 OP 的元数据。然而,在创建第二个 O 实例时,Metaspace 中没有发生任何事情,因为 O 的元数据已经加载。

图 5.2 将展示当两个 O 引用都超出作用域但垃圾回收尚未运行时内存中的情况:

图 5.2 – Metaspace(两个 O 引用超出作用域)

图 5.2 – Metaspace(两个 O 引用超出作用域)

如您所见,JVM 已经从栈中弹出了两个 O 引用。由于垃圾回收尚未运行,实例仍然在堆上。图 5.3 展示了第一次运行垃圾回收后的情况:

图 5.3 – 垃圾回收后的元空间(运行#1)

图 5.3 – 垃圾回收后的元空间(运行#1)

在前面的图中,我们可以看到垃圾收集器从堆中回收了两个(已死亡)O对象。此外,垃圾收集器将类加载器和P对象都移动到了幸存空间。

注意,即使没有O类型的对象在堆上,O的元数据仍然保留在元空间中。这是因为垃圾收集器由于堆上存在P类型的对象(同一个类加载器同时加载了OP)而无法回收O的类加载器。

图 5**.4展示了当P引用超出作用域且再次运行垃圾收集时的内存情况:

图 5.4 – 垃圾回收后的元空间(运行#2)

图 5.4 – 垃圾回收后的元空间(运行#2)

我们可以看到 JVM 已经从栈中弹出P的引用。因此,垃圾收集器回收了P类型的对象。

由于垃圾收集器现在已经回收了OP类型的所有实例,它可以回收加载OP的类加载器。现在,最终,垃圾收集器可以回收元空间中OP类的元数据。

本章到此结束。让我们回顾一下主要观点。

摘要

在本章中,我们聚焦于元空间(以前称为 PermGen)。元空间是非堆内存的一个特殊区域,用于存储类的元数据。元数据包括使 JVM 能够与类一起工作的信息:例如,方法字节码、常量和注解。当一个类首次使用时,其元数据被加载到元空间中。例如,首次创建一个对象。

默认情况下,元空间可用的本地内存是无限的。可以使用 JVM –XX:MaxMetaspaceSize标志配置最大元空间大小。可以使用–XX:MetaspaceSize标志最初设置一个阈值值或高水位标记。如果设置了阈值值并达到,这将引发垃圾收集器的运行。通过结合使用 JVM 标志–XX:MinMetaspaceFreeRatio–XX:MaxMetaspaceFreeRatio以及垃圾收集结果,我们可以动态地影响高水位标记,因此影响垃圾收集器下一次运行的间隔。

我们通过一个例子看到了一个类的元数据如何保留在元空间中,直到垃圾收集器释放加载该类的类加载器。这只有在所有由该类加载器加载的类都没有实例时才能发生。

现在我们已经聚焦于元空间,我们将把注意力转向下一章,本章将重点介绍配置和监控 JVM 的内存管理。

第六章:配置和监控 JVM 的内存管理

到目前为止,我们已经探讨了内存的不同区域以及它是如何被释放的,但我们还没有探讨优化 Java 虚拟机JVM)执行此操作的方式。JVM 用于管理内存的方法可以通过不同的方式进行配置。

虽然没有一种明显的方式来配置 JVM,但最佳配置实际上取决于应用程序和需求。获得最佳配置将提高应用程序的性能并最小化内存需求。监控性能和内存将有助于在用户之前发现问题。

在本章中,我们将探讨如何配置 JVM 和监控内存管理。调整 JVM 的配置通常是通过调优来完成的,这意味着你有一个起点,然后进行小的调整,并仔细测量它们的影响。以下是将要讨论的主题:

  • JVM 内存管理调优的基本知识

  • 获取内存管理的相关指标

  • Java 应用程序的性能分析

  • 调整 JVM 的配置

技术要求

本章的代码可以在 GitHub 上找到,链接为 github.com/PacktPublishing/B18762_Java-Memory-Management

JVM 内存管理调优的基本知识

对于性能改进的 JVM 调优,可能是最后的选择。看看这个代码片段:

int i = 0;
List<Integer> list = new ArrayList<>();
while(i < 100) {
    list.add((int)Math.ceil(Math.random()*1000));
}

JVM 调优有帮助吗?不,因为我们陷入了无限循环,因为 i 永远不会增加。当然,还有很多不那么明显的例子,但当代码可以被改进和优化时,这必须在考虑 JVM 调优之前完成。

如果硬件可以实际优化,这也应该在 JVM 调优之前完成。通过这种方式,我并不是说你应该通过仅仅增加更多内存来修复内存泄漏;当然,这并不是一个解决方案。但当你应用程序意外取得巨大成功而事情变得缓慢时,你升级硬件可能比深入 JVM 调优来修复这个问题更有利。当所有其他影响性能的因素都得到优化时,这就是 JVM 调优可以应用于性能改进的时候。

当我们调整 JVM 时,我们正在设置参数。但这还不算完;这需要被仔细监控。在更改任何设置之前,我们必须确保对应用程序的指标有一个很好的了解。这些新设置需要被仔细监控。如果性能有所提高,你可以尝试稍微调整参数;如果它变得更差,你可能至少需要稍微改回来以衡量差异。

到目前为止,这听起来可能像是试错,在某种程度上确实如此——这是专业进行的试错。让我们看看相关的指标来调整 JVM 的内存管理。

获取内存管理的相关指标

了解应用程序内存状况的重要指标有几个。理解以下三个定义应用程序性能的重要概念是第一步:

  • 功能良好的内存

  • 正常延迟

  • 正常的吞吐量水平

让我们逐一查看这些内容。

功能良好的内存

当您对特定应用程序有经验时,您可能知道其稳定的内存使用点。然而,需要比稳定的内存使用点更多的内存。相反,Java 应用程序需要有一定的安全内存可用,并且这部分预留内存不应接近满载。相反,为 Java 应用程序分配过多的内存也不是一个好的选择。这是因为系统的其余部分也需要一些内存来运行其他进程,因为操作系统也在运行。

如果您对应用程序在良好运行时的正常内存指标有所了解,这将有助于您测量您可能后来做出的任何调整的结果。

正常延迟

延迟也称为应用程序的响应性。具有正常延迟的应用程序会按预期和需求响应。这可以通过时间来衡量——例如,应用程序处理某个请求所需的时间,如处理传入的 HTTP 请求。

当然,测量延迟并不总是那么容易。如果我们有一个独立的 Java 应用程序,这相对简单。我们知道我们正在测量应用程序的延迟。如果我们试图测量企业应用程序的延迟,这就会变得复杂。我们需要确保我们测量的是应用程序的延迟,而不是网络问题、另一个应用程序的服务器端或我们企业应用程序景观中的任何层。在这些情况下,延迟结果的问题可能并不与我们的应用程序的内存管理相关。

吞吐量水平

吞吐量是在一定时间内应用程序可以完成的工作量。通常,您希望追求高吞吐量,但这需要更多的内存,可能会影响延迟。

分析 Java 应用程序

性能分析用于分析应用程序的运行时性能。这是一项需要谨慎处理的工作,因为它通常会对被分析的应用程序产生影响。因此,如果可能的话,建议对开发环境进行性能分析。我们将探讨使用jstatjmap命令行工具以及VisualVM应用程序进行性能分析。前两者包含在您的Java 开发工具包JDK)中;后者曾经包含在其中,但现在可以单独下载。

重要注意事项

您可以在此处下载 VisualVM:visualvm.github.io/download.xhtml.

还有其他配置文件;一些 IDE 甚至内置了自己的分析器,它们的工作方式类似。

使用 jstat 和 jmap 进行性能分析

使用两个命令行工具jstatjmap,我们可以分析和分析内存。我们将探讨如何做到这一点。

假设我们有一个简单的 Java 应用程序:

package chapter6;
import java.util.ArrayList;
import java.util.List;
public class ExampleAnalysis {
   public static List<String> stringList = new ArrayList<>();
    public static void main(String[] args) {
        for(int i = 0; i < 1000000000; i++) {
            stringList.add("String " + i);
            System.out.println(stringList.get(i));
        }
    }
}

这个应用程序并没有做很多有趣的事情,只是向我们的stringList静态列表中添加了很多String对象。

我们可以运行这个程序并查看内存中发生了什么。为了做到这一点,我们首先需要编译程序:

javac ExampleAnalysis.java

之前的命令假设您在命令提示符或终端的同一文件夹中,因为我们直接访问文件而没有前面的文件夹。此命令编译代码并将结果存储在ExampleAnalysis.class中。让我们通过执行以下命令来运行此文件(确保在第六章目录的上一个级别):

java chapter6.ExampleAnalysis

现在,我们首先需要进程 ID,以便使用jstat分析我们的代码。我们可以在命令行中运行以下命令来获取所有 Java 进程的进程 ID:

jps

命令会产生以下输出:

35169 Launcher
35397 Jps
30565
35384 ExampleAnalysis
34846

我们的程序很容易识别,因为它在其后写有类的名称。因此,我们的进程 ID 是35384

我们需要这个进程 ID 来运行jstat分析。这个命令行工具有几个选项。我们将首先以这种方式运行它:

jstat -gc -t 35384 1000 10

这将为我们具有 ID 35384的进程产生结果。-gc选项是可用于获取垃圾收集堆统计信息的选项之一。它确保显示垃圾收集堆的行为。还有一些其他标志也可以使用;这里有一些示例:

  • gccapacity: 显示各代容量的数据

  • gcnew: 显示年轻代行为的数据

  • gcnewcapacity: 显示年轻代大小的数据

  • gcold: 显示旧代和元空间的数据

  • gcoldcapacity: 显示旧代的数据

  • gcutil: 显示垃圾回收数据的摘要

-t表示应该打印时间戳。1000意味着它将每 1,000 毫秒显示一次统计信息,而10表示它将显示 10 次迭代。

输出将看起来像图 6**.1所示:

图 6.1 – 显示带有选项的 jstat 命令的输出

图 6.1 – 显示带有选项的 jstat 命令的输出

如您所见,它显示了多个列。让我们看看这些列代表什么;对于讨论来说,确切的值并不太重要。我们将从左到右逐一介绍:

  • Timestamp: 程序开始运行的时间。您可以看到它随着秒数的增加而增加,这是有道理的,因为我们请求了 1,000 毫秒的迭代。

  • S0C: 存活空间 0 当前的容量,单位为 KB。

  • S1C: 存活空间 1 当前的容量,单位为 KB。

  • S0U: 在 KB 中使用的幸存空间 0 的部分。

  • S1U: 在 KB 中使用的幸存空间 1 的部分。

  • EC: Eden 空间当前容量,单位为 KB。您可以看到,当 Eden 空间变满时,容量会扩大。

  • EU: 在 KB 中使用的 Eden 空间的部分。在第 7 行,它下降,数据被移动到旧空间。

  • OC: 旧空间当前容量,单位为 KB。

  • OU: 在 KB 中使用的旧空间的部分。您可以看到它在程序运行期间增加。

  • MC: 元空间当前容量,单位为 KB。

  • MU: 在 KB 中使用的元空间的部分。

  • CCSC: 压缩类空间容量,单位为 KB。

  • CCSU: 已使用压缩类空间,单位为 KB。

  • YGC: 发生的年轻代垃圾回收事件的数量。

  • YGCT: 年轻代垃圾回收事件的总时间。

  • FGC: 完全垃圾回收事件的总数。

  • FGCT: 完全垃圾回收事件的总时间。

  • CGC: 并发垃圾回收。

  • CGCT: 并发垃圾回收的总时间。

  • GCT: 垃圾回收的总时间。

使用jmap命令,我们可以更深入地了解当前进程的堆内存使用情况。以下是使用方法(Java 9 及以后版本):

jhsdb jmap --heap --pid 35384

jhsdb是一个 JDK 工具,可以附加到正在运行的 Java 进程,执行快照调试,并检查崩溃 JVM 的核心转储内容。这输出了当前的堆配置和用法。让我们看看如何借助 VisualVM 在 Java 分析中获得更直观的结果。

使用 VisualVM 进行分析

有许多分析器可以提供内存的视觉表示。其中之一是 VisualVM。这是一个适合获取正在运行的 Java 应用程序详细信息的工具。VisualVM 不再默认包含在 JDK 中,因此需要在此处单独安装:visualvm.github.io/

如果您的 IDE 支持分析,您也可以使用它。然而,以下示例使用 VisualVM,因为它是一个免费工具,可以轻松下载。使用 VisualVM 分析应用程序很简单。首先,启动 VisualVM。您将看到一个类似于图 6.2的屏幕。

图 6.2 – VisualVM 的启动屏幕

图 6.2 – VisualVM 的启动屏幕

图 6.2的屏幕上,我们可以检查正在运行的应用程序。应用程序标签位于左上角,垂直排列。在这里,我们可以看到正在运行的本地 Java 进程,我们可以简单地选择所需的进程。让我们开始我们的示例 Java 应用程序,我们将创建一个巨大的字符串列表。

应用程序标签中,我们可以看到进程,如图图 6.3所示。

图 6.3 – Java 进程概述

图 6.3 – Java 进程概述

现在,我们可以选择我们想要检查的进程。在这种情况下,我们想要分析具有6450 PID 的进程。一旦我们点击它,我们就会得到进程的概述,如图 6.4所示。

图 6.4 – Java 进程概述

图 6.4 – Java 进程概述

我们可以在图 6.4所示的概述中看到数据摘要。我们看到我们正在分析的过程、我们运行的 JVM 和 Java 版本,以及启动应用程序时使用的 JVM 参数。VisualVM 还可以提供更多详细的数据。在顶部,我们有几个标签页:概述监控线程采样器分析器。我们已经看到了概述标签;在图 6.5中,让我们看看监控标签下的数据。

图 6.5 – 使用 VisualVM 监控 Java 进程

图 6.5 – 使用 VisualVM 监控 Java 进程

这是我们获取应用程序中正在发生的一些重要细节的地方。我们看到四个图表。左上方的图表显示CPU 使用率,如您所见,我们为这个程序使用了相当多的 CPU。这个图表还显示了垃圾回收活动,总体上非常低。这是有道理的,因为实际上没有太多东西需要垃圾回收。垃圾回收活动与右上方的内存图表结合,为我们提供了关于应用程序内存健康状况的深刻见解。如果垃圾回收器工作非常努力(如您所见,左上方的图表中有大量的 GC 活动),并且内存持续增加(右上方的图表中代表已使用堆的较低线条),这意味着我们可能存在内存泄漏问题。基本上,如果 GC 周期过于频繁,那么这可能表明你需要深入挖掘,看看 GC 和内存是否存在问题。在这样做之后,如果 GC 周期仍然过于频繁,并且内存也没有下降,那么这就是一个红色警报,你必须进行调查。实际上,如果 JVM 在 GC 上花费了超过 98%的时间,并且恢复的堆小于 2%,则会抛出OutOfMemoryError: GC Overhead limit exceeded错误。

两个底部的图表显示了加载的 Java 类(左侧)和应用程序中的线程(右侧)。我们可以通过切换到线程标签来获取更多关于线程的详细信息。在图 6.6中,我们看到了我们应用程序中线程的概述。

图 6.6 – 我们应用程序中的线程

图 6.6 – 我们应用程序中的线程

我们可以在最左侧看到我们线程的名称。条形图表示我们的线程随时间的状态——例如,运行或等待。然后我们可以看到它们运行的时间。

采样器标签页中,如图 6.7所示,我们可以看到 CPU 或内存的表现情况。

图 6.7 – VisualVM 中的采样器标签页

图 6.7 – VisualVM 中的采样标签页

我们现在正在查看内存采样,它显示了有多少活动对象以及某个类占用了多少空间。在这里,byte数组是最大的。这很有道理,因为字符串的值存储在byte数组中。您还可以按线程过滤此概述,或查看 CPU 的性能。

在最后一个标签页中,我们可以看到剖析。剖析和采样用于类似的目的,但过程不同。采样是通过制作线程转储并分析这些转储来完成的。剖析需要在应用程序中添加一些逻辑,以便在发生某些事情时发出信号。这将对应用程序的性能产生相当大的影响。因此,您不希望在运行中的应用程序上执行此操作。尽管如此,它可以提供很多见解。

您可以在图 6.8中看到对所有类进行剖析的结果。在这里,您可以看到与采样(尽管在那个时间点分配的对象较少)相似的结果。在这种情况下,采样同样有效。

VisualVM 非常适合快速直观地了解应用程序内存的情况。在调整 JVM 和检查结果时,这将特别有用。在下一节中,我们将做的是——学习如何调整 JVM 的配置并查看这些调整的影响。

图 6.8 – 分析所有类

图 6.8 – 分析所有类

调整 JVM 的配置

JVM 的设置可以调整。调整 JVM 设置的过程称为调整。这些调整的想法是提高 JVM 的性能。再次强调,调整不应是提高性能的第一步。好的代码始终应该是首要的。

我们将查看与内存管理相关的设置:堆大小、元空间和垃圾收集器。

调整堆大小和线程栈大小

堆大小可以更改。通常,将堆大小设置为服务器可用内存的一半以下是一个最佳实践。这可能会导致性能问题,因为服务器还将运行其他进程。

默认大小取决于系统。此命令将在 Windows 系统上显示默认值:

java -XX:+PrintFlagsFinal -version | findstr HeapSize

此命令显示了 macOS 系统的默认输出:

java -XX:+PrintFlagsFinal -version | grep HeapSize

输出以字节为单位显示。您可以在图 6.9中看到我电脑的输出。

图 6.9 – macOS 系统上的输出

图 6.9 – macOS 系统上的输出

堆的大小会影响垃圾收集。这乍一看可能有些反直觉,但让我们在这里进行一个小小的思维实验。如果我们有无限的堆内存,我们需要垃圾收集吗?不,对吧?为什么运行这样一个昂贵的进程,如果我们根本不需要释放内存呢?

堆越小,我们越需要垃圾收集器活跃,因为它需要更努力地工作以保持空间可用,因为内存更容易被填满。然而,堆越大,完整的垃圾收集周期就越长。需要扫描的垃圾就越多。一个很好的经验法则是,你希望应用程序执行时间中少于 5%用于垃圾收集。

实际的调整对于不同的服务器可能不同。在这里,我们将通过启动应用程序时使用命令行来展示如何进行。请注意,我们设置的选项名称在不同的服务器之间是相同的,但设置它们的方式或位置可能不同。

当我们启动 Java 应用程序时,我们可以使用不同的内存选项。我们可以指定内存池的起始大小、最大内存池和线程栈大小。以下是设置所有为 1,024 MB 的方法:

  • -Xms1024m(初始堆大小)

  • -Xmx1024m(最大堆大小)

  • -Xss1024m(线程栈大小)

如果你想要将其设置为不同的大小,选择一个不同的大小,并相应地调整选项。你可以使用以下命令以调整后的内存设置启动 Java 应用程序(在 64 位系统上):

java -Xms4g -Xmx6g ExampleAnalysis

这将使我们的示例 Java 应用程序以 4 GB 的初始堆大小和 6 GB 的最大堆大小启动。

与你可以使用–Xmx–Xms绑定总堆大小的方式类似,你可以使用以下方式绑定年轻代大小:

  • -XX:MaxNewSize=1024m(最大新大小)

  • -XX:NewSize=1024m(最小新大小)

在这里,我们将最小和最大内存大小设置为 1,024 MB。我们可能会耗尽内存。这将导致OutOfMemoryError错误。让我们看看当这种情况发生时如何获取堆转储,以便我们可以检查出了什么问题。

记录低内存

当应用程序因内存不足错误而结束时,获取堆转储非常有帮助。堆转储是应用程序内存中对象的快照。在这种情况下,我们可以检查在内存不足时存在于应用程序中的对象。这样,我们可以用它来查看哪个对象可能溢出内存。

如果你希望 JVM 在发生OutOfMemoryError异常时创建堆转储,那么你可以在启动 JVM 时使用以下 JVM 参数:

java -XX:+HeapDumpOnOutOfMemoryError ExampleAnalysis

我们也可以指定路径:

java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/some/path/to/dumps ExampleAnalysis

使用这种方式,堆转储将被存储在指定的路径中。创建堆转储有不同的方法——例如,如果应用程序不是因为OutOfMemoryError而崩溃,jmap也可以用来创建应用程序的堆转储。

接下来,让我们看看如何配置 Metaspace。

调整 Metaspace

Metaspace 的默认行为相当特殊,因为它似乎有一个限制。由于这个限制不是真正的限制,因此很容易误解,如果达到这个限制,它将查看在垃圾回收方面可以做什么,然后它就会扩展。因此,仔细设置以下变量很重要:

  • 使用 -XX:MaxMetaspaceSize=2048m 设置最大空间大小为 2048 兆字节

  • 使用 -XX:MetaspaceSize=1024m 设置垃圾回收的阈值大小为 1024 兆字节

  • 使用以下设置最小和最大空闲比率:

    • -XX:MinMetaspaceFreeRatio=50 设置最小空间使用率阈值为 50%

    • -XX:MaxMetaspaceFreeRatio=50 的最大空间使用率设置为 50%

最小和最大空闲比率非常适合您计划动态加载大量类的情况。通过确保有足够的内存可用,您可以提高动态加载类的速度。这是因为为需要加载的类释放内存需要一些 CPU 时间。我们可以通过选择足够大的空闲比率并确保内存可用来跳过需要分配额外内存的步骤。在先前的示例中,它们被设置为 50%。

垃圾回收调优

如您现在可能已经意识到的,垃圾回收是一个昂贵的进程。优化它确实可以帮助提高应用程序的性能。您不能自己触发垃圾回收;这是 JVM 的决定。您可能已经听说过以下建议 JVM 进行垃圾回收的方法:

System.gc();

这并不保证会发生垃圾回收。因此,您不能触发垃圾回收,但您可以影响 JVM 处理垃圾回收的方式。

然而,在调整与垃圾回收相关的任何设置之前,确保您确切了解您正在做什么非常重要。为此,您需要关于垃圾收集器的扎实知识。

在调整任何设置之前,您必须查看内存使用情况。确保了解哪些空间被填满以及何时发生这种情况。一个健康的堆在 VisualVM 中看起来有点像一把锯子。它上下波动,形成尖峰,类似于锯齿。它有一定量的已使用内存,然后垃圾回收器出现并将已使用内存降低到一定的基础水平。它再次增长,然后在大约相同的使用水平上,垃圾回收器再次出现并将它降低到基础水平,以此类推。

如果您看到内存随着时间的推移而增长,并且每次垃圾回收结束时都略微高于基础水平,那么您可能有一个需要处理的内存泄漏。正如我们在 第四章 中所看到的,有几种不同的垃圾收集器实现可用。在启动 JVM 时,我们还可以选择希望它使用的垃圾收集器:

  • -XX:+UseSerialGC 使用串行垃圾回收器

  • -XX:-UseParallelGC 禁用并行垃圾回收器

  • -XX:+UseConcMarkSweepGC 使用并发标记清除垃圾回收器

  • -XX:+G1GC 使用 G1 垃圾回收器

  • -XX:+UseZGC 使用 ZGC 垃圾回收器

这并不是每个系统都能做到的,所有这些垃圾回收选择都附带了自己的额外选项。例如,我们可以选择并行垃圾回收器并指定垃圾回收器的线程数:

java -XX:+UseParallelGC -XX:ParallelGCThreads=4 ExampleAnalysis

这就是如何使用并行垃圾回收器启动应用程序,并给它分配四个线程来工作。所有垃圾回收器的选项过于复杂,无法详细讨论。详细信息可以在您使用的 Java 实现的官方文档中找到。以下是 Oracle 实现的链接,尽管在您阅读这本书的时候,可能会有更新的版本发布:docs.oracle.com/javase/9/gctuning/introduction-garbage-collection-tuning.htm.

摘要

在本章中,我们了解了在调整 JVM 时需要注意的事项。我们需要关注内存功能、延迟和吞吐量。

为了监控我们的应用程序表现如何,我们可以使用配置文件。我们已经看到了如何使用 JDK 默认提供的jstat命令行工具。之后,我们看到了如何使用 VisualVM 来更好地可视化正在发生的事情。

接下来,我们看到了如何调整我们的应用程序的堆、元空间和垃圾回收器。我们也看到了这些调整对我们简单示例应用程序的影响。

再次强调,请记住,调整 JVM 以提升性能应该是最后的步骤,并且应该首先采取更明显的行动,例如改进代码。

在介绍完这些内容后,您现在可以查看如何在下一章中避免内存泄漏。

第七章:避免内存泄漏

在上一章中,我们探讨了如何配置和监控 JVM 的内存管理。这涉及到与 JVM 调优相关的指标知识。我们讨论了如何获取这些指标,以及如何据此调整 JVM。我们还探讨了如何使用分析来深入了解调整的影响。

本章重点介绍内存泄漏。我们将以下标题下探讨内存泄漏:

  • 理解内存泄漏

  • 发现内存泄漏

  • 避免内存泄漏

让我们从理解内存泄漏开始。之后,我们将学习如何在我们的代码中找到它们,并了解如何避免和解决它们。

技术要求

本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/B18762_Java-Memory-Management

理解内存泄漏

当不再需要的对象没有被释放时,就会发生内存泄漏。这导致这些对象在内存中积累。鉴于内存是一种有限的资源,这最终可能导致你的应用程序变慢,甚至崩溃(出现内存不足OOM)错误)。

即使拥有快速的服务器或在云端托管你的应用程序,也无法让你摆脱糟糕的内存管理(内存泄漏)的影响。正如之前所述,内存是一种有限的资源,即使是快速的服务器也可能耗尽内存。如果在云端部署,简单地扩展以解决内存泄漏问题可能很有吸引力;然而,这会导致部署比实际需要的更大的实例的成本增加。甚至可能导致昂贵的云服务账单。

你耗尽内存的速度取决于内存泄漏发生在你的代码的哪个位置。如果这是一段很少运行的代码,那么内存填满需要很长时间。然而,如果这是一段经常运行的代码,它可能去得很快。

虽然内存泄漏的原因可能各不相同,但一个可能的原因是代码中的错误。这让我们转向下一个主题:发现内存泄漏。

发现内存泄漏

因此,你可能想知道通常情况下,当你的应用程序运行一段时间后开始响应变慢时会发生什么。系统管理员可能会时不时地重新启动应用程序以释放不必要的累积内存。这种需要重新启动的需求是内存泄漏的典型症状。

由于内存泄漏,内存逐渐填满,应用程序会变慢,甚至崩溃。虽然应用程序变慢不一定是由内存泄漏引起的,但这通常是这种情况。当你面对你怀疑含有内存泄漏的代码时,以下指标对于诊断应用程序非常有帮助:

  • 堆内存足迹

  • 垃圾收集活动

  • 堆转储

为了演示如何监控这些指标,我们需要一个包含内存泄漏的应用程序。图 7.1 展示了这样一个程序:

图 7.1 – 含有内存泄漏的程序

图 7.1 – 存在内存泄漏的程序

图 7.1中,我们从第 15 行开始进入一个无限循环,创建Person对象并将它们添加到ArrayList对象中。每当Person引用(p)被重新初始化时,很容易认为之前引用的每个Person对象现在都符合垃圾回收的条件。然而,事实并非如此,因为这些Person对象正被ArrayList对象引用,因此垃圾回收器无法回收它们。因此,虽然无限循环最终会导致程序耗尽内存,但内存泄漏本身是因为垃圾回收器无法回收Person对象。让我们看看我们如何诊断正在运行的代码,以帮助我们得出这个结论。

我们将使用命令行运行这个程序,因为我们很容易指定如果堆耗尽内存,我们希望将堆转储到文件中。当前目录是:

C:\Users\skennedy\eclipse-workspace\MemoryMgtBook\src\

命令行中的以下命令(为了清晰起见,分多行书写)实现了这一点:

java
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=C:\Users\skennedy\eclipse-workspace\MemoryMgtBook\src\ch7
ch7.OutOfMemoryExample

这里有趣的部分是指定的–XX选项。在第一种情况下,我们开启了HeapDumpOnOutOfMemoryError选项。这意味着如果堆耗尽内存,JVM 会将堆转储到一个文件中。我们现在需要指定该文件的位置和名称。这就是第二个–XX选项所做的事情,使用HeapDumpPath标志。

现在我们已经启动了受内存泄漏影响的应用程序,我们将使用VisualVM应用程序来监控感兴趣的指标。VisualVM 是以前与 Java SDK 一起提供的应用程序,但现在您必须从visualvm.github.io/download.xhtml(注意,这是撰写本文时的有效链接)单独下载。让我们从使用堆内存占用开始我们的诊断。

堆内存占用

我们在这里寻找的不是堆的大小,而是堆使用量。我们也非常关注垃圾回收器是否回收了使用的堆。图 7.2显示了图 7.1中概述的应用程序的堆占用:

图 7.2 – 堆内存占用

图 7.2 – 堆内存占用

如前一个屏幕截图所示,使用的堆(x轴和图表线之间的区域)迅速占据了所有可用的堆空间。垃圾回收器确实设法回收了一些内存(左边的下降),但这不是我们应用程序分配的内存。程序因OutOfMemoryError错误而耗尽内存并崩溃。这就是为什么使用的堆回到0的原因。

让我们检查这个期间垃圾回收器的活动。

垃圾回收器活动

在上一节中,我们看到了包含内存泄漏的应用程序对堆足迹的影响。有趣的是,检查垃圾收集器在那段时间的活动。图 7**.3反映了这一点:

图 7.3 – 垃圾收集器活动

图 7.3 – 垃圾收集器活动

图 7**.3显示垃圾收集器在程序运行期间非常忙碌。然而,根据图 7**.2,我们知道这并没有对释放堆上的空间(由我们的应用程序分配)产生影响。因此,尽管垃圾收集器很忙碌,堆仍然满载。这是一个典型的内存泄漏迹象。

因此,我们现在已经验证了我们的程序中存在内存泄漏。下一步是找出导致泄漏的原因。在我们的例子中,这相当明显,但为了更好地理解,让我们进一步调查。下一步将是查看我们的程序崩溃时 JVM 创建的堆转储。

堆转储

当我们运行应用程序时,我们指定了如果应用程序内存不足,则创建堆转储。这将使我们能够进一步调试为什么最初会耗尽内存。图 7**.4展示了生成的堆转储摘要:

图 7.4 – 堆转储摘要

图 7.4 – 堆转储摘要

图 7**.4中的两个值立即跳入眼帘。第一个是实例的数量(第一个箭头)。在205,591,192,这太多了。现在,我们需要知道导致内存泄漏的实例类型。第二个红色箭头突出显示ch7.Person为违规类型,因为仅该类型就有205,544,625个实例。

堆转储还使我们能够进一步深入。在这种情况下,我们将这样做,因为我们想看看是什么阻止了这些Person对象的垃圾收集。图 7**.5将帮助我们讨论这一点:

图 7.5 – 堆转储深入分析

图 7.5 – 堆转储深入分析

在前面的屏幕截图中,我们已经从摘要级别深入到对象级别。正如我们所知,有很多Person对象。通过深入到任何一个Person对象,我们可以看到引用它的类型。正如在其中一个Person对象(以蓝色突出显示)中所示,我们可以看到它是一个ArrayList对象。

现在,我们对正在发生的事情有了更清晰的认识。我们正在向一个ArrayList对象添加Person对象,其引用永远不会超出作用域。因此,垃圾收集器无法从堆中移除这些Person对象中的任何一个,最终导致OutOfMemoryError错误。

总结来说,在本节中,我们诊断了一个包含内存泄漏的程序。通过使用堆内存足迹和垃圾回收活动,我们确认了内存泄漏的存在。然后我们分析了堆转储,以确定导致问题的集合(ArrayList)和类型(Person)。下一节将讨论如何从一开始就避免内存泄漏。

避免内存泄漏

避免内存泄漏的最佳方式是编写不包含任何泄漏的代码。换句话说,我们不再需要的对象不应有回连到栈上的连接,因为这会阻止垃圾收集器回收它们。在我们探讨帮助您避免代码中泄漏的技术之前,让我们首先修复 图 7.1 中展示的泄漏。图 7.6 展示了无泄漏代码:

图 7.6 – 无泄漏程序

图 7.6 – 无泄漏程序

图 7.6 中,无限循环仍然存在。然而,第 19 行第 23 行 是新的。在这个新部分中,我们每次向 ArrayList 对象添加一个 Person 引用时,都会增加一个 i 本地变量。当我们这样做 1,000 次之后,我们重新初始化我们的 list 引用。这是至关重要的,因为它使垃圾收集器能够回收旧的 ArrayList 对象和从 ArrayList 对象引用的 1,000 个 Person 对象。此外,我们将 i 重置回 0。这将解决泄漏问题。(如果您发现这个特定示例有实际应用场景,请发送电子邮件给我们,我们将将其添加到下一版书籍中。然而,它确实很好地说明了示例图表。)

我们现在将使用与之前相同的命令行参数运行程序。程序不会生成 OutOfMemoryError 错误。我们将现在使用 VisualVM 检查代码的性能。图 7.6 反映了新无内存泄漏代码的堆内存足迹:

图 7.7 – 堆内存足迹(无泄漏代码)

图 7.7 – 堆内存足迹(无泄漏代码)

如前一个屏幕截图所示,使用的堆空间(x 轴和图表之间的区域)上下波动。下降区域反映了垃圾收集器回收内存的地方。这种模式类似于锯齿,是健康程序的标志。在最后,我们停止了程序的运行。

接下来,我们将查看那段时间的垃圾收集器活动。图 7.8 反映了这一点:

图 7.8 – 垃圾收集器活动(无泄漏代码)

图 7.8 – 垃圾收集器活动(无泄漏代码)

图 7.3(表示有内存泄漏的代码的图表)中,垃圾收集器运行在超过 5% 的水平。然而,在 图 7.8 中,垃圾收集器几乎完全不可见,几乎与 x 轴相同。这再次是健康程序的标志。由于这个程序没有耗尽堆空间,因此不需要堆转储。

常见陷阱及其避免方法

现在我们已经解决了内存泄漏问题,我们将回顾代码中的一些常见问题以及如何避免它们。我们将讨论一些技术,这些技术将使我们能够编写无泄漏的代码,并使用内存以最佳方式,而不浪费我们实际上不需要使用的资源。

一些提示比较明显,不需要很多例子,例如,如果系统允许,为你的程序分配足够的堆空间,以及不要创建不需要的对象,并在可能的情况下重用对象。其中一些需要更多的解释,我们将在下面详细说明。

栈上的不必要引用和将引用设置为 null

可能存在栈上的引用实际上不再需要。在我们前面的例子中就是这样。

重新初始化引用(或将它设置为null)是本节中用来修复内存泄漏的方法。两种方法都切断了与栈的链接,使垃圾收集器能够回收堆内存。不过,请注意,只有在你应用程序完成与对象的交互时才这样做;否则,你会得到NullPointerException异常。你可以看到以下示例:

Person personObj = new Person();
// use personObj
personObj = null;

在这个例子中,我们有一个对象引用存储在personObj中;当我们不再需要它时,我们将其设置为null。这样,在将引用设置为null的行之后,堆上的Person对象就变得适合垃圾回收(假设我们没有将引用分配给其他变量)。

这种方法是否仍然适用于今天的软件是有疑问的;对于大多数现代应用程序,这种方法不太受欢迎,但当然,也可能有合理的用例。

资源泄漏和关闭资源

当你打开资源,如文件、数据库、流等时,它们会占用内存。如果这些资源没有被关闭,这可能导致资源泄漏。在某些情况下,甚至可能导致可用资源的严重耗尽,影响应用程序的性能——例如,缓冲区可能被填满。如果你正在生成输出——例如,写入文件或提交到数据库——不关闭资源实际上可能导致数据持久化或写入错误,数据可能无法到达预期的目的地,如输出文件或数据库。

完成后关闭资源(如文件和数据库连接)是防止这种情况发生的一种方法。使用finally块或try-with-resources在这里非常有帮助。finally块总是被执行,无论是否发生异常。try-with-resources有一个内置的finally块来关闭在try部分中打开的任何资源。使用finally块或try-with-resources确保资源将被关闭。

考虑以下常规try-catch块的代码:

String path = "some path";
FileReader fr = null;
BufferedReader br = null;
try {
    fr = new FileReader(path);
    br = new BufferedReader(fr);
    System.out.println(br.readLine());
} catch(IOException e) {
    e.printStackTrace();
}
are opening a FileReader and a BufferedReader class and dealing with the checked exceptions in the catch block. However, we never close them. This way, they don’t become eligible for garbage collection. Make sure to close them. This can be done in the finally block, like so:
String path = "some path";
FileReader fr = null;
BufferedReader br = null;
try {
    fr = new FileReader(path);
    br = new BufferedReader(fr);
    System.out.println(br.readLine());
} catch(IOException e) {
    e.printStackTrace();
}
finally {
    if(br != null) {
        br.close();
    }
    if(fr != null) {
        fr.close();
    }
}

无论是否发生异常,finally块都会执行。这样,我们可以确保资源被关闭。

自 Java 7 以来,更常见的是使用try-with-resources。在try块的末尾,它将调用在try语句中初始化的对象的close()方法(这些对象必须实现AutoCloseable接口)。以下是一个示例:

String path = "some path";
try (FileReader fr = new FileReader(path);
    BufferedReader br = new BufferedReader(fr)) {
                        System.out.println(br.readLine());
} catch(IOException e) {
    e.printStackTrace();
}

如你所见,这要干净得多,并且可以防止你忘记关闭资源。因此,建议尽可能使用try-with-resources

使用StringBuilder避免不必要的字符串对象

String对象是不可变的,因此创建后不能更改。在后台,你的请求更改会导致创建一个新的String对象(它反映了你的更改),而原始的String对象保持不变。

例如,当你将一个String对象连接到另一个String对象上时,实际上你在内存中会得到三个不同的对象:原始的String对象、你想要连接的String对象以及反映连接结果的新String对象。

将字符串连接代码放入循环中,后台会创建许多不必要的对象。考虑以下示例:

String strIntToChar = "";
for(int i = 97; i < 123; i++) {
    strIntToChar += i + ": " + (char)i + "\n";
}
System.out.println(strIntToChar);

这就是循环结束后输出的String对象的样子。我们省略了中间部分,以避免使这个片段变得不必要地长:

97: a
98: b
99: c
... omitted middle ...
120: x
121: y
122: z

在这个例子中,我们创建了大量的对象,并且每个中间的concat步骤都会创建一个新的对象。例如,在第一次和第二次迭代之后,strIntToChar的值如下:

97: a
98: b

经过三次迭代后,结果如下:

97: a
98: b
99: c

所有这些中间值都存储在字符串池中。这是因为String对象是不可变的,而字符串池在这里被用作一个与我们作对的优化。

解决这个问题的方法就是使用StringBuilderStringBuilder对象是可变的。如果我们用StringBuilder重写之前的代码,由于我们不是为每个中间值创建一个单独的String对象,因此创建的对象会少得多。以下是使用StringBuilder的代码示例:

StringBuilder sbIntToChar  = new StringBuilder("");
for(int i = 97; i < 123; i++) {
    sbIntToChar.append(i + ": " + (char)i + "\n");
}
System.out.println(sbIntToChar);

当进行连接操作时,JVM 会操作原始的StringBuilder对象,因此不会创建一个新的StringBuilder对象。正如你所见,这不需要对代码进行大幅修改,但它确实大大提高了内存管理效率。因此,当频繁地连接String对象时,应使用StringBuilder

通过使用原始类型而不是包装类来管理内存使用

包装类比原始类型需要更多的内存。有时,你必须使用包装类——这不是可选的。在其他情况下,使用原始类型而不是包装类型是一个选择。例如,创建一个类型为int的局部变量而不是Integer

原始变量占用很少的内存,如果原始变量是方法局部变量,它将存储在栈上(比堆更快访问)。另一方面,包装器是类类型,总是导致在堆上创建一个对象。此外,如果可能的话,你应该使用longdouble原始类型而不是BigIntegerBigDecimal。特别是BigDecimal因其计算精度而受到欢迎。然而,这种精度是以需要更多内存和较慢的计算速度为代价的,因此只有在你确实需要精度时才使用这个类。

请注意,这并不是你正在防止的实际内存泄漏,而是通过不需要比实现应用程序目标所需的更多内存来优化内存的使用。

静态集合的问题以及为什么要避免使用它

在某些情况下,可能会诱使你在一个类中使用静态集合来保持应用程序中的对象,尤其是在你只使用 Java SE 环境并且想要存储对象时。这对健康的内存足迹来说是非常危险的。这样的例子可能看起来是这样的:

public class AvoidingStaticCollections {
    public static List<Person> personList = new
        ArrayList<>();
    public static void addPerson(Person p) {
        personList.add(p);
    }
    // other code omitted
}

这可能会迅速失控。创建的对象不能被垃圾收集,因为静态集合使它们保持活跃。有几种更好的方法可以处理这个问题。如果你真的觉得你需要这样做,那么你可能能够使用数据库来代替。

如果你正在使用HashMap类作为静态集合,那么你很可能可以使用WeakHashMap(从 Java 8 开始)来代替。这将具有对键的弱引用(请注意这一点;不是值——这些由强引用持有)。这些键引用在WeakHashMap中以弱引用的形式存储,但这不会阻止垃圾收集器从堆中移除对象。如果键不再被应用程序的其他部分使用,WeakHashMap中的条目将被移除。这意味着,如果你不希望丢失任何其他地方没有引用的信息,那么这是完全可以的。所以,如果你的意图是在HashMap中维护信息,那么你不应该使用WeakHashMap。然而,如果你不需要在堆上维护HashMap的键,如果这是唯一的引用,那么WeakHashMap可能是你堆使用的一个优化。像往常一样,在实现之前仔细研究这是否符合你的要求。

摘要

在本章中,我们学习了如何避免代码中的内存泄漏。第一步是理解内存泄漏发生在对象不再需要时,但仍然保持对栈的链接。这阻止了垃圾收集器回收它们。鉴于内存是一种有限的资源,这从来不是所希望的。随着这些对象的积累,你的应用程序会变慢,最终崩溃。

内存泄漏的一个常见来源是我们代码中的错误。然而,有方法可以调试内存泄漏。为了演示如何调试泄漏代码,我们展示了一个包含内存泄漏的程序。VisualVM 是一个工具,它使我们能够监控感兴趣的指标——堆内存占用、垃圾回收活动以及堆转储(当我们耗尽堆空间时)。

堆占用验证了内存泄漏的存在,因为它显示了使用的堆空间完全占用了可用的堆空间。换句话说,堆上的对象没有被回收。同时,垃圾收集器徒劳地非常忙碌地试图释放堆空间。为了找出哪种类型导致了问题,我们检查了堆转储。这引导我们到一个ArrayList对象,它引用了大量的Person实例。

我们处理了泄漏代码,并再次使用 VisualVM 检查了堆占用和垃圾收集器活动指标。这两个指标都变得更加健康。

然而,避免内存泄漏的最佳方式是首先不要编写它们。这类似于“预防胜于治疗”的原则。本着这个想法,我们讨论了几种常用的避免内存泄漏的技术。

这就结束了这一章。简而言之,我们首先介绍了内存泄漏发生的原因和方式。然后我们诊断并修复了包含内存泄漏的代码。最后,我们讨论了在编写代码时需要注意的事项以防止代码泄漏,以及如何首先优化内存使用。

这不仅结束了这一章,也结束了这本书。我们从一个内存概述开始,然后聚焦于不同的方面。之后,我们深入探讨了垃圾回收。这本书的最后几章专注于如何提高性能:如何调整 JVM 以及如何避免内存泄漏。

如果你想要了解更多关于 JVM 如何管理内存的信息,JVM 的官方文档就在那里等着你。你可以在这里找到最新版本:docs.oracle.com/javase/specs/index.xhtml

第八章:索引

由于此电子书版本没有固定的页码,以下页码仅作为参考,基于本书的印刷版。

A

ArrayList 31

C

值调用

代码示例 32

跳出引用问题 36-39

跳出引用问题,解决 40, 41

原始类型,复制 34

参考,复制 34-36

使用 31-34

类加载器 8

类加载 78

计算机内存 2

主内存,访问 3

主内存,概述 3, 4

并发 11

并发标记清除垃圾回收器 (CMS GC) 73, 74

并发模式故障 73

复制并压缩收集器 76

C 栈 12, 13

当前帧 15

线程 16

当前方法 15

D

防御性复制 40

E

电可擦可编程只读存储器 (EEPROM) 2

跳出引用问题

检查 31

执行引擎 8

G

G1(垃圾优先)垃圾回收器 74

垃圾回收

适合性示例 56-59

指标 75

监控 75, 76

调谐 101, 102

垃圾收集 (GC) 根 43, 44

垃圾回收实现

CMS 垃圾回收 73

探索 71

G1 垃圾回收 74

代际垃圾回收 72

并行垃圾回收 73

串行垃圾回收 72

Z 垃圾回收 75

垃圾收集器

碎片化 67-69

标记 60-63

正常清扫 66, 67

引用计数 64, 65

停止世界方法 63

清扫 66

压缩清扫 69

复制清扫 69-71

垃圾收集器活动

使用,用于诊断内存泄漏的应用程序 109

H

堆转储 100

使用,用于诊断内存泄漏的应用程序 110, 111

堆代

老年代空间 44

年轻代空间 44

堆内存占用

使用,用于诊断内存泄漏的应用程序 107, 108

堆空间

代 43

高水位标记 79

J

Java

对象,创建 21, 22

变量,创建 13

Java 8 11

Java 应用程序

分析 87

分析,使用 jstat 和 jmap 88-91

分析,使用 VisualVM 91-98

Java 开发工具包 (JDK) 87

Java 内存 1, 5

Java 本地接口 (JNI) 引用 44

Java 虚拟机 (JVM) 1, 5, 6

组件,用于内存管理 8

配置,调整 99

垃圾收集调整 101, 102

堆大小,调整 99, 100

低内存,记录 100, 101

内存管理 6

元空间,调整 101

运行时数据区域 8, 9

线程栈大小,调优 99, 100

jhsdb 91

jmap 88

jstat 88

JVM 调优

用于内存管理 86

K

Kotlin 5

L

延迟 87

活跃对象 43

M

内存泄漏 105, 106

避免 112-114

难点,避免方法 114-119

发现 106, 107

内存管理 1, 2

在 C 和 C++ 6, 7

在 Java 内存 6

问题 8

JVM 组件 8

JVM 调优,针对 86

指标 86

元空间 11, 23

探索 24

垃圾收集 79-82

JVM 设置的阈值大小,超出 79

JVM 使用 77, 78

内存,释放 78

内存不足 79

调优 101

指标,用于诊断内存泄漏的应用程序

垃圾收集器活动 109

堆转储 110, 111

堆内存占用 107, 108

指标,垃圾收集

分配率 75

平均对象存活时间 75

堆实例 75

变异率 75

指标,内存管理

正常延迟 87

吞吐量级别 87

功能良好的内存 87

小型垃圾收集算法 45, 46

展示 48-53

使用 S0 46

使用 S1 47

N

正常扫描 66, 67

婴儿空间 44

O

对象引用

管理 31

Java 对象

创建 21, 22, 28

安全问题 31

在堆上存储 27

在堆上存储 22-26

与引用对比 29-31

操作数栈 17

操作系统 (OS) 3

内存不足 (OOM) 错误 105

P

并行垃圾收集器 73

永久代 (PermGen) 空间 11, 78

原始类型 13, 14, 20, 21

在堆上 26

在栈上 26

程序计数器 (PC) 寄存器 12

Q

Quarkus 72

R

随机存取存储器 (RAM) 2

只读存储器 (ROM) 4

引用着色 75

引用 26

与对象对比 29-31

引用类型 14

记忆集 72

运行时数据区域,JVM 9

堆 10

方法区 (Metaspace) 11

本地方法栈 12, 13

程序计数器 (PC) 寄存器 12

栈 10, 11

S

串行垃圾收集器 72

Spring 72

栈帧

元素 16

帧数据 17-20

局部变量 16

操作数栈 17

值 20

栈内存 14, 15

停止世界方法 63

StringBuilder 31

字符串池 30

压缩过程清理 69

复制过程清理 69,70

T

吞吐量 87

调优 99

V

变量,Java

创建 13

基本类型 13,14

引用类型 14

在栈上存储 14,15

VisualVM 87

下载链接 87

Java 应用程序,使用 91-98 进行性能分析

URL 91

W

包装类 20

Y

年轻代空间 44

Eden 空间 44

存活空间 44

Z

Z 垃圾回收器(ZGC) 75

Packt_Logo_New

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及领先的工具来帮助你规划个人发展和提升职业生涯。更多信息,请访问我们的网站。

第九章:为什么订阅?

  • 使用来自超过 4,000 位行业专业人士的实用电子书和视频,节省学习时间,多花时间编码

  • 通过为你量身定制的技能计划提高学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于轻松访问关键信息

  • 复制粘贴、打印和收藏内容

你知道吗?Packt 为每本书都提供电子书版本,提供 PDF 和 ePub 文件。你可以在packt.com升级到电子书版本,作为印刷书客户,你有权获得电子书副本的折扣。如需了解更多详情,请联系我们 customercare@packtpub.com。

www.packt.com,你还可以阅读一系列免费技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能还会喜欢的其他书籍

如果你喜欢这本书,你可能还会对 Packt 的其他书籍感兴趣:

9781800560734_Cover

使用 Java 的领域驱动设计 - 实践指南

普雷马南德·查克拉谢卡拉恩,卡西克·克里希南

ISBN: 978-1-80056-073-4

  • 发现如何开发对问题域的共享理解

  • 在核心系统和外围系统之间建立清晰的界限

  • 识别如何使复杂系统演化和分解成良好设计的组件

  • 应用如领域故事讲述和事件风暴等细化技术

  • 实施 EDA、CQRS、事件存储和更多

  • 设计一个由紧密、松散耦合和分布式微服务组成的生态系统

  • 测试驱动 Java 中事件驱动系统的实现

  • 掌握非功能性需求如何影响边界上下文分解

9781803241432_Cover

学习 Java 17 编程 - 第二版

尼克·萨莫约洛夫

ISBN: 978-1-80324-143-2

  • 在 Java 中理解和应用面向对象原则

  • 探索 Java 设计模式和最佳实践以解决日常问题

  • 轻松构建用户友好且吸引人的 GUI

  • 在实际示例的帮助下理解微服务的使用

  • 发现编写高质量 Java 代码的技术和惯用语

  • 掌握 Java 中数据结构的使用

Packt 正在寻找像你这样的作者

如果你对成为 Packt 的作者感兴趣,请访问authors.packtpub.com并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解与全球技术社区分享。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在你已经完成了《Java 内存管理》,我们很乐意听听你的想法!如果你从亚马逊购买了这本书,请点击此处直接进入该书的亚马逊评论页面并分享你的反馈或在该购买网站上留下评论。

你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢你购买这本书!

你喜欢在旅途中阅读,但无法携带你的印刷书籍到处走吗?

你的电子书购买是否与你的选择设备不兼容?

别担心,现在每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何时间、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。

优惠远不止这些,你还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限。

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781801812856

  1. 提交您的购买证明

  2. 就这些!我们将直接将你的免费 PDF 和其他优惠发送到你的邮箱。

posted @ 2025-09-11 09:43  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报