一次并发编程问题复盘

在这里插入图片描述

一次并发编程问题复盘

别人都是代码在测试环境运行正常,然后发布生产环境后发现异常。我这好家伙,在idea中启动一切正常,一旦在命令行启动用Java命令启动,直接就不一样了

问题描述

最近在开发一个项目,其中有一个场景就是启动多个线程来执行多个(2000左右)任务,可在开发的过程中我发现一个奇怪的现象。在我用idea测试我的程序时,无论是启1个线程、10个线程、还是50个线程……和1个任务、100个任务、还是2000个任务,程序运行一切正常,运行结果也正是我所期待的。由于这个项目需要提供一个命令行工具的版本,所以我正一副准备搞完下班的样子将程序打包,然后用Java命令启动测试一下开发已久的成果。结果,果然令我大吃一惊!启动是正常了,可运行速度却只有在idea启动的一层!这是为何,简直让我百思不得其姐啊~~~

通用jstack查线程状态后,有以下发现:

  • idea工具启动程序时,core线程全部启动,且运行状态都是RUNNING状态
  • 通过命令行Java命令启动时,core线程全部启动,可运行状态都是WAITING(parking)状态
  • 程序并无死锁和较大耗时操作

一开始我以为是线程池使用有问题,所以就从线程池入手查找问题原因。这一次让我对ThreadPollExecutor也有了更深的理解,首先让我们来回顾一下它的基本知识点

ThreadPoolExecutor介绍

熟练掌握和使用ThreadPoolExecutor类是每个Java开发人员必备的技能,他能够帮助开发人员合理的管理和和执行并行任务,使用线程池不仅免去了创建和销毁线程等繁琐的线程管理工作,更重要的是极大的提升了程序的响应速度,并且在基于ThreadPoolExecutor可以扩展出更多的功能,例如ScheduledThreadPoolExecutor等

  • corePoolSize & maximumPoolSize: 核心线程数和最大线程数,线程池会根据这两个参数动态调整线程数量。
    若提交任务时线程池中的线程数小于corePoolSize则直接新建线程运行任务
    若提交任务时线程池中的线程数大于corePoolSize且小于maximumPoolSize,任务队列满时会创建新的线程运行任务,否则存入任务队列
  • keepAliveTime & unit: 线程存活时间和时间单位,若当前线程池线程数量大于corePoolSize,空闲的线程或超过keepAliveTime的线程会被终止
  • workQueue: 任务队列,若提交任务时线程池线程数量大于maximumPoolSize,则任务会被放入任务队列中等待执行。当线程池线程数量和workQueue都达到最大值时,新提交的任务将会被拒绝
  • handler: 拒绝策略(RejectedExecutionHandler),当线程池的任务缓存队列已满,并且线程池中的线程数目达到maximumPoolSize时,就需要拒绝掉该任务,采取任务拒绝策略,保护线程池

查找问题原因

在解决问题的过程中,我发现了好几个坑,下面就来一一介绍

1. 线程池的提交方法

向ThreadPoolExecutor中提交任务有两种方法,分别是execute和submit。我一开始没有研究他们到底有什么区别,默认都是用submit提交,因为“submit”这个方法名给我的感觉就是提交到任务队列,至于什么时候执行交给线程池去判断。
然鹅,现实给了我一个响亮的耳光,当我苦于思索最开始遇到的问题是什么原因时,其中**“Idea和Java命令行启动都是一切正常”** 这个现象就是在这里掉坑里了,其实他们的现象并不一样,因为submit提交的任务抛出的异常会被线程池catch掉,调用FutureTask的get方法时,才会抛出异常!!!
当用submit提交任务时,任务首先会被封装成一个FutureTask 再调用execute方法执行

    public Future<?> submit(Runnable task) {
        if (task == null) throw new NullPointerException();
        RunnableFuture<Void> ftask = newTaskFor(task, null);
        execute(ftask);
        return ftask;
    }

我们都知道,提交新的任务(Runnable)在执行时,肯定会调用run方法来执行具体逻辑,那么这个FutureTask的run方法到底干了什么呢

 public void run() {
        if (state != NEW ||
            !UNSAFE.compareAndSwapObject(this, runnerOffset,
                                         null, Thread.currentThread()))
            return;
        try {
            Callable<V> c = callable;
            if (c != null && state == NEW) {
                V result;
                boolean ran;
                try {
                    result = c.call();
                    ran = true;
                } catch (Throwable ex) {
                    result = null;
                    ran = false;
                    setException(ex);
                }
                if (ran)
                    set(result);
            }
        } finally {
            // runner must be non-null until state is settled to
            // prevent concurrent calls to run()
            runner = null;
            // state must be re-read after nulling runner to prevent
            // leaked interrupts
            int s = state;
            if (s >= INTERRUPTING)
                handlePossibleCancellationInterrupt(s);
        }
    }

别的都不用看,当我看到这个大大的try…catch后我人都傻了,原来问题出在这里,我用submit提交的任务,那么我线程抛出的异常我肯定看不到了,所以肯定是线程异常退出导致执行不成功了,终于可以知道线程执行不成功的原因了

2. 反射读取Jar信息

把提交任务的方式修改为execute后,我兴高采烈的在idea中运行程序想要知道线程到底出现什么问题时,我忽然意识到我好像忽略了什么事?咦,我前面遇到的问题不是说程序在idea中运行一切正常连结果都正常么,那如果线程出错结果是怎么对的?
果然,当我启动程序时一切如初,线程根本没有抛出什么异常信息!结果也正常!
难道真的Idea启动和命令行启动真的有区别,idea这么牛替我把线程的异常都给解决了?
我又用Java命令行启动程序,果然异常出来了。

java.lang.IllegalStateException: zip file closed
        at java.util.zip.ZipFile.ensureOpen(ZipFile.java:686)
        at java.util.zip.ZipFile.access$200(ZipFile.java:60)
        at java.util.zip.ZipFile$ZipEntryIterator.hasNext(ZipFile.java:508)
        at java.util.zip.ZipFile$ZipEntryIterator.hasMoreElements(ZipFile.java:503)
        at java.util.jar.JarFile$JarEntryIterator.hasNext(JarFile.java:253)
        at java.util.jar.JarFile$JarEntryIterator.hasMoreElements(JarFile.java:262)
        at org.reflections.vfs.ZipDir$1$1.computeNext(ZipDir.java:30)

idea到底替我们做了什么,后面再研究。先解决这个问题,在经过一番追查后,我发现我的程序为了动态加载一些外部类,会不定期的通过反射去读取这些类信息。这里我采用的一个开源工具:

<dependency>
    <groupId>org.reflections</groupId>
    <artifactId>reflections</artifactId>
    <version>0.9.12</version>
</dependency>

这个工具非常好用,我也经常在项目中用到,他的主要作用是反射读取一些类信息,比如根据注解获取所有类或者方法等等。没想到是它出了问题,心里一阵失落。再经过一番追查后,我发现好像不是它的问题,因为我发现大名鼎鼎的spring-boot也曾遇到过这个bug并在v2.3.6.RELEASE修复了这个bug。

再再经过一番追查后,我发现它底层也是调用的java.util.zip相关的类获取jar信息的,而在调用java.util.zip.ZipFile.getEntry()方法时,会先使用ensureOpen()方法判断file是不是打开状态,如果已经关闭就会抛出以上异常

public ZipEntry getEntry(String name) {
        if (name == null) {
            throw new NullPointerException("name");
        }
        long jzentry = 0;
        synchronized (this) {
            ensureOpen();
            jzentry = getEntry(jzfile, zc.getBytes(name), true);
            if (jzentry != 0) {
                ZipEntry ze = ensuretrailingslash ? getZipEntry(null, jzentry)
                                                  : getZipEntry(name, jzentry);
                freeEntry(jzfile, jzentry);
                return ze;
            }
        }
        return null;
    }
    private void ensureOpen() {
        if (closeRequested) {
            throw new IllegalStateException("zip file closed");
        }
        if (jzfile == 0) {
            throw new IllegalStateException("The object is not initialized.");
        }
    }

这么说来,reflections确实是有一些问题的,因为经过测试每个线程我都会去创建一个新的Reflections类来进行扫描,还是会出现同样的问题,我猜它底层肯定对扫描的工具进行了缓存,而缓存的扫描类读取完后又把这个file给关闭了,导致第二个线程去读取时还是获取到之前它缓存的扫描类,结果再一次扫描发现文件已经closed了,所以出现上述异常。
跟着这个思路,我再一次查看reflections的源码,果然不出我所料!

 public Reflections(final Configuration configuration) {
        this.configuration = configuration;
        store = new Store(configuration);

        if (configuration.getScanners() != null && !configuration.getScanners().isEmpty()) {
            //inject to scanners
            for (Scanner scanner : configuration.getScanners()) {
                scanner.setConfiguration(configuration);
                scanner.setStore(store.getOrCreate(scanner.getClass().getSimpleName()));
            }

            scan();

            if (configuration.shouldExpandSuperTypes()) {
                expandSuperTypes();
            }
        }
    }

在Reflections的构造函数里,有一句非常明显的具有缓存含义的代码:

scanner.setStore(store.getOrCreate(scanner.getClass().getSimpleName()));

getOrCreate?这不就是存在就获取不存在就获取的意思么,具体逻辑就不看源代码了,确实是这样。那么现在的问题就是它获取到这个scanner后,是否closed了扫描的文件?

 protected void scan(URL url) {
        Vfs.Dir dir = Vfs.fromURL(url);

        try {
            for (final Vfs.File file : dir.getFiles()) {
                // scan if inputs filter accepts file relative path or fqn
                Predicate<String> inputsFilter = configuration.getInputsFilter();
                String path = file.getRelativePath();
                String fqn = path.replace('/', '.');
                if (inputsFilter == null || inputsFilter.apply(path) || inputsFilter.apply(fqn)) {
                    Object classObject = null;
                    for (Scanner scanner : configuration.getScanners()) {
                        try {
                            if (scanner.acceptsInput(path) || scanner.acceptResult(fqn)) {
                                classObject = scanner.scan(file, classObject);
                            }
                        } catch (Exception e) {
                            if (log != null && log.isDebugEnabled())
                                log.debug("could not scan file " + file.getRelativePath() + " in url " + url.toExternalForm() + " with scanner " + scanner.getClass().getSimpleName(), e);
                        }
                    }
                }
            }
        } finally {
            dir.close();
        }
    }

再看一眼源代码,果然在最后的finally里close了!但是它关闭Vfs.Dir也是局部变量,按道理来说新的线程进入方法后也会重新获取,继续追查下去已经到了jdk的ZipFile的native方法了,问题已经找到,再查一下去已没有意义了,就此收手吧……

3. IDEA启动程序真的能解决线程异常?

虽然找到了问题的所在,我最后还是用reflections在程序启动的时候先加载一次的方式解决了问题。可有一个现象还是没有得到答案,就是这里的reflections确实closed了文件,可在**“IDEA中启动,为啥一切正常”** ,难道idea启动程序真的能解决线程异常?答案当然是否定的!

在观察idea的启动模式后,我发现idea启动程序是通过java -cp命令启动的,难道是我用java -jar方式启动的问题?
一番测试后,果然是这样的,我通过java -cp命令启动后就不存在这个异常了。说实话,这个问题也令我一度十分费解,而且我也没找到答案。
通过java -help查询Java启动命令,可以看到:

 ~ java -h
用法: java [-options] class [args...]
           (执行类)
   或  java [-options] -jar jarfile [args...]
           (执行 jar 文件)
其中选项包括:
// ....
    -cp <目录和 zip/jar 文件的类搜索路径>
    -classpath <目录和 zip/jar 文件的类搜索路径>
                  用 : 分隔的目录, JAR 档案
                  和 ZIP 档案列表, 用于搜索类文件。
// ......

jar命令是直接执行jar文件,而classpath命令则是zip/jar文件的类搜索路径。网上的资料大概也如此,至于为什么会出现这个问题,也只能有空再研究了

总结

  • 有空还是得好好读读ThreadPoolExecutor的源代码
  • java -cp确实是很多Java程序员更喜欢的启动程序的方式
  • 选择开源工具也要慎重
参考链接
  • https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html
  • https://www.jianshu.com/p/d967078f4ee4
  • https://github.com/ronmamo/reflections/issues/279
  • https://github.com/spring-projects/spring-boot/releases/tag/v2.3.6.RELEASE

关注我的公众号,第一时间获取更多优秀文章
在这里插入图片描述

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
jstack生成的Thread Dump日志.docx 系统线程状态 (Native Thread Status) 系统线程有如下状态: deadlock 死锁线程,一般指多个线程调用期间进入了相互资源占用,导致一直等待无法释放的情况。 runnable 一般指该线程正在执行状态中,该线程占用了资源,正在处理某个操作,如通过SQL语句查询数据库、对某个文件进行写入等。 blocked 线程正处于阻塞状态,指当前线程执行过程中,所需要的资源长时间等待却一直未能获取到,被容器的线程管理器标识为阻塞状态,可以理解为等待资源超时的线程。 waiting on condition 线程正处于等待资源或等待某个条件的发生,具体的原因需要结合下面堆栈信息进行分析。 (1)如果堆栈信息明确是应用代码,则证明该线程正在等待资源,一般是大量读取某种资源且该资源采用了资源锁的情况下,线程进入等待状态,等待资源的读取,或者正在等待其他线程的执行等。 (2)如果发现有大量的线程都正处于这种状态,并且堆栈信息中得知正等待网络读写,这是因为网络阻塞导致线程无法执行,很有可能是一个网络瓶颈的征兆: 网络非常繁忙,几乎消耗了所有的带宽,仍然有大量数据等待网络读写; 网络可能是空闲的,但由于路由或防火墙等原因,导致包无法正常到达; 所以一定要结合系统的一些性能观察工具进行综合分析,比如netstat统计单位时间的发送包的数量,看是否很明显超过了所在网络带宽的限制;观察CPU的利用率,看系统态的CPU时间是否明显大于用户态的CPU时间。这些都指向由于网络带宽所限导致的网络瓶颈。 (3)还有一种常见的情况是该线程在 sleep,等待 sleep 的时间到了,将被唤醒。 waiting for monitor entry 或 in Object.wait() Moniter 是Java中用以实现线程之间的互斥与协作的主要手段,它可以看成是对象或者class的锁,每个对象都有,也仅有一个 Monitor。 从上图可以看出,每个Monitor在某个时刻只能被一个线程拥有,该线程就是 "Active Thread",而其他线程都是 "Waiting Thread",分别在两个队列 "Entry Set"和"Waint Set"里面等待。其中在 "Entry Set" 中等待的线程状态是 waiting for monitor entry,在 "Wait Set" 中等待的线程状态是 in Object.wait()。 (1)"Entry Set"里面的线程。 我们称被 synchronized 保护起来的代码段为临界区,对应的代码如下: synchronized(obj){} 当一个线程申请进入临界区时,它就进入了 "Entry Set" 队列中,这时候有两种可能性: 该Monitor不被其他线程拥有,"Entry Set"里面也没有其他等待的线程。本线程即成为相应类或者对象的Monitor的Owner,执行临界区里面的代码;此时在Thread Dump中显示线程处于 "Runnable" 状态。 该Monitor被其他线程拥有,本线程在 "Entry Set" 队列中等待。此时在Thread Dump中显示线程处于 "waiting for monity entry" 状态。 临界区的设置是为了保证其内部的代码执行的原子性和完整性,但因为临界区在任何时间只允许线程串行通过,这和我们使用多线程的初衷是相反的。如果在多线程程序中大量使用synchronized,或者不适当的使用它,会造成大量线程在临界区的入口等待,造成系统的性能大幅下降。如果在Thread Dump中发现这个情况,应该审视源码并对其进行改进。 (2)"Wait Set"里面的线程 当线程获得了Monitor,进入了临界区之后,如果发现线程继续运行的条件没有满足,它则调用对象(通常是被synchronized的对象)的wait()方法,放弃Monitor,进入 "Wait Set"队列。只有当别的线程在该对象上调用了 notify()或者notifyAll()方法,"Wait Set"队列中的线程才得到机会去竞争,但是只有一个线程获得对象的Monitor,恢复到运行态。"Wait Set"中的线程在Thread Dump中显示的状态为 in Object.wait()。通常来说, 通常来说,当CPU很忙的时候关注 Runnable 状态的线程,反之则关注 waiting for monitor entry 状态的线程。 JVM线程运行状态 (JVM Thread Status)
### 回答1: 这个错误信息指的是在使用Reflections库时,无法从给定的URL中创建Vfs.Dir对象,所以程序会忽略这个异常并继续运行。 Reflections是一个用于在运行时反射Java代码的库。Vfs.Dir是Reflections库中用于表示虚拟文件系统目录的对象。如果无法从给定的URL中创建Vfs.Dir对象,可能是由于URL不正确或所指定的资源不存在,或者由于某种原因无法访问该资源。 要解决这个问题,可以尝试检查给定的URL是否正确,确保指定的资源存在并可以访问。如果这不能解决问题,可以尝试检查日志以了解更多信息,或者尝试在程序中捕获并处理这个异常。 ### 回答2: [org.reflections.Reflections] 抛出的异常表示无法根据给定的 URL 创建 Vfs.Dir。这可能是由于 URL 指向的资源无效或不存在导致的。在此情况下,Reflections 将忽略异常并继续执行,可能会影响到后续的操作和功能。 Reflections 是一个 Java 库,用于在运行时扫描和获取 Class、Method、Field 等的元数据。它依赖于 VFS(Virtual File System)来访问和解析不同类型的资源。VFS 提供了一种抽象的文件系统接口,可以统一处理来自不同来源的资源。 当 Reflections 尝试根据给定的 URL 创建 Vfs.Dir 时,它首先验证 URL 是否有效,并尝试解析其中包含的资源。如果 URL 无效或指向不存在的资源,就会抛出上述异常。为了避免中断程序流程,Reflections 选择忽略该异常,继续执行其他操作。这可能会导致在后续的扫描过程中缺失一些资源,或者无法正常解析某些类文件等。 如何解决这个问题需要根据具体情况而定。首先,我们应该检查给定的 URL 是否正确,并确保资源是存在并可以访问的。如果 URL 无误,但是仍然出现异常,可以尝试更新 Reflections 或 VFS 的版本,以确保使用最新的修复和改进。还可以查看 Reflections 和 VFS 的文档、问题跟踪和讨论,以了解是否有类似的报告和解决方案。 总之,[org.reflections.Reflections] 无法创建 Vfs.Dir from url 的异常表示 Reflections 无法根据给定的 URL 访问和解析资源。尽管它选择忽略该异常并继续执行,但可能会影响到后续的操作和功能。我们需要检查 URL 的有效性,更新相关库的版本,并参考文档和社区来解决这个问题。 ### 回答3: [org.reflections.Reflections]无法从URL创建Vfs.Dir。忽略异常并继续。 这个问题通常出现在使用Reflections库时,当尝试创建Vfs.Dir对象时,出现了异常。Reflections库是一个Java库,用于扫描和获取代码中的注解和类信息。 出现该异常的原因可以是URL参数无效或无法解析。可能的原因包括传递了无效的URL参数、URL不符合格式要求或无法从URL解析目录。 为了解决此问题,可以采取以下步骤: 1. 检查传递给Reflections库的URL参数是否正确。确保URL参数指向一个有效的目录。 2. 如果URL参数是一个本地文件系统路径,请确保路径存在且可访问。在某些情况下,可能会因为没有访问权限或路径不存在而无法创建Vfs.Dir对象。 3. 如果URL参数是一个网络地址,确保网络连接正常,并确保URL指向的资源在网络上是可访问的。 4. 检查URL的格式是否正确。URL应该采用标准格式,例如"http://example.com"或"file:/path/to/directory"。 5. 确保Reflections库的版本是最新的,可能存在旧版库中的bug已经修复。 6. 如果问题仍然存在,并且您不需要Reflections库的某些功能,可以考虑使用其他类扫描和反射库,例如Java的标准反射库或Google的Guava库。 总之,当看到[org.reflections.Reflections]无法创建Vfs.Dir from url的异常时,需要检查URL参数是否正确、目标资源是否存在和可访问,并确保Reflections库版本最新。如果问题仍然存在,可以考虑使用其他类扫描和反射库。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值