一次并发编程问题复盘
别人都是代码在测试环境运行正常,然后发布生产环境后发现异常。我这好家伙,在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
关注我的公众号,第一时间获取更多优秀文章