Tomcat 应用中并行流带来的类加载问题
本文首发于 vivo互联网技术 微信公众号
链接:https://mp.weixin.qq.com/s/f-X3n9cvDyU5f5NYH6mhxQ
作者:肖铭轩、王道环
随着 Java8 的不断流行,越来越多的开发人员使用并行流(parallel)这一特性提升代码执行效率。但是,作者发现在 Tomcat 容器中使用并行流会出现动态加载类失败的情况,通过对比 Tomcat 多个版本的源码,结合并行流和 JVM 类加载机制的原理,成功定位到问题来源。本文对这个问题展开分析,并给出解决方案。
一、问题场景
在某应用中,服务启动时会通过并行流调用 Dubbo,调用代码如下:
Lists.partition(ids, BATCH_QUERY_LIMIT).stream()
.parallel()
.map(Req::new)
.map(client::batchQuery)
.collect(Collectors.toList());
二、原因分析
1、初步定位
首先根据错误日志可以看到,由于依赖的 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** 会为 null,SerializerFactory 构造方法中对 _loader 进行了初始化,初始化代码如下,可以看出 _loader 使用的是当前线程的 contextClassLoader。
public SerializerFactory() {
this(Thread.currentThread().getContextClassLoader());
}
public SerializerFactory(ClassLoader loader) {
_loader = loader;
}
根据堆栈看到当前线程为ForkJoinWorkerThread,ForkJoinWorkerThread是Fork/Join框架内的工作线程(Java8 并行流使用的就是Fork/Join)。JDK文档指出:
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.
因此当前的线程contextClassLoader应该和创建此线程的父线程保持一致才对,不应该是null啊?
继续看ForkJoinWorkerThread创建的源码,首先使用ForkJoinWorkerThreadFactory创建一个线程,然后将创建的线程注册到ForkJoinPool中,线程初始化的逻辑和普通线程并无差别,发现单独从JDK自身难以发现问题,因此将分析转移到Tomcat中。
2、Tomcat升级带来的问题
取 Tomcat7.0.x 的一些版本做了实验和对比,发现7.0.74之前的版本无此问题,但7.0.74之后的版本出现了类似问题,实验结果如下表。


至此已经将问题定位到了是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会引起内存泄露呢?
3、contextClassLoader内存泄露之谜
在JDK1.2以后,类加载器的双亲委派模型被广泛引入。它的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把整个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载,流程如下图。


然而双亲委派的模型并不能保证应用程序加载类的过程,一个典型的例子就是JNDI服务,这些接口定义在rt.jar并由第三方提供实现,Bootstrap ClassLoader显然不认识这些代码。为了解决这个问题,JDK1.2同时引入了线程上下文类加载器(Thread Context ClassLoader)进行类加载,作为双亲委派模型的补充。
回到内存泄漏的问题上,设想一个场景,如果某个线程持有了ClassLoaderA(由ClassLoaderA加载了若干类),当应用程序需要对ClassLoaderA以及由ClassLoaderA加载出来的类卸载完成后,线程A仍然持有了ClassLoaderA的引用,然而业务方以为这些类以及加载器已经卸载干净,由于类加载器和其加载出的类双向引用,这就造成了类加载器和其加载出来的类无法垃圾回收,造成内存泄露。在并行流中,ForkJoinPool和ForkJoinWorkerThreadFactory默认是静态且共享的(JDK官方推荐,创建线程本身是相对重的操作,尽量避免重复创建ForkJoinWorkerThread 造成资源浪费),下图描绘了发生内存泄露的场景:

因此 Tomcat 默认使用SafeForkJoinWorkerThreadFactory作为ForkJoinWorkerThreadFactory,并将该工厂创建的ForkJoinWorkerThread的contextClassLoader都指定为ForkJoinPool.class.getClassLoader(),而不是JDK默认的继承父线程的contextClassLoader,进而避免了Tomcat应用中由并行流带来的类加载器内存泄露。
三、总结
在开发过程中,如果在计算密集型任务中使用了并行流,请避免在子任务中动态加载类;其他业务场景请尽量使用线程池,而非并行流。总之,我们需要避免在Tomcat应用中通过并行流进行自定义类或者第三方类的动态加载。
更多内容敬请关注 vivo 互联网技术 微信公众号

注:转载文章请先与微信号:labs2020 联系
Tomcat 应用中并行流带来的类加载问题的更多相关文章
- JDK8中的并行流
1.IntStream.parallel():获取并行流处理 2. Collection中调用parallelStream()获取并行流 3.并行排序Arrays.parallelSort()
- JAVA8给我带了什么——并行流和接口新功能
流,确定是笔者内心很向往的天堂,有他之后JAVA在处理数据就变更加的灵动.加上lambda表达不喜欢都不行.JAVA8也为流在提供另一个功能——并行流.即是有并行流,那么是不是也有顺序流.没有错.我前 ...
- java8新特性——并行流与顺序流
在我们开发过程中,我们都知道想要提高程序效率,我们可以启用多线程去并行处理,而java8中对数据处理也提供了它得并行方法,今天就来简单学习一下java8中得并行流与顺序流. 并行流就是把一个内容分成多 ...
- Java8中的流操作-基本使用&性能测试
为获得更好的阅读体验,请访问原文:传送门 一.流(Stream)简介 流是 Java8 中 API 的新成员,它允许你以声明式的方式处理数据集合(通过查询语句来表达,而不是临时编写一个实现).这有点儿 ...
- Java 8并行流的性能陷阱
并行化流被分成多个块,每个块独立处理,结果在最后汇总. CPU密集型代码如下: private long countPrimes(int max) { return range(1, max) ...
- Stream并行流详解
1.并行与并发的区别 在说到并行的时候,相信很多人都会想到并发的概念.那么并行和并发两者一字之差,有什么区别呢? 并行:多个任务在同一时间点发生,并由不同的cpu进行处理,不互相抢占资源 并行: 并发 ...
- Java8 中的流式数据处理
java8的流式处理极大了简化我们对于集合.数组等结构的操作,让我们可以以函数式的思想去操作,本篇文章将探讨java8的流式数据处理的基本使用. 一. 流式处理简介 在我接触到java8流式处理的时候 ...
- 谈一谈Java8的函数式编程(二) --Java8中的流
流与集合 众所周知,日常开发与操作中涉及到集合的操作相当频繁,而java中对于集合的操作又是相当麻烦.这里你可能就有疑问了,我感觉平常开发的时候操作集合时不麻烦呀?那下面我们从一个例子说起. 计 ...
- 流API--使用并行流
这篇博客一起来研究下使用并行流.借组多核处理器并行执行代码可以显著提高性能,但是并行编程可能十分复杂且容易出错,流API提供的好处之一是能够轻松可靠的并行执行一些操作.请求并行处理流,首先要获得一个并 ...
随机推荐
- 『深度应用』NLP机器翻译深度学习实战课程·壹(RNN base)
深度学习用的有一年多了,最近开始NLP自然处理方面的研发.刚好趁着这个机会写一系列NLP机器翻译深度学习实战课程. 本系列课程将从原理讲解与数据处理深入到如何动手实践与应用部署,将包括以下内容:(更新 ...
- 关于多线程中sleep、join、yield的区别
好了.说了多线程,那就不得不说说多线程的sleep().join()和yield()三个方法的区别啦 1.sleep()方法 /** * Causes the currently executing ...
- C#开发BIMFACE系列6 服务端API之获取文件信息
在<C#开发BIMFACE系列4 服务端API之源上传文件>.<C#开发BIMFACE系列5 服务端API之文件直传>两篇文章中详细介绍了如何将本地文件上传到BIMFACE服务 ...
- C#数据结构_查找
查找:静态查找和动态查找. 衡量查找算法的最主要的标准是平均查找长度(Average Search Length,简 称 ASL).平均查找长度是指在查找过程中进行的关键码比较次数的平均值. 顺序查找 ...
- Leetcode之深度优先搜索(DFS)专题-690. 员工的重要性(Employee Importance)
Leetcode之深度优先搜索(DFS)专题-690. 员工的重要性(Employee Importance) 深度优先搜索的解题详细介绍,点击 给定一个保存员工信息的数据结构,它包含了员工唯一的id ...
- Oracle - Tables
创建表 a: Sql语句创建 -- Create table create table Table_Name ( 字段1 VARCHAR2(50), 字段2 VARCHAR2(50) not null ...
- Java Builder 模式,你搞明白了么?
Builder 模式定义 Builder 模式中文叫作建造者模式,又叫生成器模式,它属于对象创建型模式,是将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示.建造者模式是一步一步 ...
- MongoDB的介绍安装与基本使用
MongoDB的介绍安装 关于MongoDB的介绍于安装可参考:https://www.cnblogs.com/DragonFire/p/9135630.html 除了官网下载,可以下载他人下载好分享 ...
- JDK中的Proxy技术实现AOP功能
1.需求,用户在执行某个业务方法时我们需要对这个用户进行判断是否具有权限(或者说用户是否登录了)? 例如修改用户信息,我们在update()方法前判断当前用户user是否为null(表示没有权限或者没 ...
- Python 基础(四)
open函数 open(name[,mode[,buffering[,encoding]]]) mode(访问模式): -1 : r(只读) w(只写,若文件存在则覆盖) a(追加) rb(二进制只读 ...