一次死锁问题排查记录

一次死锁问题排查记录

马草原 680 2022-11-23

一次死锁问题排查记录

问题背景

一个很简单的接口,但是部分机器会HTTP 504 Timeout
重启应用后又可以恢复,只有这个接口偶现504问题。

分析问题

Thread Dump

首先查看线程信息,发现线程都处于等待状态(wait)
(部分业务代码无法公开 涂抹掉了 无关紧要 不影响分析)
Object_wait

线程都处于wait状态 第一时间就考虑死锁问题
通过Thread Dump定位到具体执行的代码:

一个简单的工具类

public class DictEnumUtil {
    public static final List<Class> ENUM_CLASS_LIST = findclassesInPackage();

    private static List<Class> findclassesInPackage() {
        return PackageUtils.findClassesInPackage("com.xxx.enums", null);
    }
}

当第一次使用DictEnumUtil时,会触发静态变量ENUM_CLASS_LIST初始化,此时会调用findClassesInPackage()方法,而该方法的作用就是针对指定目录进行扫描,将该目录下的所有Java类扫出来,并通过Class.forName()方法进行加载,返回Class对象。

那么是加载哪个Class的时候死锁了呢?这就需要Heap Dump来分析了。

Heap Dump

无法公开业务代码数据,不影响分析 目的是为了找到加载谁的时候卡主

Heap Dump中可以看到是在执行Class.forName("xxx.xxx.xxx.InvestStatusEnum$3")时卡主的。

InvestStatusEnum枚举代码:
public enum InvestStatusEnum implements EntityStatusEnum {

    DRAFT("0", "", "Draft") {
        @Override
        public List<EntityStatusEnum> legaloldStatusList() {
            return Lists.newArrayList();
        }
    },
    FORMAL("2", "В", "Started") {
        @Override
        public List<EntityStatusEnum> legaloldStatusList() {
            return Lists.newArrayList(DRAFT, null);
        }
    },
    TERMINATED("95", "EIt", "Terminated") {
        @Override
        public List<EntityStatusEnum> legaloldStatusList() {
            return Lists.newArrayList(DRAFT, FORMAL);
        }
    };

    private final String status;
    private final String chLabel;
    private final String enLabel;


    InvestStatusEnum(String status, String chLabel, String enLabel) {
        this.status = status;
        this.chLabel = chLabel;
        this.enLabel = enLabel;
    }

    public static InvestStatusEnum getByStatus(String status) {
        for (InvestStatusEnum value : InvestStatusEnum.values()) {
            if (value.getStatus().equals(status)) {
                return value;
            }
        }
        return null;
    }

    public String getStatus() {
        return status;
    }

    public String getChLabel() {
        return chLabel;
    }

    public String getEnLabel() {
        return enLabel;
    }
}

这个枚举的写法有点不太一样,实际上这个枚举内部包含了三个内部类。

枚举中使用了{},这种写法隐藏的含义是定义了一个匿名类

执行执行Class.forName("xxx.xxx.xxx.InvestStatusEnum$3")实际上是在加载InvestStatusEnum中的内部类。

问题的根本:

如果有两个线程同时使用InvestStatusEnum,一个执行到Class.forName("xxx.InvestStatusEnum$3"),一个在实例化xxxx.InvestStatusEnum时,发生了死锁。并且由于它们俩正在加载的类并没有加载完成,因此后续如果再有其它线程执行到了需要使用InvestStatusEnum这个类的地方,也都会阻塞住!


本地重现问题

这里在本地写一个Demo来重现一下这个bug

首先定义个带有内部类的枚举:

public enum TestEnum {

    // 枚举内部类1
    TEST_ONE {

    },

    // 枚举内部类2
    TEST_TWO {

    },

    // 枚举内部类3
    TEST_THREE {

    };

}

多线程使用枚举:

public class DeadLockTest {

    public static void main(String[] args) {
        Thread a = new Thread(() -> {
            try {
                Class.forName("com.mcaoyuan.blog.TestEnum$2");
                System.out.println("Thread A is end.");
            } catch (ClassNotFoundException e) {
                System.out.println("Thread A cause Exception!");
            }
        });

        Thread b = new Thread(() -> {
            Object enumObj = TestEnum.TEST_TWO;
            System.out.println("Thread B is end.");
        });

        a.start();
        b.start();
    }

}

程序输出:

Thread B is end.
Thread A is end.

Process finished with exit code 0

看起来是没问题的,但是这个问题是偶现的 需要多试几次…
重试了大概五六次 发现控制台不输出任何代码,程序也没有终止
代码卡住了…

通过MacOS的线程采样可以看到更详细的细节:

MacOS 活动监视器-找到Java线程-采样
77443-1700041821981

从这个堆栈看就一目了然了,它们其实都在执行InstanceKlass::initialize_impl(instanceKlassHandle, Thread*)这个函数时,发生了锁等待情况(ObjectMonitor::wait()就是在在等锁)。

关于ObjectMonitor在锁原理中有说到
Synchronized锁实现原理: https://www.mcaoyuan.com/archives/synchronized

这个函数又在做什么呢?我们直接在JDK源码中搜索函数名
找到的代码如下:
InstanceKlass

从这段代码可知,在实例化InstanceKlass时,会有加锁的操作,以防止同一个类被多个线程初始化。
但是多线程同时在初始化枚举中的不同内部类的时候就出现了互相持有对方需要的锁的情况,也就是出现了死锁,程序卡在这里了。

问题修复

通过上面的原理分析可知,触发死锁需要满足两个条件:

  • 定义的枚举类包含匿名内部类
  • 两个线程分别通过Class.forName() 和 直接调用枚举值 两种方式来初始化该枚举类。

因此我们的枚举应该尽可能的简单 尽量不要定义内部类。
但是对于这次的问题,在不变更业务逻辑的前提下,最简单的修复方案,就是把DictEnumUtil这个类注册成Spring Bean,然后在初始化时显式调用扫包加载类的方法。避免在使用的时候并发加载问题。

修复后的代码:

@Component
public class DictEnumUtil {
    public static final List<Class> ENUM_CLASS_LIST = findclassesInPackage();

    @PostConstruct
    private static List<Class> findclassesInPackage() {
        return PackageUtils.findClassesInPackage("com.xxxx.enums", null);
    }
}