IDEA多线程调试(第二篇)

这篇IDEA官方帮助文档中详细地介绍了如何深层次的调试。其中包含9个调试方法。

  • 无断点调试已编译的代码
  • 无源码调试
  • 检测未知的状态和程序流
  • 调试异步程序流
  • 调试多线程程序
  • 调试长运行场景程序
  • 查找竞争条件
  • 检测死锁
  • 检测活锁

这篇博客重点讲解如何利用IDEA调试多线程程序。对调试来说,多线程程序是最大的挑战。程序很难决定和控制。
在排查并发错误的问题时,我们需要用少步骤多调整策略设置断点,因为很多并发错误都依赖于不同线程之间的特定交互,而侵入式调试(如计算表达式)会话会干扰它。如何使用各种断点的属性将干扰降低到最小?如何在程序中控制和切换不同的线程。下面通过调试不同并发错误的示例来演示IntelliJ IDEA如何帮助解决这些问题。

控制断点

IntelliJ IDEA调试器允许我们控制触发断点时采取的动作。可以定义了一个动作,或者增加动作的进一步条件。这种级别的控制对于并发错误至关重要,因为大多数只有在线程以非常特定的方式进行交互时才会被重现。断点的任何干扰都可能阻止我们重现这个bug。

断点动作

断点动作取决于我们要在调试中达到的目的。在代码中定义一个条件或者断点使得我们可以进一步查看整个系统的状态,这样需要暂停整个虚拟机。
有时候暂停一个线程比暂停虚拟机更有意义。特别的是,当整个程序时一个大系统的一部分,暂停虚拟机将会导致信息流等待存储或者请求超时的情况,影响整个系统。当有很多工作线程,我们只需要专注一个对我们有意义的线程,而让其他线程继续工作。
当我们解决并发bug时,任何操作的暂停都有可能阻止重现这个bug。我们可以让控制台仅仅打印一条消息或者表达式的值而不是暂停所有的事情。然后监测这个输出。当我们深刻知道我们需求什么的时候,这会工作的很好。

使用条件限制断点

断点让我们精准地到达我们想去的目的地。条件表达式是使用最广泛的控制条件,程序到达某个点时触发断点产生动作。当程序出现某个错误时,就可以使用条件表达式来进行断点调试。传值在程序多次执行的时候很有用,会触发事件处理、循环和感兴趣的场景执行。
Remove Once Hit选项在以下两种场景下很有用:

  • 当断点动作是输出而不是暂意味着程序命中后我们不能清除这个断点或者注销这个断点。
  • 当代码被多线程执行时,我们只想暂停其中一个。

使用这个功能可以重现并发时间,能够帮助我们暂停线程并控制线程的到达断点处的时间和顺序。

调试长运行场景程序

方法断点字段检查点都会降低代码执行速度,当相同的代码被执行很多遍,就连条件断点也会变的缓慢执行才能够被查看到。所有当遇到处理百万事件的场景,调试就会变的很麻烦,因为计算一个断点状态会把系统变得不稳定。为了克服这种情况,假设我们可以修改正在运行的代码,我们可以通过一个技巧来提高运行速度,这个技巧叫做代码内断点Breakpoint in code)。调试百万个事件而只有一个导致问题发生,事先我们又不知道哪一个是有问题的,这个技巧就会帮我们节约等待断点被触发的时间。被JVM编译和优化的代码是执行最快的代码。把这个理论引入到已执行的代码中,稍后我们可以对其操作。不需要任何断点的运行代码,只有当代码会被命中的时候再加入断点。

查找竞争条件

竞争条件是多线程程序普遍会遇到的问题。多个线程访问和修改相同的状态,就会潜在地影响它或者导致不期望的程序流。竞争条件是微妙的bug,只有当多个线程以一个非常特殊的顺序去执行的时候才会发生,通常很难重现。其它顺序的执行看起来很好并不会产生问题。
当我们在多个线程中查找竞争条件时,调试尽可能的使用非侵入方式来避免影响程序执行顺序。一旦我们掌握了导致bug到一些信息或者有执行顺序的假设,就可以使用调试特性通过断点间的依赖来重现bug。

检测导致干扰状态的竞争条件

有时候系统运行上十次甚至上百次才会出现竞争条件。如果我们怀疑在多线程代码中存在竞争条件,必须确保调试的侵入式特征不会让问题难以重现。一下是创建的一个发布者和订阅者的系统,然而所有的订阅者共享一个计数器来计算消费信息的次数。

private class Subscriber implements Runnable
{
    @Override
    public void run()
    {
        while (true)
        {
            String msg = messageQueue.poll();
            if (msg != null)
            {
                if (msg.equals(STOP))
                {
                    break;
                }
                else
                {
                    // race condition right here!
                    counter++;
                }
            }
        }
    }
}

一旦我们确定在调试模式下可以重现bug,我们尽可能地去设置一个打印断点而不是去暂停程序。然而来自所有线程的同一输出窗口打印可能会“同步”线程,这种情况下有可能会解决掉bug。所以我们要确保多次尝试来让bug重现。打印有问题的状态可以缩小我们的选择范围,让我们能够意识到问题不是出在数字的多次调用上而是出在counter变量上。

避免调试开销

竞争条件很微妙,用微妙这个词意味着对运行时环境的任何修改会“修理”它。

追踪缓冲区
引入一个内部的buffer,在其中存储感兴趣的变量。要让它局部化并且在输出打印时非常有效,要确保:

  • 每一个thread都要有一个buffer,这些buffer都是孤立的,所以不会引入新的并发问题
  • 因为每个thread都有其buffer,所以不必进行现场安全防护,避免引入同步点
  • 插入的变量不能是真正状态的引用,而可以是复制和打印消息
  • 引入的代码越少越好,减少运行代码的影响
  • 在程序执行完结后打印输出消息内容,避免线程间打印动作的同步原理影响
private class Subscriber implements Runnable
{
    private int index = 0;
    private final int[] traceBuffer = new int[NUMBER_OF_SUBSCRIBERS_AND_PUBLISHERS * 100];

    @Override
    public void run()
    {
        while (true)
        {
            String msg = messageQueue.poll();
            if (msg != null)
            {
                if (msg.equals(STOP))
                {
                    break;
                }
                else
                {
                    traceBuffer[index++] = counter;
                    // race condition right here!
                    counter++;
                }
            }
        }
    }
}

上述代码中引入了整型数组常量,其容量足够大。我们在counter加1前存储其值,在所有的操作完成后,查看数组的值,如若发现重复的值,就可以验证我们的假设。

检测产生非期望控制流的竞争条件
private class Subscriber implements Runnable
{
    @Override
    public void run()
    {
        String msg;
        while (true)
        {
            msg = messageQueue.poll();
            if (msg != null)
            {
                if (msg.equals(STOP))
                {
                    break;
                }
                // else do something
            }
        }
        // Will NOT work with multiple subscribers, as main thread will
        // wake up when the first subscriber is done.
        // Using a CountDownLatch here is a much better approach.
        synchronized (messageQueue)
        {
            messageQueue.notify();
        }
    }
}

上述代码中出现的竞争条件就是非期望控制流产生的,即使第二个线程正在获取消息,前一个subscriber又在唤醒主线程。这一次不能使用上述的检测方法,但是可以暂停整个应用程序来检测不同线程的位置。

  1. 在主线程唤醒后暂停线程,可以发现其中的一个subscriber线程处于running状态,这就会让我们猜测到问题的根源是notify()方法被调用太久;
  2. 暂停其中一个subscriber线程,也会导致其它的线程通知主线程,这就可以说明问题会发生在任何subscriber线程上;
  3. 为了进一步验证,在subscriber线程通知主线程前暂停整个虚拟机。可以发现有两个subscriber线程的状态是running,这就说明其中一个仍在polling,而另一个已经结束并要通知主线程;
    4.
    在这个截图中, 有两个subscriber线程被标记为running,意味着第一个将要通知主线程即将完成,另一个仍在处理消息;
  4. 为了让假设毫无问题,在subscriber获取消息的循环中设置断点,这个断点取决于上一个通知主线程完成的断点,启动这个断点调试来验证我们的理论。
检测死锁
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值