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

在 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 的清理工作

阿里架構師手寫Tomcat——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 實例。

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

創建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源碼解析

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源碼解析

整個創建 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 的責任

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

關鍵代碼如下所示:

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 的鮮活時間

方法調用棧如下所示:

阿里架構師手寫Tomcat——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架構師?這些最基本的東西你都會了嗎?

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

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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章