三个内存泄露(memory leak)问题解决记录

1,915 阅读6分钟

项目内存泄露(memory leak)问题解决记录

1 背景

最近在排查东方通(Tongweb)反复多次部署应用异常,总是出现Meatspace内存溢出的问题,最后知道原来在解部署(关闭)项目时,若项目卸载不干净,也就是应用创建的线程无法正常关闭时,会导致整个应用加载的class不能卸载。因此多次部署后无论Metaspace设置多大,总会出现内存溢出问题OutOfMemoryError:Metaspace

为了解决这个问题,需要排查应用中那些无法正常关闭的线程。下面就是本地排查中发现的内存泄露问题并做相应分析解决。

项目是web.xml类型的,服务器是tomcat8.5.39,日志是tomcat关闭时所产生的。

2 问题一: log4j2的线程不能关闭

2.1 问题日志

警告 [localhost-startStop-2] org.apache.catalina.loader.WebappClassLoaderBase.clearReferencesThreads The web application [xxxx] appears to have started a thread named [Log4j2-TF-10-Scheduled-2] but has failed to stop it. This is very likely to create a memory leak. Stack trace of thread:
 sun.misc.Unsafe.park(Native Method)
 java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
 java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:2078)
 java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(ScheduledThreadPoolExecutor.java:1093)
 java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(ScheduledThreadPoolExecutor.java:809)
 java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
 java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
 java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
 java.lang.Thread.run(Thread.java:748)

2.2 问题分析

日志里最重要的信息是a thread named [Log4j2-TF-10-Scheduled-2] but has failed to stop it,意思是一个 名叫Log4j2-TF-10-Scheduled-2的线程在程序关闭后不能停止。Scheduled ?Log4j2有什么定时任务呢?

检查log4j2的配置文件(log4j2.xml)可知,在configuration标签有一个配置monitorInterval,它的功能是配置自动扫描的时间间隔,周期性的间隔配置时间后去检测当前log4j2配置文件的变更并重新配置。

应该就是这个配置了,可以测试一下。修改Log4j2自身日志级别配置status改为TRACE,monitorInterval改为10,再去查看应用打印日志。发现有一个Log4j2-TF-5-Scheduled-1线程就是在周期性的间隔10s执行,看名字这个线程和上面无法关闭的线程是同类型的。**

<!--日志级别以及优先级排序: OFF > FATAL > ERROR > WARN > INFO > DEBUG > TRACE > ALL -->
<!--status="WARN" :用于设置log4j2自身内部日志的信息输出级别,默认是OFF-->
<!--monitorInterval="30"  :间隔秒数,自动检测配置文件的变更和重新配置本身-->
<configuration status="TRACE" monitorInterval="10">
...
</configuration>
2022-09-23 11:23:31,896 Log4j2-TF-5-Scheduled-1 TRACE WatchRunnable run triggered.
2022-09-23 11:23:31,896 Log4j2-TF-5-Scheduled-1 TRACE WatchRunnable run ended.
2022-09-23 11:23:41,912 Log4j2-TF-5-Scheduled-1 TRACE WatchRunnable run triggered.
2022-09-23 11:23:41,913 Log4j2-TF-5-Scheduled-1 TRACE WatchRunnable run ended.
2022-09-23 11:23:51,917 Log4j2-TF-5-Scheduled-1 TRACE WatchRunnable run triggered.
2022-09-23 11:23:51,917 Log4j2-TF-5-Scheduled-1 TRACE WatchRunnable run ended.

结论:通过以上分析可知问题根本就是monitorInterval配置后开启的自动监测线程无法关闭造成的。

2.3 问题解决

一般项目运行中并不会修改日志配置,所以将configuration标签里的monitorInterval直接去掉即可。

另外在测试中发现这个monitorInterval并没起作用,后面有机会再研究。

<configuration status="WARN">
...
</configuration>

3 问题二: JDBC驱动无法注销

3.1 问题日志

警告 [localhost-startStop-2] org.apache.catalina.loader.WebappClassLoaderBase.clearReferencesJdbc The web application [xxxx] registered the JDBC driver [com.microsoft.sqlserver.jdbc.SQLServerDriver] but failed to unregister it when the web application was stopped. To prevent a memory leak, the JDBC Driver has been forcibly unregistered.

3.2 问题分析

这是说JDBC驱动被应用注册了,但是应用关闭时未能注销。为了防止内存泄漏,JDBC驱动程序被Tomcat注销了。查了下说是从6.0.24版开始,Tomcat附带了内存泄漏检测功能,当webapp的/WEB-INF/lib中存在与JDBC 4.0兼容的驱动程序时,该功能会导致这种警告消息,该驱动程序在webapp启动期间使用< x1> API,但在webapp关闭期间不会自动注销自身。

3.2.1 那什么时候注册的呢?

首先要搞清楚何为注册驱动?

查看源码,jdbc驱动类地址都配置在连接池中,连通过连接池来加载驱动类。

 // 连接池是Hikari
  public void setDriverClassName(String driverClassName) {
  ......
        ClassLoader threadContextClassLoader = Thread.currentThread().getContextClassLoader();
        // 记载驱动类
        driverClass = threadContextClassLoader.loadClass(driverClassName);
  ......

这里有个疑问,连接池源码里只看到加载驱动实现类,并没有其他特殊操作。所以再看下驱动实现类源码,发现在JDBC驱动实现类的静态代码块static里,都创建了自身对象并调用DriverManager.registerDriver(jdbc object)进行注册。这里看下sqlserver和达梦(dm)的源码。

  // sqlserver的JDBC实现类SQLServerDriver
  static {
  ...
        try {
            DriverManager.registerDriver(new SQLServerDriver());
        } catch (SQLException var1) {
            var1.printStackTrace();
        }

    }
  // 达梦的JDBC实现类DmDriver
 public static DmDriver n = new DmDriver();
 static {
        try {
            DriverManager.registerDriver(n);
        } catch (Exception var0) {
            DBError.throwRuntimeException("Can not load Driver class dm.jdbc.driver.DmDriver");
        }

        IDGenerator = new AtomicLong(0L);
    }

在往下看源码,发现DriverManager.registerDriver(jdbc object)这行代码实际上是往驱动管理类的静态集合List里添加JDBC驱动的包装对象。这就是真正注册的意思了。

 public class DriverManager {

     //  List of registered JDBC drivers
     private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();

     public static synchronized void registerDriver(java.sql.Driver driver)
        throws SQLException {
        registerDriver(driver, null);
     }

     public static synchronized void registerDriver(java.sql.Driver driver,
            DriverAction da)
        throws SQLException {

        /* Register the driver if it has not already been added to our list */
        if(driver != null) {
          // 在这里
            registeredDrivers.addIfAbsent(new DriverInfo(driver, da));
        } else {
            // This is for compatibility with the original DriverManager
            throw new NullPointerException();
        }
        println("registerDriver: " + driver);
     }  
  } 

3.2.2 什么时候注销呢?

JDBC驱动是在连接池创建时注册的,也应该在连接池关闭时去注销,但是查看连接池的close方法源码并没发现此类代码。

3.2.3 结论

本质上就是JDBC驱动在项目启动时加载类并注册后,在应用关闭时也没有注销导致的内存泄露,而tomcat在关闭应用时检测到这一问题后解决并进行警告提示。

3.3 问题解决

既然JDBC驱动没有自动注销,则考虑在应用关闭时手动注销. 这里通过实现servlet的ServletContextListener,在Web应用关闭时进行JDBC的驱动注销。当然我们也可以把其他的一些关闭资源操作也放在这里。

这里主要考虑两个方面:

  • tomcat能检测到内存泄露并解决,不代表其他应用服务器可以,所以要在项目里手动控制避免内存泄露。

  • 在通过 DriverManager.deregisterDriver(driver)注销驱动时要考虑这个DriverManager类是哪个类加载器加载的(上面源码可看到驱动类共用一个CopyOnWriteArrayList)。若是父类ClassLoader(即servlet容器的类加载器)加载而不是webapp上下文的ClassLoader(例如,它们可能位于容器的lib文件夹中,而不是webapp的文件夹中,因此在整个容器中共享),取消这些注册将影响可能同一容器内正在使用它们的其他Web应用程序(甚至容器本身)。因此,在注销之前,最好检查每个驱动程序的ClassLoader是否是webapp的ClassLoader。

3.3.1 编写servlet的监听器实现类

import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Enumeration;
import java.util.logging.Logger;

public class MyContextListener implements ServletContextListener {

    private static final Logger log = Logger.getLogger(MyContextListener.class.getName());

    @Override
    public void contextInitialized(ServletContextEvent servletContextEvent) {

    }
    @Override
    public void contextDestroyed(ServletContextEvent servletContextEvent) {
        // 关闭JDBC驱动
        Enumeration<Driver> drivers = DriverManager.getDrivers();
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        while (drivers.hasMoreElements()) {
            Driver driver = drivers.nextElement();
            if (driver.getClass().getClassLoader() == cl) {
                try {
                    DriverManager.deregisterDriver(driver);
                    log.info("Close Jdbc Driver:" + driver);
                } catch (SQLException e) {
                    log.warning("Close Jdbc Driver exception:" + e);
                }
            } else {
                log.info("The JDBC driver " + driver + " not belong to this webapp's ClassLoader");
            }
        }
    }

}

3.3.2 在web.xml里配置监听器

    <listener>
     ...
    </listener>
<!--    资源清理监听器放在所有监听器最后面-->
    <listener>
        <listener-class>com..MyContextListener</listener-class>
    </listener>

4 问题三:ThreadLocal内存泄露

4.1 问题日志

26-Sep-2022 17:26:05.396 严重 [localhost-startStop-2] 
org.apache.catalina.loader.WebappClassLoaderBase.checkThreadLocalMapForLeaks 
The web application [xxxx] created a ThreadLocal with key of type 
[com.microsoft.sqlserver.jdbc.ActivityCorrelator$1] 
(value [com.microsoft.sqlserver.jdbc.ActivityCorrelator$1@3632c930]) 
and a value of type [com.microsoft.sqlserver.jdbc.ActivityId] 
(value [99e80fd2-702d-42f9-a30f-6f4852617cd9-0]) 
but failed to remove it when the web application was stopped. 
Threads are going to be renewed over time to try and avoid a probable memory leak.

26-Sep-2022 17:26:05.406 严重 [localhost-startStop-2]
 org.apache.catalina.loader.WebappClassLoaderBase.checkThreadLocalMapForLeaks 
 The web application [xxxx] created a ThreadLocal with key of type [java.lang.ThreadLocal] 
 (value [java.lang.ThreadLocal@7c53abca])
 and a value of type [com....web.CActionWrapper] 
 (value [com....web.CActionWrapper@7fa5fb7f]) 
 but failed to remove it when the web application was stopped. 
 Threads are going to be renewed over time to try and avoid a probable memory leak.

4.2 问题分析

上面两个问题都是使用ThreadLocal所产生的内存泄露问题。

第一个应该是sqlserver驱动的本身问题,别的数据库也没有这种问题,换新的驱动包应该可以解决,所以不做讨论。

第二个可以理解为每个request请求相关数据都会产生此对象,并放到static ThreadLocal里方便获取,因为是静态的,一直存在ThreadLocal的强引用,所以ThreadLocal也就不会被回收,也就能保证任何时候都能根据ThreadLocal的弱引用访问到Entry的value值,然后remove它,防止内存泄露。事实上,在响应后会调用ThreadLocal的remove方法进行清除。

正常来说应用停止时,所有请求线程也应该结束了,ThreadLocal相关的ThreadLocalMap也应该被回收了才是。这里还出现这种问题,只能理解为某些请求线程在应用停止后还未结束,此时ThreadLocal的静态强引用导致无法回收ThreadLocalMap中的value了。

4.3 问题解决

在ThreadLocalMap 中的set/get/remove方法中,会对key为null(也即是ThreadLocal为null)进行判断,如果为 null 的话,那么会把 value 置为 null 的。这就很好的避免内存泄露。

利用上述特性把threadLocal静态变量置null,这意味着所有线程中ThreadLocalMap中key都为空,相关Entry为(null,value),这时很容易触发set/get/remove方法使得Entry中value也为 null,变为(null,null)。然后再主动gc回收value对象。

    public  static  void clearForStop(){
        threadLocal = null;
        System.gc();
    }
public class MyContextListener implements ServletContextListener {
    private static final Logger log = Logger.getLogger(MyContextListener.class.getName());
    @Override
    public void contextDestroyed(ServletContextEvent servletContextEvent) {
        // 关闭JDBC驱动
          ...
        // 清除threadLocal内存泄露
        xxx.clearForStop();
    }

}

5 总结

后面若有其他发现会继续补充! 有所不足指出,希望各位大佬,多多指正。