Tomcat 是如何管理Session的
概述
學了ConcurrentHashMap
卻不知如何應用?用了Tomcat的Session卻不知其是如何實現的,Session是怎麼被建立和銷燬的?往下看你就知道了。
Session結構
不多廢話,直接上圖
仔細觀察上圖,我們可以得出以下結論-
HttpSession
是JavaEE標準中操作Session的介面類,因此我們實際上操作的是StandardSessionFacade
類 -
Session
儲存資料所使用的資料結構是ConcurrentHashMap
,如你在圖上看到的我們往Session
中儲存了一個msg
為什麼需要使用ConcurrentHashMap
呢?原因是,在處理Http請求並不是隻有一個執行緒會訪問這個Session,現代Web應用訪問一次頁面,通常需要同時執行多次請求,而這些請求可能會在同一時刻內被Web容器中不同執行緒同時執行,因此如果採用HashMap
讓我們先來看看HttpSession的包裝類。
StandardSessionFacade
在此類中我們可以學習到外觀模式(Facde)的實際應用。其定義如下所示。
public class StandardSessionFacade implements HttpSession
複製程式碼
那麼此類是如何實現Session的功能呢?觀察以下程式碼不難得出,此類並不是HttpSession的真正實現類,而是將真正的HttpSession實現類進行包裝,只暴露HttpSession介面中的方法,也就是設計模式中的外觀(Facde)模式。
private final HttpSession session;
public StandardSessionFacade(HttpSession session) {
this.session = session;
}
複製程式碼
那麼我們為什麼不直接使用HttpSession的實現類呢?
根據圖1,我們可以知道HttpSession的真正實現類是StandardSession
,假設在該類內定義了一些本應由Tomcat呼叫而非由程式呼叫的方法,那麼由於Java的型別系統我們將可以直接操作該類,這將會帶來一些不可預見的問題,如以下程式碼所示。
而如果我們將StandardSession
再包裝一層,上圖程式碼執行的時候將會發生錯誤。如下圖所示,將會丟擲型別轉換的異常,從而阻止此處非法的操作。
再進一步,我們由辦法繞外觀類直接訪問StandardSession
嗎?
事實上是可以的,我們可以通過反射機制來獲取StandardSession
,但你最好清楚自己在幹啥。程式碼如下所示
@GetMapping("/s")
public String sessionTest(HttpSession httpSession) throws ClassNotFoundException,NoSuchFieldException,IllegalAccessException {
StandardSessionFacade session = (StandardSessionFacade) httpSession;
Class targetClass = Class.forName(session.getClass().getName());
//修改可見性
Field standardSessionField = targetClass.getDeclaredField("session");
standardSessionField.setAccessible(true);
//獲取
StandardSession standardSession = (StandardSession) standardSessionField.get(session);
return standardSession.getManager().toString();
}
複製程式碼
StandardSession
該類的定義如下
public class StandardSession implements
HttpSession,Session,Serializable
複製程式碼
通過其介面我們可以看出此類除了具有JavaEE標準中HttpSession
要求實現的功能之外,還有序列化的功能。
在圖1中我們已經知道StandardSession
是用ConcurrentHashMap
來儲存的資料,因此接下來我們主要關注StandardSession
的序列化以及反序列化的實現,以及監聽器的功能。
序列化
還記得上一節我們通過反射機制獲取到了StandardSession
嗎?利用以下程式碼我們可以直接觀察到反序列化出來的StandardSession
是咋樣的。
@GetMapping("/s")
public void sessionTest(HttpSession httpSession,HttpServletResponse response) throws ClassNotFoundException,IllegalAccessException,IOException {
StandardSessionFacade session = (StandardSessionFacade) httpSession;
Class targetClass = Class.forName(session.getClass().getName());
//修改可見性
Field standardSessionField = targetClass.getDeclaredField("session");
standardSessionField.setAccessible(true);
//獲取
StandardSession standardSession = (StandardSession) standardSessionField.get(session);
//存點資料以便觀察
standardSession.setAttribute("msg","hello,world");
standardSession.setAttribute("user","kesan");
standardSession.setAttribute("password","點贊");
standardSession.setAttribute("tel",10086L);
//將序列化的結果直接寫到Http的響應中
ObjectOutputStream objectOutputStream = new ObjectOutputStream(response.getOutputStream());
standardSession.writeObjectData(objectOutputStream);
}
複製程式碼
如果不出意外,訪問此介面瀏覽器將會執行下載操作,最後得到一個檔案
使用WinHex
開啟分析,如圖所示為序列化之後得結果,主要是一大堆分隔符,以及型別資訊和值,如圖中紅色方框標準的資訊。
不建議大家去死磕序列化檔案是如何組織資料的,因為意義不大
如果你真的有興趣建議你閱讀以下程式碼
org.apache.catalina.session.StandardSession.doWriteObject
監聽器
在JavaEE的標準中,我們可以通過配置HttpSessionAttributeListener
來監聽Session的變化,那麼在StandardSession
中是如何實現的呢,如果你瞭解觀察者模式,那麼想必你已經知道答案了。
以setAttribute為例,在呼叫此方法之後會立即在本執行緒呼叫監聽器的方法進行處理,這意味著我們不應該在監聽器中執行阻塞時間過長的操作。
public void setAttribute(String name,Object value,boolean notify) {
//省略無關程式碼
//獲取上文中配置的事件監聽器
Object listeners[] = context.getApplicationEventListeners();
if (listeners == null) {
return;
}
for (int i = 0; i < listeners.length; i++) {
//只有HttpSessionAttributeListener才可以執行
if (!(listeners[i] instanceof HttpSessionAttributeListener)) {
continue;
}
HttpSessionAttributeListener listener = (HttpSessionAttributeListener) listeners[i];
try {
//在當前執行緒呼叫監聽器的處理方法
if (unbound != null) {
if (unbound != value || manager.getNotifyAttributeListenerOnUnchangedValue()) {
//如果是某個鍵的值被修改則呼叫監聽器的attributeReplaced方法
context.fireContainerEvent("beforeSessionAttributeReplaced",listener);
if (event == null) {
event = new HttpSessionBindingEvent(getSession(),name,unbound);
}
listener.attributeReplaced(event);
context.fireContainerEvent("afterSessionAttributeReplaced",listener);
}
} else {
//如果是新新增某個鍵則執行attributeAdded方法
context.fireContainerEvent("beforeSessionAttributeAdded",listener);
if (event == null) {
event = new HttpSessionBindingEvent(getSession(),value);
}
listener.attributeAdded(event);
context.fireContainerEvent("afterSessionAttributeAdded",listener);
}
} catch (Throwable t) {
//異常處理
}
}
}
複製程式碼
Sesssion生命週期
如何儲存Session
在瞭解完Session的結構之後,我們有必要明確StandardSession
是在何時被建立的,以及需要注意的點。
首先我們來看看StandardSession
的建構函式,其程式碼如下所示。
public StandardSession(Manager manager) {
//呼叫Object類的構造方法,預設已經呼叫了
//此處再宣告一次,不知其用意,或許之前此類有父類?
super();
this.manager = manager;
//是否開啟訪問計數
if (ACTIVITY_CHECK) {
accessCount = new AtomicInteger();
}
}
複製程式碼
在建立StandardSession
的時候都必須傳入Manager
物件以便與此StandardSession
關聯,因此我們可以將目光轉移到Manager
,而Manager
與其子類之間的關係如下圖所示。
ManagerBase
中可以發現以下程式碼。
protected Map<String,Session> sessions = new ConcurrentHashMap<>();
複製程式碼
Session
是Tomcat自定義的介面,StandardSession
實現了HttpSession
以及Session
介面,此介面功能更加豐富,但並不向程式設計師提供。
查詢此屬性可以發現,與Session相關的操作都是通過操作sessions
來實現的,因此我們可以明確儲存Session的資料結構是ConcurrentHashMap
。
如何建立Session
那麼Session到底是如何建立的呢?我找到了以下方法ManagerBase.creaeSession
,總結其流程如下。
- 檢查session數是否超過限制,如果有就丟擲異常
- 建立
StandardSession
物件 - 設定session各種必須的屬性(合法性,最大超時時間,sessionId)
- 生成SessionId,Tomcat支援不同的SessionId演演算法,本人除錯過程其所使用的SessionId生成演演算法是
LazySessionIdGenerator
(此演演算法與其他演演算法不同之處就在於並不會在一開始就載入隨機數陣列,而是在用到的時候才載入,此處的隨機陣列並不是普通的隨機陣列而是SecureRandom
,相關資訊可以閱讀大佬的文章) - 增加session的計數,由於Tomcat的策略是隻計算
100
個session的建立速率,因此sessionCreationTiming
是固定大小為100的連結串列(一開始為100個值為null
的元素),因此在將新的資料新增到連結串列中時必須要將舊的資料移除連結串列以保證其固定的大小。session建立速率計算公式如下(1000*60*counter)/(int)(now - oldest)
其中
now
為獲取統計資料時的時間System.currentTimeMillis()
oldest
為佇列中最早建立session的時間counter
為佇列中值不為null
的元素的數量- 由於計算的是
每分鐘的速率
因此在此處必須將1000乘以60(一分鐘內有60000毫秒)
public Session createSession(String sessionId) {
//檢查Session是否超過限制,如果是則丟擲異常
if ((maxActiveSessions >= 0) &&
(getActiveSessions() >= maxActiveSessions)) {
rejectedSessions++;
throw new TooManyActiveSessionsException(
sm.getString("managerBase.createSession.ise"),maxActiveSessions);
}
//該方法會建立StandardSession物件
Session session = createEmptySession();
//初始化Session中必要的屬性
session.setNew(true);
//session是否可用
session.setValid(true);
//建立時間
session.setCreationTime(System.currentTimeMillis());
//設定session最大超時時間
session.setMaxInactiveInterval(getContext().getSessionTimeout() * 60);
String id = sessionId;
if (id == null) {
id = generateSessionId();
}
session.setId(id);
sessionCounter++;
//記錄建立session的時間,用於統計資料session的建立速率
//類似的還有ExpireRate即Session的過期速率
//由於可能會有其他執行緒對sessionCreationTiming操作因此需要加鎖
SessionTiming timing = new SessionTiming(session.getCreationTime(),0);
synchronized (sessionCreationTiming) {
//sessionCreationTiming是LinkedList
//因此poll會移除連結串列頭的資料,也就是最舊的資料
sessionCreationTiming.add(timing);
sessionCreationTiming.poll();
}
return session;
}
複製程式碼
Session的銷燬
要銷燬Session,必然要將Session從ConcurrentHashMap
中移除,順藤摸瓜我們可以發現其移除session的程式碼如下所示。
@Override
public void remove(Session session,boolean update) {
//檢查是否需要將統計過期的session的資訊
if (update) {
long timeNow = System.currentTimeMillis();
int timeAlive =
(int) (timeNow - session.getCreationTimeInternal())/1000;
updateSessionMaxAliveTime(timeAlive);
expiredSessions.incrementAndGet();
SessionTiming timing = new SessionTiming(timeNow,timeAlive);
synchronized (sessionExpirationTiming) {
sessionExpirationTiming.add(timing);
sessionExpirationTiming.poll();
}
}
//將session從Map中移除
if (session.getIdInternal() != null) {
sessions.remove(session.getIdInternal());
}
}
複製程式碼
被銷燬的時機
主動銷燬
我們可以通過呼叫HttpSession.invalidate()
方法來執行session銷燬操作。此方法最終呼叫的是StandardSession.invalidate()
方法,其程式碼如下,可以看出使session
銷燬的關鍵方法是StandardSession.expire()
public void invalidate() {
if (!isValidInternal())
throw new IllegalStateException
(sm.getString("standardSession.invalidate.ise"));
// Cause this session to expire
expire();
}
複製程式碼
expire
方法的程式碼如下
@Override
public void expire() {
expire(true);
}
public void expire(boolean notify) {
//省略程式碼
//將session從ConcurrentHashMap中移除
manager.remove(this,true);
//被省略的程式碼主要是將session被銷燬的訊息通知
//到各個監聽器上
}
複製程式碼
超時銷燬
除了主動銷燬之外,我們可以為session設定一個過期時間,當時間到達之後session會被後臺執行緒主動銷燬。我們可以為session設定一個比較短的過期時間,然後通過JConsole
來追蹤其呼叫棧,其是哪個物件哪個執行緒執行了銷燬操作。
如下圖所示,我們為session設定了一個30秒的超時時間。
ManagerBase.remove
方法上打上斷點,等待30秒之後,如下圖所示
Tomcat會開啟一個後臺執行緒,來定期執行子元件的backgroundProcess
方法(前提是子元件被Tomcat管理且實現了Manager
介面)
@Override
public void backgroundProcess() {
count = (count + 1) % processExpiresFrequency;
if (count == 0)
processExpires();
}
public void processExpires() {
long timeNow = System.currentTimeMillis();
Session sessions[] = findSessions();
int expireHere = 0 ;
if(log.isDebugEnabled())
log.debug("Start expire sessions " + getName() + " at " + timeNow + " sessioncount " + sessions.length);
//從JConsole的圖中可以看出isValid可能導致expire方法被呼叫
for (int i = 0; i < sessions.length; i++) {
if (sessions[i]!=null && !sessions[i].isValid()) {
expireHere++;
}
}
long timeEnd = System.currentTimeMillis();
if(log.isDebugEnabled())
log.debug("End expire sessions " + getName() + " processingTime " + (timeEnd - timeNow) + " expired sessions: " + expireHere);
processingTime += ( timeEnd - timeNow );
}
複製程式碼
我們可以來看看介面中Manager.backgroundProcess
中註釋,簡略翻譯一下就是backgroundProcess
會被容器定期的執行,可以用來執行session清理任務等。
/**
* This method will be invoked by the context/container on a periodic
* basis and allows the manager to implement
* a method that executes periodic tasks,such as expiring sessions etc.
*/
public void backgroundProcess();
複製程式碼
總結
- Session的資料結構如下圖所示,簡單來說就是用
ConcurrentHashMap
來儲存Session
,而Session
則用ConcurrentHashMap
來儲存鍵值對,其結構如下圖所示。
這意味著,不要拼命的往Session裡面新增離散的資料,把離散的資料封裝成一個物件效能會更加好 如下所示
//bad
httpSession.setAttribute("user","kesan");
httpSession.setAttribute("nickname","點贊");
httpSession.setAttribute("sex","男");
....
複製程式碼
//good
User kesan = userDao.getUser()
httpSession.setAttribute("user",kesan);
複製程式碼
-
如果你為Session配置了監聽器,那麼對Session執行任何變更都將直接在當前執行緒執行監聽器的方法,因此最好不要在監聽器中執行可能會發生阻塞的方法。
-
Tomcat會開啟一個後臺執行緒來定期執行
ManagerBase.backgroundProcess
方法用來檢測過期的Session並將其銷燬。
思想遷移
物件生成速率演演算法 此演演算法設計比較有趣,並且也可以應用到其他專案中,因此做如下總結。
- 首先生成一個固定大小的連結串列(比如說100),然後以null元素填充。
- 當建立新的物件時,將建立時間加入連結串列末尾中(當然是封裝後的物件),然後將連結串列頭節點移除,此時被移除的物件要麼是null節點要麼是最早加入連結串列的節點
- 當要計算物件生成速率時,統計連結串列中不為null的元素的數量除以當前的時間與最早建立物件的時間的差,便可以得出其速率。(注意時間單位的轉換)