Java-故障排除指南-全-
Java 故障排除指南(全)
原文:Troubleshooting Java
译者:飞龙
前置内容
前言
软件开发者实际上是如何谋生的?对于这个问题,“实现软件”是最常见的答案。但这意味着什么?仅仅是编写代码吗?好吧,不是的。虽然代码确实是软件开发者所做的一切的结果,但编写代码的活动只占软件开发者工作时间的一小部分。软件开发者的大部分时间实际上用于设计解决方案、阅读现有代码、理解其执行方式以及学习新事物。编写代码是软件开发者成功完成所有这些任务的结果。因此,程序员的大部分时间都在阅读现有解决方案,而不是有效地编写新的功能。
清洁编码作为一个主题,最终具有相同的目的:教导开发者如何编写更易于阅读的解决方案。开发者意识到,从一开始就编写一个易于阅读的解决方案比花时间试图理解它更有效率。但我们需要诚实并承认,并非所有解决方案都足够干净,可以快速理解。我们总会遇到需要理解某些外部功能执行的场景。
事实上,软件开发者花费大量时间研究应用程序的工作原理。他们阅读并检查应用程序代码库和相关依赖项中的代码,以弄清楚为什么某些事情没有按预期工作。开发者有时阅读代码只是为了了解或更好地理解某个特定的依赖项。在许多情况下,阅读代码是不够的,您必须找到替代(有时更复杂)的方法来弄清楚您的应用程序做了什么。为了理解环境如何影响您的应用程序或您的 Java 应用程序运行的 JVM 实例,您可以使用性能分析、调试和日志调查的组合。如果您对您的选项了如指掌,并且知道如何从中选择,您将节省宝贵的时间。记住,这是开发者花费大部分时间在做的事情。这种开发活动可以非常有益。
我设计这本书是为了帮助人们优化他们调查软件开发挑战的方式。在书中,您将找到最相关的调查技术,这些技术将通过示例应用。我们将讨论调试、性能分析、使用日志以及有效地结合这些技术。在整个书中,我会给您提供有价值的技巧和窍门,帮助您提高效率并更快地解决问题(即使是那些最困难的)。换句话说,这本书的总体目的是让您作为一个开发者变得更加高效。
我希望这本书能给您带来显著的价值,并帮助您在快速找到您调查问题的根本原因方面变得更加高效。
致谢
没有在本书的开发过程中给予我帮助的许多聪明、专业和友好的人,这本书是不可能完成的。
我要向我的妻子丹妮拉表示衷心的感谢,她一直在我身边,提供了宝贵的意见,并持续地支持和鼓励我。我还想特别感谢所有给予我宝贵建议的同事和朋友,他们的建议帮助我在最初的目录和提案中取得了进展。
我要感谢整个 Manning 团队在制作这本有价值的资源中所提供的巨大帮助。我特别想感谢玛琳娜·迈克尔、尼克·沃茨和让-弗朗索瓦·莫林,他们表现出极大的支持和专业性。他们的建议为这本书增添了巨大的价值。感谢迪尔德丽·希姆,我的项目经理;米歇尔·米切尔,我的校对编辑;以及凯蒂·滕南特,我的校对员。
我要感谢我的朋友伊奥安娜·戈兹为本书创作的插图。她将我的想法变成了书中你将看到的卡通画。
我还要感谢所有审阅手稿并提供有用反馈的人,这些反馈帮助我改进了本书的内容。我特别想感谢 Manning 的审稿人——亚历克斯·古特、亚历克斯·祖罗夫、阿姆拉赫·乌穆德卢、阿南德·纳塔拉扬、安德烈斯·达米安·萨科、安德烈·斯托西克、阿尼迪亚·邦多帕达亚、阿图尔·斯里尼瓦斯·霍特、贝基·胡特、邦妮·马莱克、布伦特·霍纳德尔、卡尔·霍普、卡塔林·马泰伊、克里斯托弗·卡德尔、西塞罗·赞多纳、科西莫·达米亚诺·普雷特、丹尼尔·R·卡尔、德舒旺·唐、费尔南多·伯纳迪诺、加博尔·哈贾、加瓦·图利、贾姆佩罗·格兰特莱拉、乔治·齐克拉乌里、戈文达·萨班穆里、哈里尔·卡拉科塞、雨果·费盖雷多、雅各波·比斯凯拉、詹姆斯·R·伍德鲁夫、杰森·李、贾维德·阿萨罗夫、让-巴普蒂斯特·邦·恩特梅、杰罗恩·范·威尔根堡、约尔·卡普林、尤尔格·马蒂、克日什托夫·卡米切克、拉蒂夫·本齐内、莱昂纳多·戈梅斯·达·席尔瓦、马诺杰·雷迪、马库斯·盖塞尔、马特·迪梅尔、马特·韦尔克、迈克尔·科莱斯迪斯、迈克尔·沃尔、米哈尔·奥斯亚克、奥利弗·科滕、奥卢布尼米·奥贡萨尼、帕奥洛·布鲁纳斯蒂、彼得·萨博斯、普拉布蒂·普拉卡什、拉杰什·巴拉莫汉、拉杰什·莫哈南、拉韦什·夏尔马、鲁本·冈萨雷斯-鲁比奥、阿布杜·萨马杜·萨雷、西梅翁·莱泽尔松、西蒙内·卡菲罗、斯瓦万蒂·雷迪、斯维塔·纳图、谭伟、谭儒夫、特拉维斯·尼尔森、亚科夫·博格列夫和尤里·克莱曼——以及给予我建议的朋友们:玛丽亚·奇图、阿德里安·布图鲁加、米切拉·瓦卡里乌克、卡塔林·马泰伊。
关于本书
谁应该阅读这本书
自从你打开这本书以来,我假设你是一位使用 JVM 语言的开发者。你可能使用 Java,但也可能使用 Kotlin 或 Scala。无论你使用哪种 JVM 语言,你都会发现这本书的内容很有价值。它教你相关的调查技巧,你可以使用这些技巧来识别问题的根本原因(即错误),以及如何轻松学习新技术。作为一名软件开发者,你可能已经注意到你在理解应用程序做什么上花费了大量的时间。像其他开发者一样,你可能花更多的时间阅读代码、调试或使用日志,而不是编写代码。那么,为什么不在你工作日中做得最多的事情上变得更加高效呢?
在这本书中,我们将讨论并应用以下主题的例子:
-
简单和高级调试技术
-
高效使用日志来理解应用行为
-
性能分析 CPU 和内存资源消耗
-
性能分析以找到执行代码
-
性能分析以了解应用如何与持久数据交互
-
分析应用之间如何相互通信
-
监控系统事件
无论你的经验如何,你会发现这本书在学习新的调查技术时很有帮助,或者如果你已经是一位经验丰富的开发者,你会发现这是一本很好的复习资料。
阅读本书的先决条件是理解 Java 语言的 basics。我故意使用 Java 设计了所有示例(即使它们适用于任何 JVM 语言),以确保一致性。如果你对 Java 有基本的理解(类、方法、基本的指令,如决策性或重复性指令以及声明变量),你应该能够理解书中的讨论。
本书如何组织:路线图
本书分为三部分,共涵盖 12 章。我们将从调试技术开始讨论(在本书的第一部分),讨论并应用简单和更高级的调试技术,并讨论你可以在调查各种场景时如何使用它们来节省时间。我选择从调试开始讨论,因为这是在调查应用在开发阶段某些功能行为时通常的第一步。有些人问我为什么我没有从日志开始,因为它们是生产问题调查的第一种技术。虽然这是真的,但开发者在开始实现功能时必须处理调试器,所以我认为章节的更好安排是从调试技术开始。
在第一章中,我们讨论了本书讨论的调查技术的相关性,并制定了一个学习它们的计划。第 2、3 和 4 章专注于调试,并教你相关的技能,从添加简单的断点到在远程环境中调试应用。第五章,是第一部分的最后一章,讨论了日志。调试和使用日志是构建应用程序最简单(且最常用)的调查技术。
本书第二部分讨论了性能分析技术。普遍观点认为,与调试和研究日志相比,性能分析在现代应用中更为高级且使用较少。虽然我同意性能分析更为高级,但我将展示你可以使用许多性能分析技术来更有效地调查现代 JVM 应用中的问题或研究被认为是必需的框架。
第六章开启了本书的第二部分,讨论了识别您的应用程序在管理 CPU 和内存资源方面是否存在故障。第七章详细介绍了这个主题,并展示了如何找到导致特定延迟的应用程序部分,以及如何观察应用程序在特定时间执行的内容。在第六章和第七章中,我们使用了 VisualVM,这是一个免费工具。第八章继续第七章的讨论,并使用更高级的视觉工具,这些工具通常只有通过授权的剖析工具才能获得。对于本章讨论的细节,我们使用了 JProfiler,这不是一个免费使用的工具。
第九章和第十章专注于更微妙的剖析技术。您将学习到可以在处理应用程序执行背后的多线程架构中深藏的问题时节省时间的技能。第十一章通过解决如何调查应用程序的内存管理来结束第二部分。
本书以第三部分结束,这部分仅有一章:第十二章。在这一章中,我们超越了单个应用程序的边界,讨论了由多个应用程序组成的广泛系统中调查问题的方法。
章节的顺序是我推荐您阅读的顺序,但每个章节都专注于不同的主题。因此,如果您对某个特定主题感兴趣,可以直接跳转到该章节。例如,如果您对调查内存管理问题感兴趣,可以直接跳转到第十一章。
关于代码
本书包含许多源代码示例,无论是编号列表还是与普通文本并列。在这两种情况下,源代码都使用固定宽度字体如这样来格式化,以将其与普通文本区分开来。有时代码也会加粗,以突出显示与章节中先前步骤不同的代码,例如当新功能添加到现有代码行时。
在许多情况下,原始源代码已被重新格式化;我们添加了换行符并重新整理了缩进,以适应书中的可用页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续符(➥)。此外,当代码在文本中描述时,源代码中的注释通常也会从列表中删除。代码注释伴随着许多列表,并突出显示重要概念。
您可以从本书的在线版本(liveBook)中获取可执行的代码片段,网址为livebook.manning.com/book/troubleshooting-java。本书中示例的完整代码可以从 Manning 网站www.manning.com下载。
liveBook 讨论论坛
购买《Java 故障排除》包括对 Manning 的在线阅读平台 liveBook 的免费访问。使用 liveBook 的独特讨论功能,您可以在全球范围内或针对特定章节或段落添加评论。为自己做笔记、提问和回答技术问题,以及从作者和其他用户那里获得帮助都非常方便。要访问论坛,请访问livebook.manning.com/book/troubleshooting-java/discussion。您还可以在livebook.manning.com/discussion了解更多关于 Manning 论坛和行为准则的信息。
Manning 对我们读者的承诺是提供一个场所,在那里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向他提出一些挑战性的问题,以免他的兴趣转移!只要这本书有售,论坛和以前讨论的存档将可通过出版商的网站访问。
作者在线
我建议您在线与我保持联系。您肯定会在我的 YouTube 频道youtube.com/c/laurentiuspilca上找到大量与 Java 应用故障排除相关的优质学习材料,您也可以在 Twitter 上关注我 @laurspilca。
关于作者

Laurențiu Spilcă是 Endava 的一名专注的开发主管和培训师,他在那里负责领导并咨询来自欧洲、美国和亚洲多个地点的多个项目。自 2007 年以来,他一直在软件开发领域工作。Laurențiu 认为,不仅要提供高质量的软件,还要分享知识并帮助他人提升技能是至关重要的。这种信念驱使他设计和教授与 Java 技术相关的课程,并举办演讲和研讨会。Laurențiu 还是《Spring Security in Action》(Manning,2020)一书的作者,他最近完成了《Spring Start Here》(Manning,2021)。
关于封面插图
《Java 故障排除》封面上的图像是“Homme de l’Istrie”,或“伊斯特里亚人”,取自 Jacques Grasset de Saint-Sauveur 的作品集,该作品集于 1797 年出版。每一幅插图都是手工精心绘制和着色的。
在那些日子里,人们通过他们的服饰很容易就能识别出他们住在哪里,以及他们的职业或社会地位。Manning 通过基于几个世纪前丰富多样的地区文化的封面,以及像这样的作品集中的图片,来庆祝计算机行业的创新精神和主动性。
第一部分. 代码库调查的基础
作为软件开发者,在实际应用中工作通常涉及调查你的代码是如何工作的。在修复问题和实现新功能时,你必须理解应用的行为。你使用多种技术来完成这个目的,例如调试、日志记录、性能分析等等,这些技术我们将在本书中深入分析。
在第一部分,我们首先介绍开发者接触到的第一种技术:调试和日志记录。当你在开发一个应用时,必须经常进行调试。例如,假设你有一小段代码,你需要理解它是如何工作的。你使用调试器暂停应用的执行,深入探究应用是如何处理数据的。然后,当你的应用在某个环境中运行时,你可以大量依赖日志,它们为你提供了关于可能出错的地方的所需线索。
在第一章中,我们将讨论了解调查技术的必要性,并对其有一个整体的认识,这些内容将在本书的其余部分详细阐述。然后,我们将按照开发者接触这些技术的顺序来介绍它们。在第二章到第四章中,我们将讨论调试。在第五章中,我们将详细介绍在调查中实现和使用日志记录的必要细节。

1 揭示应用程序的隐蔽之处
本章涵盖
-
代码调查技术的定义
-
我们使用哪些代码调查技术来理解 Java 应用程序
软件开发者有多种责任——其中大部分取决于他们如何理解他们正在工作的代码。软件开发者花费大量时间分析代码,以弄清楚如何纠正问题、实现新功能,甚至学习新技术。时间是宝贵的,因此开发者需要高效的调查技术以提高生产力。学习如何高效地理解你的代码是本书的主要内容。
备注:软件开发者通常花更多的时间理解软件的工作方式,而不是编写代码来实现新功能或纠正错误。
![]()
通常,软件开发者使用“调试”一词来指代任何调查技术;然而,这只是用于检查作为代码实现的逻辑的多种工具之一。虽然调试应该意味着“发现问题并解决它们”,但开发者使用它来命名分析代码工作方式的不同目的:
-
学习一个新的框架
-
找到问题的根本原因
-
理解现有逻辑以扩展其新功能
1.1 如何更容易地理解你的应用程序
首先,了解代码调查是什么以及开发者如何进行代码调查非常重要。在接下来的这一节中,我们将探讨一些常见的场景,在这些场景中你可以应用本书中将要学习的技术。
我将“调查代码”定义为分析软件能力特定行为的过程。你可能想知道,“为什么有这样的通用定义?调查的目的是什么?”在软件开发历史的早期,查看代码有一个明确的目的:找到和纠正软件错误(即“错误”)。这就是为什么许多开发者仍然使用“调试”这个术语。看看“调试”这个词是如何构成的:
de-bug = 消除错误,消除错误
在许多情况下,我们仍然调试应用程序以查找和纠正错误。但与软件开发早期相比,现在的应用程序更加复杂。在许多情况下,开发者发现自己正在调查特定软件能力的工作方式,仅仅是为了学习特定的技术或库。调试不再仅仅是找到特定问题;它还关于正确理解其行为(图 1.1;参见mng.bz/M012)。

图 1.1 代码调查不仅关于在软件中找到问题。如今,应用程序复杂。我们经常使用调查技术来理解应用程序的行为,或者简单地学习新技术。
为什么我们要分析应用程序中的代码?
-
找到特定问题
-
理解特定软件能力的工作方式,以便我们可以增强它
-
学习特定的技术或库
许多开发者也因为乐趣而调查代码,因为探索代码的工作原理很有趣。有时它也可能变得令人沮丧,但没有什么能比找到问题的根本原因或最终理解事物是如何工作的感觉更好(图 1.2)。

图 1.2 调查代码不需要太多的体力劳动,但调试有时会让你感觉像劳拉·克劳馥或印第安纳·琼斯。许多开发者享受解决软件问题谜团的独特感觉。
我们可以应用各种调查技术来研究软件的行为。正如我们将在本章后面讨论的,开发者(尤其是初学者)常常错误地认为调试等同于使用调试器工具。调试器是一个你可以用来读取并更轻松地理解应用程序源代码的软件程序,通常是通过在特定指令上暂停执行并逐步运行代码来实现的。这是研究软件行为的一种常见方式(通常是开发者首先学习的方式)。但并非只有这一种技术,而且它并不适用于所有场景。我们将在第二章和第三章中讨论使用调试器的标准和更高级的方法。图 1.3 展示了你将在本书中学到的各种调查技术。

图 1.3 代码调查技术。根据具体情况,开发者可以选择使用这些技术中的一种或多种来理解某个功能是如何工作的。
当开发者解决一个错误时,他们大部分时间都在理解一个特定的功能。他们最终做出的更改有时会将问题简化到一行代码——一个缺失的条件、一个缺失的指令或一个误用的运算符。不是编写代码,而是理解应用程序的工作原理占据了开发者的大部分时间。
在某些情况下,仅仅阅读代码就足以理解它,但阅读代码并不像阅读一本书。当我们阅读代码时,我们不会从上到下按逻辑顺序阅读漂亮的短段落。相反,我们从一种方法跳到另一种方法,从一个文件跳到另一个文件;有时我们感觉自己像是在一个庞大的迷宫中前进并迷失方向。(关于这个话题,我推荐 Felienne Hermans 写的优秀书籍《程序员的大脑》[Manning, 2021])。
在许多情况下,源代码的编写方式并不易于阅读。是的,我知道你在想什么:它应该是这样的。我也同意你的看法。今天,我们学习了许多代码设计模式和原则以及如何避免代码异味,但让我们说实话:在太多情况下,开发者并没有正确地使用这些原则。此外,遗留应用程序通常不遵循这些原则,仅仅是因为这些原则在那些功能编写时很多年前并不存在。但你还必须能够调查这样的代码。
查看列表 1.1。假设你在尝试确定你正在工作的应用中问题根本原因时找到了这段代码。这段代码肯定需要重构。但在你重构它之前,你需要了解它在做什么。我知道有些开发者可以阅读这段代码并立即理解它在做什么,但我不属于他们。
为了更容易地理解列表 1.1 中的逻辑,我使用了一个调试器——一个允许我在特定行上暂停执行并手动运行每个指令,同时观察数据如何变化,以便逐行检查它如何与给定的输入一起工作(如我们将在第二章中讨论)。凭借一些经验和一些技巧(我们将在第二章和第三章中讨论),你会发现通过解析这段代码几次,它计算了给定输入之间的最大值。这段代码是本书提供的 da-ch1-ex1 项目的一部分。
列表 1.1 需要使用调试器的难以阅读的逻辑
public int m(int f, int g) {
try {
int[] far = new int[f];
far[g] = 1;
return f;
} catch(NegativeArraySizeException e) {
f = -f;
g = -g;
return (-m(f, g) == -f) ? -g : -f;
} catch(IndexOutOfBoundsException e) {
return (m(g, 0) == 0) ? f : g;
}
}
有些场景不允许你导航代码,或者使导航更加困难。今天,大多数应用依赖于库或框架等依赖项。在大多数情况下,即使你有访问源代码的权限(当你使用开源依赖项时),仍然很难跟踪定义框架逻辑的源代码。有时,你可能甚至不知道从哪里开始。在这种情况下,你必须使用不同的技术来理解应用。例如,你可以使用分析器工具(如你将在第六章到第九章中学习的)来识别在决定开始调查之前执行的代码。
其他场景不会给你运行应用的机会。在某些情况下,你可能需要调查导致应用崩溃的问题。如果遇到问题的应用是一个生产服务,你需要快速使其可用。因此,你需要收集详细信息并使用它们来识别问题,并改进应用以避免未来出现相同的问题。这种依赖于应用崩溃后收集的数据的调查称为事后调查。对于此类案例,你可以使用日志、堆转储或线程转储——我们在第十章和第十一章中将讨论的故障排除工具。
1.2 使用调查技术的典型场景
让我们讨论一些使用代码调查方法的常见场景。我们必须从现实世界的应用中分析一些典型案例,以强调本书主题的重要性:
-
理解为什么特定的代码或软件功能提供的不同结果与预期不符
-
学习如何应用所使用的依赖技术工作
-
识别导致应用缓慢等性能问题的原因
-
找出应用突然停止的案例的根本原因
对于每个展示的案例,你都会发现一或多个有助于调查应用逻辑的技术。稍后,我们将深入探讨这些技术,并通过示例演示如何使用它们。
1.2.1 揭秘意外输出
你需要分析代码的最常见场景是某些逻辑最终产生了与预期不同的输出。这听起来可能很简单,但解决起来并不一定容易。
首先,让我们定义输出。对于应用来说,这个术语可能有多种定义。输出可能是应用控制台中的某些文本,也可能是数据库中更改的某些记录。我们可以将输出视为应用发送到不同系统的 HTTP 请求或客户端请求的 HTTP 响应中发送的数据。
定义:执行可能引起数据变化、信息交换或对不同组件或系统采取行动的逻辑的任何结果,都是一种输出。
我们如何调查一个特定部分的应用没有达到预期执行结果的情况?我们通过根据预期输出选择适当的技术来实现。让我们看看一些例子。
场景 1:简单情况
假设一个应用应该将一些记录插入到数据库中。然而,应用只添加了部分记录。也就是说,你期望在数据库中找到比应用实际产生的更多数据。
分析的最简单方法就是使用调试工具来跟踪代码执行并理解其工作原理(图 1.4)。你将在第二章和第三章中学习调试器的主要功能。调试器会在你选择的特定代码行处添加断点,暂停应用执行,然后允许你手动继续执行。你可以逐条运行代码指令,以便查看变量值的变化并即时评估表达式。

图 1.4 使用调试器,你可以在特定指令之前暂停执行,然后通过手动逐条运行指令来观察应用逻辑如何通过改变数据。
这个场景是最简单的,通过正确学习使用所有相关的调试器功能,你可以迅速找到此类问题的解决方案。不幸的是,其他情况更为复杂,调试工具并不总是足以解决谜题并找到问题的原因。
小贴士:在许多情况下,一种调查技术不足以理解应用的行为。你需要结合各种方法来更快地理解更复杂的行为。
![]()
场景 2:我应该从哪里开始调试的情况?
有时候,你可能无法使用调试器,仅仅是因为你不知道要调试什么。假设你的应用程序是一个包含许多代码行的复杂服务。你调查了一个问题,其中应用程序没有在数据库中存储预期的记录。这肯定是一个输出问题,但出于定义你的应用程序的数千行代码之外,你不知道哪个部分实现了你需要修复的功能。
我记得有一个同事正在调查这样一个问题。由于找不到入手的地方而感到压力,他大声说道:“我希望调试器有一个方法,可以让你在应用程序的所有行上添加断点,这样你就可以看到它实际使用了什么。”
我同事的说法很有趣,但在调试器中拥有这样的功能并不是解决方案。我们还有其他方法来解决这个问题。你很可能会通过使用性能分析器来缩小可以添加断点的代码行的可能性。
性能分析器是一个你可以用来识别应用程序运行时执行了哪些代码的工具(图 1.5)。这对于我们的场景是一个很好的选择,因为它会给你一个关于如何使用调试器开始调查的想法。我们将在第六章到第九章中讨论使用性能分析器,你将了解到你拥有的选项不仅仅只是观察执行中的代码。

图 1.5 使用性能分析器识别执行中的代码。如果你不知道从哪里开始调试,性能分析器可以帮助你识别正在运行的代码,并给你一个关于你可以使用调试器的想法。
场景 3:多线程应用程序
当处理通过多个线程实现的逻辑或多线程架构时,情况变得更加复杂。在许多这类情况下,使用调试器并不是一个选择,因为多线程架构往往对干扰很敏感。
换句话说,当使用调试器时,应用程序的行为方式会有所不同。开发者将这种特性称为海森堡执行或海森堡虫(图 1.6)。这个名字来源于 20 世纪的物理学家维尔纳·海森堡,他提出了不确定性原理,该原理指出,一旦你干扰了一个粒子,它的行为就会改变,因此你不能同时准确地预测它的速度和位置(plato.stanford.edu/entries/qt-uncertainty/)。如果你干扰了多线程架构,它可能会改变其行为方式,就像你干扰量子力学粒子一样。

图 1.6 海森堡执行。在多线程应用程序中,当调试器干扰应用程序的执行时,它可能会改变应用程序的行为。这种变化不允许你正确调查你想要研究的初始应用程序行为。
对于多线程功能,我们有大量的案例。这就是为什么我认为这些场景是最难测试的。有时分析器是一个好选择,但即使是分析器也可能干扰应用程序的执行,所以这也不一定有效。另一个选择是在应用程序中使用日志(我们将在第五章中讨论)。对于某些问题,你可以找到一种方法将线程数减少到一个,这样你就可以使用调试器进行调查。
场景 4:向特定服务发送错误的调用
你可能需要调查一个应用程序没有正确与其他系统组件或外部系统交互的场景。假设你的应用程序向另一个应用程序发送 HTTP 请求。你被第二个应用程序的维护者通知,HTTP 请求没有正确的格式(可能缺少一个标题或请求体包含错误的数据)。图 1.7 直观地展示了这个案例。

图 1.7 错误的输出可能是你的应用程序向另一个系统组件发送错误请求。你可能会被要求调查这种行为并找到其根本原因。
这是一个错误输出场景。你该如何处理它?首先,你需要确定代码的哪个部分发送请求。如果你已经知道,你可以使用调试器来调查应用程序是如何创建请求的,并确定出了什么问题。如果你需要找到应用程序的哪个部分发送请求,你可能需要使用分析器,正如你将在第六章到第九章中学到的。你可以使用分析器来确定在执行过程中的某个时刻正在执行什么代码。
这里有一个我经常使用的小技巧,当我必须处理像这样一个复杂的案例时,由于某种原因,我无法直接识别应用程序发送请求的地址:我将其他应用程序(我的应用程序错误地发送请求的应用程序)替换为一个存根。一个存根是一个我可以控制以帮助我识别问题的假应用程序。例如,为了确定代码的哪个部分发送请求,我可以让我的存根阻止请求,这样我的应用程序就会无限期地等待响应。然后,我简单地使用一个分析器来确定被存根阻止的代码。图 1.8 展示了存根的使用。将此图与图 1.7 进行比较,以了解存根是如何替换真实应用程序的。

图 1.8 你可以用存根替换你的应用程序调用的系统组件。你控制存根以快速确定你的应用程序从哪里发送请求。你还可以在修复问题后使用存根来测试你的解决方案。
1.2.2 学习某些技术
调查技术分析代码的另一种用途是了解某些技术是如何工作的。一些开发者开玩笑说,6 小时的调试可以节省 5 分钟阅读文档的时间。虽然阅读文档在学习新事物时也是必不可少的,但有些技术仅通过阅读书籍或规范是难以学会的。我总是建议我的学生深入一个特定的框架或库,以正确理解它。
TIP 对于你学习的任何技术(框架或库),花些时间回顾你写的代码。总是尝试更深入,调试框架的代码。
![]()
我将从我最喜欢的 Spring Security 开始。乍一看,Spring Security 可能看起来微不足道。它只是实现认证和授权,对吧?事实上,它确实是——直到你发现将这两种能力配置到应用程序中的各种方法。如果你配置错误,你可能会遇到麻烦。当事情不工作时,你必须处理那些不工作的事情,而处理这些不工作的事情的最佳选择就是调查 Spring Security 的代码。
莫过于调试帮助我理解 Spring Security。为了帮助他人,我将我的经验和知识融入了一本书中,《Spring Security in Action》(Manning,2020)。在这本书中,我提供了 70 多个项目供你使用,不仅是为了重新创建和运行,也是为了调试。我邀请你调试你阅读的书籍中提供的所有示例,以学习各种技术。
我通过调试学到的第二种技术是 Hibernate。Hibernate 是一个用于实现应用程序与 SQL 数据库交互能力的高级框架。Hibernate 是 Java 世界中知名度最高、使用最广泛的框架之一,因此对于任何 Java 开发者来说,学习它都是必须的。
学习 Hibernate 的基础知识很容易,你可以通过简单地阅读书籍来完成。但在现实世界中,使用 Hibernate(包括如何使用和在哪里使用)远不止基础知识那么简单。对我来说,如果不深入研究 Hibernate 的代码,我肯定不会像现在这样对这一框架了解得这么多。
我给你的建议很简单:对于你学习的任何技术(框架或库),花些时间回顾你写的代码。总是尝试更深入,调试框架的代码。这将使你成为一个更好的开发者。
1.2.3 澄清缓慢的原因
应用程序中偶尔会出现性能问题,就像任何其他问题一样,在你知道如何解决它们之前,你需要先调查它们。学习正确使用不同的调试技术来识别性能问题的原因至关重要。
根据我的经验,在应用程序中最常见的性能问题与应用程序响应速度有关。然而,尽管大多数开发者认为缓慢和性能是等同的,但这并不正确。缓慢问题(应用程序对给定触发器响应缓慢的情况)只是性能问题的一种。
例如,我曾经不得不调试一个消耗设备电池过快的移动应用程序。我有一个使用连接到外部设备的蓝牙库的 Android 应用程序。由于某种原因,该库在未关闭的情况下创建了大量的线程。这些保持打开状态且无目的运行的线程被称为僵尸线程,通常会导致性能和内存问题。它们通常也很难调查。
然而,这种电池消耗过快的类型的问题也是应用程序性能问题。在网络上传输数据时使用过多网络带宽的应用程序是另一个性能问题的良好例子。
让我们专注于缓慢问题,这是最常遇到的。许多开发者害怕缓慢问题。通常,这并不是因为这些问题的识别很困难,而是因为它们可能很难解决。使用性能分析器找到性能问题的原因通常是一项容易的工作,正如你将在第六章到第九章中了解到的那样。除了识别哪些代码执行外,正如第 1.2.1 节中讨论的,性能分析器还会显示应用程序在每条指令上花费的时间(图 1.9)。

图 1.9 使用性能分析器调查缓慢问题。性能分析器显示了代码执行期间每条指令花费的时间。这个性能分析器功能对于识别性能问题的根本原因非常出色。
在许多情况下,缓慢问题是由 I/O 调用引起的,例如从文件或数据库读取或写入,或通过网络发送数据。因此,开发者通常会采取经验主义的方法来找出问题的原因。如果你知道哪个功能受到影响,你可以专注于该功能执行的 I/O 调用。这种方法也有助于缩小问题范围,但通常你仍然需要一个工具来识别其确切位置。
1.2.4 理解应用程序崩溃
有时候,应用程序由于各种原因完全停止响应。这类问题通常被认为比其他问题更难调查。在许多情况下,应用程序崩溃仅在特定条件下发生,因此你无法在本地环境中重现(故意制造问题)。
每次调查问题时,你都应该首先尝试在一个可以研究问题的环境中重现它。这种方法给你的调查提供了更多的灵活性,并帮助你确认你的解决方案。然而,我们并不总是幸运到能够重现一个问题。应用程序崩溃通常也不容易重现。
我们在两种主要类型的应用程序崩溃场景中找到问题:
-
应用程序完全停止。
-
应用程序仍在运行,但不会响应用户请求。
当应用程序完全停止时,通常是因为它遇到了无法恢复的错误。最常见的情况是内存错误导致这种行为。对于 Java 应用程序,堆内存填满且应用程序不再工作的情况由OutOfMemoryError消息表示。
为了调查堆内存问题,我们使用堆转储,它提供了特定时间点堆内存内容的快照。你可以配置 Java 进程,在发生OutOfMemoryError消息并且应用程序崩溃时自动生成这样的快照。
堆转储是强大的工具,可以提供大量关于应用程序内部如何处理数据的详细信息。我们将在第十一章中更详细地讨论如何使用它们。但让我们先快速看一下一个简短的例子。
列表 1.2 展示了填充内存的Product类实例的小代码片段。你可以在书中提供的项目 da-ch1-ex2 中找到这个应用程序。该应用程序持续向列表中添加Product实例,导致预期的OutOfMemoryError消息。
列表 1.2 一个导致OutOfMemoryError消息的应用程序示例
public class Main {
private static List<Product> products = ❶
new ArrayList<>();
public static void main(String[] args) {
while (true) {
products.add( ❷
new Product(UUID.randomUUID().toString())); ❸
}
}
}
❶ 我们声明了一个存储Product对象引用的列表。
❷ 我们持续向列表中添加Product实例,直到堆内存完全填满。
❸ 每个产品实例都有一个字符串属性。我们使用一个唯一的随机标识符作为其值。
图 1.10 显示了为该应用程序的一次执行创建的堆转储。你可以轻松地看到Product和String实例占据了大部分堆内存。堆转储就像内存的地图。它提供了许多细节,包括实例之间的关系以及值。例如,即使你看不到代码,你仍然可以根据这些实例的数量接近程度注意到Product和String实例之间的联系。不用担心这些方面看起来很复杂。我们将在第十一章中详细讨论你需要了解的所有关于使用堆转储的知识。

图 1.10 堆转储就像堆内存的地图。如果你学会如何阅读它,它就会给你提供关于应用程序内部如何处理数据的宝贵线索。堆转储有助于你调查内存问题或性能问题。在这个例子中,你可以轻松地找到哪个对象占据了应用程序的大部分内存,以及Product和String实例之间的关系。
如果应用程序仍在运行但停止响应用户请求,那么线程转储是分析发生情况的最佳工具。图 1.11 展示了线程转储的示例以及该工具提供的一些详细信息。在第十章中,我们将讨论生成和分析线程转储以调查代码。

图 1.11 线程转储提供了在转储时正在运行的线程的详细信息。它包括线程状态和堆栈跟踪,这些信息告诉你线程正在执行什么或是什么阻止了它们。这些细节对于调查为什么应用卡住或遇到性能问题非常有价值。
1.3 你将在本书中学到什么
这本书是为具有各种经验水平的 Java 开发者编写的,从初学者到专家。你将学习各种代码调查技术、应用它们的最佳场景以及如何应用它们以节省你在故障排除和调查上的时间。
如果你是一名初级开发者,你很可能会从这本书中学到很多东西。有些开发者可能只有在多年的经验之后才掌握所有这些技术;而有些人可能永远也掌握不了。如果你已经是专家,你可能发现你已经知道了很多东西,但你仍然有很大机会找到你以前没有机会遇到的新颖和令人兴奋的方法。
当你完成这本书后,你将学会以下技能:
-
应用不同的方法使用调试器来理解应用的逻辑或找到问题
-
使用分析器调查隐藏的功能,以更好地理解你的应用或应用的具体依赖项是如何工作的
-
分析代码技术以确定你的应用或其依赖项是否导致了某个问题
-
调查应用内存快照中的数据以识别应用处理数据时可能存在的问题
-
使用日志来识别应用行为中的问题或识别安全漏洞
-
使用远程调试来识别你在不同环境中无法重现的问题
-
正确选择使用哪些应用调查技术以使你的调查更快
摘要
-
你可以使用各种调查技术来分析软件行为。
-
根据你的情况,一种调查技术可能比另一种更有效。你需要知道如何选择正确的方法来使你的调查更高效。
-
在某些情况下,结合使用多种技术可以帮助你更快地识别问题。了解每种分析技术的工作原理,将使你在处理复杂问题时具有很大的优势。
-
在许多情况下,开发者使用调查技术来学习新知识,而不是解决问题。当学习像 Spring Security 或 Hibernate 这样的复杂框架时,仅仅阅读书籍或文档是不够的。加速学习的一个极好方法是调试使用你想要更好地理解的技术示例。
-
如果你能在可以研究的环境中重现一个情况,那么调查这个情况会更容易。重现问题不仅可以帮助你更容易地找到其根本原因,而且还可以帮助你确认当解决方案应用时它是有效的。
2 通过调试技术理解你的应用程序逻辑
本章涵盖
-
何时使用调试器以及何时避免使用
-
使用调试器调查代码
不久前,在一次我的钢琴课上,我将我想学习的歌曲乐谱分享给了我的钢琴老师。当他第一次阅读乐谱时就演奏了这首歌,我感到非常震撼。“这太酷了!”我想。“一个人是如何获得这种技能的?”
然后,我想起了几年前我在我工作的公司的一个同伴编程会议中与一位新聘的初级程序员一起。轮到我坐在键盘前,我们正在使用调试器调查一段相对较大且复杂的代码。我开始在代码中导航,相对快速地按键盘上的键,使我能够跳过、进入和退出特定的代码行。我专注于代码,但相当平静和放松,几乎忘记了我旁边有人(我太粗鲁了)。我听到这个人说:“哇,慢一点。你太快了。你能读懂那代码吗?”
我意识到这种情况与我的钢琴老师经验相似。你如何获得这种技能?答案是比你想象的简单:努力工作和积累经验。虽然练习是宝贵的,并且需要花费大量时间,但我有一些可以帮助你更快提高技术的建议。在本章中,我们讨论了在理解代码时使用的重要工具之一:调试器。
定义 调试器是一种工具,它允许你在特定的行上暂停执行,并手动执行每条指令,同时观察数据如何变化。
![]()
使用调试器就像使用谷歌地图导航:它帮助你找到你代码中实现的复杂逻辑的路径。它也是理解代码时最常用的工具。
调试器通常是开发者学习的第一个工具,以帮助他们理解代码的功能。幸运的是,所有 IDE 都自带调试器,因此你不需要做任何特别的事情就可以拥有它。在这本书中,我使用 IntelliJ IDEA Community 作为我的示例,但任何其他 IDE 都相当相似,并且提供了(有时外观不同)我们将讨论的相同选项。尽管调试器似乎是一个大多数开发者都知道如何使用的工具,但你可能会在本章和第三章中找到一些使用调试器的新技巧。
我们将在 2.1 节中开始讨论开发者如何阅读代码以及为什么在许多情况下,仅仅阅读代码并不足以理解它。进入调试器或分析器(我们将在第六章至第九章中讨论)。在 2.2 节中,我们将通过一个示例继续讨论使用调试器的最简单技术。

2.1 当分析代码不足以理解时
让我们先讨论如何阅读代码以及为什么仅仅阅读逻辑有时不足以理解它。在本节中,我将解释阅读代码的工作原理以及它与阅读其他事物(如故事或诗歌)的不同之处。为了观察这种差异并理解解码代码复杂性的原因,我们将使用一个实现简短逻辑片段的代码片段。了解我们的大脑如何解释代码背后的内容有助于你意识到需要像调试器这样的工具。
任何代码调查场景都是从阅读代码开始的。但是阅读代码与阅读诗歌不同。当你阅读一首诗时,你会按照给定的线性顺序逐行阅读文本,让你的大脑组装并想象其含义。如果你两次阅读同一首诗,你可能会理解不同的事情。
然而,对于代码来说,情况正好相反。首先,代码不是线性的。当你阅读代码时,你不会简单地逐行阅读。相反,你会跳进跳出指令,以理解它们如何影响正在处理的数据。阅读代码更像是一个迷宫,而不是一条直线。而且,如果你不专心,你可能会迷路并忘记你从哪里开始。其次,与诗歌不同,代码对每个人来说总是意味着相同的事情。这个含义是你的调查目标。
就像你会用指南针找到你的路一样,调试器可以帮助你更容易地识别你的代码做了什么。例如,我们将使用 decode(List<Integer> input) 方法。你可以在书中提供的项目 da-ch2-ex1 中找到这段代码。
列表 2.1 调试方法的示例
public class Decoder {
public Integer decode(List<String> input) {
int total = 0;
for (String s : input) {
var digits = new StringDigitExtractor(s).extractDigits();
total += digits.stream().collect(Collectors.summingInt(i -> i));
}
return total;
}
}
如果你从顶部行读到底部行,你必须假设一些事情的工作方式来理解它。这些指令真的在执行你认为它们正在做的事情吗?当你不确定时,你必须深入挖掘并观察代码实际上做了什么——你必须分析其背后的逻辑。图 2.1 指出了给定代码片段中的两个不确定性:
-
StringDigitExtractor()构造函数做什么?它可能只是创建一个对象,或者也可能做其他事情。它可能会以某种方式改变给定参数的值。 -
调用
extractDigits()方法的结果是什么?它返回一个数字列表吗?它也改变了我们在创建StringDigitsExtractor构造函数时使用的对象内部的参数吗?

图 2.1 当阅读一段代码时,你通常需要弄清楚组成该逻辑的一些指令背后发生了什么。方法名并不总是足够有暗示性,而且你不能完全依赖它们。相反,你需要深入了解这些方法做了什么。
即使是小块代码,您可能也需要深入了解指令。您检查的每个新代码指令都会创建一个新的调查计划,并增加其认知复杂性(见图 2.2 和 2.3)。您越深入逻辑,打开的计划越多,过程就越复杂。

图 2.2 比较您阅读诗歌的方式与您阅读代码的方式。您逐行阅读诗歌,但阅读代码时却四处跳跃。

图 2.3 阅读代码与阅读诗歌不同,并且要复杂得多。您可以想象阅读代码就像在两个维度上阅读。一个维度是从上到下阅读一段代码。第二个维度是进入一个特定的指令以详细了解它。
阅读诗歌通常只有一条路径。代码分析则会在同一逻辑片段中创建许多路径。您打开的新计划越少,过程就越简单。您必须在跳过某些指令、使整体调查过程更简单或深入了解以更好地理解每个单独的指令并提高过程复杂性之间做出选择。
TIP 总是尝试通过最小化您为调查而打开的计划数量来缩短阅读路径。使用调试器可以帮助您更轻松地导航代码,跟踪您的位置,并观察应用程序在执行过程中如何更改数据。
![]()
2.2 使用调试器调查代码
在本节中,我们讨论一个可以帮助您最小化阅读代码时的认知努力,以理解其工作原理的工具——调试器。所有集成开发环境(IDE)都提供调试器,尽管不同 IDE 的界面可能略有不同,但选项通常是相同的。本书中,我将使用 IntelliJ IDEA Community,但我鼓励您使用您最喜欢的 IDE,并将其与书中的示例进行比较。您会发现它们非常相似。
调试器通过以下方式简化调查过程:
-
提供一种在特定步骤暂停执行并按自己的节奏手动执行每个指令的方法。
-
在代码的阅读路径中显示您所在的位置和您从哪里来;这样,调试器就像一张您可以使用的地图,而不是试图记住所有细节。
-
显示变量所持有的值,使调查更容易可视化并处理。
-
允许您通过使用监视器和评估表达式来即时尝试事物。
让我们再次以项目 da-ch2-ex1 中的示例为例,并使用最直接的调试器功能来理解代码。
列表 2.2 我们想要理解的代码片段
public class Decoder {
public Integer decode(List<String> input) {
int total = 0;
for (String s : input) {
var digits = new StringDigitExtractor(s).extractDigits();
total += digits.stream().collect(Collectors.summingInt(i -> i));
}
return total;
}
}
我敢肯定你正在想,“我什么时候知道该使用调试器?”这是一个合理的问题,我想要在进一步讨论之前回答。主要前提是知道你想要调查哪部分逻辑。正如你将在本节中学到的,使用调试器的第一步是选择一个你想让执行暂停的指令。
注意:除非你已经知道你需要从哪个指令开始你的调查,否则你不能使用调试器。
![]()
在现实世界中,你会遇到你事先不知道想要调查的具体逻辑片段的情况。在这种情况下,在你能够使用调试器之前,你需要应用不同的技术来找到你想要使用调试器调查的代码部分(我们将在后面的章节中讨论)。在本章和第三章中,我们将只关注使用调试器,所以我们将假设你以某种方式找到了你想要理解的那段代码。
回到我们的例子,我们应该从哪里开始?首先,我们需要阅读代码,弄清楚我们理解和不理解的部分。一旦我们确定了逻辑变得不清晰的地方,我们就可以执行应用程序,并“告诉”调试器暂停执行。我们可以在那些不清楚的代码行上暂停执行,以观察它们如何改变数据。为了“告诉”调试器在哪里暂停应用程序的执行,我们使用断点。
定义:断点是我们用来标记的行,我们希望调试器在这里暂停执行,以便我们可以调查实现的逻辑。调试器将在执行带有断点的行之前暂停执行。
![]()
在图 2.4 中,我标记了那些相对容易理解的代码(考虑到你掌握了语言基础)。正如你所见,这段代码接受一个列表作为输入,解析列表,处理列表中的每个项目,并最终以某种方式计算出一个整数,这是方法返回的。此外,方法实现的过程在没有调试器的情况下也容易确定。

图 2.4 假设你掌握了语言基础,你很容易就能看出这段代码接受一个集合作为输入,并解析这个集合来计算一个整数。
在图 2.5 中,我标记了通常在理解方法做什么时造成困难的那几行。这些代码行更难以解析,因为它们隐藏了自己的实现逻辑。你可能认识digits.stream().collect (Collectors.summingInt(i -> i)),因为它是从 Java 8 开始随 JDK 提供的 Stream API 的一部分。但关于new StringDigitExtractor(s).extractDigits(),我们无法说同样的话。因为这是我们要调查的应用程序的一部分,这条指令可能做任何事情。

图 2.5 在这段代码中,我阴影了更难以理解的代码行。当你使用调试器时,在使代码更具挑战性的第一行设置第一个断点。
开发者选择编写代码的方式也可能增加额外的复杂性。例如,从 Java 10 开始,开发者可以使用var来推断局部变量的类型。推断变量类型并不总是明智的选择,因为它可能会使代码更难以阅读(图 2.5),从而增加一个使用调试器会很有用的场景。
提示:在用调试器调查代码时,从你无法理解的代码的第一行开始。
![]()
在过去许多年里,我在培训初级开发人员和学生的过程中观察到,在许多情况下,他们会在特定代码块的第一行开始调试。虽然你当然可以这样做,但如果你首先在不使用调试器的情况下阅读代码,并尝试弄清楚你是否能理解代码,则会更有效率。然后,直接从引起困难的地方开始调试。这种方法将为你节省时间,因为你可能会发现你不需要调试器就能理解特定逻辑中的发生情况。毕竟,即使你使用调试器,你也只需要查看你不理解的代码。
在某些情况下,你会在某一行添加断点,因为其意图并不明显。有时你的应用程序会抛出异常;你在日志中看到这一点,但不知道哪一行之前的代码导致了问题。在这种情况下,你可以在应用程序抛出异常之前添加一个断点来暂停应用程序的执行。但理念保持不变:避免暂停你理解的指令的执行。相反,使用断点来关注你想要关注的代码行。
对于这个例子,我们将从在图 2.6 中显示的第 11 行添加断点开始:
var digits = new StringDigitExtractor(s).extractDigits();
通常,要在任何 IDE 中的某一行添加断点,你可以在行号上或附近点击(或者更好的是,使用键盘快捷键;对于 IntelliJ,你可以在 Windows/Linux 上使用 Ctrl-F8,或者在 macOS 上使用 Command-F8)。断点将以圆圈的形式显示,如图 2.6 所示。确保你使用调试器运行你的应用程序。在 IntelliJ 中,寻找一个表示为小虫子图标按钮,它靠近你用来启动应用程序的按钮。你也可以右键单击主类文件,并在上下文菜单中使用调试按钮。当执行到达你标记的断点所在的行时,它会暂停,允许你手动导航。

图 2.6 在行号附近点击以在特定行添加断点。然后,使用调试器运行应用程序。执行将在你标记的断点所在的行暂停,并允许你手动控制。
由于快捷键可能会根据您使用的操作系统而改变和不同(一些开发者甚至更喜欢自定义它们),我通常不会讨论它们。然而,我建议您检查您的 IDE 手册,并学习如何使用键盘快捷键。
注意 记住,您始终需要使用调试选项来执行应用程序,以便拥有一个活动的调试器。如果您使用运行选项,由于 IDE 没有将调试器附加到运行进程,断点将不会被考虑。某些 IDE 可能会默认运行您的应用程序并附加调试器,但如果不是这种情况(例如 IntelliJ 或 Eclipse),则应用程序的执行将不会在您定义的断点上暂停。
当调试器在您标记为断点的行上的特定指令处暂停代码执行时,您可以使用 IDE 显示的宝贵信息。在图 2.7 中,您可以看到我的 IDE 显示了两个基本的信息:
-
作用域内所有变量的值——了解所有变量的值及其值有助于您理解正在处理哪些数据以及逻辑如何影响数据。记住,执行是在带有断点的行的执行之前暂停的,因此数据状态保持不变。
-
执行堆栈跟踪——这显示了应用程序如何执行调试器暂停执行的那行代码。堆栈跟踪中的每一行都是调用链中涉及的方法。执行堆栈跟踪有助于您可视化执行路径,而无需记住在使用调试器通过代码导航时如何到达特定的指令。
TIP 您可以添加尽可能多的断点,但最好一次只使用有限数量的断点,并专注于那些代码行。我通常一次不会使用超过三个断点。我经常看到开发者添加过多的断点,忘记它们,并在调查的代码中迷失方向。
![]()
通常,观察作用域内变量的值是容易理解的。但,根据您的经验,您可能或可能不知道执行堆栈跟踪是什么。第 2.2.1 节讨论了执行堆栈跟踪以及为什么这个工具是必不可少的。然后我们将讨论如何使用如步过、步入和步出等基本操作来导航代码。如果您已经熟悉执行堆栈跟踪,可以直接跳过 2.2.1 节,直接进入 2.2.2 节。

图 2.7 当执行在给定的代码行上暂停时,您可以看到所有作用域内的变量及其值。您还可以使用执行堆栈跟踪来在通过代码行导航时记住您所在的位置。
2.2.1 执行堆栈跟踪是什么,我如何使用它?
执行堆栈跟踪是在调试代码时使用的宝贵工具。就像一张地图一样,执行堆栈跟踪显示了执行路径到调试器暂停的具体代码行,并帮助你决定进一步导航的位置。

图 2.8 执行堆栈跟踪的最顶层是调试器暂停执行的地方。执行堆栈跟踪中的所有其他层都是上述层所代表的方法被调用的地方。堆栈跟踪的底层(第一层)是当前线程执行开始的地方。
图 2.8 提供了执行堆栈跟踪和树形格式下执行的比较。堆栈跟踪显示了方法是如何相互调用,直到调试器暂停执行的地方。在堆栈跟踪中,你可以找到方法名、类名和导致调用的行。
我最喜欢的执行堆栈跟踪的使用之一是找到执行路径中的隐藏逻辑。在大多数情况下,开发者使用执行堆栈跟踪仅仅是为了理解某个方法是从哪里被调用的。但你也需要考虑,使用框架(如 Spring、Hibernate 等)的应用有时会改变方法的执行链。
例如,Spring 应用通常使用被称为方面(在 Java/Jakarta EE 术语中,它们被称为拦截器)的解耦代码。这些方面实现了框架在特定条件下增强特定方法执行的逻辑。不幸的是,这种逻辑通常很难观察,因为在阅读代码时你无法直接在调用链中看到方面代码(图 2.9)。这种特性使得调查给定的功能变得具有挑战性。

图 2.9 一个方面逻辑完全与代码解耦。因此,在阅读代码时,很难看到还有更多将要执行的逻辑。在调查某个功能时,这种隐藏逻辑执行的情况可能会令人困惑。
让我们通过一个代码示例来检查这种行为以及执行堆栈跟踪在这种情况下是如何有帮助的。你可以在书中提供的项目 da-ch2-ex2 中找到这个示例(附录 B 提供了打开项目和启动应用的重温内容)。该项目是一个小型 Spring 应用,它在控制台打印参数的值。
列表 2.3、2.4 和 2.5 展示了这三个类的实现。正如列表 2.3 所示,main()方法调用ProductController的saveProduct()方法,发送参数值"Beer"。
列表 2.3 主类调用ProductController的saveProduct()方法
public class Main {
public static void main(String[] args) {
try (var c =
new AnnotationConfigApplicationContext(ProjectConfig.class)) {
c.getBean(ProductController.class).saveProduct("Beer"); ❶
}
}
}
❶ 我们使用参数值“啤酒”调用 saveProduct()方法。
在列表 2.4 中,你可以看到 ProductController 的 saveProduct() 方法只是用接收到的参数值调用 ProductService 的 saveProduct() 方法。
列表 2.4 ProductController 调用 ProductService
@Component
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
public void saveProduct(String name) {
productService.saveProduct(name); ❶
}
}
❶ ProductController 调用服务并发送参数值。
列表 2.5 显示了 ProductService 的 saveProduct() 方法,它在控制台打印参数值。
列表 2.5 ProductService 打印参数值
@Component
public class ProductService {
public void saveProduct(String name) {
System.out.println("Saving product " + name); ❶
}
}
❶ 在控制台打印参数值
如图 2.10 所示,流程相当简单:
-
main()方法调用名为ProductController的实例的saveProduct()方法,将值"Beer"作为参数发送。 -
然后,
ProductController的saveProduct()方法调用另一个实例ProductService的saveProduct()方法。 -
ProductService实例在控制台打印参数值。

图 2.10 方法 main() 调用 ProductController 的 saveProduct() 方法,将值 "Beer" 作为参数值发送。ProductController 的 saveProduct() 方法调用 ProductService 实例,发送与接收到的相同的参数值。Product-Service 实例在控制台打印参数值。预期的是在控制台打印 "Beer"。
自然地,你会假设当你运行应用程序时,会打印出以下消息:
Saving product Beer
然而,当你运行项目时,消息是不同的:
Saving product Chocolate
这怎么可能呢?为了回答这个问题,首先要做的是使用执行堆栈跟踪来找出谁改变了参数值。在打印出不同值的行上设置断点,以调试模式运行应用程序,并观察执行堆栈跟踪(图 2.11)。你发现,不是 ProductController 实例的 ProductService 的 saveProduct() 方法,而是有一个方面改变了执行。如果你查看方面类,你确实会看到方面负责将 "Beer" 替换为 "Chocolate"(列表 2.6)。

图 2.11 执行堆栈跟踪显示方面已改变执行。这个方面是参数值改变的原因。如果不使用堆栈跟踪,找出应用程序为何与预期行为不同将会更困难。
以下代码显示了通过替换 ProductController 发送到 ProductService 的值来改变执行的方面。
列表 2.6 改变执行的方面逻辑
@Aspect
@Component
public class DemoAspect {
@Around("execution(* services.ProductService.saveProduct(..))")
public void changeProduct(ProceedingJoinPoint p) throws Throwable {
p.proceed(new Object[] {"Chocolate"});
}
}
在当今的 Java 应用程序框架中,方面(Aspects)是一个非常吸引人和有用的特性。但是,如果你没有正确使用它们,它们可能会使应用程序难以理解和维护。当然,在这本书中,我们正在讨论相关的技术,这些技术可以帮助你在这种情况下识别和理解代码。但是,相信我,如果你需要为应用程序使用这项技术,这意味着该应用程序不易维护。一个编写良好的应用程序(没有技术债务)总是比一个需要投入精力进行调试的应用程序更好。如果你对更好地理解 Spring 中方面的工作原理感兴趣,我建议你阅读我写的另一本书的第六章,Spring Start Here(Manning,2021)。
2.2.2 使用调试器导航代码
在本节中,我们讨论使用调试器导航代码的基本方法。你将学习如何使用三个基本导航操作:
-
跳过—继续执行同一方法中的下一行代码。
-
进入—继续在当前行调用的方法中执行。
-
退出—将执行返回到调用你正在调查的方法的方法。
要开始调查过程,你必须确定你想要调试器暂停执行的第一行代码。为了理解逻辑,你需要导航代码行并观察当不同的指令执行时数据如何变化。
在任何 IDE 的 GUI 上都有按钮和键盘快捷键,可以用来执行导航操作。图 2.12 展示了这些按钮在 IntelliJ IDEA Community GUI 中的外观,这是我使用的 IDE。

图 2.12 导航操作帮助你以受控的方式“遍历”应用程序逻辑,以确定代码的工作方式。要导航代码,你可以使用 IDE 的 GUI 上的按钮或使用与这些操作关联的键盘快捷键。
TIP 即使在开始时你发现使用 IDE 的 GUI 上的按钮更容易,我仍然建议你使用键盘快捷键。如果你习惯了使用键盘快捷键,你会发现它们比鼠标快得多。
![]()
图 2.13 直观地描述了导航操作。你可以使用跳过操作进入同一方法中的下一行。通常,这是最常用的导航操作。

图 2.13 导航操作。跳过操作允许你进入同一方法中的下一条指令。当你想要开始一个新的调查计划并深入了解特定指令时,你可以使用进入操作。你可以使用退出操作返回到上一个调查计划。
有时候你需要更好地理解特定指令发生了什么。在我们的例子中,你可能需要进入extractDigits()方法,以便清楚地了解它做什么。在这种情况下,你使用“进入”操作。当你想返回到decode()方法时,你可以使用“退出”操作。
你还可以可视化执行堆栈跟踪中的操作,如图 2.14 所示。

图 2.14 从执行堆栈跟踪的角度看导航操作。当你退出时,你向下进入堆栈跟踪并关闭一个调查计划。当你进入时,你打开一个新的调查计划,因此你在堆栈跟踪中向上移动,它变得更大。当你单步执行时,你保持在同一个调查计划中。如果方法结束(返回或抛出异常),单步执行将关闭调查计划,你就像退出时一样向下进入堆栈跟踪。
理想情况下,当你试图理解一段代码的工作原理时,尽可能多地使用“单步执行”操作。你进入得越多,你打开的调查计划就越多,因此调查过程就越复杂(图 2.15)。在许多情况下,你只需通过单步执行并观察输出,就可以推断出特定代码行的作用。

图 2.15 电影《盗梦空间》(2010)描绘了在梦中做梦的想法。你梦得越深,你待在那里的时间就越长。你可以将这个想法与进入一个方法和打开一个新的调查层进行比较。你进入得越深,你花在调查代码上的时间就越多。
图 2.16 展示了使用“单步执行”导航操作的结果。执行在 12 行暂停,比我们最初使用断点暂停调试器的那一行低一行。digits变量现在也被初始化了,所以你可以看到它的值。

图 2.16 当你单步执行一行时,执行将在同一方法中继续。在我们的例子中,执行在第 12 行暂停,你可以看到由第 11 行初始化的digits变量的值。你可以使用这个值来推断第 11 行做了什么,而无需深入了解。
尝试多次继续执行。你会观察到,在第 11 行,对于每个字符串输入,结果是一个包含给定字符串中所有数字的列表。通常,逻辑足够简单,只需分析几次执行的输出就可以理解。但如果你不能仅通过执行就弄清楚一行代码的作用怎么办?

图 2.17 使用“进入”功能可以观察当前指令的整个执行过程。这开启了一个新的调查计划,允许你解析特定指令背后的逻辑。你可以使用执行堆栈跟踪来回溯执行流程。
如果您不理解发生了什么,您需要在该行上更详细地了解情况。这应该是您的最后选择,因为它要求您打开一个新的调查计划,这会使您的过程复杂化。但是,当您没有其他选择时,您可以进入指令以获取代码执行更多细节。图 2.17 显示了进入Decoder类第 11 行的结果:
var digits = new StringDigitExtractor(s).extractDigits();
如果您进入了指令,请先花时间阅读那行代码背后的内容。在许多情况下,查看代码就足以发现发生了什么,然后您可以回到之前进入的地方。我经常观察到学生急于调试他们进入的方法,而没有先深吸一口气阅读那段代码。为什么先阅读代码很重要?因为进入一个方法会打开另一个调查计划,所以,如果您想高效,您必须重新执行调查步骤:
-
阅读方法并找到您不理解的第一行代码。
-
在该代码行上设置一个断点,并从那里开始调查。

图 2.18 步出操作允许您关闭一个调查计划并返回到执行堆栈跟踪中的上一个计划。使用步出操作可以节省时间,因为您不必逐条指令执行,直到当前执行计划自行关闭。步出操作为您提供了一条返回到之前调查的执行计划的捷径。
通常,如果您停下来阅读代码,您会发现您不需要继续那个调查计划。如果您已经理解了发生了什么,您只需简单地返回到之前的位置。您可以使用步出操作来完成这一点。图 2.18 显示了从extractDigits()方法使用步出操作时发生的情况:执行返回到decode(List <String> input)方法中的上一个调查计划。
提示:步出操作可以为您节省时间。在进入新的调查计划(通过进入代码行)时,首先阅读新的代码片段。一旦您理解了代码的功能,就退出新的调查计划。
![]()
为什么下一执行行不总是下一行?
当与调试器讨论代码导航时,我经常提到“下一执行行”。我想确保我清楚“下一行”和“下一执行行”之间的区别。
下一执行行是应用程序将要执行的代码行。当我们说调试器在第 12 行暂停执行时,下一行总是第 13 行,但下一执行行可能不同。例如,如果第 12 行没有抛出异常,如以下图所示,下一执行行将是第 13 行,但如果第 12 行抛出异常,下一执行行将是第 18 行。您可以在项目 da-ch2-ex3 中找到这个例子。
当使用单步执行操作时,执行将继续到下一执行行。

在这个图中,我们从第 12 行开始单步执行,第 12 行抛出异常;执行继续到第 18 行,这是下一执行行。换句话说,下一执行行不总是下一行。
2.3 当使用调试器可能不够时
调试器是一个优秀的工具,可以帮助你通过在代码中导航来分析代码,理解它是如何与数据一起工作的。但并非所有代码都可以用调试器来调查。在本节中,我们讨论了一些使用调试器不可行或不充分的场景。你需要意识到这些情况,以免浪费时间使用调试器。
在使用调试器(或仅使用调试器)通常不是正确方法时,以下是一些最常遇到的调查场景:
-
当你不知道代码的哪个部分创建了输出时,调查输出问题
-
调查性能问题
-
调查整个应用失败时的崩溃
-
调查多线程实现
提示:记住,使用调试器的关键先决条件是知道在哪里暂停执行。
![]()
在开始调试之前,你需要找到生成错误输出的代码部分。根据应用的不同,可能更容易找到实现逻辑中发生某事的位置。如果应用有一个清晰的类设计,找到负责输出的应用部分相对容易。如果应用缺乏类设计,可能更难发现事情发生的地方,因此也就更难确定在哪里使用调试器。在接下来的章节中,你将学习到其他几种技术。其中一些技术,如分析应用或使用存根,将帮助你确定使用调试器开始调查的位置。
性能问题是一组通常无法用调试器调查的问题。慢速应用或完全卡住的应用是常见的性能问题。在大多数情况下,性能分析和对数技术(我们将在第五章到第九章中讨论)将帮助你解决此类场景。对于应用完全阻塞的特定实例,获取和分析线程转储通常是调查的最直接路径。我们将在第十章中讨论分析线程转储。
如果应用遇到了问题并且执行停止(应用崩溃),你无法在代码上使用调试器。调试器允许你在执行过程中观察应用。如果应用不再执行,调试器显然无济于事。根据发生的情况,你可能需要审计日志,正如我们在第五章中将要讨论的,或者调查线程或堆转储,这些将在第十章和第十一章中学习。
大多数开发者发现多线程实现是最具挑战性的调查对象。这样的实现很容易受到你使用调试器等工具的干扰。这种干扰会产生海森堡效应(在第一章中讨论):当你使用调试器时,应用程序的行为与你不干扰它时不同。正如你将学到的,有时你可以将调查隔离到一条线程并使用调试器。但在大多数情况下,你将不得不应用一系列技术,包括调试、模拟和存根以及性能分析,以了解应用程序在最复杂场景中的行为。
摘要
-
每次你打开一个新的逻辑块(例如,进入一个定义其自身逻辑的新方法),你就打开了一个新的调查计划。
-
与文本段落不同,阅读代码不是线性的。每条指令可能会创建一个你需要调查的新计划。你探索的逻辑越复杂,你需要打开的计划就越多。你打开的计划越多,过程就越复杂。加快代码调查过程的一个技巧是尽可能少地打开计划。
-
调试器是一个工具,它允许你在特定行暂停应用程序的执行,这样你就可以逐步观察应用程序的执行以及它管理数据的方式。使用调试器可以帮助你减少阅读代码时的认知负荷。
-
你可以使用断点标记你想要调试器暂停应用程序执行的特定代码行,以便你可以评估作用域内所有变量的值。
-
你可以跨过一行,这意味着继续执行同一计划中的下一行,或者进入一行,这意味着深入到调试器暂停执行的指令的细节。你应该尽量减少进入一行的次数,更多地依赖跨过。每次进入一行,调查路径都会变长,过程也会更加耗时。
-
尽管使用鼠标和 IDE 的 GUI 导航代码在最初可能更舒适,但学习使用这些操作的键盘快捷键将帮助你更快地调试。我建议你学习你最喜欢的 IDE 的键盘快捷键,并使用它们而不是用鼠标触发导航。
-
进入一行后,首先阅读代码并尝试理解它。如果你能弄清楚发生了什么,使用退出操作返回到之前的调查计划。如果你不理解发生了什么,确定第一条不清楚的指令,添加一个断点,并从那里开始调试。
3 使用高级调试技术查找问题根本原因
本章涵盖了
-
使用条件断点来调查特定场景
-
使用断点在控制台记录调试消息
-
在调试过程中更改数据以强制应用程序以特定方式执行
-
在调试过程中重新运行代码的某个部分
在第二章中,我们开始讨论使用调试器的最常见方法。当调试某个已实现的逻辑部分时,开发者通常会使用代码导航操作,如跳过、进入和退出一行。了解如何正确使用这些操作有助于您调查代码,以便更好地理解或找到问题。
但调试器是一个比许多开发者所意识到的更强大的工具。开发者们在仅使用基本导航进行代码调试时有时会感到困难,而如果他们使用调试器提供的其他(不太为人所知)方法,则可以节省大量时间。
在本章中,您将了解如何充分利用调试器提供的功能:
-
条件断点
-
断点作为日志事件
-
修改内存中的数据
-
丢弃执行帧
我们将讨论一些超越基本方式的代码导航方法,您将了解如何以及何时使用这些方法。我们将使用代码示例来讨论这些调查方法,以便您了解如何使用它们来节省时间,以及在何时避免使用它们。
3.1 使用条件断点最小化调查时间
在本节中,我们将讨论使用条件断点来在满足特定条件下暂停应用程序执行的代码行。
定义:条件断点是与条件关联的断点,因此调试器只有在条件满足时才会暂停执行。在调查场景中,当您只对代码部分与给定值如何工作感兴趣时,条件断点非常有用;在适当的情况下使用条件断点可以节省您的时间,并帮助您更容易地理解应用程序的工作方式。
![]()
让我们通过一个例子来了解条件断点是如何工作的,以及您可能希望使用它们的典型情况。列表 3.1 展示了一个返回字符串值列表中数字之和的方法。您可能已经从第二章中熟悉了这种方法。我们也将使用这段代码来讨论条件断点。然后,我们将把这个简化示例与您可能在现实世界案例中遇到的类似情况进行比较。这个例子可以在本书提供的项目 da-ch3-ex1 中找到。
列表 3.1 使用条件断点进行调查
public class Decoder {
public Integer decode(List<String> input) {
try {
int total = 0;
for (String s : input) {
var digits = new StringDigitExtractor(s).extractDigits();
var sum = digits.stream().collect(Collectors.summingInt(i -> i));
total += sum;
}
return total;
} catch (Exception e) {
return -1;
}
}
}
当调试一段代码时,你通常只对特定值下的逻辑工作方式感兴趣。例如,假设你怀疑实现的逻辑在某个特定情况下(例如,某些变量具有特定值)工作得不好,并且你想证明这一点。或者你只是想了解在特定情况下发生了什么,以便更好地了解整个功能。
假设在这种情况下,你只想调查变量sum有时为零的原因。你如何只关注这个特定案例呢?你可以使用单步跳过操作来导航代码,直到你观察到方法返回零。这种方法在像这样的演示示例(足够小)中可能是可接受的。但在现实世界的案例中,你可能需要多次跳过才能达到预期的案例。实际上,在现实世界的场景中,你可能甚至不知道你想要调查的特定情况何时出现。

图 3.1 使用条件断点暂停特定情况的执行。在这个图中,我们只想在sum为零的情况下暂停第 14 行的执行。我们可以在断点上应用一个条件,指示调试器只有在给定状态为真时才考虑该断点。这有助于你更快地到达想要调查的场景。
使用条件断点比在代码中导航到想要研究的条件更高效。图 3.1 展示了如何在 IntelliJ IDEA 中应用条件到断点上。右键点击你想要添加条件的断点,并写下该断点所应用的条件。条件需要是一个布尔表达式(它应该是可以评估为真或假的某种东西)。在断点上使用sum == 0条件,你告诉调试器只有在变量sum为零的情况下才考虑该断点并暂停执行。
当你使用调试器运行应用程序时,执行只有在如图 3.2 所示的循环首次迭代一个不包含数字的字符串时才会暂停。这种情况导致变量sum为零,因此断点上的条件被评估为真。

图 3.2 一个条件断点。图中第 14 行被多次执行,但调试器只有在变量sum为零时才会暂停执行。这样,我们就跳过了所有我们不感兴趣的案例,从而可以开始关注与我们的调查相关的条件。
条件断点可以节省你的时间,因为你不需要搜索你想要调查的特定情况。相反,你允许应用程序运行,当满足某个条件时,调试器会暂停执行,让你可以从这一点开始调查。尽管使用条件断点很容易,但许多开发者似乎忘记了这种方法,浪费了大量时间调查本可以用条件断点简化的情况。
设置条件断点是调查代码的绝佳方式。然而,它们也有其缺点。条件断点可能会显著影响执行性能,因为调试器必须持续拦截你使用的范围内的变量值,并评估断点条件。
小贴士:使用少量条件断点。最好一次只使用一个条件断点,以避免过多地减慢执行速度。
![]()
使用条件断点的另一种方法是记录特定的执行细节,例如各种表达式值和特定条件的堆栈跟踪(图 3.3)。

图 3.3 要在 IntelliJ 中对断点进行高级配置,你可以点击更多按钮。
不幸的是,这个功能仅在特定的 IDE 中工作。例如,尽管你可以在 Eclipse 中以与这里描述相同的方式使用条件断点,但 Eclipse 不允许你仅为了记录执行细节而使用断点(图 3.4)。

图 3.4 并非所有 IDE 都提供相同的调试工具。所有 IDE 都提供基本操作,但某些功能,如记录执行细节而不是暂停执行,可能不存在。在 Eclipse 中,你可以定义条件断点,但你不能使用日志功能。
你可能会问自己是否应该只为这些示例使用 IntelliJ IDEA。即使本书中的大多数示例都使用 IntelliJ IDEA,这并不意味着这个 IDE 比其他 IDE 更好。我使用过许多与 Java 相关的 IDE,例如 Eclipse、Netbeans 和 JDeveloper。我的建议是,你不应该过于习惯使用一个 IDE。相反,尝试使用各种选项,这样你可以决定哪个更适合你和你所在的团队。
3.2 使用不会暂停执行的断点
在本节中,我们讨论使用断点记录你可以稍后用于调查代码的消息。我最喜欢的使用断点的方式是记录可以帮助我了解应用程序执行期间发生了什么的细节,而无需暂停执行。正如你将在第五章中学到的,在某些情况下,记录是一种出色的调查实践。许多开发者在与添加日志指令时挣扎,而他们本可以简单地使用条件断点。

图 3.5 条件断点高级配置。除了指定断点的条件外,你还可以指示调试器不要为给定的断点暂停执行。相反,你可以简单地记录你需要了解情况的数据。
图 3.5 展示了如何配置一个不会暂停执行的条件断点。相反,当达到带有断点的行时,调试器会记录一条消息。在这种情况下,调试器记录了digits变量的值和执行堆栈跟踪。

图 3.6 使用不暂停执行的断点。相反,当行被达到时,调试器会记录一条消息。调试器还会记录digits变量的值和执行堆栈跟踪。
图 3.6 显示了配置了条件断点后运行应用程序的结果。注意,调试器在控制台中记录了执行堆栈跟踪,digits变量的值是一个空列表:[]。这类信息可以帮助你解决你在现实场景中调查的代码的难题。
3.3 动态改变调查场景
在本节中,你将学习另一种非常有价值的技巧,这将使你的代码调查更容易:在调试过程中更改作用域内变量的值。在某些情况下,这种方法可以节省大量时间。我们将从讨论在实时更改变量值最有效的方法的场景开始。然后我将通过一个示例演示如何使用这种方法。
在本章的早期,我们讨论了条件断点。条件断点允许你告诉调试器在特定条件下暂停执行(例如,当给定变量具有某个值时)。通常,我们调查的是执行时间很短的逻辑,使用条件断点就足够了。对于像通过 REST 端点调用的逻辑(特别是如果你有在环境中重现问题的正确数据)这样的案例,你只需在适当的时候使用条件断点来暂停执行。这是因为你知道通过端点调用的东西不会花费很长时间。但考虑以下场景:
-
你调查了一个执行时间很长的进程的问题。比如说,这是一个计划中的进程,有时需要超过一个小时才能完成其执行。你怀疑某些给定的参数值导致了错误的输出,你想要在决定如何纠正问题之前确认你的怀疑。
-
你有一段执行速度很快的代码,但你无法在你的环境中重现问题。这个问题只出现在你无法访问的、由于安全限制而无法访问的生产环境中。你认为问题出现在某些参数具有特定值时。你想要证明你的理论是正确的。
在场景 1 中,断点(无论是条件断点还是非条件断点)并不那么有帮助。除非你调查的是在过程开始时发生的某些逻辑,否则运行过程并等待执行在带有断点的行上暂停会花费太多时间(图 3.7)。

图 3.7 通常情况下,在调查长时间运行过程中的问题时,使用断点并不是一个真正的选择。执行达到你要调查的代码部分可能需要很长时间,如果你不得不多次重新运行该过程,你肯定会花费太多时间在上面。
执行堆栈跟踪:视觉表示与文本表示
注意控制台打印堆栈跟踪的方式。你通常会找到以文本格式而不是视觉格式表示的执行堆栈跟踪。文本表示的优点是它可以存储在任何文本格式输出中,例如控制台或日志文件。
下图显示了调试器提供的执行堆栈跟踪的视觉表示和文本表示之间的比较。在这两种情况下,调试器都提供了可以帮助你理解特定代码行是如何执行的相同基本细节。
在这个特定的情况下,堆栈跟踪告诉我们执行是从Main类的main()方法开始的。记住,堆栈跟踪的第一层是底部的一层。在第 9 行,main()方法调用了Decoder类的decode()方法(层 2),然后调用了我们标记为断点的行。

调试器中执行堆栈跟踪的视觉表示与其文本表示的比较。堆栈跟踪显示了方法是如何被调用的,并提供了足够详细的说明,以便你理解执行路径。
对于场景 2,使用断点有时可能是可能的。在第四章中,我们将讨论远程调试,你将了解何时远程调试是一种有用的调查技术。但让我们暂时假设(因为我们还没有讨论过)在这种情况下不能应用远程调试。相反,如果你对导致问题的原因有一个想法,你只需要证明它但没有正确的数据,你可以使用变量值的即时更改。
图 3.8 展示了当调试器暂停执行时如何更改作用域中某个变量的数据。在 IntelliJ IDEA 中,你右键单击想要更改值的变量。你在这个动作中完成,调试器显示作用域中变量的当前值。让我们看看我们之前的例子,da-ch3-ex1。

图 3.8 在作用域内设置变量的新值。当调试器在给定行上暂停执行时,它会显示作用域内变量的值。你也可以更改这些值以创建一个新的调查案例。在某些情况下,这种方法可以帮助你验证关于代码行为的怀疑。
一旦你选择了想要更改的变量,按照图 3.9 所示设置值。记住,你必须使用符合变量类型的值。这意味着如果你更改一个String变量,你仍然需要使用一个String值;你不能使用long或Boolean值。

图 3.9 将变量的值更改以观察应用程序在不同条件下的执行行为。
当你继续执行时,如图 3.10 所示,应用程序现在使用新的值。而不是为值"ab1c"调用extractDigits(),应用程序使用了值"abcd"。该方法返回的列表为空,因为字符串"abcd"不包含数字。

图 3.10 当使用单步执行操作时,应用程序使用你设置的s变量的新值。extractDigits()返回一个空列表,因为字符串"abcd"不包含数字。在变量上动态设置值允许你在没有所需输入数据的情况下测试不同的场景。
让我们比较第 3.1 节中讨论的条件断点方法与动态更改数据。在这两种情况下,你首先需要有一个关于可能引起问题的代码部分的思路。如果你
-
你拥有导致你想要调查的场景的数据。在我们的例子中,我们需要执行提供列表中行为所需的价值。
-
你正在调查的代码执行时间不会太长。例如,假设我们有一个包含许多元素的列表,并且应用程序处理每个元素需要几秒钟。在这种情况下,使用条件断点可能意味着你将不得不投入大量时间来调查你的案例。
如果你可以使用更改变量值的策略
-
你没有导致你想要调查的场景所需的数据。
-
执行代码花费的时间太长了。
我知道你现在在想什么:我们为什么要使用条件断点呢?看起来你可能应该完全避免使用条件断点,因为你可以通过在变量上动态更改值来创建任何你需要的用于调查的环境。
这两种技术都有优点和缺点。如果你只需要更改几个值,改变变量的值可能是一个很好的方法。但是,当你的更改变得更加广泛时,场景的复杂性就变得越来越难以管理。
3.4 回滚调查案例
我们不能回到过去。然而,通过调试,有时可以回溯调查。在本节中,我们讨论在调试代码时何时以及如何“回到过去”。我们称这种方法为丢弃帧、丢弃执行帧或退出执行帧。
我们将使用 IntelliJ IDEA 来查看一个示例。我们将比较本章前几节中讨论的方法,然后我们还将确定何时不能使用这种技术。
丢弃执行帧实际上是在执行堆栈跟踪中回退一层。例如,假设你进入了一个方法并想要返回;你可以丢弃执行帧以返回到方法被调用的位置。
许多开发者混淆了丢弃帧和退出,很可能是因为当前的调查计划在这两种情况下都会关闭,并且执行返回到方法被调用的位置。然而,这里有一个很大的区别。当你从方法中退出时,执行会在当前计划中继续,直到方法返回或抛出异常。然后,调试器会在当前方法退出后暂停执行。
图 3.11 展示了如何使用项目 da-ch3-ex1 中的示例来工作。你处于extractDigits()方法中,正如你可以从执行堆栈跟踪中看到的那样,它已经被Decoder类中的decode()方法调用。如果你使用退出操作,执行将继续在调用extractDigits()的方法中,直到该方法返回。然后,调试器会在decode()方法中暂停执行。换句话说,退出就像是快进这个执行计划来关闭它并返回到上一个计划。

图 3.11 退出操作通过执行方法并在方法调用后立即暂停执行来关闭当前调查计划。这个操作允许你继续执行并返回执行堆栈中的一层。
当你丢弃执行帧时,执行会返回到方法调用之前的前一个计划,这与退出不同。这样,你可以重新播放调用。如果退出操作像是快进,那么丢弃执行帧(图 3.12)就像是倒带。

图 3.12 当你丢弃一个帧时,你会返回到方法调用之前执行堆栈跟踪中的上一层。这样,你可以通过再次进入或跳过它来重新播放方法执行。
图 3.13 展示了相对于我们的示例,从extractDigits()方法中退出与回退由extractDigits()方法创建的帧之间的比较。如果你退出,你会回到decode()方法的第 12 行,从那里调用extractDigits(),调试器将要执行的下一行是第 13 行。如果你回退帧,调试器会回到decode()方法,但将要执行的下一行是第 12 行。基本上,调试器会回到extractDigits()方法执行之前的行。

图 3.13 回退帧与退出。当你回退帧时,你会回到方法执行之前的行。当你退出时,你会继续执行,但关闭当前的调查计划(由执行栈中的当前层表示)。
图 3.14 展示了如何在 IntelliJ IDEA 中使用回退帧功能。要回退当前执行帧,请在执行栈跟踪中右键单击方法的层,然后选择“回退帧”。

图 3.14 当使用 IntelliJ IDEA 时,你可以通过在执行栈跟踪中右键单击方法的层来回退帧,然后选择“回退帧”。
为什么回退帧(drop frame)是有用的,它是如何帮助节省时间的?无论你是通过端点查找你想要调查的特定案例,还是通过改变变量的值来创建一个,正如第 3.3 节中讨论的,你有时会发现重复执行相同的操作几次是有用的。理解某段代码并不总是那么简单,即使你使用调试器暂停执行并逐步进行,也是如此。但时不时地回顾步骤以及特定的代码指令如何改变数据,可能有助于你理解正在发生的事情。
当你决定通过回退帧重复特定的指令时,也需要注意。这种方法有时可能比有帮助更令人困惑。记住,如果你运行任何改变应用内部内存外值的指令,你不能通过回退帧撤销该更改。这样的例子包括(图 3.15)如下:
-
修改数据库中的数据(插入、更新或删除)
-
改变文件系统(创建、删除或更改文件)
-
调用另一个应用,该应用会改变该应用的数据
-
向由不同应用读取的消息队列中添加消息,该应用会改变该应用的数据
-
发送电子邮件消息
你可以回退导致提交事务并更改数据库中数据的帧,但回到之前的指令不会撤销事务所做的更改。如果应用调用一个端点将内容发布到不同的服务,端点调用产生的更改不能通过回退帧撤销。如果应用发送电子邮件消息,回退帧不能撤回消息,等等(图 3.15)。

图 3.15 使用丢弃帧操作可能会导致一些无法撤销的事件。例如,更改数据库中的数据,更改文件系统中的数据,调用另一个应用程序,或发送电子邮件消息。
当数据在应用程序外部更改时,你需要小心,因为有时重复相同的代码不会得到相同的结果。以一个简单的代码片段(列表 3.2,你可以在项目 da-ch3-ex2 中找到)为例。如果你在创建文件的行执行后丢弃帧,会发生什么?
Files.createFile(Paths.get("File " + i));
创建的文件会保留在文件系统中,在你丢弃帧后第二次执行代码后,你会遇到异常(因为文件已经存在)。这是一个调试时回溯时间不有帮助的简单例子。最糟糕的是,在现实世界的案例中,这并不那么明显。我的建议是避免重复执行大量代码,并且在决定使用这种方法之前,确保逻辑部分不会进行外部更改。
如果你注意到再次运行丢弃帧后出现的不寻常的差异,可能是因为代码在外部进行了更改。在大型应用程序中,观察这种行为往往并不简单。例如,你的应用程序可能使用缓存或记录数据,这些数据访问某个库以观察或执行通过拦截器(方面)完全解耦的代码。
调用 Files.createFile() 方法会在文件系统中创建一个新文件。如果你在运行此行代码后丢弃帧,你会回到调用 createFile() 方法之前的行。然而,这并不会撤销文件创建。
列表 3.2 执行时在应用程序外部进行更改的方法
public class FileManager {
public boolean createFile(int i) {
try {
Files.createFile(Paths.get("File " + i)); ❶
return true;
} catch (IOException e) {
e.printStackTrace();
}
return false;
}
}
❶ 在文件系统中创建一个新文件。
摘要
-
条件断点是与布尔条件关联的断点。只有当提供的条件为真时,即只有当特定条件适用时,调试器才会暂停执行。这样,你可以节省在代码中导航直到到达你想要开始调查的点的时间。
-
你可以使用断点在控制台记录某些变量的值,而不会挂起应用程序的执行。这种方法非常有帮助,因为你可以在不更改代码的情况下添加日志消息。
-
当调试器在特定代码行上暂停执行时,你可以实时更改数据,以根据你想要调查的内容创建自定义场景。这样,你不必等到执行到达条件断点。在某些情况下,当你没有适当的环境时,在调试过程中更改数据可以节省你本来需要准备数据的时间。
-
通过更改变量的值来创建自定义调查场景,在试图理解长时间运行过程的某个逻辑部分或当你没有在运行应用的环境中获取到所需数据时,可以是一种有效的技术。然而,一次更改一个或两个以上的变量值可能会增加相当大的复杂性,使你的调查更具挑战性。
-
你可以跳出调查计划并返回到方法调用之前的位置。这被称为丢弃帧,但有时可能会引入不希望出现的副作用。如果应用在外部进行了任何更改(例如,提交了事务并更改了一些数据库记录,更改了文件系统中的文件,或向另一个应用发出了 RESTful 调用),返回到之前的执行步骤不会撤销这些更改。
4 远程调试应用程序
本章涵盖
-
调试远程环境中的应用程序
-
通过实际示例提升调试技术
我的一个朋友最近遇到了一个问题,他在实施软件时,某个特定部分运行得非常慢。通常,当我们遇到这类性能问题时,我们会怀疑是 I/O 接口的原因(例如,数据库连接或读写文件)。记得在第一章中提到,这类接口往往会减慢应用程序的速度,因此它们很可能是罪魁祸首。但在我的朋友的情况下,接口并不是问题所在。
性能问题是由简单地生成一个随机值(存储在数据库中的通用唯一标识符[UUID])引起的。操作系统使用硬件来源(例如,鼠标移动、键盘等)来收集随机性,这被称为熵。应用程序使用这种随机性来生成随机值。但是,当我们在一个虚拟化环境中部署应用程序,如虚拟机或容器(这在今天的应用程序部署中相当常见)时,操作系统用于创建熵的来源就减少了。因此,有时应用程序创建所需随机值的熵可能不足。这种情况会导致性能问题,在某些情况下,可能会对应用程序的安全性产生负面影响。
没有直接连接到问题发生的环境,这种问题很难调查。对于这类场景,远程调试可能是解决方案。你只能在特定环境中检查某些情况。假设你的客户观察到问题,但你在自己的电脑上运行应用程序时问题并没有发生。你绝对不能仅仅通过告诉你的客户“在我的机器上它运行正常”来解决这个问题。
当你在电脑上无法重现问题时,你需要连接到问题发生的环境。虽然有时你没有任何其他选择,不得不尝试修复你无法重现的问题,但在其他时候,环境是开放的,可以进行远程调试。远程调试,或调试外部环境中的应用程序,是本章的主题(见图 4.1)。

图 4.1 远程调试应用程序。开发者可以在本地运行调试工具,但将其连接到在另一个环境中运行的应用程序实例。这种方法允许开发者调查仅在特定环境中出现的问题。
我们将本章从讨论远程调试是什么以及你何时可以期望使用它开始,以及何时不应使用这种方法。然后,为了应用这项技术,我们将研究一个需要调查的问题。你将了解应用程序需要如何配置才能进行远程调试,以及如何连接和使用远程环境的调试器。
4.1 什么是远程调试?
在本节中,我们将讨论远程调试是什么,何时使用它,以及何时避免使用它。远程调试不过是将你在第二章和第三章中学到的调试技术应用于一个不在你系统本地运行而是在外部环境中运行的应用程序。为什么你需要在一个远程环境中使用这些技术呢?为了回答这个问题,让我们简要回顾一下典型的软件开发过程。
当开发者实现一个应用程序时,他们并不是为他们的本地系统编写的。应用程序的最终目的是将其部署到生产环境中,在那里它可以帮助用户解决各种业务问题。此外,在实现软件时,我们通常不会直接在用户的或生产环境中部署应用程序,而是使用类似的环境来大致测试我们需要实现的功能和修复,然后再将它们安装到官方使用真实数据的正式环境中。

图 4.2 当构建现实世界的应用程序时,开发者通常会使用多个环境。首先,他们在开发(dev)环境中构建应用程序。然后,一旦某个功能或解决方案准备就绪,他们就会使用用户验收测试(UAT)环境向用户(或应用程序的利益相关者)展示。最后,在利益相关者确认实现可行之后,他们将应用程序安装到生产(prod)环境中。
如图 4.2 所示,开发团队在开发应用程序时至少使用三个环境:
-
开发环境(dev)——一个类似于应用程序将部署的环境。开发者主要使用这个环境来测试他们在本地系统上开发后实现的新功能和修复。
-
用户验收测试环境(UAT)——一旦在开发环境中成功测试,应用程序就被安装到用户验收测试环境中。用户可以测试新的实现和修复,并在应用程序交付到包含真实数据的真实环境中之前确认它们是否可行。
-
生产环境(prod)——在用户确认新的实现按预期工作并且他们感到舒适使用之后,应用程序被安装到生产环境中。
但如果某个实现在你本地计算机上运行正常,但在另一个环境中表现不同,你会怎么想呢?你可能想知道为什么一个应用程序可以以不同的方式工作。即使使用相同的编译应用程序,我们也可以观察到两个不同环境中应用程序行为的差异。这些差异的原因可能包括以下几方面:
-
应用程序环境中可用的数据不同。不同的环境使用不同的数据库实例、不同的配置文件等等。
-
应用程序安装的操作系统可能不同。
-
部署的编排方式可能不同。例如,一个环境可能使用虚拟机进行部署,而另一个则使用容器化解决方案。
-
每个环境中的权限设置可能不同。
-
环境可能具有不同的资源(分配的内存或 CPU)。
这些只是众多可能导致特定输出或行为不同的因素中的一部分。上一次我遇到这样的问题(不久前),应用程序由于在实现的使用案例中向使用的网络服务发送的请求不同,产生了不同的输出。由于安全问题,我们无法在开发环境中使用相同的端点,也无法连接到应用程序在出现问题的环境中使用的端点。这些条件使得调查变得具有挑战性(老实说,直到我们开始调试,我们甚至都没有考虑过端点是问题的原因)。
在这些情况下,远程调试真的可以帮助你更快地理解软件行为。然而,请记住一条重要的建议:永远不要在生产环境中使用远程调试(图 4.3)。同时,确保你始终了解你使用的环境之间的主要差异。
TIP 注意不同环境之间的差异,这会给你提供可能出错线索。这甚至可以节省你调查问题的宝贵时间,仅凭这些细节就可以经验性地给出问题的答案。
![]()

图 4.3 开发者使用开发和 UAT 环境实现应用程序。在这些环境中调试应用程序是可以的。但请记住,永远不要在生产环境中调试应用程序,因为这可能会影响应用程序的执行,干扰用户的操作,甚至暴露敏感数据,从而创建安全漏洞。
正如你将学到的,你需要将我们称之为代理的软件附加到应用程序执行中,以启用远程调试。附加调试代理的一些后果(以及为什么你不应该在生产环境中这样做)包括以下内容:
-
代理可以减慢应用程序的执行;这种缓慢可能会导致性能问题。
-
代理需要通过网络与调试工具进行通信。为了启用这一点,你需要打开特定的端口,这可能会引起安全漏洞问题。
-
调试特定的代码片段可能会干扰功能,如果应用程序的同一部分同时被其他地方使用。
-
有时调试可能会无限期地阻止应用程序,并迫使你重新启动进程。
4.2 在远程环境中进行调查
在本节中,我们考虑调试在远程环境中运行的应用程序。我将首先在第 4.2.1 节中描述场景。然后,在第 4.2.2 节中,我们将使用本书提供的应用程序(项目 da-ch4-ex1),讨论如何启动应用程序以进行远程调试,以及如何使用你在第二章和第三章中学到的技术将调试器附加到远程运行的应用程序上。
4.2.1 情景
假设你在一个团队中工作,该团队实施并维护了一个大型应用,许多客户使用该应用来管理他们的产品库存。最近,你的团队实施了一个新的功能,帮助客户轻松管理他们的成本。团队在开发环境中成功测试了行为,并在 UAT 环境中安装了应用,以便在将其移至生产之前允许用户验证该功能。然而,负责测试新功能的人告诉你,应该显示新数据的 Web 界面没有任何显示。
感到担忧,你查看了一下,很快发现问题不在于前端。但后端的某个端点似乎表现异常。当在 UAT 环境中调用该端点时,HTTP 响应状态码为 200 OK,但应用没有在 HTTP 响应中返回数据(图 4.4)。你检查了日志,但那里也没有任何显示。由于你无法在本地或开发环境中观察到这个问题,你决定在 UAT 环境中远程连接你的调试器以找到这个问题的原因。
注意:即使我们讨论的是在远程环境中运行的应用的调试,为了使示例更简单,我们使用本地系统来运行应用,然后远程连接到它。因此,你会在图中看到我使用“localhost”来访问运行应用的环境。在现实世界的场景中,应用将运行在不同的系统上,该系统将使用 IP 地址或 DNS 名称来标识。

图 4.4 你需要调查的场景。/api/product/total/costs端点应该从数据库返回总成本。相反,当向端点发送请求时,应用表现异常。HTTP 状态是 200 OK,但你预期的总成本,一个值列表,返回为 null。
4.2.2 在远程环境中查找问题
在本节中,我们使用远程调试来调查 4.2.1 节中描述的案例研究。我们首先配置并运行应用以连接到远程调试器,然后将调试器附加到应用以开始调查。
在现实世界的案例中,应用已经运行,并且很可能还没有配置为允许远程调试。因此,我们首先启动应用,这样你就能了解远程调试的全貌,并知道这种方法的先决条件。
当启动你想要远程调试的应用时,你需要确保将调试代理附加到执行中。要将调试代理附加到 Java 应用执行,你需要在java命令行中添加-agentlib:jdwp参数,如图 4.5 所示。你必须指定你将附加调试工具的端口号。基本上,调试代理充当服务器,监听在配置的端口上连接的调试工具,并允许工具运行调试操作(在断点处暂停执行、单步执行、进入等)。

图 4.5 当在本地调试应用程序时,IDE 会附加调试器。但当你在一个远程环境中运行应用程序时,你必须自己在一个应用程序启动时附加调试器代理。
你可以复制此命令:
java -jar -agentlib:jdwp=transport=dt_socket,
➥ server=y,suspend=n,address=*:5005 app.jar
注意命令中指定的少量配置:
-
transport=dt_socket配置了调试工具与调试器代理之间的通信方式。dt_socket配置意味着我们使用 TCP/IP 在网络上建立通信。这始终是建立代理和工具之间通信的方式。 -
server=y表示代理在附加到应用程序执行后充当服务器。代理等待调试工具连接到它,并通过它控制应用程序的执行。你可以使用server=n配置来连接到调试器代理而不是启动一个。 -
suspend=n告诉应用程序在没有等待调试工具连接的情况下启动。如果你想防止应用程序在你连接调试器之前启动,你需要使用suspend=y。在我们的例子中,我们有一个网络应用程序,问题出现在调用其端点时,因此我们需要在应用程序能够调用端点之前启动应用程序。如果我们正在调查服务器启动过程的问题,我们很可能需要使用suspend=y来允许应用程序在调试工具连接后才能启动。 -
address=*:5005告诉代理在系统上打开端口 5005,这是调试工具将连接以与代理通信的端口。端口号必须在系统上未被使用,并且网络需要允许调试工具和代理之间的通信(端口需要在网络中打开)。
图 4.6 显示了应用程序,从附加了调试器代理开始。注意命令执行后立即在控制台打印的消息告诉我们代理正在监听配置的端口 5005。

图 4.6 当你运行启动应用程序的命令时,你可以看到应用程序开始执行。同时,你可以看到调试代理打印了一条消息,表明它正在配置的端口 5005 上监听调试器的附加。
一旦你的远程应用程序附加了调试器代理,你就可以连接调试器以开始调查问题。记住,我们假设网络已配置为允许两个应用程序(调试工具和调试器代理)之间的通信。在我们的例子中,我们都在本地主机上运行,所以对于我们的演示,这样的网络配置不是问题。
但在实际场景中,你应该在开始调试之前始终确保可以建立通信。在大多数情况下,你可能需要基础设施团队的人帮助你打开所需的端口,如果通信不被允许的话。记住,通常出于安全原因,端口默认是关闭以供通信的。
接下来,我们将检查如何使用 IntelliJ IDEA Community 将调试器附加到远程应用。在远程环境中运行的应用程序上运行调试器的步骤如下:
-
添加一个新的运行配置。
-
配置调试代理的远程地址(IP 地址和端口)。
-
开始调试应用。

图 4.7 您可以使用 IDE 配置调试器以附加到特定环境中的已运行应用,只要应用已附加调试代理。在 IntelliJ IDEA Community 中,您需要创建一个新的运行配置来告诉调试器附加到已运行的应用。您可以通过选择“编辑配置”来添加新的运行配置。
图 4.7 展示了如何打开“编辑配置”部分以添加新的运行配置。

图 4.8 一旦您选择了“编辑配置”,您就可以添加新的配置。首先,点击加号图标,然后选择“添加新配置”。
图 4.8 展示了如何添加新的运行配置。

图 4.9 由于我们想要将调试器附加到在远程环境中运行的应用,请选择“远程 JVM 调试”配置类型。
由于我们想要连接到远程调试代理,因此需要添加一个新的远程调试配置,如图 4.9 所示。

图 4.10 给您添加的新配置起一个名字,并指定调试代理配置的地址和端口(在此处,启动应用时为端口 5005)。
配置调试代理的地址,如图 4.10 所示。在我们的情况下,我们在与调试器相同的系统上运行应用,因此我们使用 localhost。在实际环境中,如果应用运行在不同的系统上,您将不得不使用该系统的 IP 地址。我们使用端口 5005 让代理监听并与调试工具连接。

图 4.11 在开发者计算机上运行的调试工具连接到端口 5005 上的调试代理。调试代理允许调试工具控制应用。应用也会打开一个端口,但这个端口是为其客户端(在 Web 应用的情况下是浏览器)准备的。
记住,我们将调试工具连接到调试代理,该代理打开端口 5005(图 4.11)。不要将调试代理打开的端口(5005)与我们的 Web 应用端口(8080)混淆。

图 4.12 您现在可以使用新添加的配置运行调试器。点击小虫图标以启动调试器。
一旦配置就绪,开始调试器(图 4.12)。调试器将与附加到应用的调试代理“对话”,并允许您控制执行。

图 4.13 开发者需要确保他们拥有的源代码版本与用于在远程环境中生成应用程序可执行文件的版本相同。否则,调试器的操作可能与开发者调查的代码不一致,这可能会比帮助开发者理解应用程序的行为造成更多的困惑。
现在,您可以使用调试器的方式与您在第二章和第三章中学到的方式相同。注意你使用的代码版本很重要(如图 4.13 所示)。当在本地调试应用程序时,您知道 IDE 编译应用程序,然后将调试器附加到新编译的代码上。然而,当您连接到远程应用程序时,您不能再确定您拥有的源代码是否与您附加调试器的远程应用程序的编译代码相对应。如果团队开始新的任务,您需要调查的代码可能已经在涉及的相同类中进行了更改、添加或删除。使用不同的源代码版本可能导致调试器出现奇怪和令人困惑的行为。例如,调试器可能会显示您正在导航空行,甚至是方法或类之外的行。执行堆栈跟踪也可能与预期的执行不一致。
幸运的是,今天我们使用源代码版本控制软件,如 Git 或 SVN,因此我们可以始终确定创建我们部署的应用程序的源代码版本。在调试之前,您需要确保您拥有的源代码与您想要远程调查的应用程序编译成的源代码相同。使用您的源代码版本控制工具来找到确切的源代码版本。

让我们在引起担忧的第一行设置一个断点:如图 4.14 所示的 ProductService 类中的第 23 行。在这里,应用程序应该从数据库中选择要返回到 HTTP 响应中的数据。首先,我想确定数据是否正确地从数据库中检索出来,所以我在这行暂停执行并单步跳过以查看结果。

图 4.14 就像在本地调试应用程序一样,您可以添加断点并使用导航操作。在 ProductService 类的第 23 行添加一个新的断点。
在添加断点后,使用 Postman(或类似工具)发送具有意外行为的 HTTP 请求(如图 4.15 所示)。Postman(您可以从 www.postman.com/downloads/ 下载)是一个简单的工具,您可以使用它来调用给定的端点,并且最近它已经成为开发者们为此目的最喜欢的工具之一。Postman 拥有用户友好的 GUI,但如果您更喜欢命令行,您可以选择其他工具,例如 cURL。为了使示例简单,我使用 Postman。

图 4.15 当使用 Postman 发送请求时,响应不会立即返回。相反,Postman 无限期地等待响应,因为应用程序在您设置的断点所在的行上暂停了执行。
注意,Postman 不会立即显示 HTTP 响应。相反,您会看到请求仍然挂起,因为调试器在您标记断点的行上暂停了应用程序,如图 4.16 所示。现在您可以使用导航操作来调查问题。

图 4.16 IDE 显示调试器确实在您设置的断点所在的行上暂停了执行。因此,您可以使用导航操作继续调查。
使用单步跳过操作,您可以看到应用程序没有从数据库返回数据,而是抛出了异常(图 4.17)。现在您可以开始分析问题:
-
实现此功能的开发者使用原始类型来表示数据库中可能包含 null 值的列。由于 Java 中的原始类型不是对象类型,不能持有 null 值,因此应用程序会抛出异常。
-
开发者使用了
printStackTrace()方法来打印异常信息,这并不 helpful,因为您无法轻松地为各种环境配置输出。这可能是您最初在日志中看不到任何内容的原因(将在第五章中进一步讨论)。 -
由于数据库中该字段没有 null 值,因此问题没有在本地或开发环境中发生。

图 4.17 单步跳过操作显示应用程序抛出了异常。现在您对问题有了了解,可以决定如何解决它。
显然,代码需要重构,也许在下一次回顾会议中应该与团队讨论代码审查过程的增强。尽管如此,您很高兴找到了问题的原因,并知道如何解决它。
在 Eclipse IDE 中创建远程配置
我使用 IntelliJ IDEA 作为本书示例的主要 IDE。但正如我在前面的章节中所述,这本书并不是关于使用某个特定的 IDE。您可以使用您选择的任何工具应用我们讨论的技术。例如,您可以使用其他 IDE 进行远程调试,如 Eclipse。
以下图显示了如何在 Eclipse IDE 中添加新的调试配置。

在 Eclipse 中添加新的调试配置。
要在 Eclipse IDE 中添加新的调试配置,请选择 Run > Debug Configurations。您可以将调试配置配置为附加到控制远程应用程序的调试代理。
就像在 IntelliJ IDEA 中一样,您需要配置调试代理的地址(IP 地址和端口),调试工具将连接到该地址。

添加一个新的远程 Java 应用程序调试配置,并设置调试代理的地址。然后你可以保存配置并使用调试功能远程连接到应用程序进行调试。
一旦添加了配置,启动调试器并将断点添加到你想开始调查代码的地方以暂停执行。
摘要
-
有时,运行中的应用程序的具体意外行为仅在应用程序执行的环境中出现。当这种情况发生时,调试变得更加具有挑战性。
-
你可以使用调试器来调试在远程环境中执行带有某些条件的 Java 应用程序:
-
应用程序应该以附加调试代理的方式启动。
-
网络配置应允许调试工具与远程环境中附加到应用程序的调试代理之间的通信。
-
-
远程调试允许你通过连接到在远程环境中运行的进程,使用与本地调试相同的调试技术。
-
在调试远程环境中的应用程序之前,请确保调试器使用的是创建你要调查的应用程序相同的源代码副本。如果你没有确切的源代码,并且应用程序中涉及调查的部分进行了更改,调试器可能会出现异常行为,你的远程调查将变得比有帮助更具有挑战性。
5 充分利用日志:审计应用程序的行为
本章节涵盖
-
有效使用日志来理解应用程序的行为
-
正确实现应用程序中的日志功能
-
避免由日志引起的问题
在本章中,我们将讨论使用应用程序记录的日志消息。日志的概念并非随着软件的出现而出现。几个世纪以来,人们使用日志来帮助他们理解过去的事件和过程。自从书写发明以来,人们就开始使用日志,并且我们今天仍在使用它。所有船只都有航海日志。水手记录决策(方向、速度增加或减少等)和下达或接收的命令,以及遇到的任何事件(图 5.1)。如果船上设备出现问题,他们可以使用航海日志的记录来了解自己的位置,并导航到最近的岸边。如果发生事故,航海日志的记录可以在调查中用来确定如何避免不幸事件。

图 5.1 水手将事件存储在日志中,他们可以使用这些日志来确定自己的航线或分析船员对特定事件的反应。同样,应用程序存储日志消息,以便开发者可以在以后分析潜在问题或发现应用程序中的漏洞。
如果你曾经观看过棋局,你就会知道两位棋手都会记录每一步棋的移动。为什么?这些日志帮助他们之后重新创建整个游戏。他们研究自己和对手的走法,以发现潜在的错误或弱点。
出于类似的原因,应用程序也会记录日志消息。我们可以使用这些消息来了解应用程序执行时发生了什么。通过阅读日志消息,你可以像棋手重新创建整个棋局一样重新创建执行过程。当我们调查异常或不受欢迎的行为,或者更难以察觉的问题,如安全漏洞时,我们可以使用日志。
我相信你已经知道日志看起来像什么了。你至少在用 IDE(图 5.2)运行你的应用程序时见过日志消息。所有 IDE 都有一个日志控制台。这是所有软件开发者最初学习的内容之一。但是,应用程序不仅仅在 IDE 的控制台中显示日志消息。现实世界的应用程序会将日志存储起来,以便开发者可以调查特定时间点的应用程序行为。

图 5.2 IDE 日志控制台。所有 IDE 都有一个日志控制台。在本地运行应用程序时,在控制台中记录日志消息是有用的,但现实世界的应用程序也会存储日志,这些日志是理解应用程序在特定时间行为所必需的。
图 5.3 展示了标准格式日志消息的结构。日志消息只是一个字符串,所以从理论上讲,它可以是一句任何句子。然而,干净且易于使用的日志需要遵循一些最佳实践(你将在本章中学习到)。例如,除了描述外,日志消息还包含应用程序写入消息的时间戳、严重性的描述以及记录消息的应用程序部分的标记(图 5.3)。

图 5.3 优质日志消息的结构。除了描述一个情况或事件外,日志消息还应包含其他一些相关细节:应用程序记录消息的时间戳、事件的严重性以及消息被写入的位置。使用这些日志中的细节可以让你更容易地调查问题。
在许多情况下,日志是调查应用程序行为的一种有效方式。以下是一些例子:
-
调查已经发生的事件或事件时间线
-
调查那些干扰应用程序会改变应用程序行为的(海森堡)问题
-
理解应用程序长期的行为
-
对需要立即关注的临界事件发出警报
我们在调查特定应用程序功能的行为时通常不会只使用一种技术。根据场景的不同,开发者可能需要结合几种技术来理解特定的行为。在某些情况下,你将使用调试器以及日志和其他技术(你将在接下来的章节中学习)来找出为什么某些事情会以这种方式工作。
在调查问题时,我总是建议开发者在进行其他任何操作之前先检查日志(图 5.4)。日志通常能让你立即识别出异常行为,这有助于你确定调查的起点。日志不一定能回答你所有的问题,但有一个起点极其重要。如果日志消息显示了你应该从哪里开始,那么你已经节省了很多时间!

图 5.4 在你调查问题时,你应该做的第一件事就是阅读应用程序的日志。在许多情况下,日志消息为你提供了一个起点,或提供了关于下一步如何解决问题的宝贵提示。
在我看来,日志不仅极其有价值,实际上对于任何应用程序都是不可或缺的。在下一节中,我们将讨论如何使用日志,并了解日志在典型调查场景中的必要性。在 5.2 节中,你将学习如何正确实现应用程序中的日志功能。我们将讨论使用日志级别来帮助你更容易地过滤由日志引起的事件和问题。在 5.3 节中,我们将讨论使用日志和远程调试之间的区别。
我还推荐阅读 Phil Wilkins 所著的《Logging in Action》第四部分(Manning, 2022)。这一章更多地关注使用日志的调查技术,而《Logging in Action》则更深入地探讨了日志的技术细节。你还会发现使用不同于 Java(Python)的语言演示了日志记录。
5.1 检查日志中的问题
就像任何其他调查技术一样,在某些情况下使用日志是有意义的,而在其他情况下则不然。在本节中,我们将探讨各种场景,在这些场景中使用日志可以帮助你更轻松地理解软件的行为。我们将首先讨论日志消息的几个关键点,然后分析这些特征如何帮助开发者调查应用问题。
日志消息的一个最大优点是它们允许你可视化特定时间点某段代码的执行。当我们讨论第 2-4 章时,使用调试器时,你的注意力主要在当前。你查看调试器在特定代码行暂停执行时数据的外观。调试器不会给你很多关于执行历史的细节。你可以使用执行堆栈跟踪来识别执行路径,但其他所有内容都集中在当前。
相反,日志关注的是过去一段时间内应用的执行(图 5.5)。日志消息与时间有很强的关联性。

图 5.5 在使用调试器调查问题时,你关注的是当前。当你使用日志消息时,你关注的是过去的一个特定时间段。这种差异可以帮助你决定使用哪种方法。
记得考虑你的应用运行在的系统时区。由于时区不同(例如,应用运行的地方和开发者所在的地方之间),日志时间可能会偏移几个小时,这可能会造成混淆。
注意:始终在日志消息中包含时间戳。你将使用时间戳来轻松识别消息记录的顺序,这将给你一个关于应用何时写入特定消息的线索。我建议时间戳位于消息的第一部分(开头)。
![]()
5.1.1 使用日志来识别异常
日志可以帮助你在问题发生后识别问题并调查其根本原因。通常,我们使用日志来决定从哪里开始调查。然后我们继续使用其他工具和技术探索问题,例如调试器(如第 2-4 章所述)或分析器(如第 6-9 章所述)。你通常可以在日志中找到异常堆栈跟踪。下面的代码片段显示了 Java 异常堆栈跟踪的一个示例:
java.lang.NullPointerException
at java.base/java.util.concurrent.ThreadPoolExecutor
➥ runWorker(ThreadPoolExecutor.java:1128) ~[na:na]
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker
➥ run(ThreadPoolExecutor.java:628) ~[na:na]
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable
➥ run(TaskThread.java:61) ~[tomcat-embed-core-9.0.26.jar:9.0.26]
at java.base/java.lang.Thread.run(Thread.java:830) ~[na:na]
在应用程序的日志中看到这个异常堆栈跟踪或类似的内容,告诉你某个功能可能出现了问题。每个异常都有其自己的含义,有助于你确定应用程序遇到问题的位置。例如,NullPointerException 告诉你,某种方式下,一个指令通过一个不包含对象实例引用的变量引用了一个属性或方法(图 5.6)。

图 5.6 NullPointerException 表示应用程序执行遇到了没有行为实例的行为。但这并不意味着产生异常的行也是问题的原因。异常可能是根本原因的结果。你应该始终寻找根本原因,而不是局部处理问题。
注意:记住,异常发生的地方不一定是问题的根本原因的位置。异常告诉你哪里出了问题,但异常本身可能是其他地方问题的结果。它不一定是问题本身。不要急于通过添加 try-catch-finally 块或 if-else 语句来本地解决异常。首先,确保你在寻找解决问题的解决方案之前理解了问题的根本原因。
我经常发现这个概念会让初学者感到困惑。让我们以一个简单的 NullPointerException 为例,这可能是任何 Java 开发者遇到的第一个异常,也是最容易理解的。然而,当你发现日志中的 NullPointerException 时,你首先需要问自己:为什么那个引用缺失?它可能是因为应用程序之前执行的一个特定指令没有按预期工作(图 5.7)。

图 5.7 在许多情况下,局部解决问题等同于把问题扫到地毯下。如果根本原因仍然存在,以后可能会出现更多问题。记住,日志中的异常并不一定表示根本原因。
5.1.2 使用异常堆栈跟踪来识别调用方法
开发者认为的一种不寻常的技术,但我在实践中发现它很有优势,就是记录异常堆栈跟踪以识别调用特定方法的代码。自从我开始作为软件开发者职业生涯以来,我就一直在处理(通常是)大型应用的混乱代码库。我经常遇到的一个困难是在远程环境中运行应用程序时,确定谁调用了给定的方法。如果你只是阅读应用程序的代码,你会发现数百种调用该方法的方式。
当然,如果你足够幸运,并且有权访问,你可以使用第四章中讨论的远程调试。然后你可以访问调试器提供的执行堆栈跟踪。但如果你无法远程使用调试器怎么办?在这种情况下,你可以使用日志技术代替!
Java 中的异常具有常被忽视的能力:它们记录执行堆栈跟踪。在讨论异常时,我们经常将执行堆栈跟踪称为 异常 堆栈跟踪。但最终,它们是同一件事。异常堆栈跟踪显示了导致特定异常的方法调用链,即使没有抛出该异常,你也能访问到这些信息。在代码中,使用异常就足够了:
new Exception().printStackTrace();
考虑列表 5.1 中的方法。如果你没有调试器,你可以简单地像我在这个示例中做的那样,在查找执行堆栈跟踪的方法中打印异常堆栈跟踪,作为第一行。记住,这只会打印堆栈跟踪,并不会抛出异常,所以它不会干扰执行的逻辑。这个示例在项目 da-ch5-ex1 中。
列表 5.1 使用异常打印执行堆栈跟踪
public List<Integer> extractDigits() {
new Exception().printStackTrace(); ❶
List<Integer> list = new ArrayList<>();
for (int i = 0; i < input.length(); i++) {
if (input.charAt(i) >= '0' && input.charAt(i) <= '9') {
list.add(Integer.parseInt(String.valueOf(input.charAt(i))));
}
}
return list;
}
❶ 打印异常堆栈跟踪
下一个片段展示了应用如何在控制台中打印异常堆栈跟踪。在现实场景中,堆栈跟踪帮助你立即识别执行流程,这导致你想要调查的调用,正如我们在第二章和第三章中讨论的那样。在这个示例中,你可以从日志中看到,extractDigits() 方法是在 decode() 方法内部从 Decoder 类的第 11 行被调用的:
java.lang.Exception at main.StringDigitExtractor
➥ extractDigits(StringDigitExtractor.java:15)
at main.Decoder.decode(Decoder.java:11)
at main.Main.main(Main.java:9)
5.1.3 测量执行给定指令花费的时间
日志消息是衡量给定指令集执行时间的一种简单方法。你总是可以记录给定行代码前后的时间戳之间的差异。假设你正在调查一个性能问题,其中某些给定功能执行时间过长。你怀疑原因是应用执行以从数据库检索数据的查询。对于某些参数值,查询很慢,这降低了应用的总体性能。
要找出哪个参数导致了问题,你可以在日志中编写查询和查询执行时间。一旦你确定了麻烦的参数值,你就可以开始寻找解决方案。也许你需要向数据库中的表添加一个额外的索引,或者也许你可以重写查询以使其更快。
列表 5.2 展示了如何记录特定代码片段的执行时间。例如,让我们找出应用运行从数据库中查找所有产品操作所需的时间。是的,我知道,这里没有参数;我简化了示例,以便你能够专注于讨论的语法。但在现实世界的应用中,你可能会调查更复杂的操作。
列表 5.2 记录特定代码行的执行时间
public TotalCostResponse getTotalCosts() {
TotalCostResponse response = new TotalCostResponse();
long timeBefore = System.currentTimeMillis(); ❶
var products = productRepository.findAll(); ❷
long spentTimeInMillis = ❸
System.currentTimeMillis() – timeBefore;
log.info(“Execution time: ” + spentTimeInMillis); ❹
var costs = products.stream().collect(
Collectors.toMap(
Product::getName,
p -> p.getPrice()
.multiply(new BigDecimal(p.getQuantity()))));
response.setTotalCosts(costs);
return response;
}
❶ 记录方法执行前的时间戳
❷ 执行我们想要计算执行时间的那个方法
❸ 计算执行后的时间戳与执行前的时间戳之间的时间差
❹ 打印执行时间
准确测量应用程序执行给定指令花费的时间是一种简单但有效的技术。然而,我只会暂时使用这种技术在调查问题时使用。我不建议您长时间保留此类日志在代码中,因为它们很可能以后不再需要,并且会使代码更难以阅读。一旦您解决了问题,不再需要知道该行代码的执行时间,您就可以删除日志。
5.1.4 调查多线程架构中的问题
多线程架构是一种使用多个线程来定义其功能的能力类型,并且通常对外部干扰很敏感(图 5.8)。例如,如果您使用调试器或分析器(干扰应用程序执行的工具),应用程序的行为可能会改变(图 5.9)。

图 5.8 多线程架构。具有使用多个线程并发处理数据的能力的应用程序是多线程应用程序。除非显式同步,否则在独立线程(A、B 和 C)上运行的指令可以以任何顺序执行。

图 5.9 使用调试器或分析器等工具会干扰执行,使某些(或所有)线程变慢。因此,执行通常会改变,某些指令可能以与您想要调查的场景不同的顺序执行。在这种情况下,该工具就不再有用,因为您无法研究您感兴趣的行为。
然而,如果您使用日志,应用程序在运行时受到影响的可能性较小。日志有时也会在多线程应用程序中干扰,但它们对执行的影响不足以改变应用程序的流程。因此,它们可以成为检索您调查所需数据的解决方案。
由于日志消息包含时间戳(如本章前面所述),您可以按顺序排列日志消息,以找到操作执行的顺序。在 Java 应用程序中,有时记录执行特定指令的线程名称是有帮助的。您可以使用以下指令获取当前执行线程的名称:
String threadName = Thread.currentThread().getName();
在 Java 应用程序中,所有线程都有一个名称。开发者可以命名它们,或者 JVM 将使用具有模式 Thread-x 的名称来识别线程,其中 x 是一个递增的数字。例如,第一个创建的线程将被命名为 Thread-0;下一个,Thread-1;依此类推。正如我们在第十章中讨论线程转储时将讨论的,命名应用程序的线程是一种良好的实践,这样在调查案例时可以更容易地识别它们。
5.2 实现日志记录
在本节中,我们讨论了在应用程序中实现日志记录功能的最佳实践。为了使应用程序的日志消息准备好调查,并避免对应用程序的执行造成麻烦,您需要关注一些实现细节。
我们将在 5.2.1 节中首先讨论应用程序如何持久化日志,具体讨论这些实践的优势和劣势。在 5.2.2 节中,你将学习如何通过根据严重性对日志消息进行分类来更有效地使用日志消息,从而提高应用程序的性能。在 5.2.3 节中,我们将讨论日志消息可能引起的问题以及如何避免这些问题。
5.2.1 持久化日志
持久性是日志消息的一个基本特征。正如本章前面所讨论的,日志记录与其他调查技术不同,因为它更多地关注过去而不是现在。我们读取日志是为了理解已经发生的事情,因此应用程序需要存储它们,以便我们可以在以后阅读。日志消息的存储方式可以影响日志的可用性和应用程序的性能。我已与许多应用程序合作,并有幸看到开发人员实现日志消息持久化的各种方法:
-
将日志存储在非关系型数据库中
-
将日志存储在文件中
-
将日志存储在关系型数据库中
根据应用程序的功能,这些都可以是好的选择。让我们看看你需要考虑的一些主要事项,以便做出正确的决定。
将日志存储在非关系型数据库中
非关系型(NoSQL)数据库帮助你权衡性能和一致性。你可以使用 NoSQL 数据库以更高效的方式存储日志,这给数据库一个机会错过日志消息或不在应用程序写入它们的精确时间顺序中存储它们。但是,正如本章前面所讨论的,日志消息应该始终包含消息存储时的时间戳,最好放在消息的开头。
在 NoSQL 数据库中存储日志消息是常见的。在大多数情况下,应用程序使用一个完整的引擎来存储日志,并具有检索、搜索和分析日志消息的能力。今天使用最广泛的两个引擎是 ELK 堆栈(www.elastic.co/what-is/elk-stack)和 Splunk(www.splunk.com/)。
将日志存储在文件中
在过去,应用程序将日志存储在文件中。你可能会发现一些较老的应用程序直接在文件中写入日志消息,但这种方法现在不太常见,因为它通常较慢,并且搜索已记录的数据更困难。我提醒你这一点,因为你会发现在许多教程和示例中,应用程序将它们的日志存储在文件中,但在更现代的应用程序中,你应该避免这样做。
将日志存储在关系型数据库中
我们很少使用关系型数据库来存储日志消息。关系型数据库主要保证数据一致性,这确保日志消息不会丢失。一旦存储,你就可以检索它们。但一致性会带来性能上的妥协。
在大多数应用程序中,丢失一条日志消息并不是什么大问题,并且性能通常比一致性更重要。但是,正如往常一样,在现实世界应用程序中,总有一些例外。例如,全球各国政府为金融应用程序施加日志消息规定,特别是对于支付功能。这样的功能通常应该有特定的日志消息,应用程序不允许丢失。未能遵守这些规定可能导致处罚和罚款。
5.2.2 定义日志级别和使用日志框架
在本节中,我们讨论日志级别以及在应用程序中使用日志框架正确实现日志记录。我们将首先探讨为什么日志级别是必要的,然后实现一个示例。
日志级别,也称为严重性,是一种根据它们对你调查的重要性来分类日志消息的方法。应用程序在运行时通常会生成大量的日志消息。然而,你通常不需要所有日志消息中的所有细节。其中一些消息对你的调查比其他消息更重要:一些代表需要始终关注的关键事件。
最常见的日志级别(严重性)如下:
-
错误—一个关键问题。应用程序应该始终记录此类事件。通常,Java 应用程序中的未处理异常被记录为错误。
-
警告—一个可能出错的事件,但应用程序能够处理它。例如,如果一个第三方系统的连接最初失败,但应用程序在第二次尝试中成功发送了呼叫,那么这个问题应该被记录为警告。
-
信息—“常见”的日志消息。这些消息代表了应用程序的主要执行事件,有助于你理解在大多数情况下应用程序的行为。
-
调试—细粒度的详细信息,你只有在信息消息不足以满足需求时才应该启用。
注意,不同的库可能使用多于或不同的名称来表示这四个严重性级别。例如,在某些情况下,应用程序或框架可能使用严重性级别“致命”(比错误更严重)和“跟踪”(比调试更不严重)。在本章中,我仅关注在现实世界应用程序中最常遇到的严重性和术语。
![]()
根据严重性对日志消息进行分类,可以让你最小化应用程序存储的日志消息数量。你应该只允许应用程序记录最相关的细节,并在需要更多细节时才启用更多日志。
看图 5.10,它展示了日志严重性金字塔:
-
应用程序记录少量关键问题,但这些问题非常重要,因此它们始终需要被记录。
-
你越接近金字塔的底部,应用程序写入的日志消息就越多,但它们在调查中变得不那么关键,也不那么频繁地需要。

图 5.10 日志严重性金字塔。顶部是通常需要立即关注的临界日志消息。底部代表您很少需要的详细日志消息。从上到下,日志消息变得不那么重要,但数量更多。通常,调试级别的消息默认禁用,开发者可以选择在需要关于应用程序执行细粒度详细信息的研究时启用它们。
对于大多数调查案例,您不需要将消息分类为调试。此外,由于数量庞大,它们使您的调查更具挑战性。因此,调试消息通常被禁用,并且您应该仅在遇到需要更多详细信息的特定问题时启用它们。
当您开始学习 Java 时,您被教导如何使用 System.out 或 System.err 在控制台打印某些内容。最终,您学习了如何使用 printStackTrace() 记录异常消息,正如我在 5.1.2 节中所做的那样。但是,这些在 Java 应用程序中处理日志的方式并不提供足够的配置灵活性。因此,在现实世界的应用程序中,我建议您使用日志框架。
实现日志级别很简单。今天,Java 生态系统提供了各种日志框架选项,如 Logback、Log4j 和 Java 日志 API。这些框架相似,使用它们很简单。
让我们通过一个例子来实现使用 Log4j 的日志记录。此示例位于项目 da-ch5-ex2. 要使用 Log4j 实现日志记录功能,您首先需要添加 Log4j 依赖项。在我们的 Maven 项目中,您必须更改 pom.xml 并添加 Log4j 依赖项。
列表 5.3 在 pom.xml 文件中需要添加的依赖项以使用 Log4j
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.14.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>
</dependencies>
一旦在项目中添加了依赖项,您就可以在任何想要写入日志消息的类中声明一个 Logger 实例。使用 Log4j,创建 Logger 实例的最简单方法是使用 LogManager.getLogger() 方法,如列表 5.4 所示。此方法允许您写入与表示的事件严重性名称相同的日志消息。例如,如果您想以信息严重性级别记录一条消息,您将使用 info() 方法。如果您想以调试严重性级别记录一条消息,您将使用 debug() 方法,依此类推。
列表 5.4 使用不同严重性写入日志消息
public class StringDigitExtractor {
private static Logger log = LogManager.getLogger(); ❶
private final String input;
public StringDigitExtractor(String input) {
this.input = input;
}
public List<Integer> extractDigits() {
log.info("Extracting digits for input {}", input); ❷
List<Integer> list = new ArrayList<>();
for (int i = 0; i < input.length(); i++) {
log.debug("Parsing character {} of input {}", ❸
input.charAt(i), input);
if (input.charAt(i) >= '0' && input.charAt(i) <= '9') {
list.add(Integer.parseInt(String.valueOf(input.charAt(i))));
}
}
log.info("Extract digits result for input {} is {}", input, list);
return list;
}
}
❶ 为当前类声明一个用于写入日志消息的日志记录器实例
❷ 使用信息严重性写入消息
❸ 使用调试严重性写入消息
一旦您决定要记录哪些消息并使用 Logger 实例写入它们,您需要配置 Log4j 以告诉应用程序如何以及在哪里写入这些消息。我们将使用一个名为 log4j2.xml 的 XML 文件来配置 Log4j。此 XML 文件必须位于应用程序的类路径中,因此我们将将其添加到我们的 Maven 项目的资源文件夹中。我们需要定义三件事(见图 5.11):
-
logger——告诉 Log4j 哪些消息应该写入哪个 appender
-
appender——告诉 Log4j 将日志消息写入何处
-
formatter——告诉 Log4j 如何打印消息

图 5.11 展示了 appender、logger 和 formatter 之间的关系。logger 使用一个或多个 appender。logger 决定要写入什么内容(例如,仅写入包中对象的日志消息)。logger 将要写入的消息交给一个或多个 appender。每个 appender 然后以某种方式存储这些消息。appender 使用 formatter 在存储之前对消息进行格式化。
logger 定义了要记录哪些消息。在这个例子中,我们使用 Root 来从应用的任何部分写入消息。其属性 level,值为 info,意味着只有严重性为 info 及以上的消息会被记录。logger 也可以决定只记录来自特定应用部分的消息。例如,当使用框架时,你很少对框架打印的日志消息感兴趣,但你通常对应用的日志消息感兴趣,因此你可以定义一个排除框架日志消息并只打印来自应用的日志消息的 logger。记住,你只想写入必要的日志消息。否则,调查可能会变得不必要地更具挑战性,因为你必须过滤掉非必要的日志消息。
在实际应用中,你可以定义多个 appender,它们很可能会被配置为将消息存储在不同的来源,如数据库或文件系统中的文件。在第 5.2.1 节中,我们讨论了应用保留日志消息的多种方式。appender 只是负责以特定方式存储日志消息的实现。
appender 也使用一个 formatter,该 formatter 定义了消息的格式。在这个例子中,formatter 指定消息应包括时间戳和严重性级别,因此应用只需要发送描述。
列表 5.5 展示了定义了 appender 和 logger 的配置。在这个例子中,我们定义了一个 appender,它告诉 Log4j 将消息记录在系统的标准输出流(控制台)中。
列表 5.5 展示了在 log4j2.xml 文件中配置 appender 和 logger 的配置
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders> ❶
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yy-MM-dd HH:mm:ss.SSS} [%t]
%-5level %logger{36} - %msg%n"/>
</Console>
</Appenders>
<Loggers> ❷
<Root level="info">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
❶ 定义 appender
❷ 定义 logger 配置
图 5.12 可视化地展示了列表 5.5 中的 XML 配置与它定义的三个组件之间的联系:logger、appender 和 formatter。

图 5.12 配置中的组件。logger Root 接收应用写入的所有严重性级别为 info 的日志消息。logger 将消息发送到名为 Console 的 appender。appender Console 被配置为将消息发送到系统终端。它使用 formatter 在写入之前将时间戳和严重性级别附加到消息上。
下面的代码片段显示了示例运行时打印的日志部分。请注意,调试消息没有被记录,因为它们的严重性低于信息(列表 5.5 中的第 10 行)。
21-07-28 13:17:39.915 [main] INFO
➥ main.StringDigitExtractor
➥ Extracting digits for input ab1c
21-07-28 13:17:39.932 [main] INFO
➥ main.StringDigitExtractor
➥ Extract digits result for input ab1c is [1]
21-07-28 13:17:39.943 [main] INFO
➥ main.StringDigitExtractor
➥ Extracting digits for input a112c
21-07-28 13:17:39.944 [main] INFO
➥ main.StringDigitExtractor
➥ Extract digits result for input a112c is [1, 1, 2]
...
如果我们想让应用程序也记录调试严重性的消息,我们就必须更改日志定义。
列表 5.6 使用不同的严重性配置
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN"> ❶
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yy-MM-dd HH:mm:ss.SSS} [%t]
%-5level %logger{36} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="debug"> ❷
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
❶ 设置内部 Log4j 事件的日志级别
❷ 将日志级别更改为调试
在列表 5.6 中,您可以看到一个状态和一个日志级别。这通常会引起混淆。大多数时候,您关心的是 level 属性,它显示根据严重性哪些消息将被记录。在 <Configuration> 标签中的 status 属性是 Log4J 事件的严重性,库遇到的问题。也就是说,status 属性是日志库的日志配置。
我们可以将列表 5.6 中的日志记录器更改为也记录具有优先级的消息:
21-07-28 13:18:36.164 [main ] INFO
➥ main.StringDigitExtractor
➥ Extracting digits for input ab1c
21-07-28 13:18:36.175 [main] DEBUG
➥ main.StringDigitExtractor
➥ Parsing character a of input ab1c
21-07-28 13:18:36.176 [main] DEBUG
➥ main.StringDigitExtractor
➥ Parsing character b of input ab1c
21-07-28 13:18:36.176 [main] DEBUG
➥ main.StringDigitExtractor
➥ Parsing character 1 of input ab1c
21-07-28 13:18:36.176 [main] DEBUG
➥ main.StringDigitExtractor
➥ Parsing character c of input ab1c
21-07-28 13:18:36.177 [main] INFO
➥ main.StringDigitExtractor
➥ Extract digits result for input ab1c is [1]
21-07-28 13:18:36.181 [main] INFO
➥ main.StringDigitExtractor
➥ Extracting digits for input a112c
...
日志库为您提供了灵活性,让您只记录所需的内容。编写最少数量的日志消息以调查特定问题是一种良好的实践,因为它可以帮助您更容易地理解日志,并保持应用程序的性能良好和易于维护。日志库还使您能够在不重新编译应用程序的情况下配置日志。
5.2.3 日志引起的问题及其避免方法
我们存储日志消息,以便我们可以使用它们来了解应用程序在某个时间点或一段时间内的行为。在许多情况下,日志是必要的并且非常有帮助,但如果不妥善处理,它们也可能变得有害。在本节中,我们将讨论日志可能引起的主要问题以及如何避免这些问题(图 5.13):
-
安全和隐私问题—由暴露私人数据的日志消息引起
-
性能问题—由应用程序存储过多或过大的日志消息引起
-
可维护性问题—由使源代码更难阅读的日志指令引起

图 5.13 小细节可能导致大问题。开发者有时会认为应用程序的日志功能默认无害,并忽视日志可能引入的问题。然而,日志,就像所有其他软件功能一样,处理数据,如果实现不当,可能会影响应用程序的功能和可维护性。
安全和隐私问题
安全性是我最喜欢的主题之一,也是开发者在实现应用程序时需要考虑的最重要主题之一。我写的一本书就是关于安全的,如果你使用 Spring 框架实现应用程序并想了解更多关于如何保护它们的信息,我建议你阅读它:Spring Security in Action(Manning,2020)。
意想不到的是,日志有时会导致应用程序出现漏洞,在大多数情况下,这些问题发生是因为开发者没有注意到他们暴露的细节。请记住,日志使特定细节对任何可以访问它们的人可见。你始终需要考虑你记录的数据是否应该对可以访问日志的人可见(图 5.14)。

图 5.14 日志消息不应包含秘密或私人细节。任何在应用程序或应用程序部署的基础设施上工作的人都不应访问此类数据。在日志中暴露敏感细节可以帮助恶意人员(黑客)找到更容易的方法来破坏系统或创建与安全相关的问题。
以下代码片段展示了某些日志消息的示例,这些消息暴露了敏感细节并导致漏洞:
Successful login.
User bob logged in with password RwjBaWIs66
Failed authentication.
The token is unsigned.
The token should have a signature with IVL4KiKMfz.
A new notification was sent to
➥ the following phone number +1233...
这里展示的日志有什么问题?前两个日志消息暴露了私人细节。你不应该记录用于签名令牌的密码或私人密钥,或任何其他交换的信息。密码是只有其所有者应该知道的东西。因此,没有任何应用程序应该以明文形式存储任何密码(无论是在日志中还是在数据库中)。私人密钥和类似的秘密细节应该存储在秘密库中,以防止被盗。如果有人获得了此类密钥的值,他们可以冒充应用程序或用户。
第三个日志消息示例暴露了一个电话号码。电话号码被视为个人细节,并且在全球范围内,特定的法规限制了此类细节的使用。例如,欧盟在 2018 年 5 月实施了通用数据保护条例(GDPR)。任何欧盟国家的应用程序都必须遵守这些规定,以避免严重的处罚。这些规定允许任何用户请求应用程序使用的所有个人数据,并要求立即删除这些数据。在日志中存储如电话号码之类的信息会暴露这些私人细节,并使检索和删除它们变得更加困难。
性能问题
编写日志意味着将细节(通常作为字符串)发送到应用程序外部的 I/O 流中。我们可以简单地将此信息发送到应用程序的控制台(终端),或者我们可以将其存储在文件中,甚至是一个数据库,正如我们在 5.2.1 节中讨论的那样。无论如何,你需要记住,记录消息也是一个耗时指令;添加过多的日志消息会显著降低应用程序的性能。
我记得几年前我的团队调查了一个问题。亚洲的一个客户报告了我们为工厂库存目的实施的应用程序的问题。这个问题并没有造成太多麻烦,但我们发现很难找到根本原因,所以我们决定添加更多的日志信息。在交付包含微小更改的补丁后,系统变得非常慢,有时几乎无法响应,这最终导致了生产停滞,我们不得不迅速撤销我们的更改。我们 somehow managed to change a mosquito into an elephant。

但一些简单的日志信息是如何造成如此大的麻烦的呢?这些日志被配置为将消息发送到网络中的另一个独立服务器,并且它们在那里持续存在。在那个工厂,网络极其缓慢,而且日志信息被添加到一个迭代大量项目的循环中,这使得应用程序变得极其缓慢。
最后,我们学到了一些有助于我们更加小心并避免重复同样错误的事情:
-
确保你理解应用程序如何记录消息。记住,即使是同一个应用程序,不同的部署也可能有不同的配置(参见 5.2.2 节)。
-
避免记录过多的信息。不要在迭代大量元素的循环中记录日志信息。记录过多的信息也会使阅读日志变得复杂。如果你需要在大型循环中记录日志信息,使用条件来缩小记录消息的迭代次数。
-
确保应用程序仅在真正需要时存储给定的日志信息。你可以通过使用我们在 5.2.2 节中讨论的日志级别来限制你存储的日志信息数量。
-
以一种方式实现日志记录机制,使得你可以在不重启服务的情况下启用和禁用它。这将允许你切换到更细粒度的日志级别,获取所需详细信息,然后再使日志不那么敏感。
可维护性
日志信息也可能对应用程序的可维护性产生负面影响。如果你太频繁地添加日志信息,它们可能会使应用程序的逻辑更难以理解。让我们看看一个例子:尝试阅读列表 5.7 和 5.8。哪段代码更容易理解?
列表 5.7 实现简单逻辑的方法
public List<Integer> extractDigits() {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < input.length(); i++) {
if (input.charAt(i) >= '0' && input.charAt(i) <= '9') {
list.add(Integer.parseInt(String.valueOf(input.charAt(i))));
}
}
return list;
}
列表 5.8 实现简单逻辑的方法,其中包含大量日志信息
public List<Integer> extractDigits() {
log.info(“Creating a new list to store the result.”);
List<Integer> list = new ArrayList<>();
log.info(“Iterating through the input string ” + input);
for (int i = 0; i < input.length(); i++) {
log.info(“Processing character ” + i + “ of the string”);
if (input.charAt(i) >= '0' && input.charAt(i) <= '9') {
log.info(“Character ” + i +
“ is digit. Character: ” +
input.charAt(i))
log.info(“Adding character” + input.charAt(i) + “ to the list”);
list.add(Integer.parseInt(String.valueOf(input.charAt(i))));
}
}
Log.info(“Returning the result ” + list);
return list;
}
这两个都展示了相同的实现逻辑。但在列表 5.8 中,我添加了大量的日志信息,这使得方法的逻辑更难以阅读。
我们如何避免影响应用程序的可维护性?
-
你不一定需要在代码中的每条指令后都添加日志信息。识别那些提供最相关细节的指令。记住,如果现有的日志信息不够,你可以稍后添加额外的日志。
-
确保方法足够小,以至于你只需要记录参数的值和执行后方法返回的值。
-
一些框架允许你将部分代码与方法解耦。例如,在 Spring 中,你可以使用自定义方面来记录方法执行的输出(包括参数值和方法执行后的返回值)。
5.3 日志与远程调试比较
在第四章中,我们讨论了远程调试,你了解到你可以将调试器连接到在外部环境中运行的应用。我开始这个讨论是因为我的学生经常问我为什么我们需要使用日志,因为我们可以直接连接并调试特定的问题。但正如我在本章前面以及之前章节中提到的,这些调试技术并不相互排斥。有时一个比另一个更好;在其他情况下,你需要将它们一起使用。
让我们分析一下与远程调试相比,我们可以做什么以及不能做什么,以了解如何高效地使用这两种技术。表 5.1 展示了日志和远程调试的并列比较。
表 5.1 日志与远程调试比较
| 功能 | 日志 | 远程调试 |
|---|---|---|
| 可以用来理解远程执行应用的行为 | ![]() |
![]() |
| 需要特殊的网络权限或配置 | ![]() |
![]() |
| 持久存储执行线索 | ![]() |
![]() |
| 允许你在代码的指定行暂停执行以理解应用的行为 | ![]() |
![]() |
| 可以用来理解应用的行为而不干扰执行逻辑 | ![]() |
![]() |
| 建议用于生产环境 | ![]() |
![]() |
你可以使用日志和远程调试来理解远程执行应用的行为。但两种方法都有自己的困难。日志意味着应用会记录调查所需的事件和数据。如果不是这种情况,你需要添加这些指令并重新部署应用。这就是开发者通常所说的“添加额外的日志”。远程调试允许你的调试器连接到远程执行的应用,但需要授予特定的网络配置和权限。
一个很大的区别是每种技术所体现的哲学。调试关注的是现在。你暂停执行并观察应用当前的状态。日志更多地关于过去。你得到一堆日志消息并分析执行情况,关注时间线。同时使用调试和日志来理解更复杂的问题是很常见的,我可以从经验中告诉你,有时使用日志与调试取决于开发者的偏好。我有时看到开发者使用一种技术仅仅是因为他们比另一种更舒服。
摘要
-
在开始调查任何问题时,始终检查应用程序的日志。日志可能会指示出了什么问题,或者至少为您的研究提供一个起点。
-
所有日志消息都应该包含时间戳。记住,在大多数情况下,系统不保证日志存储的顺序。时间戳将帮助您按时间顺序排列日志消息。
-
避免保存过多的日志消息。并非每个细节都与调查潜在问题相关或有帮助,并且保存过多的日志消息可能会影响应用程序的性能并使代码更难以阅读。
-
只有在需要时才应实现更多日志记录。运行中的应用程序应仅记录必要的信息。如果您需要更多详细信息,您始终可以暂时启用更多日志记录。
-
日志中的异常不一定是问题的根源。它可能是问题的后果。在本地处理之前,研究导致异常的原因。
-
您可以使用异常堆栈跟踪来确定调用了哪个给定方法。在大型、混乱且难以理解的代码库中,这种方法可能非常有帮助并节省您的时间。
-
永远不要在日志消息中写入敏感细节(例如,密码、私钥或个人信息)。在日志中记录密码或私钥会引入安全漏洞,因为任何可以访问日志的人都可以看到并使用它们。写入诸如姓名、地址或电话号码等个人信息也可能不符合各种政府法规。
第二部分. 应用程序执行的深度分析
在第二部分,我们将讨论你可以用来深入调查应用程序执行的高级技术。大多数开发者都接触过调试和日志记录(在第一部分中讨论),但很少有人知道如何通过使用性能分析技术、调查线程和分析内存消耗来找到执行过程的“所有秘密”。了解这些技术是至关重要的,有时甚至是解决某些谜题的唯一方法。

在第六章,你将学习如何分析 CPU 和内存消耗。在第七章,我们将讨论使用性能分析器来识别延迟问题。在第八章和第九章,我们将使用性能分析器深入探讨多线程架构,而在第十章,我们将讨论线程转储。第二部分以第十一章结束,你将学习如何使用堆转储来识别内存消耗问题。
6 使用性能分析技术识别资源消耗问题
本章涵盖
-
评估资源消耗
-
识别资源消耗问题
“至于你,弗罗多·巴金斯,我给你 Eärendil 的光,我们最喜爱的星星。愿它在所有其他灯光熄灭的黑暗地方成为你的光明。”
——加拉德丽尔(J.R.R. 托尔金的《魔戒》)
在本章中,我们首先从使用性能分析器开始。我们将在第七章继续讨论。性能分析器可能不如 Eärendil 的光那么强大,但这个工具在所有其他灯光熄灭的黑暗情况下绝对是一盏明灯。性能分析器是一个强大的工具,它帮助我在许多困难情况下理解应用程序奇怪行为的原因。我认为学习使用性能分析器对所有开发者来说都是必须的,因为它可以成为指引你找到看似无望的问题原因的指南。正如你将在本章中学到的,性能分析器拦截正在执行的 JVM 进程,并提供极其有用的详细信息:
-
应用程序如何消耗资源,如 CPU 和内存
-
正在执行的线程及其当前状态
-
正在执行的代码及其消耗的资源(例如,每个方法执行的持续时间)
在 6.1 节中,我们将分析一些场景,以便你可以看到性能分析器提供的细节如何有用,以及为什么它们如此重要。在 6.2 节中,我们将讨论使用性能分析器解决 6.1 节中的场景。我们将在 6.2.1 节中开始安装和配置性能分析器。然后,在 6.2.2 节中,我们将分析应用程序如何消耗系统资源,而在 6.2.3 节中,我们将学习如何识别应用程序在管理使用内存方面出现问题时的情况。我们将在第七章继续讨论使用性能分析器,你将学习如何识别正在执行的代码及其相关的性能问题。
我在本章的示例中使用 VisualVM 性能分析器。VisualVM 是一个免费的性能分析器,是我多年来成功使用的优秀工具。你可以从这里下载 VisualVM:visualvm.github.io/download.xhtml。VisualVM 不是 Java 应用程序的唯一性能分析工具。一些其他知名的性能分析工具包括 Java Mission Control (mng.bz/AVQE) 和 JProfiler (mng.bz/Zplj)。
6.1 性能分析器在哪里会有用?
在本节中,我们分析了三个场景,其中性能分析工具可以帮助你:
-
识别资源使用异常
-
找到执行中的代码部分
-
识别应用程序执行中的缓慢
6.1.1 识别资源使用异常
性能分析器通常用于确定应用程序如何消耗 CPU 和内存,这有助于你理解应用程序的具体问题。因此,它是调查此类问题的第一步。观察应用程序如何消耗资源通常会引导你到两类问题:
-
线程相关的问题——通常是由缺乏或不适当的同步引起的并发问题
-
内存泄漏——应用程序未能从内存中移除不必要数据的情况,导致执行缓慢,并可能导致应用程序完全失败
我在现实世界中的应用程序中遇到了这两种类型的问题,比我愿意遇到的要多。资源使用问题的影响非常多样。在某些情况下,它们只会导致应用程序变慢;在其他情况下,它们可能导致应用程序完全失败。我必须使用分析器解决的“最喜欢”的线程相关问题是在移动设备上导致电池问题。变慢并不是最大的问题。用户抱怨说,当他们使用这个基于 Android 的应用程序时,他们的设备电池消耗得非常快。这种行为肯定需要调查。在花了一些时间观察应用程序的行为后,我发现应用程序使用的某个库有时会创建一些保持执行状态但什么也不做的线程,只是消耗系统资源。在移动应用程序中,CPU 资源的使用通常反映在电池的消耗上。
一旦你发现了潜在的问题,你就可以通过线程转储进一步调查它,正如你将在第十章中学到的。通常,此类问题的根本原因是线程的同步错误。
我也时不时地在应用程序中发现内存泄漏。在大多数情况下,内存泄漏的最终结果是一个OutOfMemoryError,这会导致应用程序崩溃。所以当我听说应用程序崩溃时,我通常会怀疑存在内存问题。
提示:每当遇到随机崩溃的应用程序时,你应该考虑内存泄漏。
![]()
资源使用异常的根本原因通常是在编码中出现的错误,即使对象不再需要,也会允许对象引用存在。请记住,尽管 JVM 有一个自动机制可以从内存中释放不再需要的数据(我们称这个机制为垃圾回收器 [GC]),但确保所有对不必要数据的引用都被移除仍然是开发者的责任。如果我们实现保留对象引用的代码,GC 不知道它们不再被使用,因此不会移除它们。我们称这种情况为内存泄漏。在第 6.2.3 节中,你将学习如何使用分析器来识别此类问题;然后,在第十一章中,你将学习如何使用堆转储来研究其根本原因。
6.1.2 查找执行代码
作为一名开发人员和顾问,我有时与大型、复杂且混乱的代码库合作。有好几次,我不得不调查一个特定应用程序的功能;我能够重现问题,但不知道代码的哪个部分参与了其中。几年前,我调查了一个运行某些进程的遗留应用程序的问题。公司的管理层做出了一个不太明智的决定,让只有一个开发者负责代码。没有人知道那里有什么或者如何与之合作。当那个开发者离开时,没有留下任何文档或友好的代码库,我被要求帮助确定问题的原因。第一次看到代码让我有点害怕:应用程序缺少类设计,Java 和 Scala 混合了一些由 Java 反射驱动的代码。
在这种情况下,你如何确定需要调查的代码?幸运的是,性能分析器具有采样执行代码的能力。该工具拦截方法,并以可视化的方式显示执行的内容,为你提供足够的信息开始调查。一旦你找到了正在执行的代码,你可以阅读它,并最终使用第二章到第四章中讨论的调试器。
使用性能分析器,你可以找到幕后执行的代码,而无需首先查看代码。这种能力被称为采样,当代码如此混乱以至于你甚至无法理解正在调用什么时,这种能力尤其有用。
6.1.3 识别应用程序执行中的缓慢
在某些情况下,你必须处理性能问题。对于这类情况,你想要回答的一般问题是:“为什么执行这么慢?”从经验上看,开发者总是首先怀疑与 I/O 通信相关的代码部分。调用网络服务、连接到数据库或存储数据到文件都是可能导致应用程序延迟的 I/O 操作。然而,I/O 调用并不总是导致缓慢问题的原因。即使如此,除非你完全熟悉代码库(这种情况很少发生),否则没有一些帮助,仍然很难找到问题来源。
幸运的是,性能分析器非常“神奇”,并且具有拦截执行代码和计算每段代码消耗的资源的能力。我们将在第七章中讨论这些能力。
6.2 使用性能分析器
在本节中,我们探讨如何使用性能分析器来解决第 6.1 节中讨论的问题。我们从安装和配置 VisualVM(在第 6.2.1 节中)开始。然后,我们将检查性能分析器的调查能力。我使用一个应用程序来演示每个主题;这个应用程序足够小,可以让你专注于展示的主题,但同时也足够复杂,与我们的讨论相关。
在第 6.2.2 节中,我们将讨论系统资源消耗以及如何确定你的应用程序是否存在与过度消耗相关的问题。在第 6.2.3 节中,你将了解应用程序可能遇到哪些内存问题以及如何发现它们。
6.2.1 安装和配置 VisualVM
在本节中,你将学习如何安装和配置 VisualVM。在使用分析器之前,你需要确保正确安装和配置此工具或类似工具。然后,你可以使用本书提供的示例来尝试本章中讨论的每个功能。如果你在现实世界的项目中工作,我建议使用你正在实施的应用程序中的技术。
安装 VisualVM 很简单。一旦你从官方网站(visualvm.github.io/download.xhtml)下载了适用于你的操作系统的版本,你需要做的唯一一件事是确保你正确配置了 VisualVM 要使用的 JDK 位置。在 VisualVM 文件夹中的 etc/visualvm.config 配置文件中,定义你的系统中的 JDK 位置。你需要将 JDK 路径分配给 visualvm_jdkhome 变量,并取消注释该行(移除其前面的 #),如下面的代码片段所示。VisualVM 与 Java 8 或更高版本兼容:
visualvm_jdkhome="C:\Program Files\Java\openjdk-17\jdk-17"
一旦配置了 JDK 位置,你就可以使用安装应用程序的 bin 文件夹中的可执行代码来运行 VisualVM。如果你正确配置了 JDK 位置,应用程序将启动,你将看到一个类似于图 6.1 所示的界面。

图 6.1 VisualVM 欢迎屏幕。一旦你配置并启动 VisualVM,你会发现这个工具具有简单且易于学习的图形用户界面。在欢迎屏幕的左侧是本地运行的可调查进程。
让我们启动一个 Java 应用程序。你可以使用本书提供的项目 da-ch6-ex1。你可以使用 IDE 启动应用程序,或者直接从控制台启动应用程序。分析 Java 进程不受应用程序启动方式的影响。
一旦启动应用程序,VisualVM 在左侧显示进程。通常,如果你没有明确为进程指定特定名称,VisualVM 将使用主类名,如图 6.2 所示。

图 6.2 双击进程名称以使用 VisualVM 调查该进程,并将出现一个新标签页。在这个标签页中,VisualVM 为探索该特定进程提供了所有需要的功能。
通常,启动应用程序应该足够了。然而,在某些情况下,由于各种问题,VisualVM 无法连接到本地进程,如图 6.3 所示。在这种情况下,首先尝试的是在启动要分析的应用程序时,使用 VM 参数显式指定域名:
-Djava.rmi.server.hostname=localhost

图 6.3 如果工具似乎工作不正常,您需要检查其配置方式。当配置的 JVM 发行版不在 VisualVM 支持的发行版中时,可能会出现此类问题。有时,由于某些原因,工具可能无法连接到您想要调查的本地进程。在这种情况下,使用符合工具要求的另一个 JVM 发行版,或审查您想要调查的进程是如何启动的。
类似的问题也可能是由使用 VisualVM 不支持的热点版本引起的。如果添加-Djava.rmi.server.hostname=localhost参数不能解决您的问题,请检查您配置的 JVM 发行版是否在 VisualVM 支持的发行版中(根据其网站上的下载部分:visualvm.github.io/download.xhtml)。
6.2.2 观察 CPU 和内存使用情况
您可以使用分析器做的最简单的事情之一是观察您的应用如何消耗系统资源。这样,您可以发现应用中的问题,如内存泄漏或僵尸线程。
定义:内存泄漏是指您的应用没有释放不再需要的数据。随着时间的推移,将没有更多的空闲内存。这是一个问题。
![]()
在本节中,您将了解到您可以使用分析器来直观地确认您的应用行为不正确。例如,僵尸线程是那些持续执行并消耗应用资源的线程。您可以使用 VisualVM 轻松观察到此类问题。
我准备了一些项目来向您展示如何使用分析器来识别导致异常资源消耗的应用问题。我们将逐个运行书中提供的应用,并使用 VisualVM 来观察行为并识别异常。
让我们从应用 da-ch6-ex1 开始。该应用的想法很简单:两个线程持续向列表中添加值,而另外两个线程持续从该列表中移除(消费)值。我们通常称这种实现为生产者-消费者方法,这是一种在应用中常见的多线程设计模式。
列表 6.1 生产者线程向列表添加值
public class Producer extends Thread {
private Logger log = Logger.getLogger(Producer.class.getName());
@Override
public void run() {
Random r = new Random();
while (true) {
if (Main.list.size() < 100) { ❶
int x = r.nextInt();
Main.list.add(x); ❷
log.info("Producer " + Thread.currentThread().getName() +
" added value " + x);
}
}
}
}
❶ 为列表设置最大值数量
❷ 在列表中添加一个随机值
以下代码显示了消费者线程的实现。
列表 6.2 消费者线程从列表中移除值
public class Consumer extends Thread {
private Logger log = Logger.getLogger(Consumer.class.getName());
@Override
public void run() {
while (true) {
if (Main.list.size() > 0) { ❶
int x = Main.list.get(0);
Main.list.remove(0); ❷
log.info("Consumer " + Thread.currentThread().getName() +
" removed value " + x);
}
}
}
}
❶ 检查列表中是否包含任何值。
❷ 如果列表包含值,则从列表中移除第一个值
Main类创建了两个生产者线程实例和两个消费者线程实例。
列表 6.3 Main类创建并启动生产者和消费者线程
public class Main {
public static List<Integer> list = new ArrayList<>(); ❶
public static void main(String[] args) {
new Producer().start(); ❷
new Producer().start(); ❷
new Consumer().start(); ❷
new Consumer().start(); ❷
}
}
❶ 创建一个列表以存储生产者生成的随机值
❷ 启动消费者和生产者线程
这个应用错误地实现了一个多线程架构。更确切地说,多个线程并发访问和修改一个类型为ArrayList的列表。因为ArrayList不是 Java 中的并发集合实现,它不会自己管理线程的访问。多个线程访问这个集合可能会进入竞争条件。当多个线程竞争访问同一资源时,就会发生竞争条件。也就是说,它们在争夺访问同一资源。
在项目 da-ch6-ex1 中,实现缺少线程同步。当你运行应用时,由于竞争条件引起的异常,一些线程在短时间内停止,而其他线程则永远保持活跃,什么也不做(僵尸线程)。我们将使用 VisualVM 来识别所有这些问题。然后,我们将运行项目 da-ch6-ex2,它对应用进行了修正,同步了访问列表的线程。我们将比较 VisualVM 显示的第一个示例和第二个示例的结果,以了解正常应用和有问题的应用之间的差异。
应用将快速运行然后停止(可能在控制台显示异常堆栈跟踪)。下面的代码片段显示了应用在控制台打印的日志消息的样子:
Aug 26, 2021 5:22:42 PM main.Producer run
INFO: Producer Thread-0 added value -361561777
Aug 26, 2021 5:22:42 PM main.Producer run
INFO: Producer Thread-1 added value -500676534
Aug 26, 2021 5:22:42 PM main.Producer run
INFO: Producer Thread-0 added value 112520480
你可能会认为,因为这个应用只有三个类,你不需要使用分析器来找出问题——阅读代码在这里就足够了。确实,只有三个类,你可能不需要使用单独的工具就能找出问题。这是因为我们使用的应用是简化示例,以便你能够专注于使用分析器。但在现实世界中,应用更加复杂,没有适当的工具(如分析器)很难发现这些问题。
即使应用看起来像是暂停的,当你使用 VisualVM 调查幕后发生的事情时,你也能看到一些有趣的东西。为了调查这种意外行为,请按照以下步骤操作:
-
检查进程 CPU 使用情况。
-
检查进程内存使用情况。
-
可视化调查正在执行的线程。
进程消耗了大量的 CPU 资源,所以,某种程度上,它似乎仍然活跃。为了观察其资源消耗,在 VisualVM 中双击左侧面板中的进程名称后,使用监视器选项卡。在这个选项卡上,你找到的一个小部件显示了 CPU 使用情况(图 6.4)。

图 6.4 使用 VisualVM 观察 CPU 资源的使用情况。监视器选项卡上的小部件显示了进程使用了多少 CPU 以及多少使用是由 GC 引起的。这些信息有助于你了解应用是否有执行问题,并且是调查下一步的绝佳指导。在这个特定示例中,进程消耗了大约 50%的 CPU。GC 不会影响这个值。这些迹象通常是僵尸线程的指标,这些僵尸线程通常由并发问题生成。
消费者和生产者线程似乎已经进入了一个持续运行的状态,即使它们没有正确完成它们的工作,也会消耗系统的资源。在这种情况下,这种状态是竞态条件的结果,因为线程正在尝试访问和修改一个非并发集合。但我们已经知道应用程序有问题。我们想要观察这些问题引起的症状,这样在类似的其他情况下,我们就会知道我们的应用程序遇到了相同的问题。
在这个小部件中,您还可以找到 GC 使用的 CPU 资源量。GC 是 JVM 机制,用于从内存中删除应用程序不再需要的数据。GC CPU 使用情况是宝贵的信息,因为它可以表明应用程序存在内存分配问题。如果 GC 消耗大量的 CPU 资源,这可能表明应用程序存在内存泄漏问题。
在这种情况下,GC 没有消耗任何 CPU 资源。这也不是一个好迹象。换句话说,应用程序正在消耗大量的处理能力,但没有进行任何处理。这些迹象通常表明僵尸线程,这通常是并发问题的后果。
下一步是查看显示内存消耗的小部件。这个小部件被巧妙地放置在显示 CPU 消耗的小部件附近,如图 6.5 所示。我们将在 6.2.3 节中更详细地讨论这个小部件,但此时请注意,应用程序几乎不消耗任何内存。这种行为,再次,不是一个好迹象,因为它相当于说,“应用程序什么也没做。”仅使用这两个小部件,我们就可以得出我们很可能面临一个并发问题的结论。

在 CPU 使用率小部件的右侧,您会发现内存使用率小部件。在这个例子中,应用程序几乎不使用内存。这也是为什么 GC 活动为零的原因。不消耗任何内存的应用程序意味着应用程序什么也没做。
我们将在第十章中讨论使用线程转储。现在,我们将只关注配置文件提供的高级小部件,并将比较这些小部件对健康和不健康应用程序提供的结果的比较。
在深入调查执行中的线程之前,我更喜欢使用 VisualVM 来直观地观察线程是如何执行的。在大多数情况下,这样做会给我一些线索,告诉我需要关注哪些线程。一旦我得到这些信息,我就使用线程转储来查找并发问题,并学习如何修复它。
![]()
图 6.6 显示了“线程”选项卡,您可以在“监视器”选项卡附近找到它。线程选项卡提供了执行线程及其状态的视觉表示。在这个例子中,应用程序启动的所有四个线程都在执行状态中。

图 6.6 线程选项卡提供了对存活线程及其状态的视觉表示。小部件显示了所有进程线程,包括由 JVM 启动的线程,这有助于你轻松识别应该注意哪些线程,并最终使用线程转储进行更深入的调查。
并发问题可能产生不同的结果。不一定所有线程都将保持存活,例如。有时并发访问可能导致异常,这会中断某些或所有线程。以下代码片段显示了应用程序执行期间可能发生的此类异常的示例:
Exception in thread "Thread-1"
➥ java.lang.ArrayIndexOutOfBoundsException:
➥ Index -1 out of bounds for length 109
at java.base/java.util.ArrayList.add(ArrayList.java:487)
at java.base/java.util.ArrayList.add(ArrayList.java:499)
at main.Producer.run(Producer.java:16)
如果发生此类异常,则某些线程可能会停止,并且线程选项卡不会显示它们。图 6.7 显示了一个应用程序抛出异常,只有一个线程保持存活。

图 6.7 如果应用程序执行期间发生异常,一些线程可能会停止。此图显示了一个并发访问导致三个线程发生异常并停止的情况。只有一个线程仍然存活。记住,多线程应用程序中的并发问题可能导致不同的意外结果。
在本例中,我们只关注发现资源消耗问题。下一步是使用线程转储来确定并发问题的确切原因。我们将在第七章中讨论有关线程转储的所有内容,但就目前而言,让我们专注于识别资源消耗问题。我们将在一个健康的应用程序上运行相同的验证,并将其与我们的不健康应用程序进行比较。这样,你就会知道如何立即识别正确的和不正确的应用程序行为。
项目 da-ch6-ex2 中的示例是刚刚查看的相同应用程序的修正版本。我添加了一些同步块以避免线程的并发访问并消除竞争条件问题。我为消费者和生产者同步代码块都使用了list实例作为线程监视器。
列表 6.4 消费者访问同步
public class Consumer extends Thread {
private Logger log = Logger.getLogger(Consumer.class.getName());
public Consumer(String name) {
super(name);
}
@Override
public void run() {
while (true) {
synchronized (Main.list) { ❶
if (Main.list.size() > 0) {
int x = Main.list.get(0);
Main.list.remove(0);
log.info("Consumer " +
Thread.currentThread().getName() +
" removed value " + x);
}
}
}
}
}
❶ 使用列表实例作为线程监视器来同步对列表的访问
以下代码显示了应用于Producer类的同步。
列表 6.5 生产者访问同步
public class Producer extends Thread {
private Logger log = Logger.getLogger(Producer.class.getName());
public Producer(String name) {
super(name);
}
@Override
public void run() {
Random r = new Random();
while (true) {
synchronized (Main.list) { ❶
if (Main.list.size() < 100) {
int x = r.nextInt();
Main.list.add(x);
log.info("Producer " +
Thread.currentThread().getName() +
" added value " + x);
}
}
}
}
}
❶ 使用列表实例作为线程监视器来同步对列表的访问
我还为每个线程设置了自定义名称。我总是推荐这种方法。你注意到在前一个示例中 JVM 为我们的线程提供的默认名称了吗?通常,Thread-0、Thread-1、Thread-2 等等并不是你可以轻易用来识别特定线程的名称。我总是尽可能地给线程设置自定义名称,这样我就可以快速地识别它们。此外,我给它们起的名字以下划线开头,这样排序起来更容易。首先,我在 Consumer 和 Producer 类(分别见列表 6.4 和 6.5)中定义了构造函数,并使用 super() 构造函数来命名线程。然后,我给它们命名,如列表 6.6 所示。
列表 6.6 为线程设置自定义名称
public class Main {
public static List<Integer> list = new ArrayList<>();
public static void main(String[] args) {
new Producer("_Producer 1").start();
new Producer("_Producer 2").start();
new Consumer("_Consumer 1").start();
new Consumer("_Consumer 2").start();
}
}
注意,启动此应用程序后,控制台会持续显示日志。应用程序不会像示例 da-ch6-ex1 那样停止。让我们使用 VisualVM 来观察资源消耗。在 CPU 利用率小部件中,你可以看到应用程序消耗的 CPU 较少,而内存使用情况小部件显示应用程序在运行时使用了一些分配的内存。此外,我们还可以观察到 GC 的活动。正如你将在本章后面学到的那样,内存图右侧的谷底是 GC 活动的结果。

图 6.8 在正确同步代码后,资源消耗小部件看起来不同。CPU 消耗较低,应用程序使用了一些内存。
线程选项卡显示监视器有时会阻塞线程,这仅允许一次只有一个线程通过同步块。线程不会连续运行,这使得应用程序消耗的 CPU 较少,如图 6.8 所示。图 6.9 显示了线程选项卡中的线程可视化。
注意:即使我们添加了同步块,一些代码仍然位于这些块之外。因此,线程可能仍然看起来是并发运行的(如图 6.9 所示)。

图 6.9 线程选项卡帮助你可视化应用程序中线程的执行。由于线程的名称以下划线开头,你可以简单地按名称排序它们以查看它们分组。注意,它们的执行有时会被监视器中断,这允许一次只有一个线程通过同步代码块。
6.2.3 识别内存泄漏
在本节中,我们讨论内存泄漏以及如何确定你的应用程序何时受到影响。内存泄漏是指应用程序存储并保留对未使用对象的引用(见图 6.10)。由于这些引用,GC(负责从应用程序内存中删除不需要数据的机制)无法删除这些对象。随着应用程序继续添加更多数据,内存会填满。当应用程序没有足够的空间添加新数据时,它会抛出 OutOfMemoryError 并停止。我们将使用一个简单的应用程序,该应用程序会导致 OutOfMemoryError,以演示如何使用 VisualVM 识别内存泄漏。

图 6.10 OutOfMemoryError 像一个定时炸弹。应用程序未能移除它不再使用的对象的引用。由于应用程序保留这些实例的引用,垃圾回收器无法从内存中移除这些实例。随着更多对象的创建,内存会填满。在某个时刻,堆中不再有空间分配其他对象,应用程序会因 OutOfMemoryError 而失败。
在项目 da-ch6-ex3 提供的示例中,你可以找到一个简单的应用程序,它在列表中存储随机实例,但从不移除它们的引用。以下代码提供了一个简单实现的示例,该实现会产生一个 OutOfMemoryError。
列表 6.7 产生 OutOfMemoryError
public class Main {
public static List<Cat> list = new ArrayList<>();
public static void main(String[] args) {
while(true) {
list.add(new Cat(new Random().nextInt(10))); ❶
}
}
}
❶ 持续向列表中添加新实例,直到 JVM 内存耗尽
类 Cat 是一个简单的 java 对象,如下代码片段所示:
public class Cat {
private int age;
public Cat(int age) {
this.age = age;
}
// Omitted getters and setters
}
让我们运行这个应用程序,并使用 VisualVM 观察资源使用情况。我们特别感兴趣的是显示内存使用情况的控件。当内存泄漏影响你的应用程序时,这个控件可以确认使用的内存会持续增长。垃圾回收器试图从内存中释放未使用的数据,但它移除的数据太少。最终,内存被填满,应用程序无法存储新数据,并抛出 OutOfMemoryError(图 6.11)。

图 6.11 当内存泄漏影响你的应用程序时,使用的内存会持续增长。垃圾回收器尝试释放内存,但无法移除足够的数据。使用的内存增加,直到应用程序无法分配更多新数据。此时,应用程序抛出 OutOfMemoryError 并停止。在许多情况下,内存泄漏还会导致垃圾回收活动加剧,这可以在 CPU 资源使用小部件中看到。
如果你让应用程序运行足够长的时间,你最终会在应用程序的控制台中看到错误堆栈跟踪:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3689)
at java.base/java.util.ArrayList.grow(ArrayList.java:238)
at java.base/java.util.ArrayList.grow(ArrayList.java:243)
at java.base/java.util.ArrayList.add(ArrayList.java:486)
at java.base/java.util.ArrayList.add(ArrayList.java:499)
at main.Main.main(Main.java:13)
重要的是要记住,OutOfMemoryError 的堆栈跟踪并不一定表明导致问题的位置。由于应用程序只有一个堆内存位置,某个线程可能引起问题,而另一个线程可能不幸成为最后一个尝试使用内存位置的线程,从而引发错误。唯一确定识别根本原因的方法是使用堆转储,你将在第十一章中学习。

图 6.12 健康应用程序与受内存泄漏影响的应用程序的内存使用量比较。垃圾回收器能够为健康应用程序从内存中释放不需要的数据,分配的空间永远不会填满。受内存泄漏影响的应用程序不允许垃圾回收器移除足够的数据。在某个时刻,内存完全填满,生成 OutOfMemoryError。
图 6.12 比较了正常行为和受内存泄漏影响的应用程序的行为,如图 6.12 所示。对于正常执行(未受内存泄漏影响)的应用程序,请注意图表有峰值和谷值。应用程序分配内存使其填满(峰值),并且有时垃圾回收器会移除不再需要的数据(谷值)。这种起伏通常是调查的能力未受内存泄漏影响的良好迹象。
然而,如果你看到内存逐渐填满而垃圾回收器(GC)没有清理它,那么你的应用程序可能存在内存泄漏。一旦你怀疑有内存泄漏,你需要进一步使用堆转储来调查。
你可以控制 Java 应用程序中分配的堆大小。这样,你可以增加 JVM 为你的应用程序分配的最大限制。然而,给应用程序更多的内存并不是解决内存泄漏的解决方案。但这种方法可以作为一个临时解决方案,给你更多时间来解决问题的根本原因。要为应用程序设置最大堆大小,请使用 JVM 属性-Xmx,后跟你想分配的量(例如,-Xmx1G将分配最大堆大小为 1 GB)。你可以使用类似的方法使用-Xms属性设置最小初始堆大小(例如,-Xms500m将分配最小堆大小为 500 MB)。
除了正常的堆空间外,任何应用程序也会使用一个元空间:JVM 存储应用程序执行所需类元数据的内存位置。在 VisualVM 中,你可以在内存分配小部件中观察到元空间的分配。要评估元数据分配,请使用小部件的元空间选项卡,如图 6.13 所示。

图 6.13 元空间是用于存储类元数据的内存的一部分。在特定情况下,元空间可能会溢出。VisualVM 内存分配小部件也显示了元空间的用法。
元数据空间中的OutOfMemoryError发生得较少,但并非不可能。我最近处理了一个应用程序的此类案例,该应用程序误用了数据持久性的框架。通常,如果误用,使用 Java 反射的框架和库最有可能生成此类问题,因为它们通常依赖于动态代理和间接调用。
在我的情况下,应用程序误用了名为 Hibernate 的框架。如果你已经听说过 Hibernate,那也不会让我感到惊讶,因为它是目前管理 Java 应用程序中持久数据最常用的解决方案之一。Hibernate 是一个优秀的工具,它有助于实现应用程序最常用的持久化功能,同时消除了编写不必要的代码的需要。Hibernate 管理实例的上下文并将对上下文的更改映射到数据库。但是,它不推荐用于非常大的上下文。换句话说,不要一次性处理来自数据库的太多记录!
我遇到问题的应用程序定义了一个计划中的进程,从数据库中加载许多记录并以定义的方式处理它们。似乎在某个时刻,此进程获取的记录数量如此之大,以至于加载操作本身导致了元空间的填充;问题在于框架的误用,而不是框架的 bug。开发者不应该使用 Hibernate,而应该使用替代的、更底层的解决方案,如 JDBC。
问题非常关键,我必须找到短期解决方案,因为完全重构将花费很长时间。就像堆一样,你可以自定义元空间的大小。使用-XX:MaxMetaspaceSize属性,你可以扩大元空间(例如,-XX:MaxMetaspaceSize=100M),但请记住,这并不是解决问题的真正方法。此类情况的长期解决方案是重构功能,以避免一次性在内存中加载大量记录,并在必要时最终使用替代的持久化技术。
摘要
-
分析器是一个工具,它允许你观察应用程序的执行,以识别其他情况下更难发现的问题的原因。分析器会显示给你
-
应用程序如何消耗系统资源,如 CPU 和内存
-
执行的代码以及每个方法执行的持续时间
-
不同线程上方法执行的调用栈
-
执行线程及其状态
-
-
分析器提供了优秀的可视化控件,帮助你更快地理解某些方面。
-
你可以使用分析器观察 GC 的执行,这有助于你识别应用程序未正确从内存中释放未使用数据(内存泄漏)等问题。
7 使用剖析技术寻找隐藏问题
本章涵盖
-
对应用程序执行进行采样以找到当前正在执行的方法
-
观察执行时间
-
识别应用程序执行的 SQL 查询
在第六章中,我说过分析器是一个强大的工具,可以在所有灯光都熄灭时为你指明一条道路。但我们讨论的只是分析器能力的一小部分。分析器为调查应用程序的执行提供了强大的工具,学会正确使用这些工具可以在许多场景中帮助你。
在许多情况下,我不得不评估或调查我几乎无法阅读的代码库中的应用程序执行——一些公司将其隐藏在衣柜中的旧应用程序,其代码设计不佳。在这种情况下,分析器是找到在特定功能被触发时正在执行什么的唯一有效方法。现在你可以看到为什么我把分析器比作 Eärendil 的光:正如加拉德丽尔所说,它真的是许多黑暗地方的灯光,其他所有灯光都已熄灭。
在本章中,我们将通过剖析分析三种调查技术,我认为这些技术非常有价值:
-
采样以找出应用程序代码的哪个部分在执行
-
剖析执行(也称为仪器化)以识别错误行为和优化
-
剖析应用程序以识别其用于与数据库管理系统(DBMS)通信的 SQL 查询
我们将在第八章继续讨论应用程序执行的先进可视化技术。当适当使用时,这些技术可以节省你大量时间来找到各种问题的原因。不幸的是,尽管这些技术很强大,但许多开发者对它们不熟悉。一些开发者知道这些技术存在,但往往认为它们难以使用(在本章中,我将向你展示事实正好相反)。因此,他们尝试使用其他方法来解决可以用分析器(如本章所示)更高效解决的问题。
为了确保你正确理解如何使用这些技术以及可以调查哪些问题,我创建了四个小型项目。我们将使用这些项目来应用我们讨论的剖析技术。第 7.1 节讨论了采样——这是一种用于识别在特定时间执行哪些代码的技术。在第 7.2 节中,你将了解分析器如何提供比采样更多的执行细节。第 7.3 节讨论了如何使用分析器获取应用程序发送给数据库管理系统(DBMS)的 SQL 查询的详细信息。
7.1 采样以观察执行代码
什么是采样,它如何对你有益?采样是一种使用分析器来识别应用程序执行哪些代码的方法。采样不会提供关于执行的许多细节,但它描绘了发生的大致情况,为你提供了关于你需要进一步分析的有价值信息。因此,在分析应用程序时,采样始终应该是第一步,而且,正如你将看到的,在许多情况下,采样甚至可能足够。对于本节,我准备了项目 da-ch7-ex1. 我们将使用分析器来采样此应用程序,并了解我们如何使用 VisualVM 识别与特定功能执行时间相关的问题。
我们将使用的一个用于演示采样的项目是一个小型应用程序,它暴露了一个端点 /demo。当有人使用 cURL、Postman 或类似工具调用此端点时,应用程序会进一步调用由 httpbin.org 暴露的端点。
我喜欢使用 httpbin.org 来演示许多示例和演示。Httpbin.org 是一个开源的用 Python 编写的 Web 应用程序和工具,它暴露了你可以用来测试你实现的不同内容的模拟端点。
在这里,我们调用一个端点,httpbin.org 以给定的延迟响应。我们将在这个示例中使用 5 秒的延迟来模拟应用程序中的延迟场景,而 httpbin.org 模拟了问题的根本原因。
对于延迟,我们理解应用程序的反应速度比预期慢。
![]()
这种场景也在图 7.1 中进行了视觉表示。

图 7.1 我们正在调查的应用程序暴露了一个端点:/demo。当你调用此端点时,你必须等待 5 秒以等待应用程序响应。我们需要了解为什么端点响应需要这么长时间。我们知道我们的应用程序从 httpbin.org 调用一个模拟端点,这导致了延迟,但我们要学习如何使用分析器来调查这种场景。这样,你就会知道如何使用类似的技术来解决现实世界中的问题。
分析方法分为两个步骤:
-
采样以找出执行了哪些代码以及你应该在哪里进行更详细的调查(本节中讨论的方法)。
-
分析(也称为 instrumentation)以获取有关特定代码执行更多细节。
有时步骤 1(采样)就足以理解一个问题,你可能不需要分析应用程序(步骤 2)。正如你将在本章和第八章到第十章中学习的,如果需要,分析可以提供更多关于执行的细节。但首先,你需要知道要分析代码的哪个部分,而为此,你使用采样。
在我们的例子中,问题是如何发生的?当调用/demo端点时,执行需要 5 秒钟(如图 7.2),我们认为这太长了。理想情况下,我们希望执行时间少于 1 秒,因此我们需要了解为什么调用/demo端点需要这么长时间。延迟是由什么引起的?是我们的应用程序,还是其他原因?
当你在未知代码库中调查缓慢问题时,使用分析器应该是你的首选。问题不一定需要涉及端点。在这个例子中,端点是最简单的解决方案。但在任何涉及缓慢的情况——调用端点、执行进程或对特定事件进行简单方法调用——分析器都应该是你的首选。

图 7.2 当调用端点时(在这个图中,使用 cURL),应用程序大约需要 5 秒钟来响应。在我们的场景中,我们使用分析器来调查这个延迟问题。
首先,启动应用程序,然后启动 VisualVM(我们将用它来进行调查)。记住要添加 VM 选项-Djava.rmi.server.hostname=localhost,,正如我们在第六章中讨论的那样。这允许 VisualVM 连接到进程。从左侧的列表中选择进程,然后选择采样标签,如图 7.3 所示,以开始采样执行。

图 7.3 要开始采样执行,从左侧的列表中选择进程,然后选择采样标签。
采样执行有三个目的:
-
为了找出什么代码在执行——采样显示了幕后执行的代码,这是找到需要调查的应用程序部分的一种极好方式。
-
为了识别 CPU 消耗——我们将使用它来调查延迟问题并了解哪些方法共享执行时间。
-
为了识别内存消耗——这允许我们分析与内存相关的问题。我们将在第十一章中更详细地讨论采样和内存分析。

图 7.4 分析器以列表形式显示所有活动线程。你可以展开每个项目以查看执行堆栈和近似执行时间。当应用程序执行时,新创建的线程会出现在列表中,你可以分析它们的执行。
选择 CPU(如图 7.4 所示)以开始采样性能数据。VisualVM 显示所有活动线程及其堆栈跟踪。然后分析器拦截进程执行并显示所有调用的方法和近似执行时间。当你调用/demo端点时,分析器会显示应用程序执行该功能时幕后发生的事情。

图 7.5 堆栈跟踪显示了应用程序执行的代码。你可以看到每个方法和随后调用的每个方法。这种视图有助于你在调查特定功能时快速找到想要关注的代码。
我们现在可以调用 /demo 端点并观察会发生什么。如图 7.5 所示,列表中出现了一些新的线程。当调用 /demo 端点时,应用启动了这些线程。当你打开它们时,你应该能够精确地看到应用在执行过程中的行为。
在我讨论执行时间等细节之前,我想强调这个第一步是多么重要。很多时候,当我分析代码时,我只是使用采样来确定查找问题的位置。我可能甚至没有在调查性能或延迟问题,而只是在寻找开始调试的点。记住我们第二章到第四章的讨论:要调试某个东西,你需要知道在哪里添加断点来暂停应用的执行。如果你不知道在哪里添加断点,你就无法调试。采样可以在你不知道从哪里开始调试的情况下为你提供一些线索(特别是在本章开头提到的那些情况下,应用缺乏清晰的代码设计)。
让我们查看执行堆栈以了解分析器向我们展示了什么。当你想要找出哪些代码正在执行时,你只需展开堆栈跟踪,直到它显示你感兴趣的应用的函数。当你调查一个延迟问题(如本例所示)时,你可以展开堆栈跟踪以观察最大执行时间,如图 7.6 所示。

图 7.6 当你展开执行堆栈时,你可以找到哪些方法正在执行以及它们执行花费了多长时间。你还可以推断出它们等待了多久以及它们工作了多少。分析器显示了应用的代码库方法以及应用使用的特定依赖项(库或框架)中调用的方法。
我通过选择最后一个方法中的小 (+) 按钮来展开执行堆栈。分析器显示,理解执行并找到导致延迟的方法大约花费了 5 秒。在这种情况下,我们看到只有一个方法导致了缓慢:HttpURLConnection 类的 getResponseCode() 方法。
提示:记住,在现实世界的场景中,并不总是只有一个方法花费了所有的执行时间。你经常会发现,时间被多个执行的方法所共享。规则是首先关注执行时间最长的那个方法。
![]()
本例的一个重要方面是 CPU 时间(方法工作的时间)为零。尽管该方法在执行过程中花费了 5 秒,但它没有使用 CPU 资源,因为它正在等待 HTTP 调用结束并获取响应。我们可以得出结论,问题不在于应用;相反,它之所以缓慢,仅仅是因为它等待 HTTP 请求的响应。
区分总 CPU 时间和总执行时间非常有价值。如果一个方法消耗 CPU 时间,这意味着该方法“工作”了。在这种情况下,为了提高性能,你通常必须调整(如果可能)算法以最小化其复杂性。如果一个方法的执行消耗了少量的 CPU 时间但具有较长的执行时间,那么这个方法很可能是正在等待某事:一个动作可能需要很长时间,但应用什么也不做。在这种情况下,你需要弄清楚你的应用正在等待什么。
另一个需要观察的重要方面是,分析器不仅拦截你的应用代码库。你可以看到,在应用执行期间也会调用依赖项的方法。在这个例子中,应用使用名为 OpenFeign 的依赖项来调用httpbin.org端点。你可以在不属于你应用代码库的堆栈跟踪包中看到这一点。这些包是你应用用来实现其功能的依赖项的一部分。OpenFeign 可能是其中之一,就像这个例子一样。
OpenFeign 是 Spring 技术生态系统中的一项项目,Spring 应用可以使用它来调用 REST 端点。由于这个例子是一个 Spring 应用,你将在堆栈跟踪中找到与 Spring 相关的技术包。你不需要了解堆栈跟踪的每一部分做什么。在现实世界的场景中,你也不会知道这一点。实际上,这本书是关于理解你还不了解的代码。如果你想学习 Spring,我建议从Spring Start Here(Manning,2021)开始,这是另一本我写的书。你还可以在Spring Start Here中找到关于 OpenFeign 的详细信息。
为什么观察依赖项的方法如此重要?因为,有时,使用其他方法几乎不可能确定给定依赖项的执行内容。看看我们应用中编写的调用httpbin.org端点的代码(参见列表 7.1)。你无法看到发送 HTTP 请求的实际实现。这是因为,正如今天许多 Java 框架所发生的那样,依赖项使用动态代理来解耦实现。
列表 7.1 使用 OpenFeign 的 HTTP 客户端实现
@FeignClient(name = "httpBin", url = "${httpBinUrl}")
public interface DemoProxy {
@PostMapping("/delay/{n}")
void delay(@PathVariable int n);
}
动态代理为应用提供了一种在运行时选择方法实现的方式。当一个应用功能使用动态代理时,它实际上可能调用一个接口声明的方 法,而不知道在运行时会给它什么实现来执行(图 7.7)。使用框架的功能更容易,但缺点是不知道在哪里调查问题。

图 7.7 框架将抽象的实现保持独立,并在执行期间动态提供它们。因为实现是解耦的,并且应用在运行时提供它,所以通过阅读代码很难找到它。
我个人使用采样的一种情况是在学习一个新的框架或库时。采样帮助我理解新功能背后执行的内容。我在学习 Hibernate 和 Spring Security 时应用了这种方法,这两个框架功能复杂,这帮助我快速理解如何使用给定的功能。
![]()
7.2 分析以了解方法执行了多少次
找到执行哪些代码是至关重要的,但有时这还不够。我们经常需要更多细节来精确理解给定的行为。例如,采样不提供方法调用的次数。一个应用程序可能只花费 50 毫秒执行,但如果它调用方法一千次,那么在采样时它将花费 50 秒来执行。为了演示如何使用分析器获取执行细节并确定何时有用,我们将再次使用书中提供的一些项目。我们将从项目 da-ch7-ex1 开始,我们也在 7.1 节中使用过,但这次我们将讨论关于执行细节的分析。
启动项目 da-ch7-ex1 提供的应用程序。当您分析应用程序时,您不应该调查整个代码库。相反,您只需要过滤对您调查至关重要的部分。分析是一个非常消耗资源的操作,所以除非您有一个非常强大的系统,否则分析整个应用程序将花费大量时间。这也是我们总是从采样开始的原因之一——如果需要,确定进一步分析的部分。
提示:永远不要分析应用程序的整个代码库。您应该首先基于采样决定您想要分析以获取更多细节的应用程序部分。
![]()
在这个例子中,我们将忽略应用程序的代码库(不包括依赖项),只从依赖项中获取 OpenFeign 类。请注意,在实际应用程序中,您不能引用整个应用程序的代码,因为这可能会消耗大量时间和资源。在这个小例子中,这不会成为问题,但对于大型应用程序,在分析时始终尽可能限制拦截的代码。
在图 7.8 中,您可以看到如何应用这些限制。在“分析器”标签页的右侧,您可以指定要拦截应用程序的哪个部分。在这个例子中,我们使用以下设置:
-
com.example.**—com.example所有包和子包中的代码 -
feign.**—feign所有包和子包中的代码

图 7.8 在执行过程中分析应用程序的一部分以获取给定方法被调用的次数的详细信息。我们可以看到导致 5 秒延迟的方法只被调用了一次,这意味着调用次数在这里不会引起问题。
您可以使用以下简单规则来过滤您想要分析包和类:
-
将每条规则写在单独的一行上。
-
使用一个星号(*)来引用一个包;例如,如果我们想分析
com.example包中的所有类,我们可以使用com.example.*。 -
使用两个星号(**)来引用一个包及其所有子包。在这种情况下,通过使用
com.example.**,我们指的是com.example包中的所有类以及其任何子包中的类。 -
如果你只想分析某个类,请写出该类的全名;例如,我们可以使用
com.example.controllers.DemoController来仅分析这个类。
我在 7.1 节中讨论的采样执行后选择了这些包。因为我观察到具有延迟问题的方法调用来自 feign 包的类,所以我决定将这个包及其子包添加到列表中,以获取更多信息。
在这个特定情况下,调用次数似乎并没有引起问题:该方法只执行一次,并且大约需要 5 秒钟来完成其执行。少量方法调用意味着我们没有重复不必要的执行(正如你将在本章后面学到的那样,这是许多应用程序中常见的问题)。
在另一种情况下,你可能观察到对给定端点的调用仅花费了 1 秒钟,但方法(由于某些设计不佳)被调用了 5 次。那么,问题就出在应用程序中,我们就知道如何以及在哪里解决它。在第 7.3 节中,我们将分析这样的问题。
7.3 使用分析器识别应用程序执行的 SQL 查询
在本节中,你将学习如何使用分析器来识别应用程序发送给数据库管理系统的 SQL 查询。这个主题无疑是我的最爱之一。如今,几乎每个应用程序都至少使用一个关系型数据库,而且几乎在所有情况下,都会时不时地遇到由 SQL 查询引起的延迟。此外,如今的应用程序使用更高级的方法来实现持久层;在许多情况下,应用程序发送的 SQL 查询是由框架或库动态创建的。这些动态生成的查询难以识别,但分析器可以施展一些魔法,极大地简化你的调查。
我们将使用项目 da-ch7-ex2 实现的场景来学习一个方法执行了多少次,并拦截应用程序在关系型数据库上运行的 SQL 查询。然后,我们将演示即使在应用程序与框架一起工作且不直接处理查询的情况下,也可以检索执行的 SQL 查询。最后,我们将通过几个示例进一步讨论这个主题。
7.3.1 使用分析器检索框架未生成的 SQL 查询
本节通过一个示例演示如何使用分析器获取应用程序执行的 SQL 查询。我们将使用一个简单的应用程序,该应用程序直接将查询发送到数据库管理系统,而不使用框架。
让我们开始项目 da-ch7-ex2 并使用“分析器”选项卡,正如你在第 7.2 节中学到的。项目 da-ch7-ex2 也是一个小型应用程序。它配置了一个内存数据库,包含两个表(产品表和购买表),并使用一些记录填充了这两个表。
当调用 /products 端点时,应用程序会公开所有已购买的产品。这里的“已购买产品”指的是在购买表中至少有一条购买记录的产品。目的是在不首先分析代码的情况下分析应用程序调用此端点时的行为。这样,我们可以看到仅通过使用分析器我们可以得到多少信息。
在图 7.9 中,我们使用“分析器”选项卡,因为你已经学过了第 7.1 节中的采样,但请记住,在任何实际场景中,你都是从采样开始的。我们启动应用程序,并使用 cURL 或 Postman 调用 /products 端点。分析器精确地显示了发生了什么:
-
调用了属于
PurchaseController类的findPurchasedProductNames()方法。 -
此方法将调用委托给了
PurchaseService类中的getProductNamesForPurchases()方法。 -
ProductService类中的getProductNamesForPurchases()方法调用了PurchaseRepository中的findAll()。 -
ProductService类中的getProductNamesForPurchases()方法调用了ProductRepository中的findProduct()10 次。

图 7.9 当分析应用程序时,我们观察到其中一个方法被调用了 10 次。我们现在需要问自己这是否是一个设计问题。因为我们现在对整个算法有了大致的了解,并且知道执行了哪些代码,如果我们无法弄清楚发生了什么,我们还可以调试应用程序。
这不令人惊叹吗?我们甚至没有查看代码,就已经对这次执行了解了很多。这些细节非常棒,因为现在你知道了确切的位置可以进入代码,以及你可以期望找到什么。分析器给你提供了类名、方法名以及它们是如何相互调用的。现在让我们来看看列表 7.2 中的代码,并找出所有这些发生的地方。通过使用分析器,我们可以看到大多数事情都发生在 PurchaseService 类中的 getProductNamesForPurchases() 方法中,所以这很可能是我们需要分析的地方。
列表 7.2 在 PurchaseService 类中实现的算法
@Service
public class PurchaseService {
private final ProductRepository productRepository;
private final PurchaseRepository purchaseRepository;
public PurchaseService(ProductRepository productRepository,
PurchaseRepository purchaseRepository) {
this.productRepository = productRepository;
this.purchaseRepository = purchaseRepository;
}
public Set<String> getProductNamesForPurchases() {
Set<String> productNames = new HashSet<>();
List<Purchase> purchases = purchaseRepository.findAll(); ❶
for (Purchase p : purchases) { ❷
Product product =
productRepository.findProduct(p.getProduct()); ❸
productNames.add(product.getName()); ❹
}
return productNames; ❺
}
}
❶ 从数据库表获取所有购买记录
❷ 遍历每个产品
❸ 获取已购买产品的详细信息
❹ 将产品添加到集合中
❺ 返回产品集合
观察实现的行为:应用程序在列表中获取一些数据,然后迭代它以从数据库中获取更多数据。这样的实现通常表明存在设计问题,因为你通常可以将这么多查询的执行减少到一次。显然,执行的查询越少,应用程序就越高效。
在这个例子中,直接从代码中检索查询是轻而易举的。由于性能分析器显示了它们的确切位置,并且应用程序很小,因此找到查询不是问题。但是,现实世界的应用程序并不小,在许多情况下,直接从代码中检索查询并不容易。但不必再担心了!你可以使用性能分析器检索应用程序发送给数据库管理系统的所有 SQL 查询。你可以在图 7.10 中看到这一演示。不是选择 CPU 按钮,而是选择 JDBC 按钮以开始对 SQL 查询进行性能分析。

图 7.10 性能分析器拦截了应用程序通过 JDBC 驱动程序发送到数据库管理系统的 SQL 查询。这为你提供了一个简单的方法来获取查询,运行它们,观察代码库的哪个部分运行了它们,以及查询执行了多少次。
工具在幕后执行的操作非常简单:Java 应用程序通过 JDBC 驱动程序将 SQL 查询发送到数据库管理系统。性能分析器拦截驱动程序并在驱动程序将查询发送到数据库管理系统之前复制它们。图 7.11 显示了这种方法。结果是令人惊叹的,因为你可以直接复制并粘贴查询到你的数据库客户端中,在那里你可以运行它们或调查它们的计划。

图 7.11 在 Java 应用程序中,与关系型数据库管理系统的通信是通过 JDBC 驱动程序完成的。性能分析器可以拦截所有方法调用,包括 JDBC 驱动程序的方法调用,并检索应用程序发送给数据库管理系统的 SQL 查询。你可以获取这些查询并在你的调查中使用它们。
性能分析器还显示了查询发送的次数。在这种情况下,应用程序第一次查询发送了 10 次。这种设计是有缺陷的,因为它多次重复相同的查询,从而浪费了不必要的时间和资源。实现代码的开发者试图获取购买信息,然后获取每个购买的详细信息。但是,通过在两个表(产品和购买)之间进行连接的简单查询就可以一步解决问题。幸运的是,使用 VisualVM,你确定了原因,并且你知道如何确切地更改以改进这个应用程序。
图 7.12 显示了如何找到发送查询的代码库部分。你可以展开执行堆栈,通常可以在应用程序代码库中找到第一个方法。

图 7.12 对于每个查询,性能分析器还提供了执行堆栈跟踪。你可以使用堆栈跟踪来识别你的应用程序代码库的哪个部分发送了查询。
列表 7.2 显示了性能分析器识别的代码。一旦你确定了问题的来源,就是时候阅读代码并找到一种优化实现的方法。在这个例子中,所有内容都可以合并为一个查询。这看起来可能像是一个愚蠢的错误,但请相信我,你甚至会在由强大组织实施的大型应用程序中找到这类情况。
列表 7.3 ProductService 类中算法的实现
@Service
public class PurchaseService {
// Omitted code
public Set<String> getProductNamesForPurchases() {
Set<String> productNames = new HashSet<>();
List<Purchase> purchases = purchaseRepository.findAll(); ❶
for (Purchase p : purchases) { ❷
Product product = productRepository.findProduct(p.getProduct()); ❸
productNames.add(product.getName());
}
return productNames;
}
}
❶ 应用程序获取所有产品的列表。
❷ 遍历每个产品
❸ 获取产品详情
示例 da-ch7-ex2 使用 JDBC 将 SQL 查询发送到数据库管理系统 (DBMS)。应用程序直接在 Java 代码(列表 7.3)中以原生形式包含 SQL 查询,因此你可能认为直接从代码中复制查询并不那么困难。但在当今的应用程序中,你不太可能在代码中遇到原生查询。如今,许多应用程序使用诸如 Hibernate(最常用的 Java 持久化 API [JPA] 实现)或 Java 面向对象查询(JOOQ)之类的框架,而原生查询并不直接在代码中。(你可以在他们的 GitHub 仓库中找到有关 JOOQ 的更多详细信息:github.com/jOOQ/jOOQ)。
列表 7.4 使用原生 SQL 查询的存储库
@Repository
public class ProductRepository {
private final JdbcTemplate jdbcTemplate;
public ProductRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public Product findProduct(int id) {
String sql = "SELECT * FROM product WHERE id = ?"; ❶
return jdbcTemplate.queryForObject(sql, new ProductRowMapper(), id);
}
}
❶ 应用程序发送到 DBMS 的原生 SQL 查询
7.3.2 使用分析器获取框架生成的 SQL 查询
让我们看看一些更加非凡的事情。为了进一步证明分析器在调查 SQL 查询方面的有用性,让我们回顾项目 da-ch7-ex3。从算法的角度来看,这个项目与上一个项目做的是同样的事情:返回购买产品的名称。我故意保留了相同的逻辑以简化示例并使其具有可比性。
下一个代码片段显示了 Spring Data JPA 存储库的定义。存储库是一个简单的接口,你无处看到 SQL 查询。使用 Spring Data JPA,应用程序根据方法的名称或以特定方式定义查询的方式(称为 Java 持久化查询语言 [JPQL],它基于应用程序的对象)在幕后生成查询。无论哪种方式,都没有简单的方法可以从代码中复制和粘贴查询。
一些框架根据你编写的代码和配置在幕后生成 SQL 查询。在这些情况下,获取执行的查询更具挑战性。但分析器可以通过在它们发送到 DBMS 之前从 JDBC 驱动程序中提取它们来帮助你:
public interface ProductRepository
extends JpaRepository<Product, Integer> {
}
分析器来救命。由于该工具在应用程序将查询发送到 DBMS 之前拦截查询,我们仍然可以使用它来找出应用程序使用的确切查询。启动应用程序 da-ch7-ex3 并使用 VisualVM 分析 SQL 查询,就像我们之前对前两个项目所做的那样。
图 7.13 显示了在分析/products端点调用时工具显示的内容。应用程序发送了两个 SQL 查询。请注意,查询中的别名有奇怪的名字,因为查询是由框架生成的。此外,请注意,即使服务中的逻辑相同,并且应用程序调用存储库方法 10 次,第二个查询也只执行一次,因为 Hibernate 在可能的情况下优化了执行。现在,如果需要,您可以复制并使用 SQL 开发客户端调查这个查询。在许多情况下,调查慢查询需要在 SQL 客户端中运行以观察查询的哪个部分给 DBMS 带来了困难。

图 7.13 即使在使用框架时,分析器仍然可以拦截 SQL 查询。这使得调查变得容易得多,因为您不能像使用 JDBC 和原生查询那样直接从代码中复制查询。
查询只执行一次,即使方法被调用了 10 次。持久化框架通常会做这类技巧吗?虽然它们很聪明,但有时它们在幕后所做的事情可能会增加复杂性。此外,如果有人没有正确理解框架,可能会编写出导致问题的代码。这也是使用分析器检查框架生成的查询并确保应用程序按预期工作的重要原因。
我遇到的大多数需要调查的框架问题如下:
-
慢查询导致的延迟——使用分析器检查执行时间很容易发现
-
框架生成的多个不必要的查询(通常由开发人员称为 N+1 查询问题引起)——使用分析器确定查询执行次数很容易发现
-
由糟糕的应用程序设计生成的长事务提交——使用 CPU 分析很容易发现
当框架需要从多个表中获取数据时,它通常知道要组合一个查询并在一次调用中获取所有数据。然而,如果您没有正确使用框架,它可能只使用初始查询获取部分数据,然后,对于最初检索到的每条记录,运行一个单独的查询。因此,而不是只运行一个查询,框架将发送一个初始查询加上N个其他查询(一个用于第一次查询检索到的N条记录中的每一条);我们称这种情况为N+1 查询问题,这通常通过执行多个查询而不是一个查询来创建显著的延迟。
大多数开发人员似乎都倾向于使用日志或调试器来调查这类问题。但根据我的经验,这两种方法都不是确定问题根本原因的最佳选择。
使用日志处理此类情况的首要问题是难以确定哪个查询导致了问题。在现实场景中,应用程序可能会发送数十个查询——其中一些多次执行,而且在大多数情况下,它们都很长并且使用了大量参数。使用性能分析器,它以列表形式显示所有查询及其执行时间和执行次数,你可以几乎瞬间发现问题。第二个问题是,即使你确定了可能引起问题的查询(比如说,在监控日志时,你观察到应用程序执行某个查询需要很长时间),直接运行该查询也不是一件简单的事情。在日志中,你发现参数与查询是分开的。
你可以通过向 da-ch7-ex3 文件的应用程序属性中添加一些参数来配置应用程序以打印 Hibernate 生成的查询:
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.type.descriptor.sql=trace
注意,你必须使用不同的方式来配置日志,具体取决于你用来实现应用程序的技术。在本书提供的示例中,我们使用 Spring Boot 和 Hibernate。下面的列表显示了应用程序如何在日志中打印查询。
列表 7.5 Hibernate 发送的本地查询日志
Hibernate:
Select ❶
product0_.id as id1_0_0_,
product0_.name as name2_0_0_
from
product product0_
where
product0_.id=?
2021-10-16 13:57:26.566 TRACE 9512 --- [nio-8080-exec-2] ❷
➥ o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as ❷
➥ [INTEGER] - [1] ❷
2021-10-16 13:57:26.568 TRACE 9512 --- [nio-8080-exec-2] ❸
➥ o.h.type.descriptor.sql.BasicExtractor : extracted value ([name2_0_0_] : ❸
➥ [VARCHAR]) - [Chocolate] ❸
❶ 应用程序生成的查询
❷ 第一个参数的值
❸ 第二个参数的值
日志显示查询并给出查询的输入和输出。但如果你想单独运行它,你需要将参数值绑定到查询上。而且,当多个查询被记录时,寻找所需内容可能会非常令人沮丧。日志也不会显示应用程序的哪个部分运行了查询,这可能会使你的调查更加困难。
我建议你在调查延迟问题时始终从性能分析器开始。你的第一步应该是采样。当你怀疑与 SQL 查询相关的问题时,继续对 JDBC 进行性能分析。然后,问题将容易理解,你可以根据需要使用调试器或日志来确认你的猜测。
![]()
7.3.3 使用性能分析器获取编程生成的 SQL 查询
为了完整性,让我们再举一个例子,演示当应用程序以编程方式定义查询时,性能分析器是如何工作的。我们将调查一个由 Hibernate(我们示例中使用的框架)生成的查询在应用程序中使用 criteria 查询(一种使用 Hibernate 定义应用程序持久层的编程方式)时的性能问题。你永远不会使用这种方法编写查询,无论是原生的还是 JPQL。
正如列表 7.6 所示,它展示了使用条件查询重新实现的 ProductRepository 类,这种方法更冗长。通常认为它更难,并且更容易出错。项目 da-ch7-ex4 中的实现包含一个错误,这可能导致现实世界应用程序中出现重大的性能问题。让我们看看我们能否找到这个问题,并确定分析器如何帮助我们理解出了什么问题。
列表 7.6 使用条件查询定义的存储库
public class ProductRepository {
private final EntityManager entityManager;
public ProductRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
public Product findById(int id) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Product> cq = cb.createQuery(Product.class); ❶
Root<Product> product = cq.from(Product.class); ❷
cq.select(product); ❸
Predicate idPredicate =
cb.equal(cq.from(Product.class).get("id"), id); ❹
cq.where(idPredicate); ❺
TypedQuery<Product> query = entityManager.createQuery(cq);
return query.getSingleResult(); ❻
}
}
❶ 创建一个新的查询
❷ 指定查询选择产品
❸ 选择产品
❹ 在下一行定义成为 where 子句一部分的条件
❺ 定义 where 子句
❻ 运行查询并提取结果
我们使用 JDBC 分析来拦截应用程序发送给 DBMS 的查询。你可以看到它包含产品表与自身的交叉连接(图 7.14)。这是一个大问题!在我们的表中只有 10 条记录时,我们没有观察到任何可疑之处。但在现实世界的应用程序中,表会有更多的记录,这个交叉连接将创建巨大的延迟,最终甚至产生错误的输出(重复的行)。简单地使用 VisualVM 拦截查询并读取它就能显示问题。

图 7.14 分析器可以通过 JDBC 驱动程序拦截发送给 DBMS 的任何 SQL 查询。在这里,我们发现了生成的查询中的一个问题——一个不必要的交叉连接,它会导致性能问题。
下一个问题,“为什么应用程序以这种方式生成查询?” 我喜欢关于 JPA 实现,如 Hibernate 的说法:“好事是它们使查询生成透明并最小化工作。坏事是它们使查询生成透明,使应用程序更容易出错。” 当与这样的框架一起工作时,我通常建议开发者在开发过程中分析查询,以便提前发现此类问题。使用分析器更多的是为了审计目的,而不是寻找问题,但这样做是一个良好的安全措施。
在下面的例子中,我故意引入了这个具有重大影响的小错误。我两次调用了 from() 方法,指示 Hibernate 进行交叉连接。
列表 7.7 交叉连接问题的原因
public class ProductRepository {
// Omitted code
public Product findById(int id) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Product> cq = cb.createQuery(Product.class);
Root<Product> product = cq.from(Product.class); ❶
cq.select(product);
Predicate idPredicate = cb.equal(
cq.from(Product.class).get("id"), id); ❷
cq.where(idPredicate);
TypedQuery<Product> query = entityManager.createQuery(cq);
return query.getSingleResult();
}
}
❶ 只调用一次 CriteriaQuery 的 from() 方法
❷ 再次调用 CriteriaQuery 的 from() 方法
解决这个问题很简单:使用产品实例而不是在第二次调用 CriteriaQuery 的 from() 方法,如下所示。
列表 7.8 修正交叉连接问题
public class ProductRepository {
// Omitted code
public Product findById(int id) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Product> cq = cb.createQuery(Product.class);
Root<Product> product = cq.from(Product.class);
cq.select(product);
Predicate idPredicate = cb.equal(product.get("id"), id); ❶
cq.where(idPredicate);
TypedQuery<Product> query = entityManager.createQuery(cq);
return query.getSingleResult();
}
}
❶ 使用已经存在的 Root 对象。
一旦你做出这个小的改动,生成的 SQL 查询将不再包含不必要的交叉连接(图 7.15)。然而,应用程序仍然多次运行相同的查询,这并不理想。应用程序运行的计算算法应该重构以获取数据,最好只使用一个查询。

图 7.15 通过消除辅助的 select() 方法调用,交叉连接消失了。然而,这个应用程序的整体算法应该进行修订,因为它仍然多次运行相同的查询,这并不理想。
摘要
-
分析器拦截应用程序的执行并提供有关正在执行代码的基本细节,例如每个线程的执行堆栈跟踪、每个方法执行所需的时间以及某个特定方法被调用的次数。
-
在调查延迟问题时,使用分析器的第一步是采样——这是分析器拦截正在执行代码的一种方式,而不需要获取很多细节。采样资源消耗较少,并允许您观察执行的总体情况。
-
样本提供三个基本细节:
-
执行了什么代码——在调查问题时,有时您不知道代码的哪个部分被执行,您可以通过采样找到这个方面。
-
每个方法的总执行时间——这个细节有助于您确定代码的哪个部分可能导致潜在的延迟问题。
-
总 CPU 执行时间——这个细节有助于您确定代码是否将执行时间花在“工作”还是等待某事上。
-
-
有时采样就足以了解问题的来源。但在许多情况下,您需要更多细节。您可以通过分析执行来获取这些细节。
-
分析是一个资源消耗的过程。在现实世界的应用程序中,几乎总是不可能分析整个代码库。因此,当分析细节时,您应该过滤特定的包和类,这些包和类是您想要集中调查的对象。通常,您可以通过首先采样执行来确定应用程序的哪个部分需要关注。
-
通过分析获得的必要细节是方法调用的次数。在采样时,您知道方法执行的总时间,但不知道它被调用的频率。这个方面在识别运行缓慢或使用错误的方法时非常重要。
-
您可以使用分析器来获取应用程序发送给数据库管理系统的 SQL 查询。分析器拦截任何查询,无论应用程序的持久层使用什么技术实现。这对于调查使用框架(如 Hibernate)与数据库交互的应用程序的慢查询非常有价值。
8 使用高级可视化工具分析剖析数据
本章涵盖
-
检测与关系型数据库连接的问题
-
使用调用图更快地理解应用程序的设计
-
使用火焰图更轻松地可视化应用程序的执行
-
分析应用程序发送到 NoSQL 数据库服务器的查询
本章讨论了一些有价值的技巧,这些技巧可以在调查特定场景时使生活变得更轻松。我们首先通过检查 Java 应用程序与关系型数据库服务器之间连接问题的方法来开始本章。我们在第七章中已经讨论了 SQL 查询的剖析,但有时当应用程序与 DBMS 建立通信时会出现问题。这种情况下,应用程序甚至可能完全无响应,因此找到这种问题的原因至关重要。
在第 8.2 节中,我将向您展示我最喜欢的理解给定执行场景背后代码的方法之一——使用调用图的一个简单方法,调用图是应用程序对象之间依赖关系的视觉表示。我发现调用图很有帮助,尤其是在处理我以前从未见过的混乱代码时。鉴于我相信大多数开发者在职业生涯的某个阶段都必须处理混乱的代码库,了解这种方法将是有帮助的。
第七章讨论了可视化应用程序执行的最常用方法之一——执行栈。您学习了如何使用 VisualVM 进行采样或剖析时生成执行栈,以及如何使用它来识别执行延迟。在第 8.3 节中,我们将使用执行栈的不同表示:火焰图。火焰图是一种可视化应用程序执行的方法,它侧重于执行代码和执行时间。从额外的角度查看相同的数据有时可以帮助您更容易地找到您正在寻找的内容。正如您将在第 8.3 节中学习的,火焰图为您提供了应用程序执行的另一种视角,这有助于您识别潜在的延迟和性能问题。在第 8.4 节中,我们将讨论分析应用程序的持久层如何工作的技术,当它不使用关系型数据库,而是使用我们所说的“NoSQL 技术家族”中的不同持久化方法时。
对于本章中讨论的主题,VisualVM 是不够的。VisualVM 是一个出色的免费工具,我在使用剖析器调查的 90%以上的场景中都使用它,但它有其局限性。
为了展示本章中讨论的功能,我们将使用 JProfiler ( mng.bz/RvVn),这是一个授权的剖析器。JProfiler 提供了我们与 VisualVM 讨论的所有内容,但它还具备 VisualVM 没有的功能(以一个小价格)。您可以使用软件提供的试用版来尝试剖析本书中使用的示例,并形成自己对 VisualVM 和 JProfiler 之间差异的看法。
8.1 检测 JDBC 连接的问题
我们在第七章中讨论了使用 SQL 查询调查问题的许多细节。但是,一个应用与 DBMS 之间发送查询所需的连接是怎样的呢?忽视连接管理可能会导致问题,在本节中,我们将讨论这些问题以及如何找到它们的根本原因。
有些人可能会争论,应用使用框架和库来处理大多数情况下的连接管理,因此这类问题不再发生。然而,经验告诉我,这些问题仍然存在,主要是因为开发者依赖于许多事情自动完成。有时我们应该使用更不常见且更底层的实现,而不是依赖于框架提供的功能,大多数这类问题都发生在这里。
让我给你讲一个我最近不得不处理的问题的故事。在一个特定的服务(使用 Spring 实现)中,开发者必须实现一个不太常见的功能:一种取消存储过程(在数据库级别运行的过程)执行的方法。实现并不复杂,但它需要直接访问连接对象。在大多数情况下,允许 Spring 在幕后使用连接就足够了。Spring 是一个健壮的框架,易于定制,你可以轻松访问它管理的连接,但它在你访问它们之后仍然管理这些连接吗?答案是有时。而这个“有时”就是事情变得有趣(同时也更具挑战性)的原因。
开发者发现,在 Spring 管理事务的标准方法执行中,框架也会在结束时关闭连接。该过程是通过 Spring Batch 实现的批处理方法取消的。在这种情况下,框架不会关闭连接;你必须自己管理它们。开发者在这两种情况下都使用了相同的方法,但没有意识到在其中一个情况下连接没有被正确关闭,这可能会造成大问题。幸运的是,开发错误及时发现,没有造成损害。
这个故事展示了为什么本节中你将学习的技巧仍然相关。你使用的框架名称并不重要,你可能永远不知道幕后发生的一切,所以准备好以任何方式调查你的应用执行始终是相关的。
我们将使用书中提供的项目 da-ch8-ex1。这个项目定义了一个简单的应用,它有一个巨大的问题:它的一个方法“忘记”关闭它打开的 JDBC 连接。应用创建一个 JDBC 连接以向 DBMS 发送 SQL 查询。一旦应用不再需要这些连接,就必须始终关闭 JDBC 连接。所有 DBMS 都向客户端(即应用)提供获取有限数量连接的能力(通常数量很少,如 100)。当应用打开所有这些连接但又不关闭它们时,它就无法连接到数据库服务器(图 8.1)。

图 8.1 数据库管理系统允许应用打开有限且通常数量较小的连接。当应用达到可以打开的连接限制时,数据库管理系统不允许应用打开其他连接。在这种情况下,应用可能无法执行其工作。
数据库管理系统并不总是提供精确的 100 个连接。这个数字可以在数据库级别进行配置。当与数据库一起工作时,最好找到(通常通过询问数据库管理员)应用可以打开的最大连接数。
注意:为了简化我们的演示,我们将使用一个持久层,该层限制连接数为 10。
![]()
让我们开始项目 da-ch8-ex1 并分析应用的行为。该项目定义了一个简单的应用,该应用在数据库中存储有关产品的详细信息。应用在/products路径上公开一个端点。通过调用端点,应用返回基于其数据库中存储的数据的详细信息。当你第一次调用端点时,应用几乎立即响应。但是当你向同一端点发送第二个请求时,应用在 30 秒后响应并显示一个错误信息,如图 8.2 所示。

图 8.2 当第一次调用/products端点时,应用立即响应,返回包含单词“巧克力”的列表。但是当你尝试第二次调用该端点时,应用似乎卡住了大约 30 秒,然后显示一个错误信息。
应用实际执行的操作对我们演示来说并不重要,所以我就不会深入讨论其功能细节了,但想象一下,一个在独立项目上工作的朋友向你求助并展示了这样的问题。他们没有提供太多关于他们的应用如何工作的细节(在现实世界的应用中,这可能是一个复杂的企业案例)。你还能帮助他们吗?我们首先分析他们向我们展示的行为。
我们想要找出导致问题行为的原因。你查看日志并立即怀疑问题与 JDBC 连接有关。下面的代码片段中显示的异常信息告诉你,应用无法建立连接,很可能是由于数据库管理系统不允许打开其他连接。但让我们假设我们并不能总是依赖日志。最终,我们无法确定是否是应用使用的另一个框架或库生成了直接的异常信息:
java.sql.SQLTransientConnectionException:
➥ HikariPool-1 - Connection is not available,
➥ request timed out after 30014ms. ❶
at com.zaxxer.hikari.pool.HikariPool
➥ .createTimeoutException(HikariPool.java:696) at
➥ com.zaxxer.hikari.pool.HikariPool
➥ .getConnection(HikariPool.java:197)
at [CA]com.zaxxer.hikari.pool.HikariPool
➥ .getConnection(HikariPool.java:162)
at [CA]com.zaxxer.hikari.HikariDataSource
➥ .getConnection(HikariDataSource.java:128)
at [CA]com.example.repositories.PurchaseRepository
➥ .findAll(PurchaseRepository.java:31)
at [CA]com.example.repositories.PurchaseRepository
➥ $$FastClassBySpringCGLIB$$d661c9a0.invoke(<generated>)
❶ 异常信息
正如我在第七章中建议的,每次性能分析调查都应该从采样开始,这为你提供了执行概述和继续研究所需的详细信息。如果你使用了 VisualVM,采样结果将类似于图 8.3。

图 8.3 在采样执行后,我们有更多理由怀疑与数据库管理系统建立连接存在问题。在执行堆栈中,我们看到应用程序等待了 30 秒来获取连接。
在采样并观察日志中的异常堆栈跟踪后,我们知道我们的应用程序在连接到数据库管理系统时存在问题。但造成这个问题的原因可能是什么?这可能是两件事之一:
-
应用程序与数据库管理系统之间的通信失败是由于某些基础设施或网络问题。
-
数据库管理系统不愿意为我们应用程序提供连接:
-
因为认证问题
-
因为应用程序已经消耗了数据库管理系统可以提供的所有连接
-
由于在我们的情况下,问题总是在第二次发送请求时发生(有一个定义的模式可以重现它),我们可以排除通信问题。这必须是数据库管理系统没有提供连接。但由于第一次调用工作良好,这不可能是一个认证问题。不太可能凭据发生了变化,所以最有可能的原因是我们应用程序有时没有关闭连接。现在我们只需要找到这种情况发生的地方。记住,遇到问题的方法不一定是导致问题的方法。可能这个方法是“不幸”的那个,在其他人“吃掉”所有其他连接之后尝试获取连接。
但是,使用 VisualVM,您不能明确地调查 JDBC 连接,因此我们无法使用它来识别哪个连接保持打开状态。相反,我们将继续使用 JProfiler 进行调查。将 JProfiler 附加到正在运行的 Java 进程与使用 VisualVM 非常相似。让我们一步一步地遵循这种方法。
首先,在 JProfiler 主窗口的左上角选择“开始中心”,如图 8.4 所示。

图 8.4 通过选择 JProfiler 窗口左上角的“开始中心”菜单,使用 JProfiler 开始采样或性能分析会话。
出现一个弹出窗口(图 8.5),您可以在左侧选择快速附加以获取所有本地运行的 Java 进程的列表。选择您想要分析的过程,然后选择开始。就像 VisualVM 一样,您可以通过主类的名称或进程 ID(PID)来识别进程。

图 8.5 在弹出窗口中,选择快速附加,并在列表中选择您想要分析的过程。然后选择开始按钮以开始性能分析会话。
JProfiler 会询问您是否想要使用采样或仪器(仪器相当于我们所说的使用 VisualVM 进行性能分析),如图 8.6 所示。我们选择仪器,因为我们使用性能分析器来获取 JDBC 连接的详细信息,因此希望更深入地分析执行过程。

图 8.6 为了更深入地分析执行情况,我们需要选择工具,这在 VisualVM 中相当于我们所说的分析。
在左侧菜单的数据库下,选择 JDBC。然后按照图 8.7 所示开始 JDBC 分析。

图 8.7 使用 JProfiler 开始 JDBC 分析,首先在左侧菜单中选择 JDBC;然后开始分析过程。
一旦开始分析,我们最感兴趣的两个标签页是连接和连接泄漏(图 8.8)。这些标签页显示了应用程序打开到 DBMS 的连接的详细信息,我们将使用它们来识别问题的根本原因。

图 8.8 连接和连接泄漏标签页显示了应用程序创建的连接的详细信息,包括潜在的问题连接。我们将使用这些详细信息来了解我们应用程序中问题的来源。
现在是时候重现问题并分析执行情况了。向/products端点发送请求,看看会发生什么。连接标签页显示,正如图 8.9 所示,创建了多个连接。由于我们不知道应用程序做了什么,所以许多连接并不一定意味着问题。但我们期望应用程序在需要时关闭这些连接。我们需要弄清楚的是,应用程序是否正确地关闭了这些连接。

图 8.9 连接泄漏标签页证实了我们的怀疑(图 8.10);通过向/products端点发送请求,我们看到应用程序创建了多个连接。我们不知道应用程序具体做了什么,但这可能令人担忧。
打开许多连接,但连接在端点响应后长时间未关闭。这是连接泄漏的明显迹象。如果我们没有明确启动 CPU 分析(我稍后会展示如何进行),你只能看到创建连接的线程名称。有时线程名称就足够了,在这种情况下,你甚至不需要启动 CPU 分析。然而,在这种情况下,它并不能提供足够的信息来识别创建连接的代码。

图 8.10 连接泄漏标签页显示了每个连接的状态。我们关注的是关闭晚或从未关闭的连接。在这里,应用程序打开的连接在端点向客户端发送响应后仍然存活很长时间,这强烈表明存在问题。
但这还不够,是吗?我们怀疑应用程序获取 DBMS 连接时可能存在问题。现在我们需要使用分析器的 CPU 分析功能来识别代码库中创建连接并忘记关闭它们的部分。
我们仍然需要一种方法来识别创建泄漏连接的代码。幸运的是,JProfiler 也可以帮助我们做到这一点,但我们需要在启用 CPU 分析后重新进行练习。在 CPU 分析激活时,JProfiler 将为每个泄漏连接显示创建连接的方法的堆栈跟踪。
图 8.11 展示了如何启用 CPU 分析以及如何找到每个泄漏连接的堆栈跟踪。

图 8.11 启用 CPU 分析后,JProfiler 显示堆栈跟踪,这有助于你确定哪个应用程序代码部分创建了泄漏连接。
让我们直接查看我们的示例 da-ch8-ex1 中的代码。我们观察到该方法确实创建了一个似乎在任何地方都没有关闭的连接。我们找到了根本原因!
列表 8.1 识别问题根本原因
public Product findProduct(int id) throws SQLException {
String sql = "SELECT * FROM product WHERE id = ?";
Connection con = dataSource.getConnection(); ❶
try (PreparedStatement statement = con.prepareStatement(sql)) {
statement.setInt(1, id);
ResultSet result = statement.executeQuery();
if (result.next()) {
Product p = new Product();
p.setId(result.getInt("id"));
p.setName(result.getString("name"));
return p;
}
}
return null;
}
❶ 这行代码创建了一个永远不会关闭的连接。
项目 da-ch8-ex2 修复了代码。通过将连接添加到try-with-resources块中,应用程序将在try块结束时关闭连接,当连接不再需要时。
列表 8.2 通过关闭连接解决问题
public Product findProduct(int id) throws SQLException {
String sql = "SELECT * FROM product WHERE id = ?";
try (Connection con = dataSource.getConnection(); ❶
PreparedStatement statement = con.prepareStatement(sql)) {
statement.setInt(1, id);
ResultSet result = statement.executeQuery();
if (result.next()) {
Product p = new Product();
p.setId(result.getInt("id"));
p.setName(result.getString("name"));
return p;
}
}
return null;
}
❶ 连接在try-with-resources块中声明,在try块结束时关闭连接。
在应用修正后,我们可以再次对应用程序进行分析。现在,JProfiler 中的连接选项卡显示只创建了一个连接,连接泄漏选项卡为空,确认问题确实得到了解决(图 8.12)。当你测试应用程序时,你也会看到你可以向/products端点发送多个请求。

图 8.12 修复错误后,我们使用 JProfiler 确认没有更多的连接泄漏。我们观察到应用程序一次只打开一个连接,并在不再需要时正确关闭连接。连接泄漏选项卡没有显示其他故障连接。
你是否想知道在现实场景中避免此类问题的最佳实践是什么?我建议开发者每次实现或修复错误后花大约 10 分钟的时间,使用分析器测试他们所工作的功能。这种做法可以帮助在开发的早期阶段识别由错误的查询或错误连接管理引起的延迟问题。
8.2 使用调用图理解应用程序的代码设计
在本节中,我们讨论了我最喜欢的一种理解应用程序类设计的技术:将执行过程可视化为一个调用图。这项技术在处理混乱的代码时特别有帮助,这是与新的应用程序一起工作的一个可能结果。
到目前为止,我们已使用堆栈跟踪来理解执行。执行堆栈跟踪是有价值的工具,我们已经看到了我们可以用它们做什么。它们之所以有用,是因为它们以直观的文本方式表示,这使得它们可以打印在日志中(通常作为异常堆栈跟踪)。但从视觉角度来看,它们在快速识别对象和方法调用之间的关系方面并不出色。调用图是表示分析器收集数据的不同方式,并且更多地关注对象和方法调用之间的关系。
为了演示如何获取调用图,我们将使用书中提供的示例 da-ch8-ex2 来演示如何使用调用图快速了解哪些对象和方法在执行背后起作用,而无需分析代码。当然,这并不是要完全避免代码;你最终仍然需要深入代码,但通过首先使用调用图,你将有一个更好的初步了解发生了什么。
我们将继续使用 JProfiler 进行演示。由于调用图是表示 CPU 分析数据的一种方式,我们首先需要开始 CPU 分析。图 8.13 显示了如何开始 CPU 分析,这将导致堆栈跟踪(在 JProfiler 中称为调用树)。我们将研究调用应用程序公开的/products端点时会发生什么。

图 8.13 从左侧菜单中选择调用树以开始分析 CPU(记录 CPU 数据)。向/products端点发送请求,分析器最初将记录的数据显示为堆栈跟踪,包括调用次数和执行时间的详细信息。
右键单击堆栈跟踪中的一行,然后选择显示调用图,以将收集的执行数据可视化为调用图(图 8.14)。

图 8.14 要从执行堆栈跟踪中获取调用图表示,右键单击堆栈跟踪中的一行,然后选择显示调用图。
JProfiler 将生成一个调用图表示,重点关注在生成调用图时所选行的定义的方法。最初,你只知道这个方法是从哪里被调用的以及这个方法调用了什么。你可以进一步导航并观察整个调用链(图 8.15)。调用图还提供了关于执行时间和调用次数的详细信息,但它的主要焦点是对象和方法调用之间的关系。

图 8.15 调用图显示了执行情况,主要关注对象和方法调用之间的关系。你可以线性地导航方法执行链,以确定每个方法是从哪里被调用的以及该方法调用了什么。调用图还显示了应用程序代码库中的对象和方法,以及应用程序使用的库和框架中的对象和方法。
8.3 使用火焰图查找性能问题
另一种可视化已分析执行的方法是使用火焰图。如果调用图关注对象和方法调用之间的关系,那么火焰图在识别潜在延迟方面最有帮助。它们只是以不同的方式查看方法执行栈提供相同细节的一种方式,但正如章节引言中提到的,相同数据的其他表示可能有助于识别特定信息。
我们将继续使用示例 da-ch8-ex2 进行演示。我们将使用 JProfiler 将执行栈表示更改为火焰图,并讨论新表示的优点。
在第 8.1 节和第 8.2 节中讨论了生成调用树之后,您可以使用菜单栏上的“分析”项将其更改为火焰图,如图 8.16 所示。

图 8.16 要将执行栈(调用树)更改为火焰图,请在菜单中选择“分析”,然后选择“显示火焰图”。
火焰图是一种将执行树表示为栈的方式。这个花哨的名字是因为这个图通常看起来像火焰。这个栈的第一层是线程执行的第一个方法。然后,每一层以上的方法都是由下面一层调用的。图 8.17 展示了为图 8.16 中的执行树创建的火焰图。

图 8.17 火焰图是执行跟踪的栈表示。每一层显示的是下面一层调用的方法。栈的第一层(底部)是线程的开始。这样,我们垂直地看到执行栈,而火焰图水平地表示每一层相对于下面一层花费的时间。
一个方法可以调用多个其他方法。在火焰图中,被调用的方法将出现在同一层。在这种情况下,每个方法的长度是相对于调用它的方法(下面一层)花费的时间。在图 8.17 中,您可以看到ProductRepository类中的findById()方法和PurchaseRepository类中的findAll()方法都是从ProductService类中的getProductNamesForPurchases()方法调用的。

图 8.18 当多个方法共享同一层时,它们都是由下面的方法调用的。表示长度的总和等于下面方法的长度。每个方法的长度是相对于总执行时间的相对表示。在这种情况下,findAll()的执行时间比findById()长得多。
在图 8.18 中,我们观察到ProductService类中的getProductNamesForPurchases()是ProductRepository类中的findById()方法和PurchaseRepository类中的findAll()方法的底层。此外,findById()和findAll()共享相同的层。但请注意,它们的长度并不相同。长度是相对于调用者的执行而言的,因此在这种情况下,findById()的执行时间小于findAll()的执行时间。

图 8.19 为了使火焰图着色并便于阅读,请使用顶部菜单中的“着色”项添加着色规则。这些规则定义了火焰图中哪些层应该着色以及使用哪种颜色。
你可能已经注意到,在这张图中很容易迷失方向。这只是一个用于学习目的的简单示例;在实际应用程序中,火焰图可能要复杂得多。为了减轻这种复杂性,你可以使用 JProfiler 根据方法、类或包名称着色层。图 8.19 展示了如何使用着色来标记火焰图中的特定层。你使用顶部菜单中的“着色”项添加着色规则。你可以添加多个着色规则来指定哪些层应该着色以及你喜欢的颜色。

图 8.20 着色层级有助于突出你想要关注的火焰图的特定部分。你可以同时使用多种颜色,这有助于你比较执行时间。
在图 8.20 中,你可以看到我如何突出显示名称中包含“Purchase”一词的方法的层级,并将它们着色为蓝色(本书印刷版中的深灰色)。
8.4 分析 NoSQL 数据库上的查询
应用程序通常使用关系型数据库,但在许多情况下,某些实现需要不同的持久化技术。我们称之为NoSQL 技术,我们实现的应用程序可以从大量此类实现中选择。其中一些最著名的例子是 MongoDB、Cassandra、Redis 和 Neo4J。一些分析器,如 JProfiler,可以拦截应用程序发送到特定 NoSQL 服务器以与数据库交互的查询。
JProfiler 可以拦截发送到 MongoDB 和 Cassandra 的事件,这些详细信息在调查使用此类持久化实现的应用程序行为时可能有助于节省时间。因此,在本节中,我们将使用一个小应用程序来演示如何使用 JProfiler 观察应用程序与 MongoDB 数据库的交互。
项目 da-ch8-ex3 与 MongoDB 协作。该应用程序实现了一些端点:一个用于在数据库中存储产品详情,另一个用于返回所有先前添加的产品列表。为了简化,产品仅由名称和唯一 ID 表示。
要跟随本节,您首先需要在本地安装一个 MongoDB 服务器,项目 da-ch8-ex3 将连接到该服务器。您可以从官方网站下载并安装 MongoDB Community Server:www.mongodb.com/try/download/community。
一旦安装了服务器,您就可以开始项目 da-ch8-ex3。我们还将把 JProfiler 附加到该进程。要开始监控 MongoDB 事件,请在左侧菜单的“数据库”下选择 MongoDB 部分并开始录制。为了观察 JProfiler 如何呈现事件,我们将调用应用程序公开的两个端点。您可以使用 cURL 命令(如下面的片段所示)或 Postman 等工具调用这两个端点:
curl -XPOST http://localhost:8080/product/Beer ❶
curl http://localhost:8080/product ❷
❶ 向数据库添加名为“Beer”的产品
❷ 获取数据库中的所有产品
图 8.21 显示了 JProfiler 拦截的两个事件。该工具显示与每个事件相关的堆栈跟踪(调用树)。我们可以获取关于调用次数和执行时间的详细信息。

图 8.21 JProfiler 可以拦截应用程序对 NoSQL 数据库执行的操作。在这个例子中,JProfiler 拦截了两个事件:对名为“product”的文档的更新和读取。这样,您可以监控您的应用程序与 NoSQL 数据库之间的交互,并考虑特定操作的调用次数和执行时间。分析器还为您提供特定操作的完整堆栈跟踪,以便您可以快速找到导致特定事件的代码。
摘要
-
如 VisualVM 之类的免费工具提供了大量的小部件,可以帮助进行任何调查。但如 JProfiler 之类的授权工具可以通过不同的方式表示调查数据,使调查更加有效。
-
有时应用程序在连接到 DBMS 时会遇到问题。通过使用 JProfiler,您可以更轻松地调查与关系型数据库服务器的 JDBC 连接问题。您可以评估连接是否保持打开状态,并识别代码中“忘记”关闭它们的部分。
-
调用图是可视化执行堆栈的另一种方式,主要关注对象和方法调用之间的关系。因此,调用图是您可以用来更轻松地理解应用程序执行背后的类设计的优秀工具。
-
火焰图提供了可视化分析数据的另一种视角。您可以使用火焰图更容易地发现导致执行延迟和长堆栈跟踪的区域。您可以在火焰图中着色特定的层,以更好地可视化执行。
-
一些授权的工具提供扩展功能,例如调查应用程序之间的通信或您的应用程序与 NoSQL 数据库服务器之间的通信。
9 在多线程架构中调查锁
本章涵盖
-
监控应用程序的线程
-
识别线程锁及其原因
-
分析等待的线程
在本章中,我们讨论了调查利用多线程架构的应用程序执行的方法。通常,开发者发现实现多线程架构是应用程序开发中最具挑战性的事情之一,而使应用程序性能良好又带来了另一个维度的难度。本章中讨论的技术将使你对这类应用程序的执行有更清晰的了解,从而更容易识别问题并优化应用程序的执行。
为了正确理解本章的内容,你需要了解 Java 中线程机制的基础,包括线程状态和同步。为了复习,请阅读附录 D;它不会给你所有关于 Java 中线程和并发的可能知识(那需要自己的书架),但它会提供足够的细节来理解本章的讨论。
9.1 监控线程以获取锁
在本节中,我们讨论线程锁以及如何分析它们以发现潜在的问题或优化应用程序执行的机会。"线程锁"是由不同的线程同步方法引起的,通常用于控制多线程架构中事件流的流程。以下是一些例子:
-
一个线程想要防止其他线程在它更改资源时访问该资源。
-
一个线程需要等待另一个线程完成或达到其执行中的某个特定点,然后才能继续其工作。
线程锁是必要的;它们帮助应用程序控制线程。但是实现线程同步留下了很多错误的空间。错误实现的锁可能导致应用程序冻结或性能问题。我们需要使用分析器来确保我们的实现是最优的,并通过最小化锁的时间来使应用程序更高效。
在本节中,我们将使用一个小型应用程序(项目 da-ch9-ex1)来实现一个简单的多线程架构。我们将使用分析器来分析应用程序执行期间的锁。我们想了解线程是否被锁定以及它们的行为如何:
-
哪个线程锁定了另一个
-
线程被锁定的次数
-
线程暂停而不是执行的时间
这些细节使我们能够了解应用程序执行是否优化,以及我们是否有改进应用程序执行的方法。我们用于示例的应用程序实现了两个并发运行的线程:生产者和消费者。生产者生成随机值并将它们添加到列表实例中,而消费者从生产者使用的同一集合中移除值(图 9.1)。

图 9.1 应用程序启动了两个线程,我们称之为“生产者”和“消费者”。这两个线程使用一个公共资源:它们改变ArrayList类型的列表实例。生产者生成随机值并将其添加到列表中,而消费者同时移除生产者添加的值。
让我们跟随应用程序在列表 9.1、9.2 和 9.3 中的实现,看看在调查执行时我们可以期待什么。在列表 9.1 中,你可以找到启动两个线程实例的Main类。我在启动线程之前让应用程序等待 10 秒钟,这样我们就有时间启动分析器并观察整个线程的时间线。应用程序将线程命名为_Producer和_Consumer,以便我们在使用分析器时可以轻松识别它们。
列表 9.1 应用程序的Main方法,它启动两个线程
public class Main {
private static Logger log = Logger.getLogger(Main.class.getName());
public static List<Integer> list = new ArrayList<>();
public static void main(String[] args) {
try {
Thread.sleep(10000); ❶
new Producer("_Producer").start(); ❷
new Consumer("_Consumer").start(); ❸
} catch (InterruptedException e) {
log.severe(e.getMessage());
}
}
}
在开始时等待 10 秒钟,以便程序员开始分析
启动一个生产者线程
启动一个消费者线程
在列表 9.2 中,你可以找到消费者线程的实现。该线程遍历一个包含一百万行代码的代码块(这个数字应该足够让应用程序运行几秒钟,并允许我们使用分析器来获取一些统计数据)。在每次迭代中,线程使用在Main类中声明的静态列表实例。消费者线程检查列表中是否有值,并移除列表中的第一个值。实现逻辑的整个代码块都是同步的,使用列表实例本身作为监视器。监视器不会允许多个线程同时进入它保护的同步块。
列表 9.2 消费者线程的定义
public class Consumer extends Thread {
private Logger log = Logger.getLogger(Consumer.class.getName());
public Consumer(String name) {
super(name);
}
@Override
public void run() {
for (int i = 0; i < 1_000_000; i++) { ❶
synchronized (Main.list) { ❷
if (Main.list.size() > 0) { ❸
int x = Main.list.get(0);
Main.list.remove(0); ❹
log.info("Consumer " + ❹❺
Thread.currentThread().getName() +
" removed value " + x);
}
}
}
}
}
在消费者的同步代码块上迭代一百万次
使用在主类中定义的静态列表作为监视器同步代码块
仅当列表不为空时才尝试消费一个值
消费列表中的第一个值并移除该值
记录移除的值
列表 9.3 展示了生产者线程的实现,它与消费者线程的实现非常相似。生产者也会遍历一个包含一百万行代码的代码块。对于每次迭代,生产者生成一个随机值并将其添加到在Main类中静态声明的列表中。这个列表就是消费者从中移除值的列表。生产者只有在列表大小小于 100 时才添加新值。
列表 9.3 生产者线程的定义
public class Producer extends Thread {
private Logger log = Logger.getLogger(Producer.class.getName());
public Producer(String name) {
super(name);
}
@Override
public void run() {
Random r = new Random();
for (int i = 0; i < 1_000_000; i++) { ❶
synchronized (Main.list) { ❷
if (Main.list.size() < 100) { ❸
int x = r.nextInt(); ❹
Main.list.add(x); ❹
log.info("Producer " + ❺
Thread.currentThread().getName() +
" added value " + x);
}
}
}
}
}
在生产者的同步代码块上迭代一百万次
使用在主类中定义的静态列表作为监视器同步代码块
仅当列表元素少于 100 个时才添加值
生成一个新的随机值并将其添加到列表中
记录添加到列表中的值
生产者的逻辑也使用列表作为监视器进行同步。这样,一次只有一个线程,即生产者或消费者,可以更改此列表。监视器(列表实例)允许一个线程进入其逻辑,并使另一个线程在其代码块的开始处等待,直到另一个线程完成同步块的执行(图 9.2)。

图 9.2 一次只有一个线程可以进入同步代码块。要么是生产者执行其run()方法中定义的逻辑,要么是消费者执行其逻辑。
我们能否使用分析器找到这种应用行为和其他关于执行的细节?在现实世界的应用中,代码可能要复杂得多,所以仅通过阅读代码来理解应用的行为,在大多数情况下是不够的。
记住,本书中使用的项目是简化和定制以适应我们讨论的目的。不要将它们视为最佳实践,并在现实世界的应用中直接应用。
![]()
让我们使用 VisualVM 来查看在“线程监控”标签页(图 9.3)中这看起来是什么样子。注意颜色(阴影)交替,因为每个线程的大多数代码都是同步的。在大多数情况下,要么是生产者正在运行而消费者等待,要么是消费者正在运行而生产者等待。
这两个线程很少会同时执行代码。由于存在同步块之外的指令,这两个线程可以同时运行以执行代码。这种代码的一个例子是for循环,在两种情况下都是在同步块外部定义的。

图 9.3 在大多数情况下,线程将依次锁定彼此并执行它们的同步代码块。这两个线程仍然可以并发执行同步块之外的指令。
一个线程可能被同步代码块阻塞,它可能正在等待另一个线程完成其执行(连接),或者它可能被阻塞对象控制。在线程被阻塞且无法继续执行的情况下,我们说线程是锁定的。在图 9.4 中,你可以看到 JProfiler 以我们使用的方法呈现的相同信息,JProfiler 是一种替代 VisualVM 的分析器。

图 9.4 你可以使用其他分析器代替 VisualVM。在这里,你看到线程时间线在 JProfiler 中的显示方式。
9.2 分析线程锁
当与使用线程锁的应用架构一起工作时,我们希望确保应用得到最佳实现。为此,我们需要一种方法来识别锁,以找出线程被阻塞的次数和锁的时间长度。我们还需要了解在特定场景下导致线程等待的原因。我们能否以某种方式收集所有这些信息?是的,一个分析器可以告诉我们关于线程行为所需了解的一切。
我们将继续使用你在第七章中学到的相同步骤进行性能分析调查:
-
使用样本分析来了解在执行过程中发生的事情,并确定进一步深入了解的地方。
-
使用性能分析(工具化)来获取我们想要调查的特定主题的详细信息。
图 9.5 显示了应用程序执行样本的结果。在查看执行时间时,我们观察到总时间比总 CPU 时间更长。在第七章中,你看到了类似的情况,我们得出结论,当这种情况发生时,意味着应用程序正在等待某事物。

图 9.5 当总 CPU 时间短于总执行时间时,意味着应用程序正在等待某事物。我们想要弄清楚应用程序等待的是什么,以及这段时间是否可以优化。
在图 9.6 中,我们可以看到一些有趣的事情:方法在等待,但正如样本数据所示,它并没有等待其他事物。它似乎只是在等待自己。标记为“自我时间”的行告诉我们方法执行花费了多少时间。请注意,方法只花费了大约 700 毫秒的 CPU 时间作为自我时间,但作为总执行自我时间的一个更大的值 4903 毫秒。

图 9.6 该方法不是等待某事物,而是等待自身。我们观察到它的自我执行时间比总 CPU 时间更长,这通常意味着线程被锁定。线程可能被另一个线程阻塞。
在第七章中,我们处理了一个示例,其中应用程序正在等待外部服务响应。应用程序发送了一个调用,然后等待另一个服务回复。在这种情况下,为什么应用程序等待是有意义的,但这里的情况看起来很奇怪。什么可能造成这种行为?
你可能会想,“一个方法怎么能等待自己呢?它是不是太懒了?”当我们观察到这样的行为,即一个方法正在等待但不是等待外部事物时,其线程可能已经被锁定。为了获取有关线程锁定更详细的信息,我们需要通过性能分析执行进一步的分析。

图 9.7 要开始对锁进行性能分析,请使用性能分析标签页中的锁按钮。在性能分析会话结束时,我们观察到每个生产者和消费者线程上都有超过 3,600 个锁。
样本分析并没有回答我们所有的问题。我们可以看到方法正在等待,但我们不知道它们在等待什么。我们需要继续进行性能分析(工具化)以获取更多信息。在 VisualVM 中,我们使用性能分析标签页来开始锁监控。要开始对锁进行性能分析,请使用图 9.7 中所示的锁按钮,该按钮显示了性能分析结果。按钮在图中显示为禁用状态,因为性能分析会话结束时进程已经被停止。
对于每个线程,我们可以通过选择线程名称左侧的小加号(+)来深入了解。现在,你可以获取到影响线程执行的每个监视对象的详细信息。分析器显示了被其他线程阻塞的线程的详细信息,以及是什么阻塞了线程。

图 9.8 分析结果让我们对什么创建了锁以及什么受到它们的影响有了很好的理解。我们看到生产者线程只与一个监视器合作。此外,生产者线程使用监视器被消费者线程阻塞了 3,698 次。使用相同的监视器实例,生产者以类似的数量阻塞了消费者:3,699 次。
你可以在图 9.8 中找到这些详细信息。我们看到生产者线程被一个ArrayList类型的监视器实例阻塞。对象引用(图中的 4476199c)帮助我们唯一地识别对象实例,以确定是否同一个监视器影响了多个线程。它还允许我们精确地识别线程和监视器之间的关系。
图 9.8 中的发现可以这样阅读:
-
命名为
_Producer的线程被引用 4476199c 的监视器实例(ArrayList类型的实例)阻塞。 -
_Consumer线程通过获取监视器 4476199c 阻塞了_Producer线程 3,698 次。 -
生产者线程还持有(拥有)引用 4476199c 的监视器 3,699 次,或者说线程
_Producer阻塞了线程_Consumer3,699 次。
图 9.9 扩展了消费者线程的视角。你会发现所有数据都相关联。在整个执行过程中,只有一个监视实例,即ArrayList类型的实例,锁定了一个线程或另一个线程。消费者线程最终被锁定 3,699 次,而生产者线程执行了由ArrayList对象同步的代码块。生产者线程被阻塞 3,698 次,而消费者线程执行了与ArrayList监视器同步的代码块。
记住,当你在自己的计算机上执行应用程序时,你不会必然得到相同的数字。事实上,即使你在同一台计算机上重复执行,也很可能不会得到相同的数字。尽管你可能得到不同的值,但总体上,你可以做出类似的观察。
![]()

图 9.9 两个线程使用相同的监视器相互阻塞。当一个线程执行带有ArrayList实例监视器的同步代码块时,另一个线程等待。这样,一个线程被锁定 3,698 次,另一个线程被锁定 3,699 次。
对于这个演示,我使用了 VisualVM,因为它免费,而且我很熟悉它。但您也可以使用其他工具以相同的方法,例如 JProfiler。
在将 JProfiler 附加到进程(如第八章所述)后,确保您将 JVM 退出操作设置为“为分析保持 VA 活动”,如图 9.10 所示。

图 9.10 当使用 JProfiler 开始性能分析会话时,请记住将 JVM 操作设置为“为性能保持虚拟机活动”,这样您就可以在应用程序执行完毕后看到性能分析结果。
JProfiler 提供了多个视角来可视化我们使用 VisualVM 获得的相同细节,但结果是一样的。图 9.11 显示了锁的监视器历史视图报告。

图 9.11 JProfiler 显示了应用程序线程遇到的所有锁的详细历史记录。该工具显示了事件的准确时间、事件持续时间、导致锁定的监视器以及涉及的线程。
在大多数情况下,您不需要如此详细的报告。我更喜欢将事件(锁)按线程分组,或者较少的情况下,按监视器分组。在 JProfiler 中,您可以像图 9.12 所示那样分组事件。从左侧菜单的监视器使用统计中,您可以选择按参与线程或导致锁定的监视器来分组事件。JProfiler 甚至有一个更独特的选项,您可以按监视器对象的类来分组锁。

图 9.12 您可以使用监视器使用统计部分按参与线程或监视器分组锁事件。您可以使用聚合视图来了解哪些线程受影响更大以及它们受到什么影响,或者哪个监视器导致线程更频繁地停止。
如果您按参与线程分组锁事件,您将得到一个类似于 VisualVM 提供的统计。每个线程在应用程序执行期间被锁定超过 3,600 次(图 9.13)。

图 9.13 按线程分组锁事件为您提供了一个聚合视图,显示了每个线程在执行期间锁定了多少次。
执行是否最优?要回答这个问题,我们需要了解应用程序的目的。在我们的案例中,应用程序是一个简单、演示性的例子,因为它没有真正的目的,所以很难完全分析结果是否表明应用程序可以被增强。
但是,由于应用程序使用两个使用公共资源(列表)的线程,如果我们考虑到它们不能同时使用共享资源,那么我们期望以下情况:
-
总执行时间应该是 CPU 执行时间的总和(因为线程不能同时工作,它们将相互排斥),大约如此。
-
线程应该分配给执行的相似时间,并且应该锁定大约相同数量的次数。如果其中一个线程被优先考虑,另一个线程可能会陷入饥饿:线程以一种“不公平”的方式被阻塞,并且无法执行。
如果你再次查看线程分析,你可以看到两个线程得到了公平的对待。它们确实被锁定相似的次数,并且它们相互排斥但具有相似的活动(CPU 时间)执行。这是最优的,我们无法做太多来增强它。但请记住,这取决于应用做什么以及我们对它如何执行的期望。
这里有一个不同场景的例子,在这种情况下,该应用可能并不一定被认为是最佳的。假设你有一个实际处理值的程序。比如说,生产者需要更多的时间将每个值添加到列表中,而消费者需要的时间却比处理该值的时间少。在现实世界的应用中,类似的情况可能会发生:线程不需要做等效的困难“工作”。
在这种情况下,你可以增强应用:
-
最小化消费者线程的锁数量,并让它等待以允许生产者工作更多。
-
定义更多的生产者线程或让消费者线程批量(一次多个)读取和处理值。
一切都取决于应用做什么,但了解你可以做什么来使其变得更好,首先要从分析执行开始。因为你不可能有一种可以应用于所有应用的通用方法,所以我总是建议开发者使用分析器并分析在实现多线程应用时应用执行的更改。
9.3 分析等待线程
在本节中,我们分析等待被通知的线程。等待线程与锁定线程不同。监视器锁定线程以执行同步代码块。在这种情况下,我们并不期望监视器执行特定操作来“告诉”被阻塞的线程继续其执行。但监视器可以使线程等待不确定的时间,然后决定何时允许该线程继续其执行。一旦监视器使线程等待,该线程只有在被同一监视器通知后才会返回执行。使线程等待直到被通知的能力在控制线程方面提供了很大的灵活性,但如果不正确使用,也可能导致问题。

图 9.14 锁定线程与等待线程。一个锁定线程在同步块的入口处被阻塞。监视器不允许一个线程在另一个线程在块内积极运行时进入同步块。一个等待线程是监视器明确设置为阻塞状态的线程。监视器可以使其管理的任何线程在同步块中等待。等待线程只有在监视器明确告诉它可以继续执行后才能继续其执行。
为了可视化锁定线程和等待线程之间的差异,请看图 9.14。想象一下,同步块是由警察管理的受限区域。线程是汽车。警察一次只允许一辆汽车在受限区域(同步块)内运行。被阻塞的汽车我们称之为锁定。警察还可以管理受限区域内运行的汽车。警察可以命令在区域内运行的汽车等待,直到它们被明确命令继续;我们称之为等待。
我们将使用本章前面分析过的相同应用程序,并考虑以下场景:负责该应用程序的一位开发者考虑了对我们的生产者-消费者架构的改进。现在,当列表为空时,消费者线程无法执行任何操作,因此它只是多次迭代一个错误条件,直到 JVM 让它等待,以便允许生产者线程运行并向列表添加值。当生产者向列表添加 100 个值时,也会发生同样的事情。生产者线程会运行在一个错误条件上,直到 JVM 允许消费者从列表中移除一些值。
我们能否做些什么,使得消费者在没有可消费的值时等待,并且只有在我们知道列表中至少有一个值时才运行(见图 9.15)?同样,我们能否让生产者在列表中已有太多值时等待,并且只有在添加其他值有意义时才允许它运行?这种方法会使我们的应用程序更高效吗?

图 9.15 中的一些汽车是消费者线程,而另一些是生产者线程。警察命令消费者等待,如果列表中没有可以消费的值,允许生产者工作并添加值。一旦列表中至少有一个可以消费的值,警察会命令等待的消费者继续执行。
我们将更改应用程序以实现这种新行为,但也会证明,对于我们的场景,应用程序并没有变得更高效。相反,执行效率更低。
当线程无法与共享资源(列表)一起工作时,让他们等待可能看起来是个好主意。但经过分析,你会发现这反而严重影响了性能,而不是帮助应用程序运行得更快。
我总是建议在开发期间使用分析器来证明应用程序执行得最优。
![]()
列表 9.4 展示了消费者线程的新实现。当列表为空时,消费者线程会等待,因为它没有可以消费的内容。监视器使消费者线程等待,并且只有在生产者向列表添加了某些内容之后,才会通知它继续执行。我们使用 wait() 方法告诉消费者如果列表为空则等待。同时,当消费者从列表中移除值时,它会通知等待的线程,这样如果生产者在等待,现在它知道它可以继续执行,因为列表不再满。我们使用 notifyAll() 方法来通知等待的线程。您可以在项目 da-ch9-ex2 中找到此实现。
列表 9.4 在列表为空时使消费者线程等待
public class Consumer extends Thread {
// Omitted code
@Override
public void run() {
try {
for (int i = 0; i < 1_000_000; i++) {
synchronized (Main.list) {
if (Main.list.size() > 0) {
int x = Main.list.get(0);
Main.list.remove(0);
log.info("Consumer " +
Thread.currentThread().getName() +
" removed value " + x);
Main.list.notifyAll(); ❶
} else {
Main.list.wait(); ❷
}
}
}
} catch (InterruptedException e) {
log.severe(e.getMessage());
}
}
}
❶ 在从列表中消费一个元素之后,消费者通知等待的线程列表内容已发生变化。
❷ 当列表为空时,消费者会等待直到它被通知列表中已添加了某些内容。
以下代码展示了生产者线程的实现。与消费者线程类似,如果列表中有太多值,生产者线程会等待。消费者最终会通知生产者,并在从列表中消费一个值后允许它再次运行。
列表 9.5 如果列表已满,使生产者线程等待
public class Producer extends Thread {
// Omitted code
@Override
public void run() {
try {
Random r = new Random();
for (int i = 0; i < 1_000_000; i++) {
synchronized (Main.list) {
if (Main.list.size() < 100) {
int x = r.nextInt();
Main.list.add(x);
log.info("Producer " +
Thread.currentThread().getName() +
" added value " + x);
Main.list.notifyAll(); ❶
} else {
Main.list.wait(); ❷
}
}
}
} catch (InterruptedException e) {
log.severe(e.getMessage());
}
}
}
❶ 在向列表添加一个元素之后,生产者通知等待的线程列表内容已发生变化。
❷ 当列表中有 100 个元素时,生产者会等待直到它被通知列表中已移除某些内容。
如你所知,我们通过采样执行来开始我们的调查。我们已经看到一些可疑之处:执行似乎比我们之前在 9.1 节中观察到的要长得多(图 9.16)。如果你回到我们在 9.1 节中做出的前观察,你会发现整个执行只花了大约 9 秒。现在,执行需要大约 50 秒——这是一个巨大的差异。

图 9.16 通过采样执行,我们看到执行时间比我们使线程等待之前要慢。
样本细节(图 9.17)显示我们添加的 wait() 方法导致了大部分线程等待时间。由于自我执行时间非常接近 CPU 执行时间,线程并没有长时间锁定。然而,我们的目的是使我们的应用程序整体上更高效,但看起来我们只是将等待从一边移到了另一边,在这个过程中使应用程序变慢了。

图 9.17 通过分析细节,我们可以看到自我执行时间并不长,但线程被阻塞,因此等待时间更长。
我们继续通过更详细的性能分析(图 9.18)。确实,性能分析结果显示锁的数量更少,但这并没有很大帮助,因为执行速度仍然很慢。

图 9.18 锁的模式与我们的先前结果相似,但线程被锁定的频率更低。
图 9.19 展示了使用 JProfiler 获得的相同调查细节。在 JProfiler 中,一旦我们将锁事件按线程分组,我们就可以得到锁的数量和等待时间。在前面的练习中,等待时间为零,但我们有更多的锁。现在锁的数量更少,但等待时间更长。这告诉我们,当使用等待/通知方法时,JVM 在线程之间变化得更慢,而不是允许线程由同步块的监视器自然锁定和解锁。

图 9.19 使用 JProfiler 我们可以得到相同的详细信息。锁定的线程更少,但现在它们被阻塞的时间更长。
摘要
-
线程可以通过同步代码块被锁定并强制等待。当线程同步以避免同时更改共享资源时,会出现锁。
-
锁是避免竞态条件所必需的,但有时应用程序会使用有缺陷的线程同步方法,这可能导致不希望的结果,例如性能问题或甚至应用程序冻结(在死锁的情况下)。
-
由同步代码块引起的锁会减慢应用程序的执行速度,因为它们迫使线程等待而不是让它们工作。在某些实现中可能需要锁,但最好找到方法来最小化应用程序线程被锁定的时长。
-
我们可以使用性能分析器来识别何时锁会减慢应用程序,应用程序在执行过程中遇到了多少锁,以及它们会降低多少性能。
-
当使用性能分析器时,始终先采样执行,以确定应用程序的执行是否受到锁的影响。你通常会通过观察一个方法正在等待自身来识别采样时的锁。
-
如果你通过采样发现锁可能影响应用程序的执行,你可以继续使用锁分析(仪器)进行调查,这将显示受影响的线程、锁的数量、涉及的监视器以及锁定线程和引起锁的线程之间的关系。这些细节有助于你决定应用程序的执行是否最优,或者你是否可以找到方法来增强它。
-
每个应用程序都有不同的目的,因此没有理解线程锁的通用公式。一般来说,我们希望最小化线程被锁定或等待的时间,并确保线程不会被不公平地排除在执行之外(饥饿线程)。
10 使用线程转储调查死锁
本章涵盖
-
使用分析器获取线程转储
-
使用命令行获取线程转储
-
读取线程转储以调查问题
在本章中,我们将讨论如何使用线程转储来分析给定时间点的线程执行。通常,我们在应用程序变得无响应的情况下使用线程转储,例如在死锁的情况下。当多个线程暂停它们的执行并等待彼此满足某个条件时,就会发生死锁。如果假设线程 A 等待线程 B 执行某些操作,而线程 B 又等待线程 A,那么两者都无法继续执行。在这种情况下,应用程序,或者至少是它的一部分,将会冻结。我们需要了解如何分析这个问题,以找到其根本原因,并最终解决问题。
由于死锁可能导致进程完全冻结,你通常无法使用采样或分析(工具),就像我们在第九章中所做的那样。相反,你可以获取给定 JVM 进程的所有线程及其状态的统计信息。这个统计信息被称为线程转储。
10.1 获取线程转储
在本节中,我们将分析获取线程转储的方法。我们将使用一个小应用程序,该应用程序故意实现了一个导致死锁的问题。你可以在项目 da-ch10-ex1 中找到这个应用程序。我们将运行这个应用程序,等待它冻结(这应该在几秒钟内发生),然后我们将讨论获取线程转储的多种方法。一旦我们知道了如何获取线程转储,我们将讨论如何读取它们(第 10.2 节)。
让我们看看我们将要使用的应用程序是如何实现的,以及为什么它的执行会导致死锁。该应用程序使用两个线程来更改两个共享资源(两个列表实例)。一个名为“生产者”的线程在执行过程中向一个列表或另一个列表添加值。另一个名为“消费者”的线程从这些列表中移除值。如果你阅读了第九章,你可能还记得我们曾对一个类似的应用程序进行过工作。但由于该应用程序的逻辑对我们示例无关紧要,我已经从列表中省略了它,只保留了对我们演示重要的一部分——同步块。
这个例子被简化了,以便你能够专注于我们讨论的调查技术。在现实世界的应用程序中,事情通常会更加复杂。此外,错误使用同步块并不是导致死锁的唯一途径。错误使用信号量、闩锁或屏障等阻塞对象也可能导致此类问题。但你要学习的调查问题的步骤是相同的。
在列表 10.1 和 10.2 中,请注意,两个线程使用嵌套的同步块和两个不同的监视器:listA和listB。问题是其中一个线程使用监视器listA进行外部同步块,而listB用于内部。另一个线程则相反。这种代码设计留下了死锁的空间,如图 10.1 所示。
列表 10.1 使用嵌套同步块进行消费者线程
public class Consumer extends Thread {
// Omitted code
@Override
public void run() {
while (true) {
synchronized (Main.listA) { ❶
synchronized (Main.listB) { ❷
work();
}
}
}
}
// Omitted code
}
❶ 外部同步块使用 listA 监视器。
❷ 内部同步块使用 listB 监视器。
在列表 10.1 中,消费者线程使用listA作为外部同步块的监视器。在列表 10.2 中,生产者线程使用相同的监视器作为内部块,而listB监视器也在两个线程之间交换。
列表 10.2 使用嵌套同步块进行生产者线程
public class Producer extends Thread {
// Omitted code
@Override
public void run() {
Random r = new Random();
while (true) {
synchronized (Main.listB) { ❶
synchronized (Main.listA) { ❷
work(r);
}
}
}
// Omitted code
}
❶ 外部同步块使用 listB 监视器。
❷ 内部同步块使用 listA 监视器。
图 10.1 显示了两个线程如何遇到死锁。

图 10.1 如果两个线程都进入了外部同步块,但没有进入内部块,它们将陷入停滞并相互等待。我们说它们进入了死锁。
10.1.1 使用分析工具获取线程转储
当我们有一个冻结的应用程序并且想要确定问题的根本原因时,我们该怎么办?在应用程序或其部分冻结的情况下,使用分析工具分析锁可能不起作用。与第九章中我们在执行过程中分析锁不同,我们将只对应用程序的线程状态进行快照。我们将读取这个快照(即线程转储),并找出哪些线程在相互影响,导致应用程序冻结。
您可以通过使用分析工具(例如,VisualVM、JProfiler)或通过使用命令行直接调用 JDK 提供的工具来获取线程转储。在本节中,我们将讨论如何使用分析工具获取线程转储,而在第 10.1.2 节中,我们将学习如何使用命令行获取相同的信息。
我们将启动我们的应用程序(项目 da-ch10-ex1),等待几秒钟,直到它进入死锁。当应用程序不再在控制台写入消息(它卡住了)时,您就会知道应用程序进入了死锁。
使用分析工具获取线程转储是一种简单的方法。这不需要更多的操作,只需点击一下按钮。让我们使用 VisualVM 来获取线程转储。图 10.2 显示了 VisualVM 界面。您可以看到 VisualVM 非常智能,已经发现我们进程的一些线程遇到了死锁。这在“线程”标签页中有显示。

图 10.2 当应用程序的一些线程进入死锁时,VisualVM 在“线程”标签页中用消息指示这种情况。请注意,_Consumer和_Producer两个线程都在图形时间线上被锁定。要获取线程转储,您只需在窗口右上角选择“线程转储”按钮。
在收集线程转储后,界面看起来像图 10.3。线程转储以纯文本形式表示,描述了应用程序线程并提供有关它们的详细信息(例如,它们在生命周期中的状态、谁阻塞了它们等)。
起初,你可能不理解图 10.3 中的线程转储文本。在本章的后面部分,你将学习如何阅读它。
![]()

图 10.3 显示了一个纯文本线程转储,描述了应用程序的线程。在我们收集的线程转储中,我们可以找到两个死锁线程 _Consumer 和 _Producer。
10.1.2 从命令行生成线程转储
线程转储也可以使用命令行获取。这种方法在需要从远程环境获取线程转储时特别有用。大多数情况下,你无法远程分析安装在环境中的应用程序(记住,在第四章中讨论过,在生产环境中不建议进行远程分析和远程调试)。由于在大多数情况下,你只能通过命令行访问远程环境,因此你也需要知道如何以这种方式获取线程转储。
幸运的是,使用命令行获取线程转储相当简单(图 10.4):
-
找到你想要获取线程转储的进程 ID。
-
将线程转储作为文本数据(原始数据)获取,并将其保存到文件中。
-
将保存的线程转储加载到分析工具中,以便更容易阅读。

图 10.4 使用命令行获取线程转储的三个简单步骤。首先,找到你想要获取线程转储的进程 ID。其次,使用 JDK 工具获取线程转储。最后,在分析工具中打开线程转储以读取它。
步骤 1:找到要调查的进程的进程 ID
到目前为止,我们已经通过其名称(表示为主类的名称)识别了我们想要分析的过程。但是,当使用命令行获取线程转储时,你需要通过其 ID 来识别进程。如何获取正在运行的 Java 应用的进程 ID(PID)?最简单的方法是使用 JDK 提供的jps工具。下面的代码片段显示了你需要运行的命令。我们使用-l(小写“L”)选项来获取与 PID 关联的主类名称。这样,我们可以以与第六章到第九章中学习分析应用程序执行相同的方式识别进程:
jps -l
图 10.5 显示了运行命令的结果。输出第一列中的数值是 PID。第二列将主类名称与每个 PID 关联。这样,我们得到了在步骤 2 中用于获取线程转储的 PID。

图 10.5 显示了使用 JDK 提供的jps工具获取正在运行的 Java 进程的 PID。这些 PID 是获取给定进程的线程转储所必需的。
步骤 2:收集线程转储
一旦您可以通过其 PID 识别出您想要收集线程转储的进程,您就可以使用 JDK 提供的另一个工具jstack来生成线程转储。当使用jstack时,您只需要提供一个进程 ID 作为参数(而不是<<PID>>,您需要使用在第 1 步中收集到的 PID 值):
jstack <<PID>>
这样的命令执行示例是
jstack 14208
图 10.6 显示了运行jstack命令后跟一个 PID 的结果。线程转储以纯文本形式提供,您可以将其保存到文件中以便移动或加载到工具中进行调查。

图 10.6 jstack命令后跟一个 PID 将为给定进程生成线程转储。线程转储以纯文本形式显示(也称为原始线程转储)。您可以将文本收集到文件中,以便稍后导入和调查。
第 3 步:将收集到的线程转储导入分析器以方便阅读
通常,您会将jstack命令的输出、线程转储保存到文件中。将线程转储存储在文件中允许您移动它、存储它或将其导入帮助您调查其详细信息的工具。
图 10.7 显示了您如何在命令行中将jstack命令的输出放入文件中。一旦您有了文件,您可以使用文件 > 加载菜单在 VisualVM 中加载它。

图 10.7 一旦将线程转储保存到文件中,您就可以使用各种工具来打开它进行调查。例如,要在 VisualVM 中打开它,请选择文件 > 加载。
10.2 读取线程转储
在本节中,我们将讨论读取线程转储。一旦收集到线程转储,您需要了解如何读取它以及如何有效地使用它来识别问题。我们将从讨论如何在第 10.2.1 节中读取纯文本线程转储开始——这意味着您将学习如何读取由jstack(见第 10.1.2 节)提供的原始数据。然后,在第 10.2.2 节中,我们将使用一个名为 fastThread([fastthread.io/](https://fastthread.io/))的工具,它提供了一种更简单的方式来可视化线程转储中的数据。
这两种方法(读取纯文本线程转储和使用高级可视化)都很有用。当然,我们总是更喜欢高级可视化,但如果您无法获得它,您需要了解如何依赖原始数据。
10.2.1 读取纯文本线程转储
当你收集线程转储时,你会得到线程的纯文本格式描述(即原始数据)。尽管我们有工具可以轻松地可视化这些数据(我们将在第 10.2.2 节中讨论),但我一直认为对于开发者来说,理解原始表示也很重要。你可能遇到无法从生成它的环境中移除原始线程转储的情况。比如说,你远程连接到一个容器,并且只能使用命令行来深入日志并调查运行中的应用程序发生了什么。你怀疑存在与线程相关的问题,因此你想生成线程转储。如果你能以文本形式读取线程转储,你需要的就只有控制台本身了。
让我们看看列表 10.3,它显示了线程转储中的一个线程。这不过是当转储被捕获时,在应用程序中活跃的每个线程的类似显示的详细信息。以下是关于线程的详细信息:
-
线程名称
-
线程 ID
-
原生线程 ID
-
操作系统级别的线程优先级
-
线程消耗的总时间和 CPU 时间
-
状态描述
-
状态名称
-
栈跟踪
-
谁阻塞了线程
-
线程所获取的锁
首先显示的是线程名称——在我们的例子中,"_Producer"。线程名称至关重要,因为它是你在需要时识别线程转储中线程的几种方式之一。JVM 还将线程与一个线程 ID(在列表 10.3 中,tid=0x000002f964987690)关联起来。由于开发者提供了名称,因此有些线程可能会得到相同的名称。如果这种不幸的情况发生,你仍然可以通过其 ID(总是唯一的)在转储中识别线程。
在 JVM 应用程序中,线程是系统线程的包装器,这意味着你总是可以识别在幕后运行的操作系统(OS)线程。如果你需要这样做,请寻找原生线程 ID(在列表 10.3 中,nid=0xcac)。
一旦你识别了一个线程,你就识别你感兴趣的详细信息。在线程转储中,你首先得到的是线程的优先级、CPU 执行时间和总执行时间。每个操作系统都会将其运行的每个线程关联到一个优先级。我很少在线程转储中使用这个值。但是,如果你看到某个线程的活跃度不如你想象的那么高,并且你看到操作系统将其指定为较低的优先级,那么这可能是原因。在这种情况下,总执行时间也会比 CPU 执行时间高得多。记住,从第七章中,总执行时间是线程存活的时间,而 CPU 执行时间是它工作得有多好。
状态描述 是一个宝贵的细节。它用简单的英语告诉您线程发生了什么。在我们的例子中,线程是“等待监视器进入”,这意味着它在同步块的入口处被阻塞。线程可能“在监视器上定时等待”,这意味着它在定义的时间内睡眠或正在运行。与状态描述相关联的是 状态名称(运行中、等待、阻塞等)。附录 D 提供了关于线程生命周期和线程状态的很好的复习资料,以防您需要。
线程转储为每个线程提供了一个 堆栈跟踪,显示了在转储时线程正在执行代码的哪个部分。堆栈跟踪非常有价值,因为它显示了线程正在做什么。您可以使用堆栈跟踪来找到您想要进一步调试的特定代码片段,或者在慢线程的情况下,确定导致该线程延迟或阻塞的具体原因。
最后,对于获取锁或被锁定的线程,我们可以找到 它们获取的锁 和 它们等待的锁。每次您调查死锁时都会使用这些细节。它们也可以为您提供优化提示。例如,如果您看到线程获取了许多锁,您可能会想知道为什么以及如何改变其行为,使其不会阻塞那么多的其他执行。
列表 10.3 线程转储中线程详细信息的结构
"_Producer" #16 prio=5 os_prio=0 cpu=46.88ms elapsed=763.96s ❶
➥ tid=0x000002f964987690 nid=0xcac waiting for monitor entry ❷
➥ [0x000000fe5ceff000]
java.lang.Thread.State: BLOCKED (on object monitor) ❸
at main.Producer.run(Unknown Source) ❹
- waiting to lock <0x000000052e0313f8> (a java.util.ArrayList) ❺
- locked <0x000000052e049d38> (a java.util.ArrayList) ❻
❶ 线程名称以及关于资源消耗和执行时间的详细信息
❷ 线程 ID 和状态描述
❸ 线程状态
❹ 线程堆栈跟踪
❺ 阻塞当前线程的锁 ID 和监视器对象类型
❻ 当前线程产生的锁的锁 ID
关于线程转储的一个重要事项是,它们提供的细节几乎与正常的锁分析(在第九章中讨论)一样多。锁分析相对于线程转储的优势在于,它显示了执行的动态性。就像图片和电影之间的区别一样,线程转储只是给定时间点的快照(在这里,是执行期间),而分析显示了参数在执行过程中的变化。但在许多情况下,一张图片就足够了,而且更容易获得。
有时使用线程转储而不是分析器就足够了。
![]()
如果您只需要知道在给定时间执行的代码是什么,线程转储就足够了。您已经学会了使用采样来达到这个目的,但了解线程转储也能做到这一点是很好的。比如说,您无法远程分析一个应用程序,但您需要找出幕后执行的代码。您可以获取线程转储。
现在,让我们关注如何通过线程转储找到线程之间的关系。我们如何分析线程之间相互交互的方式?我们特别感兴趣的是线程之间的锁定。在列表 10.4 中,我添加了两个已知处于死锁状态的线程的线程转储细节。但问题是,“如果我们事先不知道这些细节,我们如何找到它们处于死锁状态?”
如果你怀疑存在死锁,你应该专注于线程引起的锁(图 10.8):
-
筛选出所有未阻塞的线程,以便你可以专注于可能导致死锁的线程。
-
从第一个候选线程(你在第 1 步中没有筛选的线程)开始,搜索导致它被阻塞的锁 ID。
-
找到导致该锁的线程,并检查是什么阻止了该线程。如果你在某个时刻返回到你开始的线程,那么你解析的所有线程都处于死锁状态。

图 10.8 要通过线程转储找到死锁,请遵循这三个简单的步骤。首先,移除所有未阻塞的线程。然后,从一个阻塞线程开始,使用锁 ID 找到阻止它的原因。对每个线程重复此过程。如果你返回到你已经调查过的线程,这意味着你找到了死锁。
第 1 步:筛选出未锁定的线程
首先,筛选掉所有未锁定的线程,这样你就可以只关注你正在调查的情况——死锁——的潜在候选线程。线程转储可以描述数十个线程。你想要消除噪音,只关注那些被阻塞的线程。
第 2 步:找到第一个候选线程被阻止的原因
在消除不必要的线程细节后,从第一个候选线程开始,通过导致线程等待的锁 ID 进行搜索。锁 ID 是尖括号之间的一个(在列表 10.4 中,"_Producer"等待 ID 为0x000000052e0313f8的锁)。
第 3 步:找到阻止下一个线程的原因
重复此过程。如果你在某个时刻到达一个已经调查过的线程,这意味着你找到了死锁;请参阅以下列表。
列表 10.4 查找相互锁定的线程
"_Producer" #16 prio=5 os_prio=0 cpu=46.88ms
➥ elapsed=763.96s tid=0x000002f964987690
➥ nid=0xcac waiting for monitor entry [0x000000fe5ceff000]
java.lang.Thread.State: BLOCKED (on object monitor)
at main.Producer.run(Unknown Source)
- waiting to lock <0x000000052e0313f8>
➥ (a java.util.ArrayList)
- locked <0x000000052e049d38>
➥ (a java.util.ArrayList)
"_Consumer" #18 prio=5 os_prio=0 cpu=0.00ms
➥ elapsed=763.96s tid=0x000002f96498b030
➥ nid=0x4254 waiting for monitor entry [0x000000fe5cfff000]
java.lang.Thread.State: BLOCKED (on object monitor)
at main.Consumer.run(Unknown Source)
- waiting to lock <0x000000052e049d38> (a java.util.ArrayList) ❶
- locked <0x000000052e0313f8> (a java.util.ArrayList) ❷
❶ 消费者线程等待由生产者线程发起的锁。
❷ 生产者线程等待由消费者线程发起的锁。
我们的例子演示了一个简单的死锁,假设两个线程互相锁定。按照前面讨论的三步流程,你会看到"_Producer"线程阻塞了"_Consumer"线程,反之亦然。当涉及超过两个线程时,会发生复杂的死锁。例如,线程 A 阻塞线程 B,线程 B 阻塞线程 C,线程 C 又阻塞线程 A。你可以发现一条长链,其中线程互相锁定。死锁中线程链越长,找到、理解和解决死锁就越困难。图 10.9 显示了复杂死锁和简单死锁之间的区别。

图 10.9 当只有两个线程互相阻塞时,这被称为简单死锁,但死锁可能由多个互相阻塞的线程引起。线程越多,复杂性就越大。因此,当涉及超过两个线程时,这被称为复杂死锁。
有时复杂的死锁可能会与级联阻塞线程(图 10.10)混淆。级联阻塞线程(也称为级联锁定)是你可以通过线程转储发现的不同问题。要找到级联线程,遵循与调查死锁相同的步骤。但在锁的级联中,你将看到其中一个线程正在等待一个外部事件,这导致所有其他线程也等待。

图 10.10 当多个线程进入一个链式等待的链时,会出现级联锁定。链中的最后一个线程被外部事件(如从数据源读取或调用端点)阻塞。
级联阻塞线程通常表明多线程架构设计不当。当我们设计一个多线程的应用程序时,我们实现线程化以允许应用程序并发处理事物。线程互相等待会抵消多线程架构的目的。尽管有时你需要让线程互相等待,但你不应期望出现带有级联锁的长线程链。
10.2.2 使用工具更好地理解线程转储
阅读线程转储的纯文本原始表示是有用的,但有时可能相当困难。如果可能的话,大多数人更喜欢一种更简单的方式来可视化线程转储中的数据。今天,我们可以使用工具来帮助我们更容易地理解线程转储。尽可能的情况下,我会从收集线程转储的环境中移除它。我通常更喜欢使用 fastThread (fastthread.io)来调查转储,而不是处理原始数据。
fastThread 是一个旨在帮助您阅读线程转储的在线工具。它提供免费和付费计划,但免费计划一直足够满足我的需求。只需上传包含线程转储原始数据的文件,然后等待工具提取您需要的细节并将它们以更容易理解的形式呈现。图 10.11 显示了起始页面,您可以从系统中选择包含线程转储原始数据的文件并上传它以进行分析。

图 10.11 要分析线程转储,请将包含线程转储原始数据的文件上传到fastThread.io,并等待工具以简单易懂的形式呈现细节。
fastThread 的分析显示了线程转储的各种细节,包括死锁检测、依赖图、堆栈跟踪、资源消耗,甚至火焰图(图 10.12)。

图 10.12 fastThread 以易于阅读的格式提供了各种详细信息。这些细节包括死锁检测、依赖图、资源消耗和火焰图。
图 10.13 显示了 fastThread 如何识别我们的线程转储中的死锁。

图 10.13 在分析线程转储原始数据后,fastThread 识别并提供了由_Consumer和_Producer线程引起的死锁的详细信息。
摘要
-
当两个或多个线程在等待彼此时被阻塞,它们处于死锁状态。当一个应用程序进入死锁状态时,它通常会冻结,无法继续执行。
-
您可以使用线程转储识别死锁的根本原因,这些转储显示了在生成线程转储时应用程序所有线程的状态。这些信息使您能够找到哪个线程正在等待另一个线程。
-
线程转储还显示了有关资源消耗和每个线程的堆栈跟踪等详细信息。如果这些细节足够充分,您可以使用线程转储而不是仪器进行您的调查。想象一下线程转储和剖析之间的差异,就像图片和电影之间的差异一样。使用线程转储,您只有一张静态图片,因此您错过了执行动态,但您仍然可以获取大量相关且有用的细节。
-
线程转储提供了在转储时应用程序中正在执行的线程的信息。线程转储以纯文本格式显示了线程的详细信息,包括资源消耗、线程在其生命周期中的状态、线程是否正在等待某些事物,以及它引起的或受影响的锁。
-
您可以使用性能分析器或命令行来生成线程转储。使用性能分析工具获取线程转储是最简单的方法,但当您无法将性能分析器连接到正在运行的过程(例如,由于网络限制)时,您可以使用命令行来获取转储。线程转储将允许您调查正在运行的线程及其之间的关系。
-
纯文本线程转储(也称为原始线程转储)可能难以阅读。例如,fastThread.io 等工具可以帮助您可视化这些细节。
11 在应用程序执行中查找与内存相关的问题
本章涵盖
-
通过采样执行以查找内存分配问题
-
剖析代码的一部分以确定内存分配问题的根本原因
-
获取和读取堆转储
每个应用程序都会处理数据,为了完成这项工作,应用程序需要在处理数据的同时将其存储在某个地方。应用程序会分配系统内存的一部分来处理数据,但内存并不是无限的资源。系统上运行的所有应用程序共享系统提供的有限内存空间。如果一个应用程序没有明智地管理其分配的内存,它可能会耗尽内存,使其无法继续工作。即使应用程序没有耗尽内存,使用过多的内存也会使应用程序变慢,因此错误的内存分配可能会引起性能问题。
如果应用程序没有优化其在内存中的数据分配,它可能会运行得更慢。如果应用程序需要的内存超过了系统提供的内存,应用程序将停止工作并抛出错误。因此,不良内存管理的副作用是执行缓慢甚至整个应用程序崩溃。我们编写应用程序功能以最大限度地利用其分配的内存是至关重要的。
如果应用程序没有以优化的方式分配其处理的数据,它可能会迫使垃圾回收器更频繁地运行,因此应用程序将变得更加 CPU 消耗。
![]()
应用程序应该尽可能高效地管理其资源。当我们讨论应用程序的资源时,我们主要考虑 CPU(处理能力)和内存。在第七章到第十章中,我们讨论了如何调查 CPU 消耗问题。在本章中,我们将专注于识别应用程序在内存中分配数据方面的问题。
我们将在第 11.1 节中讨论执行采样和剖析内存使用统计,本章将开始讨论。您将学习如何确定应用程序是否有内存使用问题以及如何找到导致这些问题的应用程序部分。
然后,在第 11.2 节中,我们将讨论如何获取完整的转储(即堆转储)以分析其内容。在某些情况下,当应用程序完全因为错误的内存管理而崩溃时,您无法对执行进行剖析。但是,在问题出现时获取和分析应用程序分配内存的内容可以帮助您确定问题的根本原因。
在继续本章之前,您需要记住一些关于 Java 应用程序如何分配和使用内存的基本概念。如果您需要复习,附录 E 提供了您理解本章中思想所需的所有信息。
11.1 内存问题的采样和剖析
在本节中,我们使用一个小应用程序来模拟一个错误实现的、使用过多分配内存的功能。我们使用此应用程序来讨论你可以使用的调查技术,以识别内存分配问题或代码中可以优化以更有效地使用系统内存的地方。
假设你有一个真实的应用程序,并且你注意到某些功能运行缓慢。你使用我们在第六章中讨论的技术来分析资源消耗,并发现尽管应用程序并不经常“工作”(消耗 CPU 资源),但它使用了大量的内存。当应用程序使用过多内存时,JVM 可以触发垃圾收集器(GC),这将进一步消耗 CPU 资源。记住,GC 是自动从内存中释放不再需要的数据的机制(参见附录 E 以获取复习资料)。
查看图 11.1。在第六章讨论如何分析资源消耗时,我们使用了 VisualVM 的“监视器”选项卡来观察应用程序消耗了哪些资源。您可以使用此选项卡中的内存小部件来查找应用程序何时使用大量内存。

图 11.1 VisualVM 的“监视器”选项卡中的内存小部件可以帮助你确定应用程序在任意给定时间是否比平常消耗了更多的内存。通常,监视器选项卡中的小部件,如 CPU 和内存消耗,会给我们提供如何继续调查的线索。当我们看到应用程序消耗了异常大量的内存时,我们可能会决定继续进行内存分析。
本章中我们使用的应用程序位于项目 da-ch11-ex1 中。这个小型 Web 应用程序暴露了一个端点。当调用此端点时,我们提供一个数字,端点会创建相应数量的对象实例。我们基本上发送一个请求来创建一百万个对象(足够大的数字以供我们的实验使用),然后查看分析器关于此请求执行的信息。此端点执行模拟了在现实世界中,当某个应用程序能力消耗大量应用程序内存资源时会发生什么(图 11.2)。


图 11.2 当我们调用由提供的项目 da-ch11-ex1 暴露的端点时,应用程序创建了大量实例,消耗了应用程序相当一部分内存。我们将使用分析器分析此场景。
要启动项目,请按照以下步骤操作:
-
启动项目 da-ch11-ex1。
-
启动 VisualVM。
-
在 VisualVM 中选择项目 da-ch11-ex1 的进程。
-
前往 VisualVM 的“监视器”选项卡。
-
调用
/products/1000000` 端点。 -
在 VisualVM 的“内存”选项卡中观察内存小部件。
在“监视器”选项卡中的内存小部件中,你可以看到应用程序使用了大量的内存资源。小部件看起来类似于图 11.1。当我们怀疑某些应用程序能力没有最佳地使用内存资源时,我们应该怎么做?调查过程遵循两个主要步骤:
-
使用内存采样来获取应用程序存储的对象实例的详细信息。
-
使用内存分析(仪表化)来获取执行中代码特定部分的额外详细信息。
让我们遵循在第七章到第九章中学到的相同方法来分析 CPU 资源消耗:使用采样来获取发生情况的高级视图。为了对应用程序执行进行内存使用采样,请选择 VisualVM 中的“采样器”选项卡。然后选择“内存”按钮以启动内存使用采样会话。调用端点并等待执行结束。VisualVM 屏幕将显示应用程序分配的对象。
我们在寻找占用最多内存的内容。在大多数情况下,这将是以下两种情况之一:
-
许多特定类型的对象实例被创建并填满了内存(这就是我们场景中发生的情况)。
-
某些类型的实例不多,但每个实例都非常大。
许多实例填满分配的内存是有意义的,但少数实例是如何做到这一点的呢?想象一下这个场景:你的应用程序处理大视频文件。应用程序一次可能加载两三个文件,但由于它们很大,它们填满了分配的内存。开发者可以分析是否可以优化这种能力。也许应用程序不需要一次性将整个文件加载到内存中,而只需要加载它们的一部分。
当我们开始调查时,我们不知道会陷入哪种场景。我通常会按内存占用量降序排列,然后按实例数量排列。注意图 11.3 中,VisualVM 显示了每种采样类型的内存占用量和实例数量。您需要按表格中的第二列和第三列降序排列。
在图 11.3 中,你可以清楚地看到我按“活动字节”(占用空间)降序排列了表格。然后我们可以查找我们应用程序代码库中出现在表格中的第一个类型。不要寻找原始数据类型、字符串、原始数据类型的数组或字符串数组。这些通常位于顶部,因为它们作为副作用被创建。然而,在大多数情况下,它们不会提供任何关于问题的线索。

图 11.3 我们按内存占用量降序排列采样结果。这样,我们可以看到哪些对象消耗了大部分内存。我们通常不会寻找原始数据类型、字符串、字符串数组或通常的 JDK 对象。我们主要感兴趣的是找到与我们的代码库直接相关的对象,它是导致问题的原因。在这种情况下,Product类型(它是我们代码库的一部分)占用了大量内存。
在图 11.3 中,我们可以清楚地看到类型Product引起了问题。它占用了分配内存的大部分,在“活动对象”列中,我们看到应用程序创建了该类型的一百万个实例。
分析工具将它们命名为“活动对象”,因为采样只显示你内存中仍然存在的实例。
![]()
如果您需要在整个执行过程中创建的类型实例总数,您必须使用剖析(仪器化)技术。我们将在本章后面进行此操作。
这个应用只是一个示例,但在现实世界的应用中,仅仅按占用空间排序可能不够。我们需要弄清楚问题是不是由于实例数量过多,或者每个实例是否占用了大量空间。我知道你在想什么:在这种情况下不是很明显吗?是的,但在现实世界的应用中可能不是这样,所以我总是建议开发者也按实例数量降序排序以确保。

图 11.4 我们可以按实例数量(活动对象)对采样结果进行排序。这让我们可以了解某些功能是否创建了大量的对象,这些对象对内存分配产生了负面影响。
有时采样就足以帮助您识别问题。但如果您无法通过采样执行来找出创建这些对象的应用部分呢?当您仅通过采样执行无法找到问题时,您的下一步是进行剖析(仪器化)。剖析提供了更多细节,包括代码的哪个部分创建了可能有问题实例。但请记住经验法则:当您使用剖析时,您需要首先知道要剖析什么。这就是为什么我们总是从采样开始。
由于我们知道问题是出在Product类型上,我们将对其进行分析。就像在第七章到第九章中所做的那样,您必须使用表达式指定您想要剖析的应用部分。在图 11.5 中,我仅对Product类型进行了剖析。我通过在窗口右侧的内存设置文本框中使用该类的完全限定名(包和类名)来完成此操作。

图 11.5 要对内存分配进行剖析,首先指定您想要剖析的包或类,然后通过按内存按钮开始剖析。剖析器将为您提供有关剖析类型的相关细节,包括使用的内存、实例数量、分配对象的总数以及 GC 代数。
就像第八章中 CPU 剖析的情况一样,您可以一次对更多类型进行剖析,甚至可以指定整个包。以下是一些最常用的表达式:
-
严格类型,完全限定名(例如,
com.example.model.Product)—仅搜索该特定类型 -
给定包中的类型(例如,
com.example.model.*)—仅搜索在包com.example.model中声明的类型,但不包括其子包 -
给定包及其子包中的类型(例如,
com.example.**)—在指定的包及其所有子包中进行搜索
总是记住尽可能限制您要分析的类型。如果您知道 Product 导致了问题,那么只分析这个类型是有意义的。
![]()
除了仍在内存中存在的活动对象(即该类型的实例)之外,您还将获得应用程序创建的该类型实例的总数。此外,您还将看到这些实例“存活”了 GC(我们称之为代数)多少次。
这些细节很有价值,但找到创建对象的代码部分通常更有用。如图 11.6 所示,对于每个分析类型,工具显示实例是在哪里创建的。点击表格中该行左侧的加号(+)。此功能可以快速显示问题的根本原因。

图 11.6 分析器显示了创建每个分析类型实例的代码的堆栈跟踪。这样,您可以轻松地识别出应用程序的哪个部分创建了有问题的实例。
11.2 使用堆转储查找内存泄漏
如果应用程序正在运行,您可以分析以识别任何可以优化的功能。但假设应用程序崩溃,并且您怀疑这是由于内存分配问题导致的?在大多数情况下,应用程序崩溃是由具有内存分配问题的功能(如内存泄漏)引起的——应用程序在不需要时不会在内存中释放它创建的对象。由于内存不是无限的,持续分配对象最终会填满内存,导致应用程序崩溃。在 JVM 应用程序中,这会在运行时通过抛出OutOfMemoryError来表示。
如果应用程序没有运行,您无法附加分析器来调查执行情况。但即便如此,您还有其他替代方案来调查问题。您可以使用堆转储,这是应用程序崩溃时堆内存的快照。尽管您可以在任何时候收集堆转储,但它最有用的时候是您无法因为某些原因分析应用程序——可能是因为应用程序崩溃,或者您根本无法访问分析进程,而您想确定它是否遭受了任何内存分配问题。
在下一节中,我们将讨论三种获取堆转储的可能方法,在 11.2.2 节中,我将向您展示如何使用堆转储来识别内存分配问题和它们的根本原因。在 11.2.3 节中,我们将讨论使用一种称为对象查询语言(OQL)的查询语言读取堆转储的更高级方法。OQL 类似于 SQL,但您不是查询数据库,而是使用 OQL 查询堆转储中的数据。
11.2.1 获取堆转储
在本节中,我们将讨论三种生成堆转储的方法:
-
配置应用程序,在应用程序因内存问题崩溃时自动在指定位置生成堆转储。
-
使用分析工具(如 VisualVM)。
-
使用命令行工具(如
jcmd或jmap)。
您甚至可以以编程方式获取堆转储。一些框架具有生成堆转储的能力,这允许开发者集成应用程序监控工具。要了解更多关于这个主题的信息,请参阅 Java 官方 API 文档中的 HotSpotDiagnosticMXBean 类(mng.bz/19XZ)。
项目 da-ch11-ex1 实现了一个端点,您可以使用 HotSpotDiagnosticMXBean 类生成堆转储。使用 cURL 或 Postman 调用此端点将创建转储文件:
curl http://localhost:8080/jmx/heapDump?file=dump.hprof
配置应用程序在遇到内存问题时生成堆转储
开发者经常使用堆转储来调查应用程序崩溃,当他们怀疑错误的内存分配导致问题时。因此,应用程序通常配置为在应用程序崩溃时生成内存外观的堆转储。您应该始终配置应用程序在由于内存分配问题而停止时生成堆转储。幸运的是,配置很简单。您只需在应用程序启动时添加几个 JVM 参数:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=heapdump.bin
第一个参数 -XX:+HeapDumpOnOutOfMemoryError 告诉应用程序在遇到 OutOfMemoryError(堆已满)时生成堆转储。第二个参数 XX:HeapDumpPath=heapdump.bin 指定了在文件系统中存储转储的路径。在这种情况下,包含堆转储的文件将命名为 heapdump.bin,并将位于可执行应用程序附近,从 classpath 的根目录(因为我们使用了相对路径)。确保进程具有在此路径上的“写入”权限,以便能够将文件存储在指定位置。
以下代码片段显示了运行应用程序的完整命令:
java -jar -XX:+HeapDumpOnOutOfMemoryError
➥ -XX:HeapDumpPath=heapdump.bin app.jar
我们将使用名为 da-ch11-ex2 的演示应用程序来演示这种方法。您可以在本书提供的项目中找到此应用程序。以下列表中的应用程序会持续向列表中添加 Product 类型的实例,直到内存填满。
列表 11.1 生成大量无法释放的实例
public class Main {
private static List<Product> products = new ArrayList<>();
public static void main(String[] args) {
Random r = new Random();
while (true) { ❶
Product p = new Product();
p.setName("Product " + r.nextInt());
products.add(p); ❷
}
}
}
❶ 循环无限迭代。
❷ 向列表中添加实例,直到内存填满
以下代码片段显示了简单的 Product 类型的外观:
public class Product {
private String name;
// Omitted getters and setters
}
也许您想知道为什么产品实例有一个随机名称。我们将在 11.2.2 节中讨论读取堆转储时需要它。目前,我们只对如何生成堆转储以找出为什么这个应用程序在几秒钟内填满其堆内存感兴趣。

图 11.7 您可以从您的 IDE 中配置 JVM 参数。在启动应用程序之前,在运行/调试配置中添加这些值。
您可以使用 IDE 运行应用程序并设置参数。图 11.7 展示了如何在 IntelliJ 中设置 JVM 参数。我还添加了 -Xmx 参数来限制应用程序的堆内存仅为 100 MB。这将使堆转储文件更小,并且我们的示例更容易理解。
当你运行应用时,稍等片刻,应用将会崩溃。只有 100 MB 的堆空间,内存不应该超过几秒钟就满了。项目文件夹中包含一个名为 heapdump.bin 的文件,其中包含应用停止时的堆中所有数据的详细信息。你可以使用 VisualVM 打开此文件进行分析,如图 11.8 所示。

图 11.8 你可以使用 VisualVM 打开堆转储文件进行分析。使用菜单中的“加载”按钮来查找文件。打开文件,VisualVM 将显示堆转储作为一个标签页。
使用分析器获取堆转储
有时你需要获取正在运行进程的堆转储。在这种情况下,最简单的解决方案是使用 VisualVM(或类似的分析工具)来生成转储。使用 VisualVM 获取堆转储就像点击一个按钮一样简单。只需在监视器标签页中使用堆转储按钮,如图 11.9 所示。

图 11.9 在 VisualVM 的监视器标签页中按下“堆转储”按钮以获取所选进程的堆转储。VisualVM 将转储作为标签页打开,你可以进一步调查它或将其保存到任何你想要的位置。
使用命令行获取堆转储
如果你需要获取正在运行进程的堆转储,但你的应用部署在一个你无法连接分析器的环境中,不要慌张;你仍然有选择。你可以使用 JDK 提供的命令行工具 jmap 来生成堆转储。
使用 jmap 收集堆转储有两个步骤:
-
找到你想获取堆转储的正在运行的应用的进程 ID (PID)。
-
使用
jmap将转储保存到文件中。
要找到正在运行的进程的 PID,你可以使用 jps,就像我们在第十章中做的那样:
jps -l
25320 main.Main
132 jdk.jcmd/sun.tools.jps.Jps
25700 org.jetbrains.jps.cmdline.Launcher
第二步是使用 jmap。要调用 jmap,需要指定进程 ID (PID) 和堆转储文件将要保存的位置。你还必须使用 -dump:format=b 参数指定输出为二进制文件。图 11.10 展示了在命令行中使用此工具的方法。

图 11.10 在命令行中使用 jmap 获取堆转储。你需要指定包含转储的文件的路径以及为你生成转储的进程 ID。该工具将堆转储作为二进制文件保存在请求的位置。
复制以下代码以方便使用命令:
jmap -dump:format=b,file=C:/DA/heapdump.bin 25320
现在,你可以打开使用 jmap 保存的文件,在 VisualVM 中进行调查。
11.2.2 读取堆转储
在本节中,我们将重点关注使用堆转储来调查内存分配问题。堆转储就像是在转储生成时的内存的“图片”。它包含应用在堆中所有的数据,这意味着你可以用它来检查数据和它的结构方式。这样,你可以确定哪些对象占据了分配内存的大部分,并理解为什么应用无法释放它们。
记住,在“图片”(堆转储)中你可以看到一切。如果未加密的密码或任何类型的私人数据在内存中,拥有堆转储的人将能够获取这些详细信息。
![]()
与线程转储不同,你不能将堆转储作为纯文本进行分析。相反,你必须使用 VisualVM(或任何通用的性能分析工具)。在本节中,我们将使用 VisualVM 分析我们在 11.2.1 节中为项目 da-ch11-ex2 生成的堆转储。你将学会利用这种方法来找到 OutOfMemoryError 的根本原因。
当你在 VisualVM 中打开堆转储时,性能分析工具会显示堆转储的摘要视图(图 11.11),它提供了有关堆转储文件的快速详细信息(例如,文件大小、类总数、转储中的实例总数)。你可以使用这些信息来确保你有正确的转储,以防你不是提取转储的人。

图 11.11 在打开堆转储后的初始屏幕中,VisualVM 提供了堆转储的摘要,其中包括有关转储本身以及应用程序运行的系统信息。视图还显示了占用最大内存量的类型。
有时候我不得不调查支持团队从应用程序运行的环境访问的堆转储。然而,我自己无法访问这些环境,所以我必须依赖别人为我获取数据。不止一次,我惊讶地发现我得到了错误的堆转储。我通过查看转储的大小并将其与我知道的进程配置的最大值进行比较,或者通过查看操作系统或 Java 版本来识别错误。
我的建议是首先快速检查摘要页面,确保你有正确的文件。在摘要页面,你还会找到占用大量空间的类型。我通常不依赖这个摘要,而是直接转到对象视图,在那里我开始我的调查。在大多数情况下,摘要对我来说不足以得出结论。
要切换到对象视图,请从堆转储标签页左上角的下拉菜单中选择对象(图 11.12)。这将允许你调查堆转储中的对象实例。

图 11.12 你可以切换到对象视图,这使得调查堆转储中的实例更容易。
就像内存采样和性能分析一样,我们正在寻找占用最多内存的类型。最佳方法是按实例和占用内存量降序排序,并查找属于应用程序代码库的第一种类型。不要寻找诸如原始数据类型、字符串或原始数据类型和字符串数组之类的类型。通常有很多这样的类型,而且它们不会给你很多关于错误的线索。
在图 11.13 中,您可以看到,经过排序后,Product类型似乎与问题有关。Product类型是应用程序代码库中第一个类型,它使用了大量的内存。我们需要弄清楚为什么创建了这么多实例,以及为什么垃圾收集器不能从内存中删除它们。

图 11.13 使用列排序来识别哪种类型创建了大量的实例或占用了大量的空间。始终在您的应用程序代码库中寻找第一个对象。在这种情况下,无论是实例数量还是大小,Product类型都是列表中的第一个。
您可以通过选择行左侧的小加号(+)来获取该类型所有实例的详细信息。我们已经知道有超过一百万个Product实例,但我们仍然需要找到
-
代码的哪一部分创建了这些实例
-
为什么垃圾收集器不能及时删除它们以避免应用程序失败
您可以找到每个实例所引用的内容(通过字段)以及什么引用了这个实例。由于我们知道垃圾收集器只有在没有引用者的情况下才能从内存中删除实例,因此我们寻找引用实例的内容,以查看它是否仍在处理上下文中需要,或者应用程序是否忘记了删除其引用。
图 11.14 显示了Product实例细节的扩展视图。我们可以看到该实例引用了一个String(产品名称),并且其引用保存在一个Object数组中,该数组是ArrayList实例的一部分。此外,ArrayList实例似乎保存了大量的引用(超过一百万个)。这通常不是一个好兆头,因为要么应用程序实现了未优化的功能,要么我们发现了内存泄漏。

图 11.14 实例的引用。通过使用堆转储,您可以在每个实例中找到在转储生成时被引用的其他实例。分析工具还告诉您给定引用在代码中的存储位置。在这种情况下,ArrayList,它保存了超过一百万个引用,是Main类中的一个静态变量。
要了解哪种情况,我们需要使用我们在第二章到第五章中讨论的调试和日志技术来调查代码。幸运的是,分析器会告诉你如何在代码中找到这个列表。在我们的例子中,这个列表是在Main类中声明为一个静态变量。
使用 VisualVM,我们可以轻松理解对象之间的关系。通过结合本书中学习到的其他调查技术,您拥有了处理这类问题的所有工具。复杂的问题(和应用程序)可能仍然需要大量的努力,但使用这种方法将为您节省大量时间。
11.2.3 使用 OQL 控制台查询堆转储
在本节中,我们将讨论调查堆转储的更高级方法。我们使用类似于 SQL 的查询语言从堆转储中检索详细信息。我们在 11.2.2 节中讨论的简单方法通常足以识别内存分配问题的根本原因。但当我们需要比较两个或更多堆转储的详细信息时,它们就不够了。
假设你想要比较为应用的两个或更多版本提供的堆转储,以确定在版本发布之间是否实现了有缺陷或不优化的功能。你可以逐个手动调查它们。但我会教你如何编写可以在每个堆转储上轻松运行的查询,这将为你节省时间。这就是 OQL 是一种优秀方法的地方。图 11.15 展示了如何将视图切换到 OQL 控制台,在那里你可以运行查询以调查堆转储。

图 11.15 要在 VisualVM 中切换到 OQL 视图,请从堆转储标签页左上角的下拉菜单中选择 OQL 控制台。
我们将讨论一些我认为最有用的例子,但请记住,OQL 更复杂。(你可以在 mng.bz/Pod2 上找到更多关于其功能的信息。)
让我们从简单的一个开始:选择给定类型的所有实例。比如说,我们想要从堆转储中获取所有 Product 类型的实例。要使用 SQL 查询从关系数据库中的表中获取所有产品记录,我们会编写类似这样的代码:
select * from product
要使用 OQL 查询堆转储中的所有 Product 实例,你需要编写如下代码:
select p from model.Product p
注意:对于 OQL,关键字如“select”、“from”或“where”始终以小写形式书写。类型总是用它们的完全限定名称(包+类名)给出。
![]()

图 11.16 使用 VisualVM 运行 OQL 查询。在 OQL 控制台中,在窗口底部的文本框中编写 OQL 查询,然后单击运行按钮(文本框左侧的绿色箭头)以运行查询。结果将显示在文本框上方。
图 11.16 展示了执行从堆转储中检索所有 Product 实例的简单查询结果。
注意:在学习 OQL 时,请使用小堆转储。现实世界的堆转储通常很大(4 GB 或更大)。OQL 查询会变慢。如果你只是在学习,请生成并使用我们在本章中使用的类似的小尺寸堆转储。
![]()
你可以选择任何查询实例以获取其详细信息。你可以找到保持对该实例引用的内容,该实例引用的内容,以及其值(图 11.17)。

图 11.17 你可以通过单击来访问查询实例的详细信息(引用者和被引用者)。
你还可以选择从某些实例引用的值或引用。例如,如果我们想获取所有产品名称而不是产品实例,我们可以编写以下查询(图 11.18):
select p.name from model.Product p

图 11.18:选择给定对象类型的属性。就像在 Java 中一样,你可以使用标准的点操作符来引用实例的属性。
使用 OQL,你可以同时提取多个值。为此,你需要将它们格式化为 JSON,如下一列表所示。
列表 11.2:使用 JSON 投影
select
{ ❶
name: p.name, ❷
name_length: p.name.value.length ❸
}
from model.Product p
❶ 大括号包围了 JSON 对象表示
❷ 属性名称取产品名称的值。
❸ 属性名称 _length 取产品名称中字符数的值。
图 11.19 显示了运行此查询的结果。

图 11.19:选择多个值。你可以使用 JSON 格式化在一个查询中获取多个值。
你可以将此查询修改为,例如,添加一个或多个选定值的条件。假设你只想选择名称长度超过 15 个字符的实例。你可以编写如下查询的片段:
select { name: p.name, name_length: p.name.value.length}
from model.Product p
where p.name.value.length > 15
让我们继续探讨一些稍微高级一点的内容。在调查内存问题时,我经常使用referrers()方法来获取指向特定类型实例的对象。通过使用此类内置 OQL 函数,你可以做很多有用的事情:
-
查找或查询实例引用者——可以告诉你应用是否有内存泄漏
-
查找或查询实例引用——可以告诉你特定实例是否是内存泄漏的原因
-
在实例中查找重复项——可以告诉你是否可以将特定功能优化以使用更少的内存
-
查找某些实例的子类和超类——在不查看源代码的情况下,让你了解应用的类设计
-
识别长生命周期路径——可以帮助你识别内存泄漏
要获取类型Product的所有唯一引用,你可以使用以下查询:
select unique(referrers(p)) from model.Product p
图 11.20 显示了运行此查询的结果。在这种情况下,我们可以看到所有产品实例都被一个对象——列表所引用。通常,当大量实例只有少量引用时,这是一个内存泄漏的迹象。在我们的例子中,列表保留了所有Product实例的引用,阻止 GC 从内存中删除它们。

图 11.20:选择特定类型的所有唯一引用者,这可以显示是否有一个对象阻止 GC 从内存中删除实例。这可以是一个快速识别内存泄漏的方法。
如果结果不唯一,你可以使用以下查询通过实例计数引用,以找到可能涉及内存泄漏的实例:
select { product: p.name, count: count(referrers(p))} from model.Product p
OQL 查询提供了很多机会,一旦您编写了一个查询,您就可以根据需要多次运行它,并在不同的堆转储上运行。
摘要
-
一个未针对内存分配进行优化的应用可能会引起性能问题。优化应用以智能地分配(避免浪费不必要的内存空间)内存中的数据对于应用性能至关重要。
-
性能分析工具允许您在应用执行期间采样和记录内存的使用情况。这可以帮助您识别应用中未优化的部分,并提供有关可以改进的详细信息。
-
如果在执行期间不断向内存中添加新的对象实例,但应用从未删除对新实例的引用,垃圾回收器将无法删除引用并释放内存。当内存完全被占用时,应用无法继续执行并停止。在停止之前,应用会抛出
OutOfMemoryError。 -
要调查
OutOfMemoryError,我们使用堆转储。堆转储收集应用堆内存中的所有数据,并允许您分析它以找出问题所在。 -
您可以使用几个 JVM 参数启动应用,指示它在
OutOfMemoryError失败时在指定路径生成堆转储。 -
您也可以通过使用性能分析工具或如
jmap之类的命令行工具来获取堆转储。 -
要分析堆转储,请将其加载到 VisualVM 等性能分析工具中,这允许您调查转储中的实例及其关系。这样,您可以找出应用中哪些部分未优化或存在内存泄漏。
-
VisualVM 提供了分析堆转储的更高级方法,例如 OQL 查询。OQL 是一种类似于 SQL 的查询语言,您可以使用它从堆转储中检索数据。
第三部分:在大系统中寻找问题
今天,大多数应用程序都是大型系统的一部分,这些系统使用各种架构风格。在这样的系统中,有时很难找到问题,评估单个进程是不够的。想象一下,你有一辆汽车,你有方法分析它的独立部分,但没有方法来评估这些部分之间的相互作用。你能否找到汽车可能存在的所有问题?
在第三部分,我们将讨论适用于调查由多个应用程序组成的系统的技术。我们将重点关注调查这些应用程序之间“交流”的方式,它们在部署的环境中受到的影响,以及实施系统时需要考虑的因素。
12 调查大型系统中的应用程序行为
本章涵盖
-
调查应用程序通信问题
-
在您的系统中使用日志监控工具
-
利用部署工具
在本章中,我们将超越单个应用程序的边界,讨论如何调查由系统中的应用程序协同工作引起的情况。如今,许多系统由多个相互通信的应用程序组成。大型商业系统利用各种应用程序,并且它们通常使用不同的技术和平台实现。在许多情况下,这些应用程序的成熟度也各不相同,从新服务到旧的和混乱的脚本。
调试、性能分析和日志记录并不总是足够。有时您需要找到更大的线索。一个应用程序可以独立工作得很好,但不能正确地与其他应用程序或其部署的环境集成。
我们将从 12.1 节开始,介绍调查系统服务之间通信的方法。在 12.2 节中,我们将关注在系统中实施应用程序监控的相关性以及如何使用监控工具提供的信息。我们将在 12.3 节结束本章的讨论,我们将讨论如何利用部署工具。
12.1 调查服务之间的通信
在本节中,我们讨论调查应用程序之间的通信。在一个系统中,应用程序“交谈”以履行其职责。到目前为止,我们一直专注于调查应用程序的内部工作原理以及应用程序与数据库管理系统之间的通信。但对于相互交谈的应用程序呢?是否有方法来监控由许多应用程序组成的整个系统中的事件(图 12.1)?

图 12.1 在许多情况下,调查停留在应用程序的边界内。但您可能需要超越给定进程内部发生的事情。问题或异常行为可能是由存在通信问题的问题应用程序引起的,实现监控工具将使调查这类问题更容易。
让我们讨论如何使用性能分析工具来调查应用程序之间“交流”的问题。我们将使用 JProfiler 来观察一个简单应用程序(项目 da-ch12-ex1)的通信,以暴露一个您可以调用的端点(/demo端点)。当您向此端点发送 HTTP 请求时,应用程序会向由httpbin.org提供的端点发送请求,该端点延迟 5 秒后以 200 OK HTTP 状态响应。
正如您在本节中将学到的,JProfiler 提供了一套您可以用来观察应用程序接收到的请求和发送的请求的工具。此外,您还可以调查套接字上的低级通信事件。这些方法可以帮助您确定通信问题的根本原因。
在 12.1.1 节中,我们将使用 JProfiler 观察应用程序接收到的请求。在 12.1.2 节中,我们将调查应用程序发送的请求的详细信息,而在 12.1.3 节中,我们将专注于调查套接字上的低级通信事件。
微服务
让我们坦率地谈谈微服务。你将要工作的许多系统声称它们是微服务。大多数情况下这并不真实;它们只是面向服务的架构。微服务已经(出于某种原因,我无法完全理解)成为了一个卖得相当好的品牌:
-
你想要更快地雇佣某人吗?告诉他们他们将使用微服务工作。
-
你想在销售前会议中给客户留下深刻印象吗?告诉他们你做微服务。
-
你想要更多的人参加你的演示吗?没错:只需在标题中添加微服务。
但微服务比我认为的大多数开发者理解的要复杂。如果你想要更好地理解微服务是什么,你会在那里找到大量的文献。你可以从 Chris Richardson 的《Microservices Patterns》(Manning, 2018)开始,然后阅读 Sam Newman 的《Monolith to Microservices》(O’Reilly Media, 2018)或《Building Microservices: Designing Fine-Grained Systems》,第二版(O’Relly Media, 2021),也是 Sam Newman 的作品。
无论它们是否是真正的微服务系统,你仍然需要知道如何调查问题和如何快速理解在给定场景下系统做了什么。在本章中,我们将讨论适用于微服务的调查技术,但不仅限于微服务。我更喜欢使用简单的术语服务而不是微服务。有时我会直接使用应用程序或应用。
12.1.1 使用 HTTP 服务器探针观察 HTTP 请求
当两个应用程序进行通信时,数据流向两个方向。一个应用程序要么发送请求,要么接收请求。当一个应用程序发送请求时,我们称其为客户端;当它接收请求时,我们称其为服务器。在本节中,我们专注于应用程序作为服务器接收的 HTTP 请求。我们将使用书中提供的简单应用程序(项目 da-ch12-ex1)来了解如何使用 JProfiler 监控此类事件。
在你的 IDE 中打开项目 da-ch12-ex1 并启动应用程序。使用 JProfiler 连接到应用程序,并开始记录发送到 HTTP 服务器>事件的接收到的 HTTP 请求,然后按星形图标记录单个事件。图 12.2 显示了如何开始记录事件。我们想要了解应用程序接收到的 HTTP 请求以及这些请求可以提供的信息。

图 12.2 要使用 JProfiler 开始记录接收到的 HTTP 请求,请转到 HTTP 服务器>事件,然后按星形图标记录单个事件。现在,每当被分析的应用程序接收 HTTP 请求时,JProfiler 将显示详细信息。
让我们将这个演示应用程序暴露的唯一端点称为:
curl http://localhost:8080/demo
如图 12.3 所示,JProfiler 显示了服务器接收到的请求。首先,你可以轻松地确定事件何时结束:显示的状态将是“完成”。如果操作永远不会结束,这意味着由于某种原因请求没有完全处理,或者响应没有发送回客户端,状态将是“进行中”。这样,你可以确定请求是否耗时过长,或者是否由于某些原因而延迟或中断。
HTTP 服务器事件表还显示了事件持续时间。如果事件已完成但耗时较长,你需要确定导致延迟的原因。可能是由于通信故障,你将使用第 12.1.3 节中讨论的套接字事件来观察,或者你可能需要像第七章到第九章中讨论的那样采样和性能分析。
也很重要的是要看到应用接收了多少事件。在某些情况下,一个请求不会引起麻烦,但我记得有一个情况是一个应用受到了其中一个客户端轮询(在短时间内重复发送请求)其中一个端点的影响。如果客户端在短时间内发送大量请求,并且没有任何阻止它们到达应用的因素,应用可能难以响应所有请求,甚至崩溃。

图 12.3 开始记录 HTTP 服务器事件(应用接收到的 HTTP 请求)后,性能分析工具会在页面上显示所有接收事件的详细信息。你可以轻松地看到事件是否结束以及完成所需的时间。
12.1.2 使用 HTTP 客户端探针观察应用发送的 HTTP 请求
与 HTTP 服务器事件(应用接收到的 HTTP 请求)类似,你可以对 HTTP 客户端事件(应用发送的 HTTP 请求)进行性能分析。在本节中,我们将讨论分析应用发送的 HTTP 请求以识别它们可能引起的问题。为此,我们将继续使用项目 da-ch12-ex1 中提供的应用,与我们在 12.1.1 节中使用的是同一个应用。当你调用它公开的/demo端点时,该应用会向 httpbin.org 的端点发送请求。让我们启动应用,调用/demo端点,并找出我们是否可以观察到这个应用发送的 HTTP 请求。
启动应用后,开始在 JProfiler 中记录 HTTP 客户端事件(图 12.4),并调用/demo端点:
curl http://localhost:8080/demo

图 12.4 在 JProfiler 中,你可以记录应用发送的所有 HTTP 请求,无论它们是如何发送的(应用使用的技术),它显示了诸如持续时间、状态码、调用的 HTTP 方法和 URI 以及是否遇到异常等详细信息——这些都是调查涉及应用发送的 HTTP 请求的具体场景时的有用信息。
查看此工具提供的信息(图 12.4)。您首先感兴趣的可能是描述和方法列,因为它们有助于您识别应用程序调用的端点。一旦您知道了这一点,提供最多洞察力的细节是调用持续时间、响应状态码以及是否遇到了异常。
如果您发现某个调用执行时间过长(超过您的预期),您可能需要找出原因。首先,尝试确定问题是由数据交换(通过网络)还是应用程序内部(例如,反序列化响应或处理它)引起的。
正如您将在第 12.1.3 节中看到的那样,调查套接字上的底层事件可以告诉您问题是否是通信本身,或者您是否应该查看应用程序的某些操作。如果您发现数据交换不是问题的原因,您可以将我们在第七章到第九章中讨论的配置文件技术应用于发现影响应用程序执行性能的因素。
正如在第 12.1.1 节中讨论的 HTTP 请求一样,考虑应用程序发送的 HTTP 请求的事件计数(事件表中出现多少行)非常重要。您的应用程序是否发送了过多的请求,导致其他服务响应较慢?在我之前实施的一个应用程序中,我发现应用程序由于一个错误的重试机制而频繁发送请求。由于请求是为了检索一些数据,并没有改变任何东西或导致错误输出,因此问题一开始很难被发现。在这种情况下,补充请求只会影响应用程序的性能。
12.1.3 调查套接字上的底层事件
在本节中,我们讨论调查套接字上的底层通信事件,以查看通信问题是由通信通道(例如,网络)引起的,还是由应用程序内部的故障引起的。要观察这些底层事件,您可以使用 JProfiler:转到“套接字 > 事件”部分。
启动应用程序,开始在 JProfiler 中注册事件,然后向/demo端点发送请求:
curl http://localhost:8080/demo
JProfiler 拦截套接字上的所有事件,并将它们以表格形式显示,如图 12.5 所示。

图 12.5 任何应用程序通过网络层交换的消息都使用底层的套接字。您可以使用 JProfiler 之类的分析工具来观察套接字级别的所有底层事件。要监控这些事件,请使用“套接字 > 事件”部分。这些事件可以帮助您了解应用程序是否面临网络问题,或者它是否只是没有正确管理通信。
套接字是应用程序与其通信的另一个进程的网关。在建立通信时,应用程序将执行以下套接字事件(图 12.6):
-
打开套接字以建立通信(与需要与之通信的应用程序进行握手)。
-
从套接字读取(接收数据)或通过它写入(发送数据)。
-
关闭套接字。

图 12.6 当应用程序开始数据交换时,它首先打开一个套接字。为了交换数据,应用程序可以执行多个数据交换事件(读取或写入数据事件)。当数据交换结束时,应用程序关闭套接字。
让我们更详细地讨论这些事件,并了解它们能告诉你关于应用程序行为的什么信息。
打开套接字以建立通信
应该引起你注意的是打开套接字事件的长时间执行。打开套接字不应该花费很长时间。如果它确实花费了很长时间,这表明通信通道存在问题。例如,应用程序运行的系统或虚拟机可能配置不当,或者网络可能存在问题。当打开套接字事件花费很长时间时,通常不是由你的代码问题引起的。
通过套接字写入数据或从它读取数据
通过套接字读取或写入数据是实际通信过程。两个应用程序相互连接,并交换数据。如果这个操作很慢,可能是因为大量数据传输,或者通信通道缓慢或故障。
你可以使用 JProfiler 找到通过套接字发送的数据量(参见图 12.5 中的吞吐量列),这样你可以决定缓慢是由数据量还是其他原因引起的。在我们的示例中,你可以看到应用程序接收了非常少量的数据(只有 535 字节),但它必须等待超过 5 秒。在这种情况下,我们可以得出结论,问题不是当前应用程序的问题,而是通信通道或我们的应用程序与之通信的进程的问题。
我们在示例中使用的应用程序调用httpbin.org上的一个端点,该端点会导致 5 秒的延迟。因此,我们的结论确实是正确的:其他通信端点导致了缓慢。
关闭套接字
关闭套接字不会导致缓慢。它允许应用程序释放分配给套接字的所有资源。因此,当通信结束时,应用程序需要关闭套接字。
12.2 集成日志监控的相关性
今天,许多系统采用面向服务的架构,并且随着时间的推移增加他们提供的应用程序数量。这些应用程序相互通信,交换、存储和处理数据,执行用户需要的业务功能。随着应用程序数量和应用程序规模的增加,系统变得越来越难以监控。注意到哪里出了问题已经变得相当具有挑战性。为了确定导致问题的系统部分,你可以使用日志监控工具提供的功能(图 12.7)。

图 12.7 日志监控工具帮助你轻松收集和可视化整个系统中的事件。你可以使用这些详细信息来调查问题和特定应用的行为。
定义:日志监控工具是一种你可以集成到应用中以查看整个系统中发生的异常的软件。
![]()
工具会观察所有应用的执行情况,并在应用抛出运行时异常时收集数据。然后,它会以用户友好的方式显示这些信息,帮助你更快地识别问题的原因。
我们将使用一个简单的工具,你可以配置它以收集异常事件并以易于阅读的方式呈现。Sentry(sentry.io)是我使用过许多系统中的一种日志监控工具,它在应用的开发和生产过程中都证明极为有用。Sentry 有一个免费计划,你可以用于学习目的(如本章中的示例)。
让我们创建一个故意抛出异常的应用,并将其与 Sentry 集成。此应用位于项目 da-ch12-ex2 中。
下面的代码片段展示了此应用的简单实现。我们希望使用 Sentry 来观察此应用引发的异常。
@RestController
public class DemoController {
@GetMapping ❶
public void throwException() {
throw new RuntimeException("Oh No!"); ❷
}
}
❶ 定义一个使用 HTTP GET 调用的端点
❷ 当你向端点发送请求时抛出异常
将应用与 Sentry 集成非常简单。Sentry 提供 API,允许你仅用几行代码就将各种平台开发的应用集成。官方文档提供了根据所使用的技术如何集成你的应用的示例和详细步骤。
你需要遵循的步骤很简单:
-
在 Sentry 中创建一个账户。
-
添加一个新的项目(代表你想要监控的应用)。
-
收集 Sentry 提供的项目数据源名称(DSN)地址。
-
在你的项目中配置 DSN 地址。
一旦你创建了一个账户(步骤 1),你就可以添加项目(步骤 2)。对于这两个步骤,你只需遵循sentry.io上的说明;这个过程就像在任何网站上创建账户一样简单。你添加的每个项目都会出现在你的仪表板上,如图 12.8 所示。

图 12.8 Sentry 独立监控系统中每个服务的日志,并在初始仪表板上为每个服务显示事件的简要概述。服务(称为项目)分配给团队,Sentry 可以配置为向团队成员发送事件通知的电子邮件。
我创建了 my-demo-project。一个或多个项目可以被添加到一个团队中。在这种情况下,当我添加第一个项目时,Sentry 默认创建了我的团队。如果您喜欢,可以重命名它,并在需要时添加其他人。当您有更多应用时,您可以将其分配到团队中。每个用户可以成为一个或多个团队的成员,并可以监控分配给他们的团队的应用事件。Sentry 中的团队是一种简单的方式来组织谁负责什么,并使开发者对监控某些服务负责。
由于您的应用尚未向 Sentry 发送任何事件,您的项目在柱状图中不会显示条形(如图 12.8 所示)。您首先需要告诉您的应用将事件发送到何处。为此,您需要配置 Sentry 提供的 DSN 地址,如图 12.9 所示。您可以在项目设置中的“客户端密钥”部分(步骤 3)找到 DSN 地址。

图 12.9 在项目设置中,在“客户端密钥”部分,您会找到 DSN 值,这是一个 URL。应用使用此 URL 将事件发送到 Sentry。
根据应用类型,Sentry 提供不同的配置设置方法(步骤 4)。您可以在每个平台的官方页面上找到详细步骤:docs.sentry.io/platforms/。
由于我们的项目使用 Spring Boot 作为平台,我们只需将 DSN 值添加到 application.properties 文件中的属性sentry.dsn即可。您可以在下一节中找到此配置。尽管在 Sentry 中是可选的,但我总是建议指定应用运行的环境名称。这允许您稍后筛选事件,以便只获取您感兴趣的事件:
sentry.dsn=https://ad1facd3a514422bbdaafddacf...
sentry.environment=production
图 12.10 展示了如何在您的应用中获取异常事件的详细信息。选择左侧的“问题”菜单以访问一个板,您可以在其中浏览 Sentry 从其集成的应用中捕获的所有事件。您可以筛选您想要查看事件的应用、环境和您感兴趣的时间段。

图 12.10 Sentry 收集了由监控服务引起的所有异常。在“问题”菜单中,您可以浏览这些问题的列表。您可以根据事件发生的时间、环境和导致事件的特定服务来筛选它们,这有助于您更容易地识别问题。
这个板是调查的关键起点。如果您使用 Sentry 并需要分析系统中某个服务的异常,首先检查问题板中的事件。使用 Sentry 查找异常事件比我们在第五章中讨论的搜索日志中的这些事件要快得多。
您在板上首先看到的是每个审计事件的简要详情列表。异常类型、其消息和发生次数是最重要的细节。
在每个事件中,你发现的一个基本细节是事件最后一次遇到的时间和第一次出现的时间。你可以使用这些信息来判断问题是否是反复出现的,是否经常发生,或者是否是一个孤立案例。如果事件是孤立的,你可能会发现它是由于环境中偶然的问题引起的,但由应用逻辑产生的错误是反复出现的,并且更频繁。如图 12.10 所示,所有这些细节都在主要问题板上。
如果你对特定事件的信息感兴趣,请在主要问题板上选择你需要调查的事件。Sentry 收集以下有用的细节(图 12.11):
-
异常堆栈跟踪
-
环境细节,例如操作系统、运行时 JVM 和服务器名称
-
客户端详细信息,如果异常是在 HTTP 请求过程中引起的
-
在 HTTP 请求过程中发生异常时发送的信息

图 12.11 Sentry 收集的每个事件——事件的堆栈跟踪、关于服务器和客户端环境的详细信息,甚至关于请求的详细信息(头信息、HTTP 方法等)——提供了可以帮助你识别问题根本原因的信息。
在团队管理中使用 Sentry
尽管 Sentry 是一个主要用于审计、监控和调查应用问题的工具,但我觉得它还有另一种用途,我认为这个用途非常有帮助。
作为一名开发团队领导,我既是团队领导也是技术领导。在 COVID-19 大流行之前,当我们都在办公室工作时,彼此靠近,我知道有人遇到困难要容易得多,反之亦然:当他们需要我时,更容易把一团纸扔向我以引起我的注意。但随着远程在线工作的出现,情况发生了变化。给团队增加延迟的一个因素仅仅是团队成员之间沟通的困难。
Sentry 可以配置为发送它遇到的事件的电子邮件,因此我配置了它接收电子邮件,即使是来自本地环境的事件,以了解团队成员遇到了什么困难。由于我对我的团队很了解,我知道如果有人遇到了特定的问题。在某些情况下,两个或更多团队成员遇到了相同的问题,但由于沟通存在缺陷,他们都花费了时间来调查它。
使用 Sentry,我能够立即采取行动并帮助某人,在他们花费太多时间尝试调查错误之前。我还能在他们超时或同时工作时阻止他们工作。这很酷,不是吗?
我发现特别有用的一点是,Sentry 会自动收集在为请求服务的线程上发生异常时 HTTP 请求的详细信息。你可以使用这些信息在开发环境中重现问题,或者尝试确定通过 HTTP 请求发送的任何数据是否可能导致了异常事件。虽然 Sentry 不会指出问题的原因,但它确实提供了更多的线索,帮助你更快地理解其根本原因。
注意:在许多情况下,今天的应用程序通过 HTTP 相互通信,并且异常事件很可能因此发生。Sentry 会记录 HTTP 请求的详细信息,并将其与事件关联。
![]()
12.3 使用部署工具进行调查
随着时间的推移和参与许多项目的工作,我学到了一些东西,那就是托管应用程序的环境是不同的,并且会演变。我学到的其中一个重要教训是,正确理解应用程序运行的环境在调查为什么应用程序以特定方式行为时非常有帮助。让我们讨论一种最新的部署面向服务的架构的方法,以及这种方法在调查应用程序可能遇到的问题时如何有所帮助:服务网格。
服务网格是一种控制系统中不同应用程序之间如何相互通信的方法,并且从许多角度来看,它们可以非常有帮助,包括使你的应用程序在出现问题时更容易监控和调查。我最喜欢并使用的服务网格工具是 Istio (istio.io);有关更多详情,我建议你阅读 Christian E. Posta 和 Rinor Maloku 合著的《Istio in Action》(Manning,2022 年)。
我将概述服务网格的工作原理,然后我们将讨论它们在调查应用程序执行时的一些有用方式:
-
故障注入——一种你可以强制应用程序通信失败以创建你需要调查的特定场景的方法
-
镜像——一种将生产应用程序的事件复制到测试环境中进行调查的方法

图 12.12 在服务网格部署中,每个应用的通信都被一个侧边栏应用(一个独立的应用)拦截。由于侧边栏应用拦截了交换的数据,你可以配置它来记录你需要的信息,甚至修改通信以强制系统进入你想要调查的场景。
图 12.12 直观地展示了在服务网格中部署的三个服务。每个服务都伴随着一个拦截该服务与其他应用交换数据的应用的程序。
由于侧边栏应用拦截了它所链接的服务以及其他应用之间的通信,你可以配置它以完全透明于服务的方式管理这些数据。我们将在 12.3.1 和 12.3.2 节中进一步讨论这一点。
12.3.1 使用故障注入来模拟难以复现的问题
最具挑战性的调查场景之一是那些难以在本地环境或在你有更多调试或分析访问权限的环境中复现的场景。根据我的经验,环境可以创建一些最难以复现的场景。以下事件可能会给你带来极大的麻烦,并使调查应用程序的行为变得相当困难:
-
一些故障设备导致网络故障。
-
在你的应用程序安装的地方运行的一些额外软件使整个环境变得故障或不稳定。
然而,关于这类问题,有一些重要的事情需要记住:你的应用程序应该预期它们会发生。网络永远不可能 100%可靠,你不能完全信任环境。如果你的应用程序因为网络峰值而失败,那么你的应用程序还不够可靠;不要试图把问题推给其他人——解决它!
你需要设计应用程序使其具有鲁棒性,并预期它们知道如何对不允许它们执行正常流程的外部事件做出反应。但以这种方式设计系统并不容易。作为一名开发者,你应该预料到这一点,即使你做出了巨大的努力来覆盖所有基础,问题仍然可能发生。你需要准备好调查这些问题的来源,并实施解决方案来解决它们。
我在书中多次提到这一点,但在这里重复一遍:调查问题的最佳方式是找到一种方法来复现它。尽管一些由环境引起的问题难以复现,但当你使用服务网格进行部署时,一些场景可以很容易地重新创建。
注意:调查场景的最佳方式是首先在测试环境中复现应用程序或系统的行为。
![]()
做起来最容易且最有用的事情之一是模拟一个故障通信场景。在一个面向服务的或微服务系统中,整个系统依赖于应用程序之间的通信方式。因此,能够测试当系统中的某个服务无法访问时会发生什么,这一点极为重要。你需要模拟故障行为以进行测试或调查。
由于服务网格管理应用程序的通信是由侧边栏应用程序处理的,因此你可以配置侧边栏应用程序故意异常行为,以模拟故障通信(图 12.13)。这样,你可以调查系统在这种情况下的行为。

图 12.13 你可以使用服务网格的侧边栏应用程序来强制系统进入你想要调查的场景。比如说,你想要复现一个生产案例,其中两个服务之间的通信经常中断。你可以轻松地配置一个服务网格侧边栏应用程序来强制执行进入这样的场景,以便你可以进行调查。
故障注入意味着在测试环境中故意破坏您的系统,以复制其他情况下难以复制的特定行为。
![]()
12.3.2 使用镜像促进测试和错误检测
当使用服务网格时,您可以使用的一种在另一个环境中复制问题的技术是镜像。镜像是指配置侧边栏应用程序,将服务发送到与其通信的应用程序副本的相同请求的副本。这个副本可能运行在您用于测试的不同环境中(如图 12.14 所示),这允许您使用在测试环境中运行的应用程序来调试或分析服务之间的通信。

图 12.14 您可以将侧边栏应用程序配置为将生产应用程序的事件镜像到开发中部署的服务。这样,您可以在不影响生产环境的情况下,调查在开发环境中难以复制的难题。
镜像是非常有用的调查工具,但请记住,即使您的系统使用服务网格进行部署,您也可能无法使用镜像。在许多系统中,生产环境中使用的数据是私有的,不能简单地复制到测试环境中。如果您的系统不允许从生产环境复制数据到测试环境,那么镜像也将被禁止。
摘要
-
今天的系统通常由许多相互通信的服务组成。服务之间的错误通信可能导致性能问题或甚至错误的输出。了解如何使用配置文件或服务网格等工具调查服务之间的通信至关重要。
-
您可以使用 JProfiler 截获服务器应用程序接收的 HTTP 请求和事件持续时间。然后,您可以使用这些信息来观察是否某个端点被调用次数过多或执行时间过长,从而对应用程序实例造成压力。
-
您可以使用 JProfiler 观察应用程序作为 HTTP 客户端的行为。您可以截获应用程序发送的所有请求,以及持续时间、HTTP 响应状态码和遇到的异常等详细信息。这些信息可以帮助您确定应用程序与其他服务集成的方式是否存在问题。
-
JProfiler 为您提供优秀的工具,可以直接调查应用程序建立的底层通信,通过直接调查套接字事件,这允许您隔离问题并确定问题是否与通信通道或应用程序的某个部分有关。
-
在大型面向服务的系统中,使用日志监控工具是观察问题和更快地将拼图碎片拼凑起来以找到问题根本原因的绝佳方式。日志监控工具是一种软件,它收集系统中每个应用中的异常事件,并显示你需要了解问题及其来源的信息。Sentry 是一个你可以用于系统日志监控的绝佳工具。
-
在某些情况下,你可以利用用于部署应用的工具。例如,如果你的服务部署依赖于服务网格,你可以使用服务网格功能来重现你想要调查的场景。你可以配置
-
故障注入以模拟一个工作不正常的服务,并调查在这种情况下其他服务受到的影响。
-
通过镜像来获取应用发送给接收服务副本的所有请求的副本。这个副本安装在测试环境中,你可以使用调试和性能分析技术来调查场景,而不会影响生产系统。
-
附录 A. 你需要的工具
在本附录中,你可以找到本书中讨论的示例所需的所有推荐工具的安装说明。
要打开和执行本书提供的项目,你需要安装一个 IDE。我使用了 IntelliJ IDEA:www.jetbrains.com/idea/download/. 或者,你也可以使用 Eclipse IDE:www.eclipse.org/downloads/. 否则,你可以使用 Apache Netbeans:netbeans.apache.org/download/index.xhtml.
要运行本书提供的 Java 项目,你需要安装 17 版或更高版本的 JDK。我推荐使用 OpenJDK 发行版:jdk.java.net/17/.
为了讨论分析技术以及读取堆转储和线程转储,我们使用 VisualVM:visualvm.github.io/download.xhtml. 对于我们将要讨论的一些技术,VisualVM 可能就不够用了。对于这些技术,我们将使用 JProfiler:www.ej-technologies.com/products/jprofiler/overview.xhtml.
一个可以帮助你调查线程转储的工具是 fastThread,我们在第九章中使用了它:fastthread.io/.
在整本书中,我们将使用 Postman 来调用端点以演示调查技术:www.postman.com/downloads/.
在第十二章中,我们讨论了使用 Sentry 监控日志事件:sentry.io.
附录 B. 打开项目
在本附录中,您可以找到打开和运行现有项目的步骤。本书提供的项目是使用 Java 17 编写的 Java 应用程序。我们使用这些项目来演示几种技术和工具的使用。
首先,您需要安装一个 IDE,例如 IntelliJ IDEA、Eclipse 或 Apache Netbeans。对于示例,我使用了 IntelliJ IDEA:www.jetbrains.com/idea/download/。
要运行本书提供的项目,您需要安装 JDK 版本 17 或更高版本。您可以使用任何 Java 发行版。我使用的是 OpenJDK 发行版:jdk.java.net/17/。
图 B.1 展示了如何在 IntelliJ IDEA 中打开现有项目。要选择要打开的项目,请选择文件 > 打开。

图 B.1 要在 IntelliJ IDEA 中打开现有项目,请在文件菜单中选择打开。
点击文件 > 打开,将弹出一个窗口。选择要打开的项目。图 B.2 展示了此弹出窗口。

图 B.2 在文件菜单中选择打开后,将弹出一个窗口。在此窗口中,从文件系统中选择要打开的项目,并点击确定按钮。
要运行应用程序,请右键单击包含 main() 方法的类。对于本书提供的项目,main() 方法定义在名为 Main 的类中。右键单击此类,如图 B.3 所示,并选择运行。

图 B.3 一旦打开应用程序,您就可以运行它。要运行应用程序,右键单击 Main 类并选择运行菜单项。如果您想使用调试器运行应用程序,请点击调试。
如果您想使用调试器运行应用程序,请右键单击 Main 类 > 调试。
附录 C. 推荐进一步阅读
本附录推荐了一些与本书主题相关的书籍,你可能觉得它们有用和有趣:
-
《程序员的思维》(Felienne Hermans 著,Manning,2021)探讨了当开发者调查代码时,他们的思维是如何工作的。阅读代码是理解软件的一部分,在我们应用调查技术之前,这是我们首先要做的事情。对这些方面的更好理解也将帮助你调查代码。
-
《单体到微服务》(Sam Newman 著,O’Reilly Media,2019)是我第十二章中推荐的,用于研究微服务作为一种架构风格。这本书专注于单体方法和微服务之间的区别,以及在哪里以及如何使用这两种架构风格。
-
《构建微服务:设计细粒度系统》 第二版(O’Reilly Media,2021)是 Sam Newman 的另一本书,专注于设计由细粒度服务组成的系统。作者通过清晰和详细的例子分析了所提出技术的优缺点。
-
《微服务模式》(Chris Richardson 著,Manning,2018)是我认为任何从事微服务架构工作的人都必须阅读的一本书。作者通过清晰的例子详细介绍了在大型微服务和面向服务的系统中使用的最基本技术。
-
《五行代码》(Christian Clausen 著,Manning,2021)教你干净的编码实践。如今,许多应用程序都是无结构的,难以理解。我为书中找到的许多代码示例设计了现实性,所以它们并不总是遵循干净的编码原则。但当你理解了一块不干净代码的工作原理后,你应该重构它,使其更容易理解。开发者称这个原则为“童子军规则”。在许多情况下,调试之后会进行重构,以便使代码在未来更容易理解。
-
《好代码,坏代码》(Tom Long 著,Manning,2021)是一本优秀的书籍,它教授了高质量的代码编写原则。我也推荐你阅读这本书,以提高重构和编写易于理解的应用程序的能力。
-
《软件错误与权衡》(Tomasz Lelek 和 Jon Skeet 著,Manning,2022)通过优秀的例子讨论了如何在软件开发中做出困难的决定,进行妥协,并优化决策。
-
《重构:改善既有代码的设计》(Martin Fowler 与 Kent Beck 著,Addison-Wesley Professional,2018)是任何希望提高设计和构建干净、可维护应用程序技能的软件开发者必读的另一本书。
附录 D.理解 Java 线程
在本附录中,我们将讨论 Java 应用程序中线程的基本知识。线程是应用程序运行的独立指令序列。给定线程上的操作与其他线程上的操作并发运行。今天,任何 Java 应用程序都依赖于拥有多个线程,因此几乎不可能不遇到需要更深入理解为什么特定线程没有按预期工作或难以与其他线程协作的研究场景。这就是为什么您会在本书的多个讨论中找到线程(特别是第七章到第九章,但也在本书前半部分的其他地方,当我们讨论调试时)。为了正确理解这些讨论,您需要了解一些关于线程的基本知识。本附录向您介绍了理解本书中其他讨论所必需的元素。
我们将从 D.1 节开始,我会提醒您线程的整体概念以及为什么我们在应用程序中使用它们。在 D.2 节中,我们将通过讨论线程的生命周期来详细介绍线程的执行方式。了解线程生命周期的状态和可能的转换对于调查任何与线程相关的问题都是必要的。在 D.3 节中,我们将讨论线程同步,这是一种控制执行线程的方式。错误的同步实现引入了您需要调查和解决的大多数问题。在 D.4 节中,我们将讨论最常见的与线程相关的问题。
线程是一个复杂的话题,所以我只会关注您需要了解以理解本书中展示的技术。我无法保证在几页纸内让您成为该主题的专家,因此您会在本附录的末尾找到我推荐的一些资源。
D.1 什么是线程?
在本节中,我们讨论线程是什么以及如何使用多个线程帮助应用程序。线程是运行进程中的一个独立操作序列。任何进程都可以有多个并发运行的线程,使您的应用程序能够解决多个任务,潜在地并行执行。线程是语言处理并发的一个基本组成部分。
我喜欢将多线程应用程序想象成图 D.1 所示的一组序列时间线。请注意,应用程序从一个线程(主线程)开始。这个线程启动其他线程,这些线程可以再启动其他线程,依此类推。请记住,每个线程都是独立的。例如,主线程可以在应用程序本身结束之前很久就结束其执行。当所有线程停止时,进程停止。

图 D.1 将多线程应用程序可视化为一组序列时间线。图中的每条箭头代表一个线程的时间线。应用程序从主线程开始,可以启动其他线程。一些线程一直运行到进程结束,而其他线程则提前停止。在某个特定时间,一个应用程序可以有一个或多个线程并行运行。
给定线程上的指令总是按照定义的顺序执行。如果您知道指令 A 在同一个线程上位于指令 B 之前,那么您总是知道 A 会在 B 之前发生。但由于两个线程相互独立,您不能对两个分别位于不同线程的指令 A 和 B 说同样的话。在这种情况下,A 可以在 B 之前执行,或者反之亦然(图 D.2)。有时我们可以说一种情况比另一种情况更可能,但我们不能知道哪种流程会持续执行。

图 D.2 在一个线程上有两个指令时,我们总能知道执行的确切顺序。但由于两个线程是独立的,如果指令位于不同的线程上,我们无法知道它们将执行的顺序。最多我们只能说一种情况比另一种情况更可能。
在许多情况下,您会看到工具将线程执行以序列时间线的方式可视化。图 D.3 显示了 VisualVM(本书中使用的分析器工具)如何以序列时间线的方式呈现线程执行。

图 D.3 VisualVM 以序列时间线的方式显示线程执行。这种视觉表示使得应用程序的执行更容易理解,并有助于您调查可能的问题。
D.2 线程的生命周期
一旦您可视化了线程执行,了解线程生命周期是理解它们执行的关键。在整个执行过程中,线程会经历多个状态(图 D.4)。当使用分析器(如第六章至第九章中讨论的)或线程转储(如第十章中讨论的)时,我们通常会参考线程的状态,这在试图了解执行时非常重要。了解线程如何从一个状态转换到另一个状态以及线程在每个状态下的行为对于跟踪和调查应用程序的行为至关重要。
图 D.4 直观地展示了线程状态以及线程如何从一个状态转换到另一个状态。我们可以识别 Java 线程的以下主要状态:
-
新建—线程在其实例化后(在启动之前)处于此状态。在此状态下,线程是一个简单的 Java 对象。应用程序还不能执行它定义的指令。
-
可运行—线程在其
start()方法被调用后处于此状态。在此状态下,JVM 可以执行线程定义的指令。在此状态下,JVM 将逐步将线程在两个子状态之间移动:-
准备就绪—线程不会执行,但 JVM 可以在任何时候将其放入执行状态。
-
运行—线程正在执行。当前 CPU 正在执行它定义的指令。
-
-
阻塞—线程已启动,但暂时被移出了可运行状态,因此 JVM 无法执行其指令。此状态帮助我们通过允许我们暂时“隐藏”线程从 JVM 中,使其无法执行,来控制线程的执行。在阻塞状态下,线程可以处于以下子状态之一:
-
监视—线程被同步块的监视器(控制对同步块访问的对象)暂停,并等待被释放以执行该块。
-
等待—在执行过程中,调用了监视器的
wait()方法,这导致当前线程暂停。线程将保持阻塞状态,直到调用notify()或notifyAll()方法,允许 JVM 释放正在执行的线程。 -
睡眠—在
Thread类中调用sleep()方法,将当前线程暂停一段时间。时间作为参数传递给sleep()方法。在这段时间过后,线程变为可运行状态。 -
已挂起—几乎与等待相同,当有人调用
park()方法后,线程将显示为已挂起,这会阻塞当前线程,直到调用unpark()方法。
-
-
死亡—线程在完成其指令集后死亡或终止,一个
Error或Exception使其停止,或者它被另一个线程中断。一旦死亡,线程无法再次启动。

图 D.4 线程生命周期。在其生命周期中,线程会经历多个状态。首先,线程是新的,JVM 无法运行它定义的指令。启动线程后,它变为可运行状态,并开始被 JVM 管理。线程在其生命周期中可以暂时被阻塞,在其生命周期的末尾,它进入死亡状态,从该状态无法重新启动。
图 D.4 也显示了线程状态之间的可能转换:
-
当有人调用其
start()方法时,线程从新状态变为可运行状态。 -
一旦进入可运行状态,线程会在就绪和运行之间振荡。JVM 决定哪个线程将被执行以及何时执行。
-
有时,线程会被阻塞。它可以通过几种方式进入阻塞状态:
-
在
Thread类中调用sleep()方法,将当前线程置于一个临时的阻塞状态。 -
有人调用了
join()方法,导致当前线程等待另一个线程。 -
有人调用了监视器的
wait()方法,暂停了当前线程的执行,直到调用notify()或notifyAll()方法。 -
一个同步块的监视器暂停了线程的执行,直到另一个活动线程完成同步块的执行。
-
-
线程可以在执行完毕或被其他线程中断时进入死(终止)状态。JVM 认为从阻塞状态到终止状态的转换是不可接受的。如果阻塞线程被另一个线程中断,转换会通过
InterruptedException信号表示。
D.3 同步线程
在本节中,我们将讨论同步线程的方法,这是开发者在多线程架构中控制线程所使用的。不正确的同步也是许多你必须调查和解决的问题的根本原因。我们将概述同步线程最常用的方法。
D.3.1 同步块
同步线程最简单的方法,通常是任何 Java 开发者学习同步线程的第一个概念,就是使用同步代码块。其目的是允许一次只有一个线程通过同步代码——禁止给定代码片段的并发执行。这里有两种选择:
-
块同步—在给定的代码块上应用 synchronized 修饰符
-
方法同步—在方法上应用 synchronized 修饰符
下面的代码片段展示了同步块的例子:
synchronized (a) { ❶
// do something ❷
}
❶ 括号中的对象是同步块的监视器。
❷ 同步指令块被定义在大括号之间。
下面的代码片段展示了方法同步的例子:
synchronized void m() { ❶
// do something ❷
}
❶ 应用到方法上的 synchronized 修饰符
❷ 定义在大括号中的整个方法代码块是同步的。
使用synchronized关键字这两种方式的效果相同,尽管它们看起来有些不同。你将发现每个同步块的两个重要组成部分:
-
监视器—管理同步指令执行的对象
-
指令块—实际同步的指令
方法同步似乎缺少监视器,但对此语法来说,监视器实际上是隐含的。对于非静态方法,将使用实例“this”作为监视器,而对于静态方法,同步块将使用类的类型实例作为监视器。
监视器(不能为 null)是赋予同步块意义的对象。该对象决定一个线程是否可以进入并执行同步指令。技术上,规则很简单:一旦一个线程进入同步块,它就会在监视器上获得一个锁。直到拥有锁的线程释放它,其他线程将不会被接受进入同步块。为了简化,让我们假设线程仅在退出同步块时释放锁。图 D.5 展示了一个视觉示例。想象两个同步块位于应用程序的不同部分,但由于它们都使用相同的监视器 M1(相同的对象实例),线程一次只能在一个块中执行。指令 A、B 或 C 不会并发调用(至少不是从所提供的同步块中)。

图 D.5 使用同步块的示例。应用程序的多个同步块可以使用作为监视器的相同对象实例。当这种情况发生时,所有线程都是相关的,以至于只有一个活动线程在所有同步块中执行。在这张图片中,如果一个线程进入同步块,定义指令 A 和 B,则其他线程不能进入相同的块或定义指令 C 的块。
然而,应用程序可能定义多个同步块。监视器链接多个同步块,但当两个同步块使用两个不同的监视器(图 D.6)时,这些块不是同步的。在图 D.6 中,第一个和第二个同步块也是相互同步的,因为它们使用相同的监视器。但这两个块与第三个块不同步。结果是,定义在第三个同步块中的指令 D 可以与第一个和第二个同步块中的任何指令并发执行。

图 D.6 当两个同步块不使用作为监视器的相同对象实例时,它们不是同步的。在这种情况下,第二个和第三个同步块使用不同的监视器。这意味着这两个同步块中的指令可以同时执行。
当使用分析器或线程转储等工具调查问题时,你需要了解线程被阻塞的方式。这些信息可以阐明发生了什么,为什么,或者是什么原因导致某个线程无法执行。图 D.7 展示了 VisualVM(我们在第 7-9 章中使用的分析器)如何显示同步块监视器阻塞了一个线程。

图 D.7 VisualVM 显示了线程的状态。分析器中的线程选项卡提供了每个线程所做事情的完整情况,如果线程被阻塞,则显示阻塞该线程的原因。
D.3.2 使用 wait()、notify() 和 notifyAll()
另一种线程可能被阻塞的方式是如果它被要求等待一个不确定的时间。使用同步块监视器的 wait() 方法,您可以指示线程无限期地等待。然后,其他线程可以“告诉”等待的线程继续其工作。您可以使用监视器的 notify() 或 notifyAll() 方法来做这件事。这些方法通常用于通过防止线程在不应该执行时执行来提高应用程序的性能。同时,这些方法的错误使用可能导致死锁或线程无限期等待而从未被释放到执行的情况。
记住,wait()、notify() 和 notifyAll() 只有在它们被用于同步块时才有意义。这些方法是同步块监视器的行为,因此您不能在没有监视器的情况下使用它们。使用 wait() 方法,监视器会阻塞线程一个不确定的时间。在阻塞线程的同时,它也会释放它所获得的锁,以便其他线程可以进入由该监视器同步的块。当调用 notify() 方法时,线程可以再次被执行。图 D.8 总结了 wait() 和 notify() 方法。

图 D.8 在某些情况下,一个线程应该暂停执行并等待某个事件发生。为了使线程等待,同步块的监视器可以调用它的 wait() 行为。当线程再次可执行时,监视器可以调用 notify() 或 notifyAll() 方法。
图 D.9 展示了一个更具体的场景。在第七章中,我们使用了一个实现生产者-消费者方法的示例应用,其中多个线程共享一个资源。生产者线程向共享资源添加值,消费者线程消费这些值。但如果共享资源不再有值呢?消费者在此时执行将不会受益。技术上,它们仍然可以执行,但没有值可以消费,因此允许 JVM 执行它们会导致系统不必要的资源消耗。更好的方法是在共享资源没有值时“告诉”消费者等待,并且只有在生产者添加了新的值后,才继续它们的执行。

图 D.9 wait() 和 notify() 的一个用例。当一个线程在当前条件下执行没有带来任何值时,我们可以让它等待直到进一步的通知。在这种情况下,当消费者没有可消费的值时,它不应该执行。我们可以让消费者等待,并且只有当生产者在共享资源中添加了新的值后,才能告诉他们继续。
D.3.3 线程的连接
一种相当常见的线程同步方法是让一个线程等待另一个线程完成其执行。与等待/通知模式不同的是,线程不需要等待被通知。线程只是简单地等待另一个线程完成其执行。图 D.10 展示了可能从这种同步技术中受益的场景。
假设你必须根据从两个不同的独立来源检索到的数据实现一些数据处理。通常,从第一个数据源检索数据需要大约 5 秒,从第二个数据源获取数据需要大约 8 秒。如果你按顺序执行操作,获取所有数据处理所需的时间是 5 + 8 = 13 秒。但是,你知道一个更好的方法。由于数据源是两个独立的数据库,如果你使用两个线程,你可以同时从两个数据源获取数据。但是,然后你需要确保处理数据的线程在开始之前等待检索数据的两个线程完成。为了实现这一点,你使处理线程加入检索数据的线程(图 D.10)。

图 D.10 在某些情况下,你可以使用多个线程来提高应用程序的性能。但是,你需要让一些线程等待其他线程,因为它们依赖于这些线程的执行结果。你可以使用 join 操作使一个线程等待另一个线程。
在许多情况下,连接线程是一种必要的同步技术。但是,如果使用不当,它也可能导致问题。例如,如果一个线程正在等待另一个线程,它卡住了,或者永远不会结束,那么加入它的线程将永远不会执行。
D.3.4 定义时间阻塞线程
有时,一个线程需要等待给定的时间。在这种情况下,线程处于“定时等待”状态或“睡眠”状态。以下操作是最常见的导致线程定时等待的操作:
-
sleep()—你可以始终使用Thread类中的静态sleep()方法来使当前正在执行代码的线程等待固定的时间。 -
wait(long timeout)—具有超时参数的 wait 方法可以像 D.3.2 节中讨论的没有参数的wait()方法一样使用。然而,如果你提供了一个参数,如果在此之前没有收到通知,线程将等待给定的时间。 -
join(long timeout)—这个操作与我们在 D.3.3 节中讨论的join()方法的工作方式相同,但等待最大超时时间,该时间作为参数给出。
我在应用程序中经常发现的一个常见反模式是使用sleep()来使线程等待,而不是我们在第四章中讨论的wait()方法。以我们讨论的生产者-消费者架构为例。你可以用sleep()代替wait(),但消费者应该睡眠多长时间以确保生产者有时间运行并添加值到共享资源?我们对此没有答案。例如,使线程睡眠 100 毫秒(如图 D.11 所示)可能太长或太短。在大多数情况下,如果你遵循这种方法,你最终得到的性能可能不是最好的。

图 D.11 使用定时等待方法代替wait()和notify()通常不是最佳策略。只要你的代码可以确定线程何时可以继续执行,就使用wait()和notify()代替sleep()。
D.3.5 使用阻塞对象同步线程
JDK 提供了一套令人印象深刻的工具用于同步线程。在这些工具中,一些在多线程架构中使用的最知名的类包括
-
信号量—你可以用来限制可以执行给定代码块线程数量的对象 -
CyclicBarrier—你可以用来确保至少有给定数量的线程活跃以执行给定代码块的对象 -
锁—一个提供更广泛同步选项的对象 -
闩锁—你可以用来使一些线程等待,直到其他线程中的某些逻辑执行完毕的对象
这些对象是高级实现,每个都采用定义良好的机制来简化某些场景下的实现。在大多数情况下,这些对象由于使用不当而引起麻烦,而且在许多情况下,开发者会过度设计使用它们的代码。我的建议是使用你能找到的最简单的方法来解决问题,并且在使用这些对象之前,确保你正确理解它们的工作原理。
D.4 多线程架构中的常见问题
在调查多线程架构时,你会识别出常见的问题,这些问题是各种意外行为(无论是意外输出还是性能问题)的根本原因。提前理解这些问题将帮助你更快地确定问题的来源并修复它。这些问题如下:
-
竞态条件—两个或更多线程竞争修改共享资源。
-
死锁—两个或更多线程在等待对方时陷入僵局。
-
活锁—两个或更多线程未能满足停止的条件,并且持续运行而不执行任何有用的工作。
-
饥饿—当一个线程在 JVM 执行其他线程时持续被阻塞。该线程永远不会执行它定义的指令。
D.4.1 竞态条件
当多个线程尝试并发更改同一资源时,会发生竞态条件。当这种情况发生时,我们可能会遇到意外结果或异常。通常,我们使用同步技术来避免这些情况。图 D.12 以视觉方式展示了这种情况。线程 T1 和 T2 同时尝试更改变量 x 的值。线程 T1 尝试增加值,而线程 T2 尝试减少它。这种场景可能导致应用程序重复执行时产生不同的输出。以下是一些可能的情况:
-
操作执行后,x 可能是 5——如果 T1 首先更改了值,而 T2 读取了已经更改的变量值,或者相反,变量仍然具有值 5。
-
操作执行后,x 可能是 4——如果两个线程同时读取 x 的值,但 T2 是最后一个写入的,x 将是(T2 读取的值,5,减去 1)。
-
操作执行后,x 可能是 6——如果两个线程同时读取 x 的值,但 T1 是最后一个写入的,x 将是 6(T1 读取的值,5,加上 1)。

图 D.12 竞态条件。多个线程同时尝试更改共享资源。在这个例子中,线程 T1 和 T2 同时尝试更改变量 x 的值,这可能导致不同的输出。
这种情况通常会导致意外的输出。在可能存在多个执行流程的多线程架构中,这些情况可能很难重现。有时,它们只在特定环境中发生,这使得调查变得困难。
D.4.2 死锁
死锁是两个或多个线程暂停并等待彼此的某些操作以继续执行的情况(见图 D.13)。死锁会导致应用程序(或至少其一部分)冻结,阻止某些功能运行。

图 D.13 死锁示例。在 T1 等待 T2 继续执行而 T2 又等待 T1 的情况下,线程处于死锁状态。由于它们都在等待对方,因此都无法继续执行。
图 D.14 展示了死锁可以通过代码发生的方式。在这个例子中,一个线程获得了资源 A 的锁,另一个线程获得了资源 B 的锁。但是,每个线程也需要另一个线程所拥有的资源来继续其执行。线程 T1 等待线程 T2 释放资源 A,但与此同时,线程 T2 正在等待线程 T1 释放资源 B。由于两个线程都在等待对方释放它们需要的资源,因此都无法继续执行,导致死锁。

图 D.14 死锁。线程 T1 无法进入嵌套的同步块,因为 T2 拥有资源 A 的锁。线程 T1 等待 T2 释放资源 A 以便它可以继续执行。但线程 T2 处于类似的情况:它无法继续执行,因为 T1 获得了资源 B 的锁。线程 T2 等待线程 T1 释放资源 B 以便它可以继续执行。由于两个线程都在等待对方,并且都不能继续执行,因此线程处于死锁状态。
图 D.14 中的示例很简单,但它只是一个教学示例。现实世界的情况通常更难调查和理解,可能涉及多个线程。请注意,同步块并不是线程陷入死锁的唯一方式。理解此类场景的最佳方式是使用你在第七章到第九章中学到的调查技术。
D.4.3 活锁
活锁或多或少是死锁的反面。当线程处于活锁状态时,条件总是以某种方式改变,使得线程即使应该在给定条件下停止也会继续执行。线程无法停止,它们会持续运行,通常无理由地消耗系统的资源。活锁可能导致应用程序执行中的性能问题。
图 D.15 展示了一个通过序列图表示的活锁。两个线程 T1 和 T2 在一个循环中运行。为了停止其执行,T1 在其最后一次迭代之前使一个条件为真。当 T1 下次回到该条件时,它期望它为真并停止。然而,这并没有发生,因为另一个线程 T2 将其改回为假。T2 发现自己处于相同的情况。每个线程都会改变条件,以便它可以停止,但与此同时,每个条件的变化都会导致另一个线程继续运行。

图 D.15 活锁的一个例子。两个线程依赖于一个条件来停止它们的执行。但当改变条件的值以便它们可以停止时,每个线程都会导致另一个线程继续运行。线程无法停止,因此不必要地消耗了系统的资源。
正如第四章中的死锁示例(第 4.4.2 节)一样,请记住这是一个简化的场景。现实世界中的活锁可能由更复杂的情况引起,并且可能涉及多个线程。第七章到第九章讨论了你可以用来调查此类场景的几种方法。
D.4.4 饥饿
另一个常见问题,尽管在当今的应用中不太可能发生,是饥饿。饥饿是由于某个线程始终被排除在执行之外,即使它是可运行的。该线程想要执行其指令,但 JVM 持续允许其他线程访问系统的资源。因为线程无法访问系统的资源并执行其定义的指令集,所以我们说它是饥饿的。
在早期 JVM 版本中,当开发者给某个线程设置一个非常低的优先级时,就会出现这种情况。如今,JVM 实现对这些情况的处理要聪明得多,所以(至少在我的经验中)饥饿场景发生的可能性较小。
D.5 进一步阅读
线程很复杂,在这篇附录中,我们讨论了帮助你理解本书中提到的技术的关键主题。但是,对于任何 Java 开发者来说,详细了解线程的工作原理是一项宝贵的技能。以下是我推荐您阅读的一些资源,以深入了解线程:
-
《Oracle Certified Professional Java SE 11 Developer Complete Study Guide》由 Jeanne Boyarsky 和 Scott Selikoff(Sybex,2020)编著。第十八章介绍了线程和并发,从零开始,涵盖了 OCP 认证所需的全部线程基础知识。我建议您从这本书开始学习线程。
-
Benjamin Evans、Jason Clark 和 Martijn Verburg(Manning,2022)编著的《The Well-Grounded Java Developer》的第二版,从基础到性能调优,教授并发知识。
-
Brian Goetz 等人编著的《Java Concurrency in Practice》(Addison-Wesley,2006)是一本较老的书,但它并没有失去其价值。任何想要提高自己线程和并发知识水平的 Java 开发者都应该阅读这本书。
附录 E. Java 应用程序中的内存管理
在本附录中,我们讨论了 Java 虚拟机(JVM)如何管理 Java 应用程序的内存。您在 Java 应用程序中可能遇到的一些最具挑战性的问题都与应用程序管理内存的方式有关。幸运的是,我们可以使用几种技术来分析这些问题并找到其根本原因,而无需投入大量时间。但是,为了从这些技术中受益,您首先至少需要了解一些关于 Java 应用程序如何管理其内存的基本知识。
应用程序的内存是一种有限的资源。即使今天的系统可以为应用程序在执行期间提供大量内存,我们仍然需要小心地对待应用程序如何使用这种资源。没有系统可以提供无限的内存作为魔法解决方案(图 E.1)。内存问题会导致性能问题(应用程序变慢,部署成本更高,启动速度变慢等),有时甚至可以将整个进程完全停止(例如,在OutOfMemoryError的情况下)。

图 E.1 应用程序的内存是一种有限的资源。没有魔法解决方案可以让我们为应用程序分配无限的内存。在构建应用程序时,我们需要谨慎对待内存消耗,避免无端浪费。应用程序有时可能会出现内存问题。如果某个功能使用过多的内存,它可能会引起性能问题,甚至导致完全失败。您需要准备好找到这些问题的原因并妥善解决它们。
我们将涵盖内存管理的必要方面。在第 E.1 节中,我们将讨论 JVM 如何为执行进程组织内存。您将了解三种分配应用程序内存的方式:栈、堆和元空间。在第 E.2 节中,我们将讨论栈,这是线程用于存储局部声明的变量及其数据的空间。第 E.3 节讨论堆以及应用程序在内存中存储对象实例的方式。我们的讨论将在第 E.4 节结束,涉及元空间,这是应用程序存储对象类型元数据的位置。
请注意,Java 应用程序的内存管理是复杂的。在本附录中,我将仅介绍您需要了解以理解书中所讨论内容的细节。
E.1 JVM 如何组织应用程序的内存
在本节中,我们将讨论 JVM 如何在不同内存位置组织数据,这些位置分别以不同的方式管理。理解 JVM 如何管理内存对于调查与内存相关的问题至关重要。我们将使用一些视觉元素来讨论与内存管理相关的关键方面,您将了解在 Java 应用程序的内存中哪些数据存储在哪里。然后,我们将详细说明每个内存位置的内存管理。
目前(为了简化讨论),让我们假设一个 Java 应用程序在执行过程中管理其存储的数据有两种方式:栈和堆。根据数据的定义方式,应用程序将分别在栈或堆中管理它。但在讨论哪些数据放在哪里之前,请记住一个基本细节:应用程序有多个线程,允许它并发处理数据。堆是一个单独的内存位置,应用程序的所有线程都使用它。然而,每个线程都有自己的内存位置,称为栈。这可能会在开发者第一次学习内存管理时造成困惑。图 E.2 以视觉方式展示了这些细节。

图 E.2 T1、T2 和 T3 都是 Java 应用程序的线程。所有这些线程使用相同的堆。堆是应用程序存储对象实例数据的内存位置。然而,每个线程都使用自己的内存位置,称为栈,来存储局部声明的数据。
栈是线程拥有的内存位置。每个线程都拥有一个特定的栈,该栈不与其他线程共享。线程将任何在代码块中局部声明的数据存储在这个内存位置,并由该线程执行。假设你有一个像下面代码片段中展示的方法。参数x和y以及方法代码块内声明的变量sum都是局部变量。当方法执行时,这些值将存储在线程的栈中:
public int sum(int x, int y) { ❶
int sum = x + y; ❶
return sum;
}
❶ 变量 x、y 和 sum 将存储在栈中。
堆是应用程序存储对象实例数据的内存位置。假设你的应用程序声明了一个类,例如下面代码片段中展示的Cat类。每次你使用类的构造函数new Cat()创建实例时,该实例都会进入堆:
public class Cat {
}
如果类声明了实例属性,JVM 也会将这些值存储在堆中。例如,如果Cat类看起来像下面代码片段中展示的那样,JVM 将存储每个实例的名称和年龄在堆中:
public class Cat {
private String name; ❶
private int age; ❶
}
❶ 对象的属性存储在堆中。
图 E.3 以视觉方式展示了一个数据分配的例子。注意,局部声明的变量及其值(x和c)存储在线程的栈中,而Cat实例及其数据存储在应用程序的堆中。Cat实例的引用将存储在变量c的线程栈中。甚至存储String数组引用的方法参数也将成为栈的一部分。

图 E.3 应用程序在线程的栈中保留局部声明的变量,以及在堆中定义的对象实例的数据。一个线程栈中的变量可能指向堆中的对象。在这个例子中,变量x持有值 10,变量c持有Cat实例的引用,它们都是线程栈的一部分。
E.2 线程用于存储局部数据的栈
在本节中,我们将更深入地分析栈背后的机制。在 E.1 节中,你了解到局部变量存储在栈中,并且每个线程都有自己的栈位置。现在让我们弄清楚这些值是如何存储的,以及应用何时从内存中移除它们。我们将通过一个简短的代码示例逐步使用视觉描述这个过程。一旦我们阐明了栈内存管理的机制,我们将讨论可能出错的地方以及与之相关的问题。
首先,为什么这个内存位置被称为“栈”?线程的栈使用栈数据结构的原则。栈是一个有序集合,其中你可以始终移除最近添加的元素。我们通常将此类集合可视化为一层层的堆叠,其中每一层都存储在另一层之上。你只能将新层添加到所有现有层之上,并且你只能移除顶层。这种添加和移除元素的方法也称为后进先出(LIFO)。图 E.4 通过一系列添加和移除步骤演示了栈是如何工作的。为了使示例更简单,数字是栈中的值。

图 E.4 展示了如何向栈中添加和移除值。栈是一个按顺序排列的集合,遵循后进先出(LIFO)原则。当你向栈中添加一个值时,它成为顶层——唯一可以移除的层。
你会在应用如何管理线程栈中的数据时认识到相同的行为。每当执行达到代码块的开头时,它就会在线程栈中创建一个新的层。遵循常见的栈原则,任何新的层都成为顶层,并且是第一个被移除的。在图 E.5、E.6、E.7 和 E.8 中,我们逐步跟踪一个简单代码片段的执行,以观察线程栈是如何变化的:
public static void main(String [] args) {
int x = 10;
a();
b();
}
public static void a() {
int y = 20;
}
public static void b() {
int y = 30;
}
执行从main()方法开始(图 E.5)。当执行达到main()方法的开始时,第一个层被添加到线程的栈中。这个层是一个内存位置,其中存储了代码块中声明的所有局部变量。在这种情况下,代码块声明了一个变量x,并用值10初始化该变量。这个变量将存储在这个新创建的线程栈层中。当方法结束执行时,这个层将从栈中移除。

图 E.5 展示了当执行达到代码块的开头时,在线程的栈中创建了一个新的层。代码块定义的所有变量都存储在这个新层中。当代码块结束时,这个层将被移除。这样,我们知道当这部分内存不再需要时,其中的值就会被释放。
代码块可以调用其他代码块。例如,在这种情况下,方法 main() 调用方法 a() 和 b(),它们的工作方式类似。当执行到达它们代码块的开头时,栈中会添加一个新的层。这个新层是存储所有声明为局部数据的内存位置。图 E.6 展示了执行到达方法 a() 时发生的情况。

图 E.6 另一个代码块可以从正在执行的代码块中调用。在这种情况下,方法 main() 调用方法 a()。由于 main() 没有完成,其层仍然是栈的一部分。方法 a() 创建自己的层,其中存储它定义的局部值。
当方法 a() 结束执行并返回到 main() 时,线程栈中预留的层也被移除(图 E.7)——这意味着它存储的数据不再在内存中。这样,不再需要的内存被释放,为新数据存储腾出空间。代码块在执行到达其最后一条指令、给出 return 指令或抛出异常时结束。请注意,当代码块结束时,其层总是栈顶的,符合后进先出(LIFO)原则。

图 E.7 当执行到达代码块末尾时,为该代码块打开的栈层及其包含的所有数据将被移除。在这种情况下,当方法 a() 返回时,其栈层被移除。这样,我们确保不再需要的数据从内存中移除。
方法 main() 通过调用方法 b() 继续执行。就像方法 a() 所做的那样,方法 b() 在栈中预留一个新的层来存储它声明的局部数据(图 E.8)。

图 E.8 就像方法 a() 一样,当调用方法 b() 并且执行到达其代码块的开头时,栈中会添加一个新的层。该方法可以使用这个层来存储局部数据,直到方法返回并且层被移除。
当方法 main() 最终到达其末尾时,线程结束执行,栈保持为空并且被完全移除。同时,线程进入其生命周期中描述的死亡状态,如附录 D 所述。
栈有一个默认的内存空间分配。您可以根据使用的 JVM 在这里找到精确的值:mng.bz/JVYp。此限制也可以调整,但您无法将其设置为无限。栈的一个常见问题是 StackOverflowError,这意味着栈已完全填满,无法添加更多层。当发生这种情况时,代码会抛出 StackOverflowError,并且栈已满的线程会完全停止。一个递归(或递归实现),一个在给定条件满足之前不断调用自身的函数,如果停止条件错误通常会导致此类问题。如果这个条件缺失或允许方法调用自身太多次数,栈可能会被方法在每次执行开始时创建的层填满。图 E.9 以视觉方式展示了由两个相互调用的方法引起的无限递归创建的栈。

图 E.9 每次方法的新执行都会在栈中创建一个新的层。在递归的情况下,如果方法被调用太多次数,它可能会填满栈。当栈满时,应用程序会抛出 StackOverflowError,并且当前线程会停止。
由于每个线程都有自己的栈,StackOverflowError 只会影响栈已满的线程。进程可以继续执行,其他线程不会受到影响。此外,StackOverflowError 生成堆栈跟踪,您可以使用它来识别导致问题的代码。图 E.10 展示了此类堆栈跟踪的示例。您可以使用书中提供的项目 da-app-e-ex1 来复制此堆栈跟踪。

图 E.10 由 StackOverflowError 引起的堆栈跟踪。通常,StackOverflowError 很容易识别。堆栈跟踪显示了方法反复调用自身或一组相互调用的方法,就像这个例子一样。您可以直接进入这些方法,找出它们是如何无限期地相互调用的。
E.3 应用程序使用的堆来存储对象实例
在本节中,我们将讨论堆:Java 应用程序中所有线程共享的内存位置。堆存储对象实例数据。正如您在本节中将要看到的,堆比堆栈更容易引起问题。此外,堆相关问题的根本原因更难以找到。我们将分析对象在堆中的存储方式以及谁可以保留对它们的引用,这对于理解它们何时可以从内存中移除是相关的。此外,我们还将讨论与堆相关的问题的主要原因。您需要了解这些信息,以便理解第七章到第九章中讨论的调查技术。
注意:堆具有复杂结构。由于您不会立即需要所有这些细节,我们不会讨论所有堆的细节。我们也不会讨论字符串池或堆代际等细节。
你需要记住关于堆的第一件事是,它是由所有线程共享的内存位置(图 E.11)。这不仅允许线程相关的问题,如竞态条件发生(在附录 D 中讨论),而且还使得内存问题更难以调查。由于所有线程都在相同的内存位置添加它们创建的对象实例,一个线程可能会影响其他线程的执行。如果一个线程遭受内存泄漏(这意味着它在内存中添加实例但从未移除它们),它会影响整个进程,因为其他线程也会因为内存不足而受到影响。

图 E.11 所有线程使用相同的堆位置。如果一个线程由于问题导致堆变满(内存泄漏),另一个线程可能会发出问题信号。这种情况很常见,因为问题将由第一个无法在堆中存储数据的线程报告。因为任何线程都可以发出问题信号,并且不一定是导致问题的那个线程,所以与堆相关的问题更难以解决。
在大多数情况下,当发生OutOfMemoryError时,如图 E.11 所示,这种情况是由受问题根本原因(内存泄漏)影响的线程之外的其他线程发出的信号。OutOfMemoryError是由第一个试图在内存中添加内容但无法添加的线程发出的。
垃圾回收器(GC)是通过移除不再需要的数据来释放堆的机制。当没有任何引用指向一个对象实例时,垃圾回收器知道该对象实例不再需要。因此,如果一个对象不再需要但应用程序未能移除所有引用,垃圾回收器就不会移除该对象。当应用程序持续未能移除新创建对象的引用,直到它们填满内存(导致OutOfMemoryError)时,我们说该应用程序有内存泄漏。
一个对象实例可能从堆中的另一个对象中引用(图 E.12)。内存泄漏的一个常见例子是我们持续添加对象引用的集合。如果这些引用没有被移除,那么只要这个集合在内存中,垃圾回收器就不会移除它们;它们就变成了内存泄漏。你应该特别注意静态对象(从静态变量引用的对象实例)。这些变量一旦创建就不会消失,所以除非你显式地移除引用,否则你可以假设从静态变量引用的对象将保持整个进程的生命周期。如果该对象是一个引用其他对象且这些对象永远不会被移除的集合,它可能成为潜在的内存泄漏。

图 E.12 堆中的任何对象都可以保持对堆中其他对象的引用。垃圾回收器只有在没有任何引用存在时才能移除一个对象。
一个对象实例也可以从堆栈中引用(如图 E.13)。通常情况下,从堆栈中的引用不会导致内存泄漏,因为(如 E.2 节所述)当执行到达为应用创建该层的代码块末尾时,堆栈层会自动消失。但在某些特定情况下,当与其他问题结合时,堆栈中的引用也可能引起麻烦。想象一下,一个死锁阻止了执行通过整个代码块。堆栈中的层不会被移除,如果它继续引用对象,这也可能成为内存泄漏。

图 E.13 堆栈中的变量也可以引用堆中的实例,直到所有引用都消失(包括堆栈中的引用)才能被移除。
E.4 存储数据类型的元空间内存位置
元空间是 JVM 用来存储用于创建堆中存储的实例的数据类型的内存位置(如图 E.14)。应用需要这些信息来处理堆中的对象实例。有时,在特定条件下,OutOfMemoryError 也可以影响元空间。如果元空间满了,没有更多空间供应用存储新的数据类型,应用会抛出 OutOfMemoryError,宣布元空间已满。根据我的经验,这些错误很少见,但我希望您对此有所了解。

图 E.14 元空间是应用存储数据类型描述符的内存位置。它包含了定义堆中存储的实例的蓝图。





浙公网安备 33010602011771号