ElasticJob引发的Tomcat内存泄漏问题

  • 这里记录一下遇到的一个tomcat memory leak的问题:

    • 一个使用了elastic job的webapp,在进行热部署的时候日志打印了内存泄漏异常。
  • 前置知识点
    在类使用完之后,如果满足下面的情况,类就会被卸载:
    1.该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
    2.加载该类的ClassLoader已经被回收。
    3.该类对应的java.lang.Class对象没有任何地方被引用,没有在任何地方通过反射访问该类的方法。

  • 关于tomcat memory leak
    其实tomcat的内存泄漏,根本原因是WebAppClassloader的强引用一直有效导致不能不被回收。一般有两种常见场景:
    1.容器中创建的线程没有结束


If a webapp creates a thread, by default its context classloader is set to the one of the parent thread (the thread that created the new thread). In a webapp, this parent thread is one of tomcat worker threads, whose context classloader is set to the webapp classloader when it executes webapp code.

上面这句话的意思是说,当容器中的app创建对象时,对象的context classloader会被设置为父线程(创建本线程的线程)的class loader,通常是tomcat的worker线程,其classloader是WebAppClassloader。所以说容器中的线程,包括应用自身的线程,会持有WebAppClassloader强引用。当线程不能被正常销毁时,WebAppClassloader的强引用一直存在,WebAppClassloader不能被JVM回收,因此WebAppClassloader加载的所有类都不能被卸载,产生内存泄漏。
案例:

public class LeakingServlet extends HttpServlet {
        private Thread leakingThread;

        protected void doGet(HttpServletRequest request,
                        HttpServletResponse response) throws ServletException, IOException {

                if (leakingThread == null) {
                        synchronized (this) {
                                if (leakingThread == null) {
                                        leakingThread = new Thread("leakingThread") {

                                                @Override
                                                public void run() {
                                                        synchronized (this) {
                                                                try {
                                                                        this.wait();
                                                                } catch (InterruptedException e) {
                                                                        e.printStackTrace();
                                                                }
                                                        }
                                                }
                                        };
                                        leakingThread.setDaemon(true);
                                        //leakingThread.setContextClassLoader(null);
                                        leakingThread.start();
                                }
                        }
                }
                response.getWriter().println("Hello world!");
        }
}

2.一些特殊class对象,不能被gc回收,且这些对象持有WebAppClassloader的强引用。

案例:


Webapp class instance as ThreadLocal value

Suppose that we have the following class in the common classpath (for instance in a jar in tomcat/lib) :

public class ThreadScopedHolder {
        private final static ThreadLocal threadLocal = new ThreadLocal();

        public static void saveInHolder(Object o) {
                threadLocal.set(o);
        }

        public static Object getFromHolder() {
                return threadLocal.get();
        }
} 
  

And those 2 classes in the webapp :

public class MyCounter {
        private int count = 0;

        public void increment() {
                count++;
        }

        public int getCount() {
                return count;
        }
}
public class LeakingServlet extends HttpServlet {

        protected void doGet(HttpServletRequest request,
                        HttpServletResponse response) throws ServletException, IOException {

                MyCounter counter = (MyCounter)ThreadScopedHolder.getFromHolder();
                if (counter == null) {
                        counter = new MyCounter();
                        ThreadScopedHolder.saveInHolder(counter);
                }

                response.getWriter().println(
                                "The current thread served this servlet " + counter.getCount()
                                                + " times");
                counter.increment();
        }
}

If the servlet is invoked at least once, the webapp classloader would not be GCed when the app is stopped: since the classloader of ThreadScopedHolder is the common classloader, it remains forever which is as expected. But its ThreadLocal instance has a value bound to it (for the non-terminated thread(s) that served the sevlet), which is an instance of a class loaded by the webapp classloader…


  • elastic job 在tomcat热部署时内存泄漏问题
    最直接的原因是,热部署时elasticjob的inner-job线程仍在执行,线程的context classloader为WebAppClassloader且线程仍在执行,此时仍有部分对象没有被回收,对象方法和代码仍在内存执行,导致class loader也不会被回收。
    这里我做了一个实验,就是将inner-job工作线程的context classloader设置为null,热部署仍然会提示内存泄漏问题,所以说当线程池线程不持有WebAppClassloader强引用时,还存在内存泄漏,这里可以说明是classloader加载的类没有被回收导致的。

实验:


启动Tomcat容器,且容器webapp目录下部署有一个app,此时dump堆信息可以看到有两个WebAppClassloader对象。
ElasticJob引发的Tomcat内存泄漏问题_第1张图片

其中#2是我们app的classloader,#1是忽略不看。

ElasticJob引发的Tomcat内存泄漏问题_第2张图片

这里说下tomcat类加载器,会有一个父类加载器URLClassLoader去加在tomcat必须的jar包(catalina目录),这是一些在maven中作为provided的包,放在tomcat的lib下就会被这个loader加载。然后才是WebAppClassLoader去加载我们的app。
回归正题,我们开始实验。在启动之后我进行一次热部署,这时候控制台提示内存泄漏了,dump堆信息看下:

ElasticJob引发的Tomcat内存泄漏问题_第3张图片

果然WebAppClassLoader多了一个,热部署时上一个classloader没有被回收,所以产生了内存泄漏。

前面说道两种webappclassloader不被回收的情况,我们先来看第一种,这里elastic job的线程是在以线程池的形式存在的,在我们自己的方法里加上这段代码来取消inner-job对webappclassloader的引用:

  Thread t = Thread.currentThread();
        if (null != t.getContextClassLoader()) {
            t.setContextClassLoader(null);
        }

按照上面的操作重复热部署,之后发现webappclassloader仍在增加,说明还是没有回收。首先这个一个耗时很长的job,热部署后原job线程仍在执行,容器上下文有一些对象和class没有回收,那么为什么webappclassloader没有被回收?
这事儿比较复杂,耐心做实验吧,仔细看下堆信息发现,WebAppClassloader最近的垃圾回收根节点是这个实例:
ElasticJob引发的Tomcat内存泄漏问题_第4张图片

点击去看下:

ElasticJob引发的Tomcat内存泄漏问题_第5张图片

到这里有必要在了解一下elastic job的线程池了。先上图:

图1:

图2:

elastic job 在启动的时候,对每一个job,首先会启动两个线程,一个scheduler调度线程和一个worker线程。然后在定时触发时,首次触发会新建一个线程池,以jobName为key缓存到map中,用户方法就是用线程池中的inner-job进行调度,这里我有五个分片,所以第二张图有5个inner-job.
而我们上一个实验中将context classloader设置为null,是对inner-job的。这里scheduler线程(位于调度线程池,线程池大小为1)仍然持有了webappclassloader的强引用。
ElasticJob引发的Tomcat内存泄漏问题_第6张图片

所以说要想热部署,解决内存泄漏,靠开发者自身是不行的了,这里我使用的解决方案是在elastic job控制台将app下所有的任务都关掉,这时候调度线程池也会被shutdown,剩下的就是等执行缓慢的inner-job执行完本次用户任务退出后直接热部署即可,这样不会有内存泄漏发生。

解决方案是有了,但是理论上还有些东西没有弄清,是不是因为调度线程池持有了webappclassloader导致了内存泄漏,还是说同时也存在一些webappclassloader自身加载的class以及class相应对象扔存在导致不能被回收。

还是继续实验,在控制台关闭任务后,scheduler线程很快被回收,内存中运行时间较长(几分钟)的inner-job仍然在执行
ElasticJob引发的Tomcat内存泄漏问题_第7张图片

ElasticJob引发的Tomcat内存泄漏问题_第8张图片
这个时候进行热部署,如果webappclassloader仍未被回收,就不是elastic’job线程持有类加载器强引用导致,那就可以说明正在运行的inner-job线程拥有的对象和class持有类加载器引用,
此强引用在gc可达性分析算法中是有效的
即存在一个这样的引用链 thread–>unkonwn obj or class –>WebAppClassLoader,由于thread的有效性,导致类加载器加载的类和加载器本身的引用是有效的,即根节点可达。
继续实验,步骤:
1.启动tomcat,等待第一次job执行
2.job执行,此时在控制台挂关闭任务,此时dump线程信息发现scheduler线程已经被回收
3.执行热部署,然后dump堆信息

执行完上述操作,发现内存泄漏仍然存在,看下第3步dump的堆信息,发现确实WebAppClassLoader没有被回收,查看下classloader的垃圾回收根节点,如图:
ElasticJob引发的Tomcat内存泄漏问题_第9张图片

com.alibaba.druid.pool.DruidDataSource$CreateConnectionThread 线程的context classloader持有WebAppClassLoader的强引用。

这里说明仍然在执行的job影响了WebAppClassLoader的回收。即存在这样一条调用链:
inner-job–>User DefineServcie(用户代码)–>CreateConnectionThread–>WebAppClassLoader.
没办法,只能等这个job执行完退出后,再进行热部署吧。


参考:
https://stackoverflow.com/questions/17968803/threadlocal-memory-leak

https://wiki.apache.org/tomcat/MemoryLeakProtection

关于log4j由于ThreadLocal引发内存泄漏bug:
https://bz.apache.org/bugzilla/attachment.cgi?id=28091&action=edit

解决方案:每次用过之后,线程退出前finally中调用remove方法清除引用。


你可能感兴趣的:(TOMCAT)