Tomcat Session 概述
首先 HTTP 是一個無狀態的協議, 這意味着每次發起的HTTP請求, 都是一個全新的請求(與上個請求沒有任何聯系, 服務端不會保留上個請求的任何信息), 而 Session 的出現就是為了解決這個問題, 將 Client 端的每次請求都關聯起來, 要實現 Session 機制 通常通過 Cookie(cookie 里面保存統一標識符號), URI 附加參數, 或者就是SSL (就是SSL 中的各種屬性作為一個Client請求的唯一標識), 而在初始化 ApplicationContext 指定默認的Session追蹤機制(URL + COOKIE), 若 Connector 配置了 SSLEnabled, 則將通過 SSL 追蹤Session的模式也加入追蹤機制里面 (將 ApplicationContext.populateSessionTrackingModes()方法)
Cookie 概述
Cookie 是在Http傳輸中存在於Header中的一小撮文本信息(KV), 每次瀏覽器都會將服務端發送給自己的Cookie信息返回發送給服務端(PS: Cookie的內容存儲在瀏覽器端); 有了這種技術服務端就知道這次請求是誰發送過來的(比如我們這里的Session, 就是基於在Http傳輸中, 在Cookie里面加入一個全局唯一的標識符號JsessionId來區分是哪個用戶的請求)
Tomcat 中 Cookie 的解析
在 Tomcat 8.0.5 中 Cookie 的解析是通過內部的函數 processCookies() 來進行操作的(其實就是將Http header 的內容直接賦值給 Cookie 對象, Cookie在Header中找name是"Cookie"的數據, 拿出來進行解析), 我們這里主要從 jsessionid 的角度來看一下整個過程是如何觸發的, 我們直接看函數 CoyoteAdapter.postParseRequest() 中解析 jsessionId 那部分
// 嘗試從 URL, Cookie, SSL 回話中獲取請求的 ID, 並將 mapRequired 設置為 false String sessionID = null; // 1. 是否支持通過 URI 尾綴 JSessionId 的方式來追蹤 Session 的變化 (默認是支持的) if (request.getServletContext().getEffectiveSessionTrackingModes().contains(SessionTrackingMode.URL)) { // 2. 從 URI 尾綴的參數中拿取 jsessionId 的數據 (SessionConfig.getSessionUriParamName 是獲取對應cookie的名字, 默認 jsessionId, 可以在 web.xml 里面進行定義) sessionID = request.getPathParameter( SessionConfig.getSessionUriParamName(request.getContext())); if (sessionID != null) { // 3. 若從 URI 里面拿取了 jsessionId, 則直接進行賦值給 request request.setRequestedSessionId(sessionID); request.setRequestedSessionURL(true); } } // Look for session ID in cookies and SSL session // 4. 通過 cookie 里面獲取 JSessionId 的值 parseSessionCookiesId(req, request); // 5. 在 SSL 模式下獲取 JSessionId 的值 parseSessionSslId(request); /** * Parse session id in URL. */ protected void parseSessionCookiesId(org.apache.coyote.Request req, Request request) { // If session tracking via cookies has been disabled for the current // context, don't go looking for a session ID in a cookie as a cookie // from a parent context with a session ID may be present which would // overwrite the valid session ID encoded in the URL Context context = request.getMappingData().context; // 1. Tomcat 是否支持 通過 cookie 機制 跟蹤 session if (context != null && !context.getServletContext() .getEffectiveSessionTrackingModes().contains( SessionTrackingMode.COOKIE)) { return; } // Parse session id from cookies // 2. 獲取 Cookie的實際引用對象 (PS: 這里還沒有觸發 Cookie 解析, 也就是 serverCookies 里面是空數據, 數據還只是存儲在 http header 里面) Cookies serverCookies = req.getCookies(); // 3. 就在這里出發了 Cookie 解析Header里面的數據 (PS: 其實就是 輪訓查找 Header 里面那個 name 是 Cookie 的數據, 拿出來進行解析) int count = serverCookies.getCookieCount(); if (count <= 0) { return; } // 4. 獲取 sessionId 的名稱 JSessionId String sessionCookieName = SessionConfig.getSessionCookieName(context); for (int i = 0; i < count; i++) { // 5. 輪詢所有解析出來的 Cookie ServerCookie scookie = serverCookies.getCookie(i); // 6. 比較 Cookie 的名稱是否是 jsessionId if (scookie.getName().equals(sessionCookieName)) { logger.info("scookie.getName().equals(sessionCookieName)"); logger.info("Arrays.asList(Thread.currentThread().getStackTrace()):" + Arrays.asList(Thread.currentThread().getStackTrace())); // Override anything requested in the URL // 7. 是否 jsessionId 還沒有解析 (並且只將第一個解析成功的值 set 進去) if (!request.isRequestedSessionIdFromCookie()) { // Accept only the first session id cookie // 8. 將MessageBytes轉成 char convertMB(scookie.getValue()); // 9. 設置 jsessionId 的值 request.setRequestedSessionId(scookie.getValue().toString()); request.setRequestedSessionCookie(true); request.setRequestedSessionURL(false); if (log.isDebugEnabled()) { log.debug(" Requested cookie session id is " + request.getRequestedSessionId()); } } else { // 10. 若 Cookie 里面存在好幾個 jsessionid, 則進行覆蓋 set 值 if (!request.isRequestedSessionIdValid()) { // Replace the session id until one is valid convertMB(scookie.getValue()); request.setRequestedSessionId (scookie.getValue().toString()); } } } } }
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.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 還提供了集群會話的解決方案,但是在實際項目中很少運用
- 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 信息備份到磁盤
我們來看下 StandardManager
的類圖,它也是個 Lifecycle
組件,並且 ManagerBase
實現了主要的邏輯。
Tomcat 中 Session 的創建
經過上面的Cookie解析, 則若存在jsessionId的話, 則已經set到Request里面了, 那Session又是何時觸發創建的呢? 主要還是代碼 request.getSession(), 看代碼:
public class SessionExample extends HttpServlet { public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { HttpSession session = request.getSession(); // other code...... } }
我們來看看getSession():
// 獲取 request 對應的 session public HttpSession getSession() { // 這里就是 通過 managerBase.sessions 獲取 Session Session session = doGetSession(true); if (session == null) { return null; } return session.getSession(); } // create 代表是否創建 StandardSession protected Session doGetSession(boolean create) { // There cannot be a session if no context has been assigned yet // 1. 檢驗 StandardContext if (context == null) { return (null); } // Return the current session if it exists and is valid // 2. 校驗 Session 的有效性 if ((session != null) && !session.isValid()) { session = null; } if (session != null) { return (session); } // Return the requested session if it exists and is valid Manager manager = null; if (context != null) { //拿到StandardContext 中對應的StandardManager,Context與 Manager 是一對一的關系 manager = context.getManager(); } if (manager == null) { return (null); // Sessions are not supported } if (requestedSessionId != null) { try { // 3. 通過 managerBase.sessions 獲取 Session // 4. 通過客戶端的 sessionId 從 managerBase.sessions 來獲取 Session 對象 session = manager.findSession(requestedSessionId); } catch (IOException e) { session = null; } // 5. 判斷 session 是否有效 if ((session != null) && !session.isValid()) { session = null; } if (session != null) { // 6. session access +1 session.access(); return (session); } } // Create a new session if requested and the response is not committed // 7. 根據標識是否創建 StandardSession ( false 直接返回) if (!create) { return (null); } // 當前的 Context 是否支持通過 cookie 的方式來追蹤 Session if ((context != null) && (response != null) && context.getServletContext().getEffectiveSessionTrackingModes().contains(SessionTrackingMode.COOKIE) && response.getResponse().isCommitted()) { throw new IllegalStateException (sm.getString("coyoteRequest.sessionCreateCommitted")); } // Attempt to reuse session id if one was submitted in a cookie // Do not reuse the session id if it is from a URL, to prevent possible // phishing attacks // Use the SSL session ID if one is present. // 8. 到這里其實是沒有找到 session, 直接創建 Session 出來 if (("/".equals(context.getSessionCookiePath()) && isRequestedSessionIdFromCookie()) || requestedSessionSSL ) { session = manager.createSession(getRequestedSessionId()); // 9. 從客戶端讀取 sessionID, 並且根據這個 sessionId 創建 Session } else { session = manager.createSession(null); } // Creating a new session cookie based on that session if ((session != null) && (getContext() != null)&& getContext().getServletContext().getEffectiveSessionTrackingModes().contains(SessionTrackingMode.COOKIE)) { // 10. 根據 sessionId 來創建一個 Cookie Cookie cookie = ApplicationSessionCookieConfig.createSessionCookie(context, session.getIdInternal(), isSecure()); // 11. 最后在響應體中寫入 cookie response.addSessionCookieInternal(cookie); } if (session == null) { return null; } // 12. session access 計數器 + 1 session.access(); return session; }
我們看看 manager.createSession(null);
public abstract class ManagerBase extends LifecycleMBeanBase implements Manager { //Manager管理着當前Context的所有session protected Map<String, Session> sessions = new ConcurrentHashMap<>(); @Override public Session findSession(String id) throws IOException { if (id == null) { return null; } //通過JssionId獲取session return sessions.get(id); } public Session createSession(String sessionId) { // 1. 判斷 單節點的 Session 個數是否超過限制 if ((maxActiveSessions >= 0) && (getActiveSessions() >= maxActiveSessions)) { rejectedSessions++; throw new TooManyActiveSessionsException( sm.getString("managerBase.createSession.ise"), maxActiveSessions); } // Recycle or create a Session instance // 創建一個 空的 session // 2. 創建 Session Session session = createEmptySession(); // Initialize the properties of the new session and return it // 初始化空 session 的屬性 session.setNew(true); session.setValid(true); session.setCreationTime(System.currentTimeMillis()); // 3. StandardSession 最大的默認 Session 激活時間 session.setMaxInactiveInterval(this.maxInactiveInterval); String id = sessionId; // 若沒有從 client 端讀取到 jsessionId if (id == null) { // 4. 生成 sessionId (這里通過隨機數來生成) id = generateSessionId(); } //這里會將session存入Map<String, Session> sessions = new ConcurrentHashMap<>(); session.setId(id); sessionCounter++; SessionTiming timing = new SessionTiming(session.getCreationTime(), 0); synchronized (sessionCreationTiming) { // 5. 每次創建 Session 都會創建一個 SessionTiming, 並且 push 到 鏈表 sessionCreationTiming 的最后 sessionCreationTiming.add(timing); // 6. 並且將 鏈表 最前面的節點刪除 sessionCreationTiming.poll(); } // 那這個 sessionCreationTiming 是什么作用呢, 其實 sessionCreationTiming 是用來統計 Session的新建及失效的頻率 (好像Zookeeper 里面也有這個的統計方式) return (session); } @Override public void add(Session session) { //將創建的Seesion存入Map<String, Session> sessions = new ConcurrentHashMap<>(); sessions.put(session.getIdInternal(), session); int size = getActiveSessions(); if( size > maxActive ) { synchronized(maxActiveUpdateLock) { if( size > maxActive ) { maxActive = size; } } } } } @Override public void setId(String id) { setId(id, true); } @Override public void setId(String id, boolean notify) { if ((this.id != null) && (manager != null)) manager.remove(this); this.id = id; if (manager != null) manager.add(this); if (notify) { tellNew(); } }
其主要的步驟就是:
1. 若 request.Session != null, 則直接返回 (說明同一時刻之前有其他線程創建了Session, 並且賦值給了 request)
2. 若 requestedSessionId != null, 則直接通過 manager 來進行查找一下, 並且判斷是否有效
3. 調用 manager.createSession 來創建對應的Session,並將Session存入Manager的Map中
4. 根據 SessionId 來創建 Cookie, 並且將 Cookie 放到 Response 里面
5. 直接返回 Session
Session清理
Background 線程
前面我們分析了 Session 的創建過程,而 Session 會話是有時效性的,下面我們來看下 tomcat 是如何進行失效檢查的。在分析之前,我們先回顧下 Container
容器的 Background 線程。
tomcat 所有容器組件,都是繼承至 ContainerBase
的,包括 StandardEngine
、StandardHost
、StandardContext
、StandardWrapper
,而 ContainerBase
在啟動的時候,如果 backgroundProcessorDelay
參數大於 0 則會開啟 ContainerBackgroundProcessor
后台線程,調用自己以及子容器的 backgroundProcess
進行一些后台邏輯的處理,和 Lifecycle
一樣,這個動作是具有傳遞性的,也就
關鍵代碼如下所示:
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 又是如何更新這個時間戳呢? 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(); }