龙空技术网

Java 故障诊断 - 通过调试技术理解应用的逻辑

粥屋与包屋 104

前言:

当前兄弟们对“eclipse java application没反应”大概比较注重,姐妹们都想要剖析一些“eclipse java application没反应”的相关内容。那么小编同时在网络上网罗了一些对于“eclipse java application没反应””的相关知识,希望我们能喜欢,我们快快来了解一下吧!

不久前,在我的一节钢琴课上,我与我的钢琴老师分享了一首我想学的歌曲的乐谱。 当他第一次边看乐谱边演奏这首歌时,我印象非常深刻。 我想: “ 多酷啊? 一个人是如何 GET 到这项技能的?”

然后,我想起几年前,我和我所在公司的一位新入职的初级员工参加了一次结伴编程会议。 轮到我操控键盘了,我们当时正在使用 debugger 研究一段相对较长且复杂的代码。 我开始浏览代码,以相对较快的速度按下 step over、into 和 out 特定代码行的键盘键。 我专注于代码,但非常冷静和放松,几乎忘记有人在我身边(我很粗鲁)。 我听到这新员工说:“哇,停一下。你太快了。你能读懂代码吗?”

我意识到这种情况类似于我和钢琴老师的经历。 你如何获得这项技能?答案比你想象的要简单:努力工作,积累经验。 虽然练习是无价的,需要很多时间,但我有一些建议与你分享,可以帮助你更快地提高你的技术。 本文将讨论理解代码时最重要的工具之一: debugger。

定义: debugger 是一种工具,它允许你在特定行上暂停执行,并手动执行每条指令,同时观察数据是如何变化。

使用 debugger 就像使用高德地图导航:它可以帮助您通过代码中实现的复杂逻辑找到方向。 它也是最常用的代码理解工具。

debugger 通常是开发人员学习使用的第一个工具,以帮助他们理解代码是做什么的。 幸运的是,所有的 IDE 都带有 debugger,所以你不需要做任何特殊的事情来拥有一个 debugger。 在本系列文章中的示例,我使用 IntelliJ IDEA Community,但其他任何 IDE 都非常类似,并且提供了我们将讨论的相同选项(有时具有不同的外观)。 尽管大多数开发人员似乎都知道如何使用 debugger,但在本文中,你可能会发现一些使用 debugger 的新技巧。

我们将在本文后面会讨论开发人员如何阅读代码,以及为什么在很多情况下,仅仅阅读代码不足以理解它。进入 debugger 或 profiler。然后我们通过一个例子继续讨论使用 debugger 的最简单技术。

当阅读代码是不够理解时

让我们从讨论如何阅读代码以及为什么有时只阅读逻辑不足以理解它开始。 在这,我将解释阅读代码是如何工作的,以及它与阅读其他内容(如故事或诗歌)有何不同。 为了观察这种差异,并理解是什么导致了代码解读的复杂性,我们将使用一个实现了一小段逻辑的代码。 理解我们大脑解释代码背后的原理,可以帮助你意识到对 debugger 等工具的需求。

任何代码研究场景都从阅读代码开始。但阅读代码不同于阅读诗歌。 当阅读诗句时,你会按照给定的线性顺序逐行阅读,让你的大脑组装并描绘出其中的意思。 如果你把同一节经文读两遍,你可能会理解不同的东西。

然而,对于代码来说,情况正好相反。 首先,代码不是线性的。 阅读代码时,你不能简单地逐行阅读。 相反,你需要跳出指令,了解它们如何影响正在处理的数据。 阅读代码更像一个迷宫,而不是一条笔直的路。 而且,如果你不注意,你可能会迷路,忘记你从哪里开始。 其次,与诗歌不同的是,代码对每个人来说都意味着同样的事情。 这就是你调查的目的。

就像用指南针找路一样,debugger 可以帮助你更容易地确定代码的作用。 作为例子,我们将使用 decode(List<Integer> input) 方法。

可以在 下的 da-ch2-ex1 项目中找到这些代码。

清单 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;    }}

如果你从头读到尾,你必须假设一些事情是如何工作的,才能理解它。 那些指令真的和你想的一样吗?当您不确定时,您必须深入观察代码实际做了什么,您必须分析其背后的逻辑。 图 1 指出了给定代码片段中的两个不确定性:

StringDigitExtractor() 构造函数做什么?它可能只是创建一个对象,也可能做其他事情。可能是它以某种方式改变了给定参数的值。调用 extractDigits() 方法的结果是什么?它会返回一个数字 list 吗? 在创建 StringDigitsExtractor 构造函数时,它是否也改变了我们使用的对象中的参数?

图 1

即使是一小段代码,你也可能需要深入研究指令。 你要检查的每一个新的代码指令都会创建一个新的调查计划,并增加它的认知复杂性(图 2 和图 3)。 你对逻辑的了解越深,你的执行计划就越多,这个过程就会变得越复杂。

图 2

图 3

读诗永远只有一条路。相反,代码分析通过同一段逻辑创建了许多路径。 你开启的新计划越少,过程就越简单。 您必须在跳过某个指令,使整个调查过程更简单,或深入细节以更好地理解每个指令并提高过程复杂性之间做出选择。

提示

尽量减少可供调查的计划数量,从而缩短阅读路径。使用 debugger 可以帮助你更轻松地浏览代码,跟踪你所处的位置,并观察应用程序在执行时如何更改数据。

使用 debugger 研究调查代码

我们将讨论一个工具,它可以帮助你减少阅读代码时的认知负担,以理解代码的工作原理 —— debugger。 所有 IDE 都提供了 debugger,即使不同IDE的界面可能略有不同,但选项通常是相同的。 我将使用 IntelliJ IDEA Community,但我建议你使用你最喜欢的 IDE。

debugger 通过以下方式简化了调查过程

为您提供一种方法,以特定步骤暂停执行,并按自己的速度手动执行每个指令告诉你你在代码阅读路径中的位置和起点;这样,debugger 就像一张你可以使用的地图,而不是试图记住所有细节向您显示变量持有的值,使调查更容易可视化和处理允许你通过使用观察器和计算表达式来进行测试

清单 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;    }}

我敢肯定你在想:“我怎么知道什么时候该使用 debugger?” 在进一步讨论之前,我想先回答这个问题。 主要的先决条件是了解您想要调查的逻辑。 你将在本节中了解到,使用 debugger 的第一步是选择一条希望暂停执行的指令。

注意

除非你已经知道需要从哪条指令开始调查,否则你不能使用 debugger。

在现实世界中,你会发现在某些情况下,你事先并不知道你想要研究的逻辑。 在这种情况下,在使用 debugger 之前,你需要应用不同的技术来找到你想使用 debugger 研究的那部分代码。 这里我们只关注 debugger 的使用,因此假定你找到了你想要理解的那段代码。

回到我们的例子,我们从哪里开始?首先,我们需要阅读代码并弄清楚我们理解了什么和不理解什么。 一旦我们确定哪里的逻辑变得不清晰,我们可以执行应用程序并“告诉” debugger 暂停执行。 我们可以暂停执行那些不清楚的代码行,以观察它们是如何改变数据的。为了“告诉” debugger 在哪里暂停应用程序的执行,我们使用断点。

断点是一个标记,我们在希望 debugger 暂停执行的行上使用它,以便我们可以研究实现的逻辑。debugger 将在执行标有断点的行之前暂停执行。

在图 4 中,我用阴影标出了很容易理解的代码(考虑到你了解这门语言的基础知识)。 如你所见,这段代码接受一个 list 作为输入,解析列表,处理其中的每个元素,并以某种方式计算出一个整数,该方法最终返回该整数。 此外,该方法实现的过程很容易确定,无需 debugger。

图 4

在图 5 中,我用阴影标出了这些线,这通常会给理解这个方法带来困难。 这些代码行更难解读,因为它们隐藏了自己实现的逻辑。 你可能认识 digits.stream().collect(Collectors.summingInt(i → i)),因为它从 Java 8开始就成为 JDK 提供的 Stream API 的一部分了。 但是我们不能对 new StringDigitExtractor(s).extractDigits() 说同样的话。 因为这是我们正在调查的应用程序的一部分,这个指令可能会做任何事情。

图 5

开发人员选择的编写代码的方式也可能会增加额外的复杂性。 例如,从 Java 10 开始,开发者可以使用 var 推断局部变量的类型。 推断变量的类型并不总是明智的选择,因为这会让代码更加难以阅读(如图 5 所示)。 在这种情况下,使用 debugger 就更有必要了。

提示

使用 debugger 研究代码时,从你搞不懂的第一行代码开始。

在过去多年培训初级开发人员和学生时,我观察到,在许多情况下,他们从特定代码块的第一行开始调试。 虽然你当然可以这样做,但如果你在没有 debugger 的情况下先阅读代码,并尝试确定你是否能理解代码,这会更高效。 然后,直接从造成困难的地方开始调试。 这种方法将节省您的时间,因为您可能会发现您不需要 debugger 来了解特定逻辑片段中发生了什么。 毕竟,即使你使用 debugger,你也只需要检查你不理解的代码。

在某些情况下,您可以在一行上添加断点,因为它的意图不明显。 有时你的应用会抛出异常;你可以在日志中看到这一点,但不知道是前一行代码导致了问题。 在这种情况下,你可以添加一个断点,在抛出异常之前暂停应用程序的执行。 但思路是一样的:避免暂停执行你理解的指令。相反,对你想关注的代码行使用断点。

对于这个例子,我们先在第11行添加一个断点,如图 6 所示:

var digits = new StringDigitExtractor(s).extractDigits();

一般来说,要在任何 IDE 中的某一行上添加断点,单击行号或该行号附近即可(或者更好的做法是使用快捷键; 对于 IntelliJ,你可以在 Windows/Linux 上使用 Command-F8,在 macOS 上使用 Command-F8)。 断点会显示为一个圆点,如图 6 所示。 确保使用 debugger 运行应用程序。在 IntelliJ 中,在你用来启动应用程序的按钮附近,寻找一个表示为小 bug 图标的按钮。 你也可以右键单击主类文件,并在上下文菜单中使用 Debug 按钮。当执行到你用断点标记的那一行时,它会暂停,让你可以进一步手动导航。

图 6

由于快捷方式可能会根据你使用的操作系统而改变(有些开发人员甚至喜欢自定义它们),所以我通常不打算讨论它们。 但是,我建议你查看你的 IDE 手册并学会使用键盘快捷键。

注意

记住,你总是需要使用 Debug 选项来执行应用程序,以使 debugger 处于活动状态。 如果使用 Run 选项,则不会考虑断点,因为 IDE 不会将 debugger 附加到正在运行的进程。 有些 ide 可能会默认运行你的应用程序并附加 debugger,但如果不是这样(比如 IntelliJ 或 Eclipse),那么应用程序不会在你定义的断点处暂停。

当 debugger 在你标记为断点的行上暂停特定指令的代码执行时,你可以使用 IDE 显示的有价值的信息。 在图 7 中,你可以看到我的 IDE 显示了两个基本信息:

作用域中所有变量的值 — 知道作用域中的所有变量及其值可以帮助您了解正在处理的数据以及逻辑如何影响数据。 请记住,在执行标有断点的代码行之前,执行是暂停的,因此数据状态保持不变。执行 stack 跟踪 — 这显示了应用程序如何执行 debugger 暂停执行的代码行。stack 跟踪中的每一行都是调用链中涉及的方法。 执行 stack 跟踪帮助您可视化执行路径,在使用 debugger 导航代码时,无需记住如何到达特定的指令。

提示

你可以添加任意数量的断点,但最好一次只使用有限数量的断点,并且只关注那些代码行。 我通常同时使用不超过三个断点。我经常看到开发人员添加了太多的断点,然后忘记了它们,迷失在研究过的代码中。

图 7

一般来说,观察作用域中变量的值很容易理解。 但是,根据您的经验,您可能知道也可能不知道执行 stack 跟踪是什么。 下一节讨论了执行 stack 跟踪以及为什么这个工具是必要的。 然后,我们将讨论如何使用步进(step over)、步进入(step into)和步出(step out)等基本操作导航代码。 如果您已经熟悉执行 stack 跟踪,则可以跳过下一节。

什么是执行 stack 跟踪,我如何使用它?

执行 stack 跟踪是一个有价值的工具,您可以使用它来理解代码,同时进行调试。 就像地图一样,执行 stack 跟踪显示了执行到 debugger 暂停的特定代码行的路径,并帮助你决定进一步导航到哪里。

图 8 以树的形式对执行 stack 跟踪和执行进行了比较。 stack 跟踪显示了方法如何相互调用,直到 debugger 暂停执行为止。 在 stack 跟踪中,您可以找到方法名称、类名称和导致调用的行。

图 8

我最喜欢的执行 stack 跟踪用法之一是在执行路径中寻找隐藏的逻辑。 在大多数情况下,开发人员使用执行 stack 跟踪只是为了了解某个方法是从哪里被调用的。 但是您也需要考虑使用框架(如Spring、Hibernate等)的应用程序有时会改变方法的执行链。

例如,Spring 应用程序通常使用解耦的代码,即所谓的切面(在Java/Jakarta EE术语中,它们被称为拦截器(interceptor))。 这些方面实现了框架用来在特定条件下增强特定方法执行的逻辑。 不幸的是,这种逻辑通常很难观察,因为在阅读代码时,你无法直接在调用链中看到切面代码(如图 9 所示)。 这一特性使得研究给定的能力具有挑战性。

图 9

让我们通过一个代码示例来检查这种行为,以及执行 stack 跟踪在这种情况下如何发挥作用。 这个项目是一个小型的 Spring 应用程序,它会在控制台中打印出参数的值。

代码清单 3、4 和 5 展示了这三个类的实现。 如代码清单 3 所示,main() 方法调用了 ProductControllersaveProduct() 方法,将参数值 "Beer" 发送给 ProductController

可以在 下的 da-ch2-ex2 项目中找到这些代码。

清单 3 主类调用 ProductControllersaveProduct() 方法

public class Main {    public static void main(String[] args) {        try (var c = new AnnotationConfigApplicationContext(ProjectConfig.class)) {            c.getBean(ProductController.class).saveProduct("Beer"); // 我们调用 saveProduct() 方法,参数值为 "Beer"。        }    }}

在代码清单 4 中,你可以看到 ProductControllersaveProduct() 方法只是使用接收到的参数值调用 ProductServicesaveProduct() 方法。

清单 4 ProductController 调用 ProductService

@Componentpublic class ProductController {    private final ProductService productService;    public ProductController(ProductService productService) {        this.productService = productService;    }    public void saveProduct(String name) {        productService.saveProduct(name); // ProductController 调用该服务并发送参数值。    }}

代码清单 5 展示了 ProductServicesaveProduct() 方法,它在控制台中打印出参数的值。

清单 5 ProductService 打印参数的值

@Componentpublic class ProductService {    public void saveProduct(String name) {        System.out.println("Saving product " + name); // 在控制台中打印参数值    }}

如图 10 所示,该流程非常简单:

main() 方法调用名为 ProductController bean 的 saveProduct() 方法,并将值 "Beer" 作为参数发送。然后,ProductControllersaveProduct() 方法调用另一个 bean ProductServicesaveProduct() 方法 。ProductService bean 在控制台中打印参数的值。

图 10

很自然地,你会假设当你运行这个应用程序时,会打印出以下消息:

Saving product Beer

然而,当你运行项目时,消息是不同的:

Saving product Chocolate

这怎么可能?要回答这个问题,首先要做的是使用执行 stack 跟踪来找出是谁更改了参数值。 在那行代码中添加一个断点,打印出与预期不同的值,使用 debugger 运行应用程序,并观察执行 stack 跟踪(如图 11 所示)。 而不是从 ProductController bean 获得 ProductServicesaveProduct() 方法,您会发现一个方面改变了执行。 如果回顾一下 aspect 类,你会发现 aspect 负责将 "Beer" 替换为 "Chocolate" (代码清单 6)。

图 11

下面的代码显示了通过替换 ProductController 发送给 ProductService 的值来改变执行的方面。

清单 6 改变执行的切面逻辑

@Aspect@Componentpublic class DemoAspect {    @Around("execution(* services.ProductService.saveProduct(..))")    public void changeProduct(ProceedingJoinPoint p) throws Throwable {        p.proceed(new Object[] {"Chocolate"});    }}

在当今的 Java 应用程序框架中,切面是一个非常有趣和有用的特性。 但如果你不正确使用它们,它们会使应用程序难以理解和维护。 当然,这里讨论的相关技术可以帮助你识别和理解这种情况下的代码。 但是,相信我,如果您需要在应用程序中使用这种技术,这意味着应用程序不容易维护。 一个编码干净的应用程序(没有技术债务)总是比那些以后必须投入精力调试的应用程序更好的选择。 如果你有兴趣更好地理解 Spring 中的方面是如何工作的,我建议你阅读我写的另一本书的第6章,Spring Start Here (Manning出版社,2021年)。

使用 debugger 导航代码

在本节中,我们将讨论使用 debugger 导航代码的基本方式。你将学习如何使用三种基本的导航操作:

Step over — 继续执行同一个方法中的下一行代码。Step into — 在当前行调用的方法中继续执行。Step out — 返回调用正在调查的方法的执行。

要开始调查过程,您必须确定您希望 debugger 暂停执行的第一行代码。 要理解逻辑,你需要浏览代码行,并观察不同指令执行时数据如何变化。

GUI 上有按钮和键盘快捷键,可以在任何IDE中使用导航操作。 展示了这些按钮在我使用的 IDE IntelliJ IDEA Community GUI 中的显示方式。

图 12

提示

即使一开始你觉得使用 IDE GUI 上的按钮更容易,我还是建议你使用键盘快捷键。如果你习惯使用键盘快捷键,你会发现它们比鼠标快得多。

图 13 直观地描述了导航操作。在同一个方法中,可以使用 step over 操作转到下一行。一般来说,这是最常用的导航操作。

图 13

有时你需要更好地理解特定指令会发生什么。 在我们的例子中,你可能需要输入 extractDigits() 方法才能清楚地理解它的作用。 对于这种情况,可以使用 step into 操作。如果想返回到 decode() 方法,可以使用 step out。

你还可以将执行 stack 跟踪上的操作可视化,如图 14 所示。

图 14

理想情况下,当你试图理解一段代码是如何工作的时候,你可以从尽可能多地使用 step over 操作开始。 你进入的范围越大,展开的调查计划就越多,因此调查过程也就变得越复杂(参见图 15)。 在很多情况下,只需要 step over 并观察输出,就可以推断出某一行代码的作用。

图 15

图 16 展示了使用 step over 导航操作的结果。 执行在第12行暂停,比我们最初使用断点暂停 debugger 的地方少一行。 digits 变量现在也被初始化了,因此我们可以看到它的值。

图 16

尝试多次继续执行。从第11行可以看到,对于每个字符串的输入,结果都是一个列表,其中包含给定字符串中的所有数字。 通常,只要分析几次执行的输出,逻辑就很容易理解。但是,如果仅通过执行无法知道某一行代码的作用,该怎么办呢?

如果你不明白发生了什么,你需要在那行代码中详细描述。 这应该是你的最后一个选择,因为它要求你打开一个新的调查计划,这将使你的过程复杂化。 但是,当你没有其他选择时,你可以 step into 进入指令,以获得有关代码功能的更多细节。图 17 展示了进入 Decoder 类的第11行后的结果:

var digits = new StringDigitExtractor(s).extractDigits();

图 17

如果您 step into 一个指令,请先花时间阅读该代码行背后的内容。在许多情况下,查看代码就足以发现发生了什么,然后就可以回到进入之前的位置。 我经常观察到学生们在开始调试他们的方法时,没有先冷静下来并阅读那段代码。 为什么先阅读代码很重要?因为进入一个方法会开启另一个调查计划,所以如果想提高效率,就必须重新执行调查步骤:

阅读这个方法,找出第一行你不理解的代码。在这行代码上添加一个断点,然后从那里开始调查。

通常,如果你停下来阅读代码,就会发现不需要继续执行调查计划了。 如果你已经明白发生了什么,你只需要回到之前的位置。 你可以用 step out 操作来做到这一点。 图 18 展示了从 extractDigits() 方法使用 step out 时发生的情况:执行返回到 decode(List<String> input) 方法中先前的调查计划。

提示

step out 操作可节省时间。当进入一个新的调查计划时(通过进入代码行),首先阅读新的代码片段。一旦您了解了代码的作用,就走出新的调查计划。

图 18

为什么下一行执行并不总是下一行?

在讨论使用 debugger 进行代码导航时,我经常讨论 “下一行执行”。我想确保我清楚“下一行”和“下一个执行行”之间的区别。

下一行代码是应用程序接下来执行的代码。当我们说 debugger 在第12行暂停执行时,下一行总是第13行,但下一执行行可能不同。 例如,如果第12行没有抛出异常(如下图所示),下一行执行的代码就是第13行;但如果第12行抛出异常,下一行执行的代码就是第18行。可以在 下的 da-ch2-ex3 项目中找到这些代码。

当使用 step over 操作时,执行将继续到下一个执行行。

在这幅图中,我们从第12行开始执行,第12行抛出了一个异常;执行继续到第18行,这是下一个执行行。换句话说,下一行代码并不总是下一行代码。

2.3 使用 debugger 时可能还不够

debugger 是一个很好的工具,它可以通过浏览代码来帮助你分析代码,理解它是如何处理数据的。 但并不是所有的代码都可以使用 debugger 进行研究。 在本节中,我们将讨论一些无法使用 debugger 或还不够使用 debugger 的情况。 你需要注意这些情况,这样你就不会浪费时间使用 debugger。

当使用 debugger(或仅使用 debugger)通常不是正确的方法时,以下是一些最常见的调查场景:

当你不知道哪个部分的代码产生输出时,调查输出问题调查性能问题调查整个应用程序失效的崩溃问题调查多线程的实现

提示

请记住,使用 debugger 的一个关键前提是知道在哪里暂停执行。

在开始调试之前,你需要找到产生错误输出的那部分代码。 根据应用程序的不同,在实现的逻辑中可能更容易找到发生的事情。 如果应用程序有一个干净的类设计,那么就相对容易找到应用程序中负责输出的部分。 如果应用程序缺乏类设计,那么发现哪里发生了什么以及在哪里使用 debugger 可能会更有挑战性。 在接下来的几篇中,你将学习其他几种技术。其中一些技术,例如分析应用程序或使用stub,将帮助您确定从哪里开始使用 debugger 进行调查。

性能问题是一组你通常无法用 debugger 研究的问题。 缓慢的应用程序或完全坚持不动的应用程序是常见的性能问题。 大多数情况下,性能分析和日志记录技术可以帮助你解决这些问题。 对于应用程序完全阻塞的特定情况,获取和分析 thread dump 通常是最直接的调查路径。 我们还将讨论如何分析 thread dump。

如果应用程序遇到问题并且执行停止(应用程序崩溃),你就不能在代码上使用 debugger。 debugger 允许你观察运行中的应用。 如果应用程序不再执行,debugger 显然帮不了什么忙。 根据具体情况,你可能需要审计日志,也可能需要研究 thread dump 或 heap dump。

大多数开发人员发现多线程实现是最具挑战性的。 这样的实现很容易受到工具(如 debugger)干扰的影响。 这种干扰会产生 Heisenberg 效应:使用 debugger 和不干扰 debugger 时,应用的行为是不同的。 你将了解到,有时可以将调查工作隔离到一个线程中,并使用 debugger。 但在大多数情况下,你将不得不应用一套技术,包括调试、模拟和存根,以及分析,以了解应用程序在最复杂的场景中的行为。

标签: #eclipse java application没反应