教程:检测并发问题
与单线程应用程序中的错误相比,并发相关的错误通常更棘手,因为它们具有随机性。 一个应用程序可能会无故运行一千次,但随后出于未知原因突然失败。
在本教程中,我们将分析一个代码示例,该示例演示了调试和分析多线程应用程序的核心原理。
问题
一个常见的并发相关错误示例是竞争条件。 当多个线程同时修改共享数据而未正确同步时,就会发生这种情况。 只要两个线程中的读取和写入不重叠,这样的代码可能运行良好。

这种重叠可能非常罕见,从而让我们误以为代码中没有缺陷。 但是,当线程操作重叠时,数据会被破坏。

如果我们不考虑这一点,就无法保证线程不会同时操作数据,尤其是当我们处理比单次读取和写入更复杂的内容时。 幸运的是,Java 具有内置的同步机制,确保一次只有一个线程处理数据。
让我们考虑以下代码:
addIfAbsent 方法检查列表是否包含特定元素,如果不包含,则添加它。 我们从不同的线程调用此方法两次。 两次传递相同的整数值(17 ),并且由于保护条件((!a.contains(x)) ),只有第一个调用该方法的线程应该能够添加该值。 使用 SynchronizedList 是为了 防止 竞争条件。 最后, System.out.println(a) 语句打印出列表的内容。
如果我们长期使用此代码,会发现它有时仍会产生意外结果。
要找出原因,让我们检查代码的运行方式,看看我们是否真的设法防止了竞争条件。
复现该错误
使用 IntelliJ IDEA 调试器,您可以通过控制单个线程的执行而不是整个应用程序,测试应用程序的多线程设计并重现并发相关的错误。
在将元素添加到列表的语句处设置断点。

将断点配置为仅挂起其被击中的线程。 这将确保两个线程在同一行暂停。 要执行此操作,请右键点击断点,然后点击 会话。

通过点击 运行 按钮(位于
main方法附近)并选择 调试 开始调试会话。
当程序运行时,两个线程分别在
addIfAbsent方法中被挂起。 现在,您可以在 线程 选项卡中切换线程并控制每个线程的执行。此时,两个线程都已经检查过列表中不包含
17,并准备将该数字添加到列表中。在 线程 选项卡中,切换到
Thread-0。
通过按下 F9 或点击
恢复线程,位于 调试 工具窗口的左侧。
恢复
Thread-0后,会继续将17添加到列表中,然后终止。 之后,调试器会自动切换回主线程。恢复主线程以执行剩余的语句,然后终止。
请查看 控制台 选项卡中的程序输出。

输出 [17, 17] 显示,两个线程能够绕过设置不正确的保护条件和同步,添加了相同的值。 我们使用调试器重现了事件的顺序,这表明存在竞争条件,我们需要纠正我们的方法。
修复程序
正如我们刚刚看到的,仅使用 SynchronizedList 是不够的。 它确保一次只有一个线程修改列表。 但是,我们仍然应该考虑到,检查 if (!a.contains(x)) 和修改 a.add(x) 不是一个原子操作。 因此,两个线程都能够评估条件并同时进入代码块。
让我们通过将条件包装在一个同步块中来修正代码。
现在我们可以用修正后的代码重复该过程,并确保问题不再存在。