标签归档:Servlet 3.0

一次Java内存泄露问题排查

近期随着管理平台用户增加,后台多次出现OutOfMemoryError错误,尝试着把堆内存从2G加到4G(-Xms4096m -Xmx4096m)也不顶用,隔段时间就OOM,看来是出现内存泄露了。于是在Tomcat启动脚本中加入JVM参数-XX:+HeapDumpOnOutOfMemoryError,使其OOM时把堆内存dump成文件,便于分析。今天,程序又一次OOM,我拿到了这个4G大的java_pid32693.hprof文件。打开 Eclipse Memory Analyzer(MAT),导入dump文件,内存泄露问题显而易见:
 Eclipse Memory Analyzer

根据提示,有一个ConcurrentHashMap数据结构占用了所有4G内存中的99.11%,该Map名为asyncContentMap,后面是它的package路径。

回忆了一下,这个貌似是很早以前实现过的一个实例状态自动刷新功能,管理平台有一个实例列表页面,当实例状态变化时,无需用户刷新页面,状态栏自动变更。为了前端通用,放弃Websocket、socket.io,用了最简单的ajax实现,浏览器端和管理平台间用long polling实现状态推送。下图就是整个状态自动刷新功能的示意图,管理平台接到请求后,使用Servlet 3.0的异步Servlet挂住浏览器端连接,并将请求上下文(AsyncContext)放入队列。另一方面,通过worker线程池处理请求队列里的请求,worker线程做的事情就是依次轮询后端服务(Service1,Service2...Service N)接口,判断其实例状态有无变化,如果状态变化,则取出前端请求上下文,返回响应。用户就能从浏览器得知实例状态变更,之后,再发起一个HTTP long polling请求,重复上述流程。当然,long polling有个超时时间,由管理平台控制,当对后端服务连续polling了一定时间都没有状态变化时,也要将响应返回给浏览器,使之进入下一个long polling周期。
Long Polling

要实现上述描述的功能,管理平台这边需维护两个状态,分别对应两个线程安全的数据结构。一个是请求队列LinkedBlockingQueue asyncContextList,worker线程池会从请求队列中取出浏览器请求;另一个是映射表ConcurrentHashMap asyncContentMap,用于存放异步请求和改请求附带的一些元信息,比如上一次polling后端Service的时间戳、响应值等等。根据MAT的提示asyncContentMap出现内存泄露,那么可以肯定是有些异步请求已经完成响应,但是忘了从映射表里把记录清除。检查了一下程序,的确如此,加入remove操作后问题解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
AsyncContext asyncContext = Container.asyncContextList.take();
AsyncContentDeliver asyncDeliver = Container.asyncContentMap.get(asyncContext);
 
//略去其余部分
 
if (respEntry.getResponseBody().equals(lastResponseBody)) {
  // 与上一次polling相比,实例状态未发生改变,异步请求重新入队。
  Container.asyncContextList.put(asyncContext);
  continue;
} else {// 实例状态发生了改变,进行响应。
  try {
    asyncContext.getResponse().setCharacterEncoding("UTF-8");
    asyncContext.getResponse().getWriter().print(respEntry.getResponseBody());
    asyncContext.complete();
    Container.asyncContentMap.remove(asyncContext); // BugFix!
  } catch (IOException e) {
    e.printStackTrace();
    Container.asyncContextList.put(asyncContext);
  }
}

总结这次Bug修复,深深感受到一点:编程语言始终解决不了业务逻辑层面上的内存泄露问题,资源的分配和释放程序员必须谨记在心。

--EOF--

Valve对异步Servlet的支持

今天发现的一个问题:上线已久的Tomcat7 + Servlet3.0实现的long polling功能异常。相同的代码本地运行正常,远程debug线上代码时发现程序在执行到
AsyncContext asyncContext = request.startAsync(request, response)时抛IllegalStateException异常:

1
2
3
java.lang.IllegalStateException: Not supported.
at org.apache.catalina.connector.Request.startAsync(Request.java)
......

比对了一下代码仓库中异步Servlet的基本配置,没有可疑的地方,于是基本可以确定是Servlet容器的配置问题。

找来线上Tomcat的server.xml文件,与本地的比对了一下,发现问题所在,线上Tomcat配置中的Context节点多了一条Valve配置:

1
<Valve className="org.jboss.web.rewrite.RewriteValve" />

删掉它或者在Valve节点中加一个asyncSupported="true"的属性:

1
<Valve className="org.jboss.web.rewrite.RewriteValve" asyncSupported="true" />

Tomcat所实现的Servlet3.0规定,请求在到达异步Servlet所在的调用链中,所有valve和filter都要显式声明支持asyncSupported="true",无论通过注解还是XML配置的方式。

其实还有一种比较猥琐的方式,在HttpServletRequest对象中加参数,从而绕过上述的对valve和filter的asyncSupported属性的依赖:

1
request.setAttribute("org.apache.catalina.ASYNC_SUPPORTED", true);

它的副作用就是造成了容器依赖,这段代码只对Tomcat容器有效。

--EOF--

CometD通过Tomcat容器运行

CometD官网一直推荐使用Jetty作为Servlet容器,但是公司项目大多还是使用Tomcat,所以将CometD工程从Jetty移植到Tomcat的问题首当其冲,只有在Tomcat下能稳定运行,才有将此框架纳入项目的可能性。

理论上,CometD(最新2.7版)可以运行在任意支持Servlet2.5和Servlet3.0的容器上,并且无需额外配置。今天通过Maven试了下几个标准CometD工程,配合手册完成了从Jetty到Tomcat的移植。

1. 通过Maven生成标准工程。

1
$ mvn archetype:generate -DarchetypeCatalog=http://cometd.org

目前可选的有cometd-archetype-dojo-jetty7、cometd-archetype-spring-dojo-jetty7、cometd-archetype-dojo-jetty8、cometd-archetype-jquery-jetty7、cometd-archetype-spring-jquery-jetty7和cometd-archetype-jquery-jetty8等几个,试了前三个,默认集成Spring的cometd-archetype-spring-dojo-jetty7移植成功,剩下两个默认存在一些包依赖问题,就以cometd-archetype-spring-dojo-jetty7为例记录下过程。

2.1-1. 首先尝试移植到Tomcat6 (Servlet 2.5)。测试的Tomcat版本是6.0.35。在web.xml文件中添加:

1
2
3
4
5
6
7
8
9
10
<filter>
    <filter-name>continuation</filter-name>
    <filter-class>
        org.eclipse.jetty.continuation.ContinuationFilter
    </filter-class>
</filter>
<filter-mapping>
    <filter-name>continuation</filter-name>
    <url-pattern>/cometd/*</url-pattern>
</filter-mapping>

Servlet 2.5容器不支持异步Servlet,因此CometD的做法是通过Jetty Coninuation API来模拟异步Servlet,ContinuationFilter过滤器就是调用Coninuation API来实现的。如果不添加这个过滤器,启动Tomcat时会报一个IllegalStateException异常:

1
2
3
4
java.lang.IllegalStateException: !(Jetty || Servlet 3.0 || ContinuationFilter)
at org.eclipse.jetty.continuation.ContinuationSupport
    .getContinuation(ContinuationSupport.java:150)
...

2.1-2. Done!

2.2-1. 再尝试移植到Tomcat 7 (Servlet 3)。测试的Tomcat版本是7.0.35。Tomcat 7支持Servlet 3.0,本身就支持异步Servlet,所以CometD直接利用容器的异步特性实现异步响应。先将POM文件(pom.xml)的servlet-api依赖版本替换为3.0:

1
2
3
4
5
6
<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>3.0.1</version>
    <scope>provided</scope>
</dependency>

2.2-2. 修改web.xml文件头,将版本号修改为3.0:

1
2
3
4
5
6
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee 
    http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" 
    version="3.0"
>

2.2-3. 声明CometdServlet为异步Servlet。添加<async-supported>true</async-supported>属性:

1
2
3
4
5
<servlet>
    <servlet-name>cometd</servlet-name>
    <servlet-class>org.cometd.server.CometdServlet</servlet-class>
    <async-supported>true</async-supported>  
</servlet>

另外所有在CometdServlet之前执行的Filter也要添加异步支持,例如:

1
2
3
4
5
<filter>
    <filter-name>cross-origin</filter-name>
    <filter-class>org.eclipse.jetty.servlets.CrossOriginFilter</filter-class>
    <async-supported>true</async-supported> 
</filter>

如果过滤器没有声明<async-supported>true</async-supported>属性,启动Tomcat会抛IllegalStateException异常:

1
2
3
java.lang.IllegalStateException: Not supported.
at org.apache.catalina.connector.Request.startAsync(Request.java:1673)
...

2.2-4. Done!

--EOF--

异步Servlet的超时管理

Servlet 3.0支持异步Servlet(见『异步Servlet (Servlet 3.0)』),服务器端可能会将客户端的请求挂起一段时间,这段时间内客户端和服务器端保持着一条长连接。服务器端有两种方法可以设置这条长连接的超时时间,一是Tomcat配置文件,二是在程序中为AsyncContext显式设置超时时间。

一、修改Tomcat配置文件。
Tomcat 7 server.xml配置文件中的Connector节点支持一个asyncTimeout属性,用于配置异步Servlet请求的长连接超时时间,单位是毫秒,如果不指定,则Tomcat默认10秒。例如:

1
2
3
4
5
<Connector port="8080"
        asyncTimeout="90000"
        protocol="HTTP/1.1" 
        connectionTimeout="20000"
        redirectPort="8443" />

设置异步请求的超时时间为90秒。

二、调用AsyncContext的setTimeout方法。
Servlet中调用request.startAsync()方法后即可得到当前请求的上下文,调用AsyncContext实例的setTimeout()方法也可设置此次异步请求的超时时间,setTimeout()方法参数的单位为毫秒。例如程序中设置异步请求的超时时间为90秒:

1
2
final AsyncContext asyncContext = request.startAsync(request, response);
asyncContext.setTimeout(90000);

如果有为异步请求添加监听器AsyncListener,则上述任一方法设置的超时时间到期后,监听器AsyncListener的onTimeout()会得到执行。

如果上述两种方法一起设置了,哪种方法会生效?答案是后一种方法会覆盖前一种。查看Tomcat源码中Request类(package: org.apache.catalina.connector.Request)的实现(☞SVN地址☜):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public AsyncContext startAsync(ServletRequest request,
        ServletResponse response) {
    if (!isAsyncSupported()) {
        throw new IllegalStateException("Not supported.");
    }
 
    if (asyncContext == null) {
        asyncContext = new AsyncContextImpl(this);
    }
 
    asyncContext.setStarted(getContext(), request, response,
            request==getRequest() && response==getResponse().getResponse());
    asyncContext.setTimeout(getConnector().getAsyncTimeout());
 
    return asyncContext;
}

第12行asyncContext实例初始化完毕后,紧接着第14行为asyncContext设置一个超时时间,这个超时时间便是来自于配置文件中的Connector节点(所有类型Connector,不论HTTP还是AJP协议)配置。由于第二种方法的调用时机肯定是在调用request.startAsync()方法之后,所以第二种方法会覆盖第一种方法的超时时间设置。

--EOF--

异步Servlet (Servlet 3.0)

Tomcat6开始已经通过Native API的方式支持Comet,可以作为容器实现反向Ajax应用。到了Tomcat7时代,开始全面支持Servlet 3.0规范,Servlet 3.0规范有一个新的特性就是异步Servlet。

在Servlet 2.5时代,容器接到客户端一个请求后,分配一个线程去处理此次请求,如果请求的内容涉及耗时的业务逻辑处理(如数据库操作、网络调用等)时,线程就会阻塞住,直到任务结束,返回响应,线程归还容器。这种阻塞I/O的模型一是限制了请求的并发数,二是无法实现服务器推(Server Push)类应用。Servlet 3.0规范定义了异步Servlet的处理流程,Servlet容器接到客户端一个请求后,可以将此次请求的上下文(ServletRequest, ServletResponse等)暂存起来,等到耗时的业务逻辑完成(通常在一个异步Worker线程中完成)时,从上下文中取回ServletResponse,将响应结果返回至客户端,而处理此次请求的线程在请求上下文暂存之后即可归还容器。这种将耗时操作放在Worker线程中完成的异步Servlet模型一举解决了前面提到的两个难题,反向Ajax中的Long Polling技术就是在异步Servlet的基础上实现的(服务器端先将请求挂起,等待数据准备好后返回,浏览器端的表现就是一个长连接,迟迟没有返回,一旦返回,必有数据更新)。

下面看看Servlet 3.0是如何支持异步Servlet的:
1. 在声明异步Servlet。异步Servlet可以在Web.xml文件中通过<async-supported>true</async-supported>标签或者通过@WebServlet注解的asyncSupported属性进行声明。如下:

1
2
3
4
5
<servlet> 
    <servlet-name>Async</servlet-name> 
    <servlet-class>cn.edu.zju.AsyncServlet</servlet-class> 
    <async-supported>true</async-supported> 
</servlet>

1
2
3
4
@WebServlet(urlPatterns = "/async", asyncSupported = true)
public class AsyncServlet extends HttpServlet {
 
}

如果请求到达这个Servlet之前还要过Filter,那么同样要在Web.xml的<filter>节点中声明<async-supported>true</async-supported>或者@WebFilter注解中增加asyncSupported=true属性。

2. Servlet 3.0通过AsyncContext实例来表示一次客户端请求的上下文。调用HttpServletRequest实例的startAsync方法可以得到异步Servlet上下文实例AsyncContext。

1
final AsyncContext asyncContext = request.startAsync(request, response);

此后,可以根据需要将AsyncContext实例加入任务队列,等待异步Worker线程处理具体业务逻辑。而响应本次客户端请求的线程在AsyncContext实例入队列后归还容器线程池。

3. 返回响应。调用AsyncContext实例的getRequest()和getResponse()方法即可以随时得到ServletRequest和ServletResponse对象,调用ServletResponse的getWriter().print()可以将返回值写入响应流,调用complete()方法销毁异步Servlet上下文实例,完成此次客户端请求的响应。resp.getWriter().print()可多次调用,最后在complete()调用后才返回至客户端。

1
2
3
4
AsyncContext asyncContext = Queue.pop();
//handling service logic.
asyncContext.getResponse().getWriter().print("resposne ok");
asyncContext.complete();

4. 异步Servlet监听器设置。Servlet 3.0规范允许为每个请求上下文实例AsyncContext设置一个监听器。可以监听上下文实例生命周期中的4个事件:开始、异常、超时和结束,分别对应onStartAsync、onError、onTimeout和onComplete方法。AsyncContext实例初始化后调用addListener方法即可添加监听器。

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
asyncContext.addListener(new AsyncListener(){
    //异步Servlet开始
    public void onStartAsync(AsyncEvent event) throws IOException {
        AsyncContext asyncContext = event.getAsyncContext();
        System.out.println("onStartAsync");
    }
 
    //异步Servlet处理异常
    public void onError(AsyncEvent event) throws IOException {
        AsyncContext asyncContext = event.getAsyncContext();
        System.out.println("onError");
    }
 
    //异步Servlet超时
    public void onTimeout(AsyncEvent event) throws IOException {
        AsyncContext asyncContext = event.getAsyncContext();
        System.out.println("onTimeout");
    }
 
    //异步Servlet完成
    public void onComplete(AsyncEvent event) throws IOException {
        AsyncContext asyncContext = event.getAsyncContext();
        System.out.println("onComplete");
    }
});

在AsyncListener监听器方法中,调用AsyncEvent实例的getAsyncContext()方法可以得到请求上下文实例AsyncContext。另外,在为AsyncContext实例添加监听器之前,可以通过调用setTimeout()方法设置请求上下文的超时时间,如:

1
asyncContext.setTimeout(Const.CONN_TIMEOUT);

如果不在程序中设置超时时间,容器根据配置会有一个默认超时时间,Tomcat可修改server.xml文件中Connector节点的asyncTimeout属性值来指定默认超时时间。

至此,一个支持异步Servlet的应用框架搭建完成。细节问题可查看具体文档:
AsyncContext: http://docs.oracle.com/javaee/6/api/javax/servlet/AsyncContext.html
Servlet 3.0 新特性详解: http://www.ibm.com/developerworks/cn/java/j-lo-servlet30/
Tomcat 7 - The HTTP Connector: http://tomcat.apache.org/tomcat-7.0-doc/config/http.html

--EOF--