在 web 開發中,我們經常會用到 Session 來保存會話信息,包括用戶信息、許可權信息,等等。在這篇文章中,我們將分析 tomcat 容器是如何創建 session、銷毀 session,又是如何對 HttpSessionListener 進行事件通知

tomcat session 設計分析

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.SessionIdGeneratorgetter/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 官方文檔

  • StandardManager:Manager 默認實現,在內存中管理 session,宕機將導致 session 丟失;但是當調用 Lifecycle 的 start/stop 介面時,將採用 jdk 序列化保存 Session 信息,因此當 tomcat 發現某個應用的文件有變更進行 reload 操作時,這種情況下不會丟失 Session 信息
  • DeltaManager:增量 Session 管理器,用於Tomcat集羣的會話管理器,某個節點變更 Session 信息都會同步到集羣中的所有節點,這樣可以保證 Session 信息的實時性,但是這樣會帶來較大的網路開銷
  • BackupManager:用於 Tomcat 集羣的會話管理器,與DeltaManager不同的是,某個節點變更 Session 信息的改變只會同步給集羣中的另一個 backup 節點
  • PersistentManager:當會話長時間空閑時,將會把 Session 信息寫入磁碟,從而限制內存中的活動會話數量;此外,它還支持容錯,會定期將內存中的 Session 信息備份到磁碟

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 實例。

創建Session

我們以 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 的內部結構

  • attributes:使用 ConcurrentHashMap 解決多線程讀寫的並發問題
  • creationTime:Session 的創建時間
  • expiring:用於標識 Session 是否過期
  • expiring:用於標識 Session 是否過期
  • lastAccessedTime:上一次訪問的時間,用於計算 Session 的過期時間
  • maxInactiveInterval:Session 的最大存活時間,如果超過這個時間沒有請求,Session 就會被清理、
  • listeners:這是 tomcat 的 SessionListener,並不是 servlet 的 HttpSessionListener
  • facade:HttpSession 的外觀模式,應用程序拿到的是該對象

public class StandardSession implements HttpSession, Session, Serializable {
protected ConcurrentMap<String, Object> 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<SessionListener> 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<String, Object> notes = new Hashtable<>();
protected transient Principal principal = null;
}

Session清理

Background 線程

前面我們分析了 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]);
}
}
}
}

Session 檢查

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 );
}

清理過期 Session

在上面的代碼,我們並沒有看到太多的過期處理,只是調用了 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();
}

HttpSessionListener

創建通知

前面我們分析了 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 的銷毀通知,這裡不再重複了。

更多關於Java的技術和資訊可以關注我的專欄:

Java架構築基?

zhuanlan.zhihu.com
圖標

專欄免費給大家分享Java架構的學習資料和視頻

推薦閱讀:

相關文章