在 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
接口的主要方法如下所示,它提供了 Contex
t、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 官方文檔
- 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架構學習資料(裏面有高可用、高併發、高性能及分佈式、Jvm性能調優、MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)
爲什麼某些人會一直比你優秀,是因爲他本身就很優秀還一直在持續努力變得更優秀,而你是不是還在滿足於現狀內心在竊喜!希望讀到這的您能點個小贊和關注下我,以後還會更新技術乾貨,謝謝您的支持!
資料領取方式:加入Java技術交流羣963944895
,點擊加入羣聊,私信管理員即可免費領取