阿里架構師手寫Tomcat——Session源碼解析

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

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) 用於加載/卸載會話信息,固然持久化要看不一樣的實現類web

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 的建立過程進行分析sql

public class SessionExample extends HttpServlet {
	public void doGet(HttpServletRequest request, HttpServletResponse response)
	        throws IOException, ServletException  {
		HttpSession session = request.getSession();
		// other code......
	}
}
複製代碼

整個流程圖以下圖所示:apache

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 實現了主要的邏輯。tomcat

整個建立 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) {
    一、校驗 isValid 值,若是爲 false 直接返回,說明已經被銷燬了
    synchronized (this) {   // 加鎖
        二、雙重校驗 isValid 值,避免併發問題
        Context context = manager.getContext();
        if (notify) {   
            Object listeners[] = context.getApplicationLifecycleListeners();
            HttpSessionEvent event = new HttpSessionEvent(getSession());
            for (int i = 0; i < listeners.length; i++) {
            三、判斷是否爲 HttpSessionListener,不是則繼續循環
            四、向容器發出Destory事件,並調用 HttpSessionListener.sessionDestroyed() 進行通知
            context.fireContainerEvent("beforeSessionDestroyed", listener);
            listener.sessionDestroyed(event);
            context.fireContainerEvent("afterSessionDestroyed", listener);
        }
        五、從 manager 中移除該  session
        六、向 tomcat 的 SessionListener 發出事件通知,非 HttpSessionListener
        七、清除內部的 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點擊加入羣聊,私信管理員便可免費領取

怎麼提升代碼質量?——來自阿里P8架構師的研發經驗總結

阿里P8分享Java架構師的學習路線,第六點尤其重要

每一個Java開發者應該知道的八個工具

想面試Java架構師?這些最基本的東西你都會了嗎?

畫個圖來找你的核心競爭力,變中年危機爲加油站

哪有什麼中年危機,不過是把定目標當成了有計劃

被裁人不是寒冬重點,重點是怎麼破解職業瓶頸

相關文章
相關標籤/搜索