缘起

发现线上项目, 在 Tomcat shutdown 时会报告可能的内存泄漏问题

日志如下

Sep 20, 2016 12:41:33 PM org.apache.catalina.loader.WebappClassLoader clearReferencesJdbc
SEVERE: The web application [] registered the JDBC driver [com.mysql.jdbc.Driver] but failed to unregister it when the web application was stopped. To prevent a memory leak, the JDBC Driver has been forcibly unregistered.
Sep 20, 2016 12:41:33 PM org.apache.catalina.loader.WebappClassLoader clearReferencesThreads
SEVERE: The web application [] appears to have started a thread named [Timer-0] but has failed to stop it. This is very likely to create a memory leak.
Sep 20, 2016 12:41:33 PM org.apache.catalina.loader.WebappClassLoader checkThreadLocalMapForLeaks
SEVERE: The web application [] created a ThreadLocal with key of type [com.company.util.DateUtil$4] (value [com.company.util.DateUtil$4@2831fe1d]) and a value of type [java.text.SimpleDateFormat] (value [java.text.SimpleDateFormat@f67a0200]) 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.
Sep 20, 2016 12:41:33 PM org.apache.catalina.loader.WebappClassLoader checkThreadLocalMapForLeaks

这是同事由于自己写了个 DateUtil 工具类用 ThreadLocal 来封装 SimpleDateFormat (它是线程不安全的).

它的代码如下

public class DateUtil {

	private DateUtil() {
	}

	public static final String YYYY_MM_DD_HH_MM_SS = "yyyy-MM-dd HH:mm:ss";
	public static final ThreadLocal<SimpleDateFormat> df_seconds = new ThreadLocal<SimpleDateFormat>() {
		@Override
		protected SimpleDateFormat initialValue() {
			return new SimpleDateFormat(YYYY_MM_DD_HH_MM_SS);
		}
    };
    ...
}

原因

Google 了下资料, 发现 stackoverflow 上有个比较好的解释, 我这里顺便翻译一下过来

与 ThreadLocal 组合的 PermGen 耗竭通常是由于 classloader 泄漏导致的. 例如 假设一个应用服务器有一个 worker 线程池. 它们会一直 keep alive 直到应用服务器终止. 一个部署好的 web 应用在它的类中为了保存一些 thread-local 的数据而使用一个 static ThreadLocal, 数据是 web 应用的其他类的实例(让我们叫它 SomeClass). 这是在 worker 线程内完成的(例如源于一个 HTTP 请求动作)

重要: 通过定义, 一个引用到一个 ThreadLocal value 是会一直保持的, 直到所属线程死亡或 ThreadLocal 自身是不再可达.

如果一个 web 应用在 shutdown 的时候不能清除这些引用, 一些糟糕的事将会发生: 由于 worker 线程通常不会死亡并且引用的 ThreadLocal 是 static, ThreadLocal value 会仍然引用了 SomeClass 的实例, 一个 web 应用的 class, 尽管 web 应用已经停止了!

结果就是, web 应用的 classloader 不能被GC, 这意味着所有web 应用的 class(以及所有的 static 数据)仍然是已加载的(remain loaded)(这会影响到 PermGen 内存池以及堆). 每次迭代重新部署 web 应用将会不断增加 PermGen(以及堆) 的使用.

这就是 PermGen 泄漏.

这种典型的一个例子就是 log4j 的 bug (在此期间已经被 fixed).

下面的讨论也很值得看一下.

问题是 - 为什么 web server 在应用已经 stop 的情况下, 没尝试 kill worker thread ? 我猜: web server 为会特定的应用创建 worker thread. 如果 web server 没 kill 这些 threads, 甚至在没有 ThreadLocal 的情况下, 这些 threads 将仍然在这里.

threads 永不应该被 kill, 它们仅应该被 notified/interrupted (通知/中断) 而通常是由它们自己来停止的. 并且, threads 的创建是比较昂贵的, 并且在同一个 container 内常常是跨应用共享的 – 但这是特定于具体实现的. 然而, 有些应用服务器会删除所有所属的 web 应用的线程(取决于 产品/配置), 或者定期刷新其线程以防止这种泄漏. 这也是基于特定实现的. 参看 Tomcat 的细节

感谢, 非常好的解释. 清楚了为什么 ThreadLocal 变量不会被 GC, 但为什么 classloader 以及其他的 class 也不会被 GC ? 其他的 class 不必带有 ThreadLocal 变量来处理逻辑的.

问题在于 ThreadLocal 的 value 也不会被 GC. 由于 value 是一个 shutdown 的 web 应用的一个类实例, 因此它的 classloader 以及其他的 class 也都不会被 GC 掉.

完美的解释!

解决

SimpleDateFormat 的创建是比较昂贵的, 所以要避免频繁创建它.

  • 使用其他的线程安全的日期时间库(如 Joda-Time)(例如 DateTimeFormatter 它是线程安全的, 并且是不可变对象)
  • 用完 ThreadLocal 后, 要直接删除 (调用ThreadLocal 的 remove() 方法)

JDBC

可以看到, 上面还有个 JDBC 的问题. 这个解决如下(尽管Tomcat 自动帮我们卸载 JDBC Driver了), 我们写个 ServletContextListener 来手动卸载进行清理

web.xml 注册监听器

	<listener>
		<listener-class>com.company.listener.ApplicationCleanupListener</listener-class>
	</listener>

java代码

    @Override
    public void contextDestroyed(ServletContextEvent event) {
        // 手动卸载 JDBC 驱动.
        Enumeration<Driver> drivers = DriverManager.getDrivers();
        while (drivers.hasMoreElements()) {
            Driver driver = drivers.nextElement();
            try {
                DriverManager.deregisterDriver(driver);
                log.info("deregistering jdbc driver: {}", driver);
            } catch (SQLException e) {
                log.error("deregistering jdbc driver: {} {}", driver, e);
            }

        }
    }