盒子
盒子
文章目录
  1. 0.前言
  2. 1.开发环境
  3. 2.异常及解决方案
    1. 2.1.MySQL JDBC未注销异常
    2. 2.2.Shiro权限框架会话验证调度器Scheduler未Shutdown异常
    3. 2.3.c3p0连接池链接未关闭
    4. 2.4.自定义定时任务连接池链接未关闭
  4. 3.结语

Spring程序结束时,Tomcat报内存溢出解决方案

0.前言

  其实从去年年底接到Spring项目,每次在IDEA关闭项目时,都会在控制台报出Tomcat内存溢出的异常,但是暂时看不出任何影响,一直是将就用着。但是,我这个人一直都强迫症,看着一个红色的警告就不爽,就一定要想办法解决的。最终,通过各种努力,暂时解决的这些警告了,但不知道未来的开发和部署中,会不会因此出现问题,如果有问题,我再补充这篇文章。

1.开发环境

  • Oracle JDK 1.8
  • Tomcat 8.0
  • MySQL 5.7

2.异常及解决方案

  以下多个异常,有几个是我亲历并解决的,c3p0连接池链接未关闭的异常可能以前学长学姐们遇到过,项目配置中早已解决,只是顺道提出。

2.1.MySQL JDBC未注销异常

  控制台的异常信息如下:

1
2
30-Mar-2019 13:49:36.807 警告 [localhost-startStop-2] org.apache.catalina.loader.WebappClassLoaderBase.clearReferencesJdbc The web application [ROOT] registered the JDBC driver [com.alibaba.druid.proxy.DruidDriver] but failed to unregister it when the web application was stopped. To prevent a memory leak, the JDBC Driver has been forcibly unregistered.
30-Mar-2019 13:49:36.808 警告 [localhost-startStop-2] org.apache.catalina.loader.WebappClassLoaderBase.clearReferencesJdbc The web application [ROOT] 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.

  参考 leeyom 在文章Tomcat关闭报内存溢出异解决方案里所说:

  在tomcat 6.0.24版本之后,加入了一个memory leak listener(JreMemoryLeakPreventionListener,有兴趣可详细查去源码),在tomcat stopundeployedreloaded的时候,他会检测当前应用的classloader,查看是否有引用泄露。

Tomcat定义了一系列的引用泄露规则:

  • threadlocal保持引用
  • 线程池保持引用
  • 驱动注册

  如有引用泄露,则提示错误,例如this is very likely to create a memory leak类似这样的错误。对于The web application [LeadermentEnterpriseSystemV2] 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.这种异常其实就是MySQLJDBC驱动无法注销的原因所造成的。


  于是,我参考其方法二,创建了一个CustomServletContextListener,然后在 contextDestroyed 方法中手动注销。具体如下:

  • 将MySQL JDBC驱动在pom.xml文件中更新成5.1.42以上版本。
  • 新建CustomServletContextListener.java文件,具体内容如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    public class CustomServletContextListener implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent sce) {
    }
    @Override
    public void contextDestroyed(ServletContextEvent sce) {
    // 解决Tomcat mysql 驱动内存泄漏,手动注销JDBC
    Enumeration<Driver> drivers = DriverManager.getDrivers();
    Driver d = null;
    while (drivers.hasMoreElements()) {
    try {
    d = drivers.nextElement();
    DriverManager.deregisterDriver(d);
    } catch (SQLException e) {
    e.printStackTrace();
    }
    }
    try {
    AbandonedConnectionCleanupThread.shutdown();
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    }
  • 在web.xml中注册该监听器:

    1
    2
    3
    <listener>
    <listener-class>com.jiaycer.test.CustomServletContextListener</listener-class>
    </listener>

  这样的话就可以解决MySQL驱动无法注销的问题。

2.2.Shiro权限框架会话验证调度器Scheduler未Shutdown异常

  这个问题是因为Shiro权限框架里有用到Quartz,所以出现了以下10个一样的警告:

1
2
3
4
5
30-Mar-2019 13:49:36.810 警告 [localhost-startStop-2] org.apache.catalina.loader.WebappClassLoaderBase.clearReferencesThreads The web application [ROOT] appears to have started a thread named [DefaultQuartzScheduler_Worker-1] but has failed to stop it. This is very likely to create a memory leak. Stack trace of thread:
java.lang.Object.wait(Native Method)
org.quartz.simpl.SimpleThreadPool$WorkerThread.run(SimpleThreadPool.java:519)
……
(此处省略9个除DefaultQuartzScheduler_Worker编号不同以外的相同警告)

  Tomcat关闭报内存溢出异解决方案里说,可以重新写了个过滤器,去判定session是否过期,而不采用shiro的会话验证器。而一般配置里采用的是如下的会话验证器:

1
2
3
4
<bean id="sessionValidationScheduler" class="org.apache.shiro.session.mgt.quartz.QuartzSessionValidationScheduler">
<property name="sessionValidationInterval" value="1800000"/>
<property name="sessionManager" ref="sessionManager"/>
</bean>

  因为我尚未做这部分功能,也没有找到比较好的重写教程,因此没有采用这种解决方式。在直接参看SpringQuartz相关资料时,我发现不少人认为 Quartz 为开发者做了过多的事,反而导致开发的麻烦,其中常见的就是内存泄漏,显然 Shiro 也存在这个问题。
  当我看到Spring3.2.11与Quartz2.2.1整合时内存泄漏的问题的解决中所写到的:

1
2
3
4
究其原因是开启的scheduler_Worker线程没有关闭。但Spring的SchedulerFactoryBean实现了DisposableBean接口,表示web容器关闭时会执行destroy()
……(省略源码部分)
表示工厂已经做了shutdown。所以,问题出现在真正执行的scheduler.shutdown(true)。
原来这是Quartz的bug(见https://jira.terracotta.org/jira/browse/QTZ-192),在调用scheduler.shutdown(true)后,Quartz检查线程并没有等待那些worker线程被停止就结束了。

  这时,我想到能否手动通过这种方式去shutdown那些worker线程,于是我翻看了org.apache.shiro.session.mgt.quartz.QuartzSessionValidationScheduler源码,发现其有一个受保护的方法protected Scheduler getScheduler()可以获取到Scheduler,且其他所有方法都是public的,也只有一个空构造函数:

1
2
3
4
5
6
7
protected Scheduler getScheduler() throws SchedulerException {
if (this.scheduler == null) {
this.scheduler = StdSchedulerFactory.getDefaultScheduler();
this.schedulerImplicitlyCreated = true;
}
return this.scheduler;
}

  于是,我决定尝试继承org.apache.shiro.session.mgt.quartz.QuartzSessionValidationScheduler类,并实现DisposableBean接口,让它在Servlet生命周期destroy时,自动注销:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 意在解决Tomcat关闭时,Quartz出现的内存泄漏问题
*/
public class CustomQuartzSessionValidationScheduler extends QuartzSessionValidationScheduler implements DisposableBean {
public CustomQuartzSessionValidationScheduler() {
}
public CustomQuartzSessionValidationScheduler(ValidatingSessionManager sessionManager) {
super(sessionManager);
}
@Override
public void destroy() throws Exception {
Scheduler scheduler = this.getScheduler();
scheduler.shutdown(true);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}

  最后替换之前的bean对象,再次关闭Tomcat时,不会再出现之前的警告。另外我也想说明的是,我暂时不确定这个方法是有效的,但是暂时不会出现OOM错误了。配置文件:

1
2
3
4
<bean id="sessionValidationScheduler" class="com.jiacyer.test.CustomQuartzSessionValidationScheduler">
<property name="sessionValidationInterval" value="1800000"/>
<property name="sessionManager" ref="sessionManager"/>
</bean>

2.3.c3p0连接池链接未关闭

  修改applicationContext.xml文件,添加属性 destroy-method=”close”,即可解决数据库连接未关闭的问题。

1
2
3
4
5
6
7
8
9
10
11
<bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close">
<property name="user" value="${jdbc.user}"></property>
<property name="password" value="${jdbc.password}"></property>
<property name="jdbcUrl" value="${jdbc.jdbcUrl}"></property>
<property name="driverClass" value="${jdbc.driverClass}"></property>
<property name="idleConnectionTestPeriod" value="60"/>
<property name="maxIdleTime" value="60"/>
<property name="testConnectionOnCheckin" value="false" />
<property name="testConnectionOnCheckout" value="true" />
<property name="preferredTestQuery" value="SELECT 1" />
</bean>

2.4.自定义定时任务连接池链接未关闭

  项目中不少地方需要用到定时任务,于是我想把所有的定时任务统一放在一个线程池里运行,方便统一控制,于是我写了如下TimerUtils类,关于Executor的相关知识,可以参看Executor 之 线程池及定时器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public class TimerUtils {
private static final ThreadLocal<ScheduledExecutorService> scheduledExecutorService = new ThreadLocal<ScheduledExecutorService>() {
@Override
protected ScheduledExecutorService initialValue() {
return Executors.newScheduledThreadPool(10);
}
};
/**
* 获取ScheduledExecutorService实例
* @return ScheduledExecutorService实例
*/
public static ScheduledExecutorService getInstance() {
return scheduledExecutorService.get();
}
/**
* 提交定时任务
* @param command Runnable接口的实现类
* @param delay 延迟时间,以相对时间为准
* @param timeUnit 延迟时间的单位
*/
public static void schedule(Runnable command, long delay, TimeUnit timeUnit) {
scheduledExecutorService.get().schedule(command, delay, timeUnit);
}
/**
* 提交定时任务
* @param command Runnable接口的实现类
* @param startTime 开始时间,以系统时间为准,以毫秒为准
* @param period 定时任务时间间隔,以毫秒为准
*/
public static void schedule(Runnable command, long startTime, long period) {
startTime -= System.currentTimeMillis();
scheduledExecutorService.get().scheduleAtFixedRate(command, startTime, period, TimeUnit.MILLISECONDS);
}
/**
* 提交固定时间的任务
* @param command Runnable接口的实现类
* @param initialDelay 首次开始定时任务的延迟时间,以相对时间为准
* @param period 定时任务时间间隔,以上次该任务开始时间为间隔的起始时间
* @param timeUnit 时间单位
*/
public static void scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit timeUnit) {
scheduledExecutorService.get().scheduleAtFixedRate(command, initialDelay, period, timeUnit);
}
/**
* 提交固定时间间隔任务
* @param command Runnable接口的实现类
* @param initialDelay 首次开始定时任务的延迟时间,以相对时间为准
* @param delay 定时任务时间间隔,以上次该任务结束时间为间隔的起始时间
* @param timeUnit 时间单位
*/
public static void scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit timeUnit) {
scheduledExecutorService.get().scheduleWithFixedDelay(command, initialDelay, delay, timeUnit);
}
/**
* 必须在某方法中(例如继承了ServletContextListener接口的contextDestroyed方法中)
* 手动调用shutdown方法,以结束定时任务。
* @return 所有任务是否被正常中断
*/
public static boolean shutdown() {
boolean b = false;
ScheduledExecutorService service = scheduledExecutorService.get();
if (!service.isShutdown()) {
try {
service.shutdown();
b = service.awaitTermination(10, TimeUnit.SECONDS);
if (!b)
service.shutdownNow();
} catch (Exception e) {
e.printStackTrace();
}
}
return b;
}
}

  但这时,无论我在哪里调用shutdown方法,似乎都无法停止线程池的所有线程,导致Tomcat无法正常关闭,后台始终有Java VM在运行。其原因正如在java web项目中慎用Executors以及非守护线程所说:

1
一般情况下,我们使用executors创建多线程时,就会使用默认的threadFactory(即调用只有一个参数的工厂方法),而创建出来的线程就是非守护的。而相应的程序就永远不会退出,如采用Executors创建定时调度任务时,这个调试任务永远不会退出。

  因此,解决的办法就是重写相对应的threadFactory,将TimerUtils类的静态变量scheduledExecutorService更改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static final ThreadLocal<ScheduledExecutorService> scheduledExecutorService = new ThreadLocal<ScheduledExecutorService>() {
@Override
protected ScheduledExecutorService initialValue() {
/* Executors.newScheduledThreadPool(corePoolSize) --> 创建的是非守护进程,
与非守护进程main进程同时存在,关闭Tomcat,却不能退出Java VM的问题;
重写ThreadFactory的newThread方法,将新创建的进程设置为守护进程,解决以上问题。 */
return Executors.newScheduledThreadPool(10, new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread thread = Executors.defaultThreadFactory().newThread(r);
thread.setDaemon(true);
return thread;
}
});
}
};

  并且,在CustomServletContextListener类的public void contextDestroyed(ServletContextEvent sce)方法中调用TimerUtils.shutdown()。此时,Tomcat就可以正常关闭了。

3.结语

  最终的原因归根结底就是Tomcat的进程无法释放的问题,每次问题的出现,都只有通过一步步的排查才能根本上解决。虽然很多知识尚需学习,以上问题的解决方案也未必不会干扰到以后的开发,但是至少暂时Tomcat停止的时候,不在报内存溢出的警告了。如果有更好的解决方案或者任何疑问,可以在评论区留言一起讨论。

参考贴来源:
Tomcat关闭报内存溢出异解决方案 作者:Leeyom
Spring3.2.11与Quartz2.2.1整合时内存泄漏的问题的解决 作者:Yancey.Han
在java web项目中慎用Executors以及非守护线程 作者:折腾数据折腾代码
Executor 之 线程池及定时器

转载说明

转载请注明出处,无偿提供。

支持一下
感谢大佬们的支持