Junit单元测试不支持多线程测试问题全解析

2021-08-31 14:36:51 浏览数 (1)

一、背景

今天@段段提出了一个很好的问题,她发现单元测试时如果开多个线程,主线程运行结束就结束了,并不会等待子线程结束。

如果用main方法就没问题,技术群里展开了激烈的讨论。

本文将“复现”这种现象,并给出多种解决方案,并纠正个别文章的错误,并追到源头带大家找出问题的原因。

本文会分享几点干货,如调用栈大法、发编译大法等。

二、复现场景的源码

我们的线程类

代码语言:javascript复制
public class DemoThread extends Thread {

    private static int num = 0;

    public DemoThread() {
    }

    @Override
    public void run() {
        synchronized (DemoThread.class) {
            for (int i = 0; i < 4; i  ) {
                System.out.println(Thread.currentThread().getName()   "-->"   num  );
                try {
                    TimeUnit.MILLISECONDS.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static int getNum() {
        return num;
    }
}

测试代码

代码语言:javascript复制
public class ThreadDemoTest {

    @Test
    public void test(){

        DemoThread thread1 = new DemoThread();

        DemoThread thread2 = new DemoThread();

        thread1.start();
        thread2.start();

    }
}

运行

和预期完全不符,预期应该是  线程1输出0-3,线程2输出4-7,结果就输出一个,什么情况??

三、解决方案

所有的解决方法的核心是:在子线程没结束之前让主线程阻塞住。

3.1 使用main函数

代码语言:javascript复制
public class ThreadDemoTest {


    public static void main(String[] args) {
        DemoThread thread1 = new DemoThread();

        DemoThread thread2 = new DemoThread();

        thread1.start();
        thread2.start();
    }
}

结果

发现符合预期

3.2 CountDownLatch

我们的线程类:

代码语言:javascript复制
public class DemoThread extends Thread {
    private CountDownLatch latch;
    private static int num = 0;

    public DemoThread(CountDownLatch latch) {
        this.latch = latch;
    }

    @Override
    public void run() {
        synchronized (DemoThread.class) {
            for (int i = 0; i < 4; i  ) {
                System.out.println(Thread.currentThread().getName()   "-->"   num  );
                try {
                    TimeUnit.MILLISECONDS.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            latch.countDown();
        }
    }

    public static int getNum() {
        return num;
    }
}

我们的单元测试类

代码语言:javascript复制
public class ThreadDemoTest {

    @Test
    public void test() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(2);

        DemoThread thread1 = new DemoThread(latch);

        DemoThread thread2 = new DemoThread(latch);

        thread1.start();
        thread2.start();

        latch.await();
    }
}

结果依然符合预期

3.3 使用join函数

代码语言:javascript复制
public class ThreadDemoTest {

    @Test
    public void test() throws InterruptedException{

        DemoThread thread1 = new DemoThread();

        DemoThread thread2 = new DemoThread();

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();
    }
}

结果依然符合预期

当然还有其他办法,殊途同归。

四、研究

有一些人对这种现象进行了研究如《Junit单元测试不支持多线程测试--原因分析和问题解决》,很有道理。

但是最起码在Idea里发现有出入。

但是在其中提到的TestRunner各个退出虚拟机的地方断点,整个运行过程中没有一个断点停下来。

上调用栈大法:

我们发现主函数的入口是在Idea的jar包中。

我们双击调用栈这里,发现进不去源码(正常是可以双击进去的)。

肿么办?难道就此放弃??应该不是我们的风格。 

皮皮虾我们走,去找源码去。

在网上搜了半天没找到,囧....

然后,我们就此放弃??不是我们的风格。

看这里包名:com.intellij.rt.execution.junit 这是一个很重要的线索

既然Idea可以加载到这个类,这个Jar包应该在我们的类路径里,而包名就是intellij显然应该在Idea的安装目录中。

隐藏的很深,去lib找一下,没有,去plugins找一下,

进去,发现这几个很可疑

上Java的jar包反编译工具大法

把这几个jar包都放到jd-gui(下载地址:http://java-decompiler.github.io/)中反编译一下看看,发现junit.jar里就有我们想找的入口

其中system.exit这里是关键,另外属性里看到的值和上面断点。

感兴趣可以一层一层直接跟到Junit4的源码里看看,入口在这里:

org.junit.runner.JUnitCore#run(org.junit.runner.Runner)

原理就是如果不主动阻塞,主线程停止后,main方法会调用System.exit退出虚拟机,如果子线程耗时较长,导致子线程没执行完就销毁了。

即test方法运行在主线程中,外层函数执行完test等操作后执行System.exit来退出虚拟机,这个时候thread1和thread2可能还没执行完,就被销毁了。

而使用main方法

启动完thread2后,主线程结束,但是只有还有普通线程(非守护线程),虚拟机就不会主动退出,而我们也没有写代码让虚拟机退出,因此虚拟机需等待thread1和thread2运行完毕才退出。因此打印出了0-7。

其实即使使用main函数如果我们最后加上退出虚拟机的命令效果也是一样,依然是子线程没执行完虚拟机退出:

另外回头看

另外我们看顶层的main函数,参数包含了junit版本,和测试类和测试方法,作为运行时参数传给JUnitStarter的main函数。

五、Learn More

通过上面的分析我们不仅要了解到为什么单元测试时,主线程结束就结束了而不等待子线程。

更重要的是我们要掌握常见的调试和研究源码的方法,如调用栈大法,反编译jar包大法。

通过查看调用栈,可以了解整个调用的全貌,可以去查看上层大调用代码,这对学习源码、熟悉某个框架有极大的帮助。

当我们拉取不到源码或者项目里暂时不需要加入这个jar包时,可以直接下载jar包拖到反编译工具中去看。

六、总结

我们不要轻易的相信网上的各种文章,如果有时间要自己去写个demo代码去断点调试一下,实践是检验真理的标准! 

这点和《几个本地搭建练习项目来学习的小技巧分享》所提到的非常一致,我们可以在本地创建demo项目去研究和学习源码等。

另外我们不仅要思考问题,还要写代码去印证自己的想法,当要诡异的现象时,要及时研究其原因,这是你深入学习某个知识点的最好机会之一。

0 人点赞