研讨课分享-多线程debug

【优秀研讨分享】多线程debug思路及工具

本文记录了 3 月 31 日研讨课上本组的讨论和汇报内容,受老师和助教邀请整理成技术文章并发布。

小组成员:

  • 谢逸凡
  • 李嘉鹏
  • 池昀震
  • 刘尚鹭
  • 陈伟杰
  • 鹿煜恒
  • 权尚浩然

研讨课讨论分享——多线程debug思路及工具

多线程死锁问题的原因

多线程的死锁即存在一直等待锁却永远拿不到锁的线程。为什么会出现这个问题?我们可以用抽象逻辑的方式分析。

我们假设一个线程等待某个锁,那么这个被阻塞的状态可以抽象为一个线程A指向当前锁的拥有者B的有向线段。只有B释放锁,状态才会消除。

image-1

这时如果B再等待C,则有:

image-2

这样形成的等待链并不会导致死锁,而真正导致死锁的是环形结构:

image-3

如果把局部等待链看作同一个线程来分析,则可以简化为

image-4

可以看出来,只要程序中出现了这种环形结构,就有可能产生死锁。每条线段的终点对应着一个获取了锁的线程,如果我们简化问题为每个锁只能被一个线程获取,则每条线段都唯一代表一个多线程共享的锁。这意味着我们只要保证所有线程之间不可能形成环形结构,就从原理上保证了不可能产生死锁。

观察可以得知,环形结构的形成前提是其中的节点线程在拥有一个锁的同时想要获取另一个锁,而另一个锁的拥有者因为某种原因阻塞于当前线程已经拥有的锁,即多个锁的同时获取问题。对于任何一个线程,我们想要确保不会出现死锁只需要保证不出现这种循环等待链

如何保证这一点呢? 我们提出假设:在任意一个线程中需要同时获取多个锁的地方,只要在线程有交互的全局范围内保证任意两个锁获取的先后顺序一致就可以保证不出现循环等待即死锁。

我们来证明这一点。如果满足这个条件,则任意线程中正在阻塞获取的锁的全局排序一定大于所有已经获得的锁的排序。如果存在死锁环,任取其中的一个节点A,设它是锚定节点,它所拥有的锁的最大全局排序为X。由于A指向的节点包含A还没有获取的节点,则A指向的节点已经获取的锁所拥有的最大全局排序一定大于X,更新A为这个新的节点、更新X为新节点的最大全局排序,并重复上述操作。当A再一次为锚定节点时,我们得到了A的已拥有锁的最大全局排序大于自身这一矛盾,由此我们得证,只要满足了这个条件,就不可能出现死锁问题。

线程安全问题的常见误区

只要对象线程安全就不需要手动上锁锁?

不一定,线程安全的对象只能保证访问对象的结果正确,如果涉及到课上讲到的 Read-modify-write和check-then-act等计算方法则无法保证程序正确。需要手动上锁以确保状态不再计算过程中被其他线程改变。

死锁问题调试工具-jconsole

在此我简单介绍一个java自带的工具:jconsole,它保存在java安装目录的bin目录中,即java.exe的同目录下。

这个工具有着强大的调试能力,它既可以查看每个进程目前的执行位置,也可以查看进程cpu时间,为我们的调试带来极大便利。

为此我将以一个死锁为例分几步进行介绍,首先我们构造一个死锁程序,这个程序由于获取锁的顺序不一致并添加了延时,故会100%触发死锁。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class Thread1 implements Runnable {
    Lock lock1;
    Lock lock2;
    public Thread1(boolean lock1First,Lock lock1,Lock lock2){
        if(lock1First){
            this.lock1 = lock1;
            this.lock2 = lock2;
        }else{
            this.lock2 = lock1;
            this.lock1 = lock2;
        }
    }

    @Override
    public void run() {
        lock1.lock();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        lock2.lock();
        lock1.unlock();
        lock2.unlock();
    }
}
public class Main {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock1 = new ReentrantLock();
        ReentrantLock lock2 = new ReentrantLock();
        Thread t1 = new Thread(new Thread1(true,lock1,lock2)); //先获取lock1
        Thread t2 = new Thread(new Thread1(false,lock1,lock2));//先获取lock2
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

直接在idea里执行,或者打包为locktest.jar后使用java -jar locktest.jar执行。这里选择后者,以模拟实际各种评测机的运行,在运行后,我们发现线程卡死没有输出。

运行工具并连接进程

首先运行该工具,cmd命令行执行jconsole

如果未找到该命令,则可以手动查找正确版本的java安装目录下的bin目录,将该目录加到系统Path环境变量中或者直接在该目录下运行jconsole.exe

得到以下窗口,其中本地进程中包含了当前系统正在运行的java虚拟机。

在此,我们可以看到其中pid为2676的一项对应着我们卡死的进程,单机它点击连接,选择不安全的连接,等待连接成功。

image-20230331105813616

连接成功后,我们得到以下窗口:

image-20230331110451205

工具简要介绍

  1. 概览:查看线程数量、类数量、堆内存使用量等数据。
  2. 内存:查看各内存各部分的占用比例,可以手动触发GC

image-20230331110638652

  1. 线程:多线程调试的关键,可以手动查看各个线程的运行状态,并且可以通过堆栈跟踪分别查看每个线程目前执行位置,这正是我们可以用来查找死锁的方式。点击下方检测死锁可以一键筛选死锁线程进行进一步分析。

image-20230331110700398

  1. 类:不做过多介绍,主要显示已加载类数。
  2. VM概要:可以查看进程CPU时间,对于轮询sleep等方案的调试可以进行数据构造和检查以排查cpu tle。
  3. MBean:不涉及,不进行介绍。

死锁分析实例

我们以上述构造的代码为例尝试分析死锁原因。在线程中点击检测死锁,得到两个相关死锁线程:

image1

image2

可见,Thread-0和Thread-1都执行到了Main.java第24行,对应代码lock2.lock();,状态为WAITING,锁的拥有者是对方。如此我们便准确定位了相关代码行数,得知两个线程在获得了各自的lock1之后同时等待对方拥有的锁,最终导致死锁。

总结

上述分析工具仅仅是排查死锁问题的辅助,更重要的仍然是对于代码逻辑的分析。

Licensed under CC BY-NC-SA 4.0
京ICP备2021032224号-1
Built with Hugo
主题 StackJimmy 设计
vi ./themes/hugo-theme-learn/layouts/partials/footer.html