在 web 开发中,我们经常会用到 Session 来保存会话信息,包括用户信息、权限信息,等等。在这篇文章中,我们将分析 tomcat 容器是如何创建 session、销毁 session,又是如何对 HttpSessionListener 进行事件通知
tomcat session 组件图如下所示,其中 Context
对应一个 webapp 应用,每个 webapp 有多个 HttpSessionListener
, 并且每个应用的 session 是独立管理的,而 session 的创建、销毁由 Manager
组件完成,它内部维护了 N 个 Session
实例对象。在前面的文章中,我们分析了 Context
组件,它的默认实现是 StandardContext
,它与 Manager
是一对一的关系,Manager
创建、销毁会话时,需要借助 StandardContext
获取 HttpSessionListener
列表并进行事件通知,而 StandardContext
的后台线程会对 Manager
进行过期 Session 的清理工作
org.apache.catalina.Manager
接口的主要方法如下所示,它提供了 Context
、org.apache.catalina.SessionIdGenerator
的 getter/setter 接口,以及创建、添加、移除、查找、遍历 Session
的 API 接口,此外还提供了 Session
持久化的接口(load/unload) 用于加载/卸载会话信息,当然持久化要看不同的实现类
public interface Manager {
public Context getContext();
public void setContext(Context context);
public SessionIdGenerator getSessionIdGenerator();
public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator);
public void add(Session session);
public void addPropertyChangeListener(PropertyChangeListener listener);
public void changeSessionId(Session session);
public void changeSessionId(Session session, String newId);
public Session createEmptySession();
public Session createSession(String sessionId);
public Session findSession(String id) throws IOException;
public Session[] findSessions();
public void remove(Session session);
public void remove(Session session, boolean update);
public void removePropertyChangeListener(PropertyChangeListener listener);
public void unload() throws IOException;
public void backgroundProcess();
public boolean willAttributeDistribute(String name, Object value);
}
tomcat8.5 提供了 4 种实现,默认使用 StandardManager
,tomcat 还提供了集群会话的解决方案,但是在实际项目中很少运用,关于 Manager
的详细配置信息请参考 tomcat 官方文档
Session 相关的类图如下所示,StandardSession
同时实现了 javax.servlet.http.HttpSession
、org.apache.catalina.Session
接口,并且对外提供的是 StandardSessionFacade
外观类,保证了 StandardSession
的安全,避免开发人员调用其内部方法进行不当操作。而 org.apache.catalina.connector.Request
实现了 javax.servlet.http.HttpServletRequest
接口,它持有 StandardSession
的引用,对外也是暴露 RequestFacade
外观类。而 StandardManager
内部维护了其创建的 StandardSession
,是一对多的关系,并且持有 StandardContext
的引用,而 StandardContext
内部注册了 webapp 所有的 HttpSessionListener
实例。
我们以 HttpServletRequest#getSession()
作为切入点,对 Session 的创建过程进行分析
public class SessionExample extends HttpServlet {
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
HttpSession session = request.getSession();
// other code......
}
}
整个流程图如下图所示(查看原图):
tomcat 创建 session 的流程如上图所示,我们的应用程序拿到的 HttpServletRequest
是 org.apache.catalina.connector.RequestFacade
(除非某些 Filter 进行了特殊处理),它是 org.apache.catalina.connector.Request
的门面模式。首先,会判断 Request
对象中是否存在 Session
,如果存在并且未失效则直接返回,因为在 tomcat 中 Request
对象是被重复利用的,只会替换部分组件,所以会进行这步判断。此时,如果不存在 Session
,则尝试根据 requestedSessionId
查找 Session
,而该 requestedSessionId
会在 HTTP Connector 中进行赋值(如果存在的话),如果存在 Session
的话则直接返回,如果不存在的话,则创建新的 Session
,并且把 sessionId
添加到 Cookie
中,后续的请求便会携带该 Cookie
,这样便可以根据 Cookie
中的sessionId
找到原来创建的 Session
了
在上面的过程中,Session
的查找、创建都是由 Manager
完成的,下面我们分析下 StandardManager
创建 Session
的具体逻辑。首先,我们来看下 StandardManager
的类图,它也是个 Lifecycle
组件,并且 ManagerBase
实现了主要的逻辑。
整个创建 Session
的过程比较简单,就是实例化 StandardSession
对象并设置其基本属性,以及生成唯一的 sessionId
,其次就是记录创建时间,关键代码如下所示:
public Session createSession(String sessionId) {
// 限制 session 数量,默认不做限制,maxActiveSessions = -1
if ((maxActiveSessions >= 0) &&
(getActiveSessions() >= maxActiveSessions)) {
rejectedSessions++;
throw new TooManyActiveSessionsException(sm.getString("managerBase.createSession.ise"), maxActiveSessions);
}
// 创建 StandardSession 实例,子类可以重写该方法
Session session = createEmptySession();
// 设置属性,包括创建时间,最大失效时间
session.setNew(true);
session.setValid(true);
session.setCreationTime(System.currentTimeMillis());
// 设置最大不活跃时间(单位s),如果超过这个时间,仍然没有请求的话该Session将会失效
session.setMaxInactiveInterval(getContext().getSessionTimeout() * 60);
String id = sessionId;
if (id == null) {
id = generateSessionId();
}
session.setId(id);
sessionCounter++; // 这个地方不是线程安全的,可能当时开发人员认为计数器不要求那么准确
// 将创建时间添加到LinkedList中,并且把最先添加的时间移除,主要还是方便清理过期session
SessionTiming timing = new SessionTiming(session.getCreationTime(), 0);
synchronized (sessionCreationTiming) {
sessionCreationTiming.add(timing);
sessionCreationTiming.poll();
}
return (session);
}
在 tomcat 中是可以限制 session 数量的,如果需要限制,请指定 Manager
的 maxActiveSessions
参数,默认不做限制,不建议进行设置,但是如果存在恶意攻击,每次请求不携带 Cookie
就有可能会频繁创建 Session
,导致 Session
对象爆满最终出现 OOM。另外 sessionId
采用随机算法生成,并且每次生成都会判断当前是否已经存在该 id,从而避免 sessionId
重复。而 StandardManager
是使用 ConcurrentHashMap
存储 session 对象的,sessionId
作为 key,org.apache.catalina.Session
作为 value。此外,值得注意的是 StandardManager
创建的是 tomcat 的 org.apache.catalina.session.StandardSession
,同时他也实现了 servlet 的 HttpSession
,但是为了安全起见,tomcat 并不会把这个 StandardSession
直接交给应用程序,因此需要调用 org.apache.catalina.Session#getSession()
获取 HttpSession
。
我们再来看看 StandardSession
的内部结构
public class StandardSession implements HttpSession, Session, Serializable {
protected ConcurrentMap attributes = new ConcurrentHashMap<>();
protected long creationTime = 0L;
protected transient volatile boolean expiring = false;
protected transient StandardSessionFacade facade = null;
protected String id = null;
protected volatile long lastAccessedTime = creationTime;
protected transient ArrayList listeners = new ArrayList<>();
protected transient Manager manager = null;
protected volatile int maxInactiveInterval = -1;
protected volatile boolean isNew = false;
protected volatile boolean isValid = false;
protected transient Map notes = new Hashtable<>();
protected transient Principal principal = null;
}
前面我们分析了 Session 的创建过程,而 Session 会话是有时效性的,下面我们来看下 tomcat 是如何进行失效检查的。在分析之前,我们先回顾下 Container
容器的 Background 线程。
tomcat 所有容器组件,都是继承至 ContainerBase
的,包括 StandardEngine
、StandardHost
、StandardContext
、StandardWrapper
,而 ContainerBase
在启动的时候,如果 backgroundProcessorDelay
参数大于 0 则会开启 ContainerBackgroundProcessor
后台线程,调用自己以及子容器的 backgroundProcess
进行一些后台逻辑的处理,和 Lifecycle
一样,这个动作是具有传递性的,也就是说子容器还会把这个动作传递给自己的子容器,如下图所示,其中父容器会遍历所有的子容器并调用其 backgroundProcess
方法,而 StandardContext
重写了该方法,它会调用 StandardManager#backgroundProcess()
进而完成 Session 的清理工作。看到这里,不得不感慨 tomcat 的责任
关键代码如下所示:
ContainerBase.java(省略了异常处理代码)
protected synchronized void startInternal() throws LifecycleException {
// other code......
// 开启ContainerBackgroundProcessor线程用于处理子容器,默认情况下backgroundProcessorDelay=-1,不会启用该线程
threadStart();
}
protected class ContainerBackgroundProcessor implements Runnable {
public void run() {
// threadDone 是 volatile 变量,由外面的容器控制
while (!threadDone) {
try {
Thread.sleep(backgroundProcessorDelay * 1000L);
} catch (InterruptedException e) {
// Ignore
}
if (!threadDone) {
processChildren(ContainerBase.this);
}
}
}
protected void processChildren(Container container) {
container.backgroundProcess();
Container[] children = container.findChildren();
for (int i = 0; i < children.length; i++) {
// 如果子容器的 backgroundProcessorDelay 参数小于0,则递归处理子容器
// 因为如果该值大于0,说明子容器自己开启了线程处理,因此父容器不需要再做处理
if (children[i].getBackgroundProcessorDelay() <= 0) {
processChildren(children[i]);
}
}
}
}
backgroundProcessorDelay
参数默认值为 -1
,单位为秒,即默认不启用后台线程,而 tomcat 的 Container 容器需要开启线程处理一些后台任务,比如监听 jsp 变更、tomcat 配置变动、Session 过期等等,因此 StandardEngine
在构造方法中便将 backgroundProcessorDelay
参数设为 10(当然可以在 server.xml
中指定该参数),即每隔 10s 执行一次。那么这个线程怎么控制生命周期呢?我们注意到 ContainerBase
有个 threadDone
变量,用 volatile
修饰,如果调用 Container 容器的 stop 方法该值便会赋值为 false,那么该后台线程也会退出循环,从而结束生命周期。另外,有个地方需要注意下,父容器在处理子容器的后台任务时,需要判断子容器的 backgroundProcessorDelay
值,只有当其小于等于 0 才进行处理,因为如果该值大于0,子容器自己会开启线程自行处理,这时候父容器就不需要再做处理了
前面分析了容器的后台线程是如何调度的,下面我们重点来看看 webapp 这一层,以及 StandardManager
是如何清理过期会话的。StandardContext
重写了 backgroundProcess
方法,除了对子容器进行处理之外,还会对一些缓存信息进行清理,关键代码如下所示:
StandardContext.java
@Override
public void backgroundProcess() {
if (!getState().isAvailable())
return;
// 热加载 class,或者 jsp
Loader loader = getLoader();
if (loader != null) {
loader.backgroundProcess();
}
// 清理过期Session
Manager manager = getManager();
if (manager != null) {
manager.backgroundProcess();
}
// 清理资源文件的缓存
WebResourceRoot resources = getResources();
if (resources != null) {
resources.backgroundProcess();
}
// 清理对象或class信息缓存
InstanceManager instanceManager = getInstanceManager();
if (instanceManager instanceof DefaultInstanceManager) {
((DefaultInstanceManager)instanceManager).backgroundProcess();
}
// 调用子容器的 backgroundProcess 任务
super.backgroundProcess();
}
StandardContext
重写了 backgroundProcess
方法,在调用子容器的后台任务之前,还会调用 Loader
、Manager
、WebResourceRoot
、InstanceManager
的后台任务,这里我们只关心 Manager
的后台任务。弄清楚了 StandardManager
的来龙去脉之后,我们接下来分析下具体的逻辑。
StandardManager
继承至 ManagerBase
,它实现了主要的逻辑,关于 Session 清理的代码如下所示。backgroundProcess 默认是每隔10s调用一次,但是在 ManagerBase
做了取模处理,默认情况下是 60s 进行一次 Session 清理。tomcat 对 Session 的清理并没有引入时间轮,因为对 Session 的时效性要求没有那么精确,而且除了通知 SessionListener
。
ManagerBase.java
public void backgroundProcess() {
// processExpiresFrequency 默认值为 6,而backgroundProcess默认每隔10s调用一次,也就是说除了任务执行的耗时,每隔 60s 执行一次
count = (count + 1) % processExpiresFrequency;
if (count == 0) // 默认每隔 60s 执行一次 Session 清理
processExpires();
}
/**
* 单线程处理,不存在线程安全问题
*/
public void processExpires() {
long timeNow = System.currentTimeMillis();
Session sessions[] = findSessions(); // 获取所有的 Session
int expireHere = 0 ;
for (int i = 0; i < sessions.length; i++) {
// Session 的过期是在 isValid() 里面处理的
if (sessions[i]!=null && !sessions[i].isValid()) {
expireHere++;
}
}
long timeEnd = System.currentTimeMillis();
// 记录下处理时间
processingTime += ( timeEnd - timeNow );
}
在上面的代码,我们并没有看到太多的过期处理,只是调用了 sessions[i].isValid()
,原来清理动作都在这个方法里面处理的,相当的隐晦。在 StandardSession#isValid()
方法中,如果 now - thisAccessedTime >= maxInactiveInterval
则判定当前 Session 过期了,而这个 thisAccessedTime
参数在每次访问都会进行更新
public boolean isValid() {
// other code......
// 如果指定了最大不活跃时间,才会进行清理,这个时间是 Context.getSessionTimeout(),默认是30分钟
if (maxInactiveInterval > 0) {
int timeIdle = (int) (getIdleTimeInternal() / 1000L);
if (timeIdle >= maxInactiveInterval) {
expire(true);
}
}
return this.isValid;
}
而 expire
方法处理的逻辑较繁锁,下面我用伪代码简单地描述下核心的逻辑,由于这个步骤可能会有多线程进行操作,因此使用 synchronized
对当前 Session 对象加锁,还做了双重校验,避免重复处理过期 Session。它还会向 Container 容器发出事件通知,还会调用 HttpSessionListener
进行事件通知,这个也就是我们 web 应用开发的 HttpSessionListener
了。由于 Manager
中维护了 Session
对象,因此还要将其从 Manager
移除。Session 最重要的功能就是存储数据了,可能存在强引用,而导致 Session 无法被 gc 回收,因此还要移除内部的 key/value 数据。由此可见,tomcat 编码的严谨性了,稍有不慎将可能出现并发问题,以及出现内存泄露
public void expire(boolean notify) {
1、校验 isValid 值,如果为 false 直接返回,说明已经被销毁了
synchronized (this) { // 加锁
2、双重校验 isValid 值,避免并发问题
Context context = manager.getContext();
if (notify) {
Object listeners[] = context.getApplicationLifecycleListeners();
HttpSessionEvent event = new HttpSessionEvent(getSession());
for (int i = 0; i < listeners.length; i++) {
3、判断是否为 HttpSessionListener,不是则继续循环
4、向容器发出Destory事件,并调用 HttpSessionListener.sessionDestroyed() 进行通知
context.fireContainerEvent("beforeSessionDestroyed", listener);
listener.sessionDestroyed(event);
context.fireContainerEvent("afterSessionDestroyed", listener);
}
5、从 manager 中移除该 session
6、向 tomcat 的 SessionListener 发出事件通知,非 HttpSessionListener
7、清除内部的 key/value,避免因为强引用而导致无法回收 Session 对象
}
}
由前面的分析可知,tomcat 会根据时间戳清理过期 Session,那么 tomcat 又是如何更新这个时间戳呢?我们在 StandardSession#thisAccessedTime
的属性上面打个断点,看下调用栈。原来 tomcat 在处理完请求之后,会对 Request
对象进行回收,并且会对 Session 信息进行清理,而这个时候会更新 thisAccessedTime
、lastAccessedTime
时间戳。此外,我们通过调用 request.getSession()
这个 API 时,在返回 Session 时会调用 Session#access()
方法,也会更新 thisAccessedTime
时间戳。这样一来,每次请求都会更新时间戳,可以保证 Session 的鲜活时间
方法调用栈如下所示:
关键代码如下所示:
org.apache.catalina.connector.Request.java
protected void recycleSessionInfo() {
if (session != null) {
session.endAccess(); // 更新时间戳
}
// 回收 Request 对象的内部信息
session = null;
requestedSessionCookie = false;
requestedSessionId = null;
requestedSessionURL = false;
requestedSessionSSL = false;
}
org.apache.catalina.session.StandardSession.java
public void endAccess() {
isNew = false;
if (LAST_ACCESS_AT_START) { // 可以通过系统参数改变该值,默认为false
this.lastAccessedTime = this.thisAccessedTime;
this.thisAccessedTime = System.currentTimeMillis();
} else {
this.thisAccessedTime = System.currentTimeMillis();
this.lastAccessedTime = this.thisAccessedTime;
}
}
public void access() {
this.thisAccessedTime = System.currentTimeMillis();
}
前面我们分析了 Session
的创建过程,但是在整个创建流程中,似乎没有看到关于 HttpSessionListener
的创建通知。原来,在给 Session 设置 id 的时候会进行事件通知,和 Session 的销毁一样,也是非常的隐晦,个人感觉这一块设计得不是很合理。
创建通知这块的逻辑很简单,首先创建 HttpSessionEvent
对象,然后遍历 Context 内部的 LifecycleListener,并且判断是否为 HttpSessionListener
实例,如果是的话则调用 HttpSessionListener#sessionCreated()
方法进行事件通知。
public void setId(String id, boolean notify) {
// 省略部分代码
if (notify) {
tellNew();
}
}
public void tellNew() {
// 通知 org.apache.catalina.SessionListener
fireSessionEvent(Session.SESSION_CREATED_EVENT, null);
// 获取 Context 内部的 LifecycleListener,并判断是否为 HttpSessionListener
Context context = manager.getContext();
Object listeners[] = context.getApplicationLifecycleListeners();
if (listeners != null && listeners.length > 0) {
HttpSessionEvent event = new HttpSessionEvent(getSession());
for (int i = 0; i < listeners.length; i++) {
if (!(listeners[i] instanceof HttpSessionListener))
continue;
HttpSessionListener listener = (HttpSessionListener) listeners[i];
context.fireContainerEvent("beforeSessionCreated", listener); // 通知 Container 容器
listener.sessionCreated(event);
context.fireContainerEvent("afterSessionCreated", listener);
}
}
}
我们在前面分析清理过期 Session时大致分析了 Session 销毁时会触发 HttpSessionListener
的销毁通知,这里不再重复了。