Tomcat 应用中并行流带来的类加载问题

本文首发于 vivo互联网技术 微信公众号 
连接:https://mp.weixin.qq.com/s/f-X3n9cvDyU5f5NYH6mhxQ
做者:肖铭轩、王道环html

随着 Java8 的不断流行,愈来愈多的开发人员使用并行流(parallel)这一特性提高代码执行效率。可是,做者发如今 Tomcat 容器中使用并行流会出现动态加载类失败的状况,经过对比 Tomcat 多个版本的源码,结合并行流和 JVM 类加载机制的原理,成功定位到问题来源。本文对这个问题展开分析,并给出解决方案。java

1、问题场景

在某应用中,服务启动时会经过并行流调用 Dubbo,调用代码以下:apache

Lists.partition(ids, BATCH_QUERY_LIMIT).stream()
     .parallel()
     .map(Req::new)
     .map(client::batchQuery)
     .collect(Collectors.toList());

调用日志中发现大量的 WARN 日志com.alibaba.com.caucho.hessian.io.SerializerFactory.getDeserializer Hessian/Burlap:‘XXXXXXX’ is an unknown class in null:java.lang.ClassNotFoundException: XXXXXXX,在使用接口返回结果的时候抛出错误 java.lang.ClassCastException: java.util.HashMap cannot be cast to XXXXXXX。tomcat

2、缘由分析

一、初步定位

首先根据错误日志能够看到,因为依赖的 Dubbo 服务返回参数的实体类没有找到,致使 Dubbo 返回的数据报文在反序列化时没法转换成对应的实体,类型强制转化中报了java.lang.ClassCastException。经过对线程堆栈和WARN日志定位到出现问题的类为com.alibaba.com.caucho.hessian.io.SerializerFactory,因为 _loader 为 null 因此没法对类进行加载,相关代码以下:微信

try {
       Class cl = Class.forName(type, false, _loader);
       deserializer = getDeserializer(cl);
   } catch (Exception e) {
       log.warning("Hessian/Burlap: '" + type + "' is an unknown class in " + _loader + ":\n" + e);
    log.log(Level.FINER, e.toString(), e);
   }

接下来继续向上定位为何 _loader 会为 nullSerializerFactory 构造方法中对 _loader 进行了初始化,初始化代码以下,能够看出 _loader 使用的是当前线程的 contextClassLoader。框架

public SerializerFactory() {
    this(Thread.currentThread().getContextClassLoader());
}

public SerializerFactory(ClassLoader loader) {
    _loader = loader;
}

根据堆栈看到当前线程为ForkJoinWorkerThread,ForkJoinWorkerThread是Fork/Join框架内的工做线程(Java8 并行流使用的就是Fork/Join)。JDK文档指出:ide

The context ClassLoader is provided by the creator of the thread for use by code running in this thread when loading classes and resources. If not set, the default is the ClassLoader context of the parent Thread.this

所以当前的线程contextClassLoader应该和建立此线程的父线程保持一致才对,不该该是null啊?url

继续看ForkJoinWorkerThread建立的源码,首先使用ForkJoinWorkerThreadFactory建立一个线程,而后将建立的线程注册到ForkJoinPool中,线程初始化的逻辑和普通线程并没有差异,发现单独从JDK自身难以发现问题,所以将分析转移到Tomcat中。线程

二、Tomcat升级带来的问题

取 Tomcat7.0.x 的一些版本作了实验和对比,发现7.0.74以前的版本无此问题,但7.0.74以后的版本出现了相似问题,实验结果以下表。

Tomcat 应用中并行流带来的类加载问题Tomcat 应用中并行流带来的类加载问题

至此已经将问题定位到了是Tomcat的版本所致,经过源代码比对,发现7.0.74版本以后的Tomcat中多了这样的代码:

if (forkJoinCommonPoolProtection && IS_JAVA_8_OR_LATER) {
    // Don't override any explicitly set property
    if (System.getProperty(FORK_JOIN_POOL_THREAD_FACTORY_PROPERTY) == null) {
        System.setProperty(FORK_JOIN_POOL_THREAD_FACTORY_PROPERTY,
                "org.apache.catalina.startup.SafeForkJoinWorkerThreadFactory");
    }
}
private static class SafeForkJoinWorkerThread extends ForkJoinWorkerThread {

   protected SafeForkJoinWorkerThread(ForkJoinPool pool) {
       super(pool);
       setContextClassLoader(ForkJoinPool.class.getClassLoader());
   }
}

在 Java8 环境下,7.0.74 版本以后的 Tomcat 会默认将 SafeForkJoinWorkerThreadFactory 做为 ForkJoinWorkerThread 的建立工厂,同时将该线程的 contextClassLoader 设置为ForkJoinPool.class.getClassLoader(),ForkJoinPool 是属于rt.jar包的类,由BootStrap ClassLoader加载,因此对应的类加载器为null。至此,_loader为空的问题已经清楚,可是Tomcat为何要画蛇添足,将null做为这个 ForkJoinWorkerThread的contextClassLoader呢?

继续对比Tomcat的changeLog http://tomcat.apache.org/tomcat-7.0-doc/changelog.html 发现Tomcat在此版本修复了由ForkJoinPool引起的内存泄露问题 Bug 60620 - [JRE] Memory leak found in java.util.concurrent.ForkJoinPool,为何线程的contextClassLoader会引发内存泄露呢?

三、contextClassLoader内存泄露之谜

在JDK1.2之后,类加载器的双亲委派模型被普遍引入。它的工做过程是:若是一个类加载器收到了类加载的请求,它首先不会本身去尝试加载这个类,而是把整个请求委派给父类加载器去完成,每个层次的类加载器都是如此,所以全部的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈本身没法完成这个加载请求时,子加载器才会尝试本身去加载,流程以下图。

Tomcat 应用中并行流带来的类加载问题Tomcat 应用中并行流带来的类加载问题

然而双亲委派的模型并不能保证应用程序加载类的过程,一个典型的例子就是JNDI服务,这些接口定义在rt.jar并由第三方提供实现,Bootstrap ClassLoader显然不认识这些代码。为了解决这个问题,JDK1.2同时引入了线程上下文类加载器(Thread Context ClassLoader)进行类加载,做为双亲委派模型的补充。

回到内存泄漏的问题上,设想一个场景,若是某个线程持有了ClassLoaderA(由ClassLoaderA加载了若干类),当应用程序须要对ClassLoaderA以及由ClassLoaderA加载出来的类卸载完成后,线程A仍然持有了ClassLoaderA的引用,然而业务方觉得这些类以及加载器已经卸载干净,因为类加载器和其加载出的类双向引用,这就形成了类加载器和其加载出来的类没法垃圾回收,形成内存泄露。在并行流中,ForkJoinPool和ForkJoinWorkerThreadFactory默认是静态且共享的(JDK官方推荐,建立线程自己是相对重的操做,尽可能避免重复建立ForkJoinWorkerThread 形成资源浪费),下图描绘了发生内存泄露的场景:

Tomcat 应用中并行流带来的类加载问题

所以 Tomcat 默认使用SafeForkJoinWorkerThreadFactory做为ForkJoinWorkerThreadFactory,并将该工厂建立的ForkJoinWorkerThread的contextClassLoader都指定为ForkJoinPool.class.getClassLoader(),而不是JDK默认的继承父线程的contextClassLoader,进而避免了Tomcat应用中由并行流带来的类加载器内存泄露。

3、总结

在开发过程当中,若是在计算密集型任务中使用了并行流,请避免在子任务中动态加载类;其余业务场景请尽可能使用线程池,而非并行流。总之,咱们须要避免在Tomcat应用中经过并行流进行自定义类或者第三方类的动态加载。

相关文章
相关标签/搜索