namalzi

← All posts

Digging into Tomcat's Source 11: Sessions — How JSESSIONID Is Made and Removed

Tomcat · Published

We follow, in the source, what Tomcat does behind one call to request.getSession() — where it reads the session ID, how it makes a new one, and when it throws it away. Cookies and ;jsessionid, the 32-character ID built from SecureRandom, the two kinds of expiry (on a request and on a background thread), and StandardManager saving sessions to a file across restarts, each checked with a small example.

What this post covers

This is the eleventh post in the "Digging into Tomcat's Source" series. In part 10 we watched a WAR become a running StandardContext. This time we look at how a web app remembers a user — the session. When a servlet calls request.getSession(), the response picks up a Set-Cookie: JSESSIONID=… header, and from the next request on, the same session object comes back. We follow what happens in between.

There are four questions to answer.

Item Details
Target Apache Tomcat
Version 12.0.0-M1 (main)
Commit 17aa1769b0 (2026-10-23)
Main classes connector/Request, connector/CoyoteAdapter, session/ManagerBase, session/StandardManager, session/StandardSession, util/StandardSessionIdGenerator

All line numbers in this post refer to this commit. How to get the same source and build the Docker image for the examples is in part 1. Comparing with the stable 11.0.x branch, the files ManagerBase, StandardManager and StandardSession in the session package, the ID generators and the cookie configuration class are identical, and the session-handling parts of Request, CoyoteAdapter and Response are the same. Everything in this post applies to 11.0.x as well.

The session package alone is twelve Java files and more than 5,800 lines. This post picks only the main path a newcomer can follow. Things left out, such as cluster replication, are listed by name and location in "What this post leaves out" at the end.

Terms to know first

A session is server-side storage that ties several HTTP requests together as "from the same user". Every HTTP request stands on its own, so the server attaches a ticket called a session ID to its response and has the browser bring that ticket back with the next request. The server finds the storage by that ID and knows it is "the same person as before". Shopping carts and login state live here.

JSESSIONID is the default session cookie name defined by the Servlet specification. When cookies can't be used, the ID travels at the end of the URL as ;jsessionid=…; this is called URL rewriting. The ;name=value part of a URL is a path parameter, and how Tomcat strips it off while decoding the URI was covered in part 7.

A Manager (session manager) is the Tomcat component that owns all sessions of one web app. It is the <Manager> element you write inside <Context> in server.xml or context.xml. If you write nothing, a StandardManager is used. There is one per web app (Context), so sessions of /app1 and /app2 know nothing about each other. What a Context is was covered in part 2.

maxInactiveInterval answers "if no request arrives for this many seconds after the last one, should we drop the session?". The <session-timeout> in web.xml (in minutes) becomes this value. Tomcat's conf/web.xml sets it to 30 minutes (conf/web.xml lines 613–615).

Serialization turns a Java object into bytes so it can be written to a file or sent over a network. Only objects that implement java.io.Serializable can be serialized the default way. It is used when sessions are saved to disk.

The background process is the periodic housekeeping Tomcat's containers do. Detecting class changes (the reload from part 4), auto-deployment (part 10), and the session expiry check in this post all run at this time.

1. The big picture: one Manager per web app, one map inside it

Here are the classes involved in sessions on one page.

Classes involved in sessions. One StandardManager is attached to each StandardContext. StandardManager extends ManagerBase, which holds sessions, a ConcurrentHashMap keyed by session ID, and a StandardSessionIdGenerator. The ID generator takes 16 bytes from a SecureRandom pool and turns them into 32 hexadecimal characters. The values in the map are StandardSession objects, and the HttpSession a servlet gets is a StandardSessionFacade wrapping one. Only when pathname is set does StandardManager save sessions to a SESSIONS.ser file in the work folder. Yellow boxes are the places that actually hold something. The heart of session storage is a single map in memory.

For a web app with no <Manager> of its own, StandardContext.startInternal() creates a StandardManager and attaches it at startup (core/StandardContext.java lines 4542–4567; line 4557 is new StandardManager()). If there is a cluster and the web app is <distributable/>, a cluster manager is created instead, which this post does not cover.

The place that holds the sessions is a single field of ManagerBase.

    protected Map<String,Session> sessions = new ConcurrentHashMap<>();

java/org/apache/catalina/session/ManagerBase.java line 147

It is a map from session ID to StandardSession object. Many request threads create and look up sessions at the same time, hence ConcurrentHashMap. Looking a session up is nothing more than reading this map.

    @Override
    public Session findSession(String id) throws IOException {
        if (id == null) {
            return null;
        }
        return sessions.get(id);
    }

java/org/apache/catalina/session/ManagerBase.java lines 752–758

findSession() does not look at expiry; if the session is in the map, it returns it. The caller checks expiry separately with isValid(). This becomes important in section 4.

A servlet does not get the StandardSession itself

StandardSession implements both the Servlet API's HttpSession and Tomcat's internal Session interface (StandardSession.java line 68). The internal one has methods a web app must not touch, such as setId(), expire() and setManager(). So servlets are given a shell, StandardSessionFacade, that implements only HttpSession (StandardSession.java lines 467–473, StandardSessionFacade.java line 27). It is the same reason as the RequestFacade we saw in part 7. Example 1's output shows class : org.apache.catalina.session.StandardSessionFacade.

2. How a request brings its session ID

CoyoteAdapter pulls the ID out early

Reading the session ID happens long before the servlet, in CoyoteAdapter.postParseRequest(), which we saw in part 7 — right after the Mapper has picked this request's web app (Context). Each web app can configure its own cookie name and tracking modes, so the ID can only be read once the web app is known.

            // Now we have the context, we can parse the session ID from the URL
            // (if any). Need to do this before we redirect in case we need to
            // include the session id in the redirect
            String sessionID;
            if (request.getServletContext().getEffectiveSessionTrackingModes().contains(SessionTrackingMode.URL)) {

                // Get the session ID if there was one
                sessionID = request.getPathParameter(SessionConfig.getSessionUriParamName(request.getContext()));
                if (sessionID != null) {
                    request.setRequestedSessionId(sessionID);
                    request.setRequestedSessionURL(true);
                }
            }

            // Look for session ID in cookies and SSL session
            try {
                parseSessionCookiesId(request);
            } catch (IllegalArgumentException e) {
                // Too many cookies
                if (!response.isError()) {
                    response.setError();
                    response.sendError(400, e.getMessage());
                }
                return true;
            }
            parseSessionSslId(request);

java/org/apache/catalina/connector/CoyoteAdapter.java lines 716–741

The order matters. First the URL's ;jsessionid=, then the cookies. Inside parseSessionCookiesId() there is code that overwrites with the cookie value, under the comment "Override anything requested in the URL" (lines 1027–1033). When both are present, the cookie wins (as long as cookie tracking is on for the web app; if it is off, cookies are not read at all, lines 1010–1013). If there are several cookies with the same name, the first one is taken, but if it isn't valid, the next one is tried (lines 1037–1043).

All this step does is write a string into the requestedSessionId field. It does not check yet whether a session with that ID actually exists. The cookie and parameter names come from SessionConfig: <Context sessionCookieName="…"> → web.xml's <cookie-config><name> → the default, and the defaults are these two constants.

    private static final String DEFAULT_SESSION_COOKIE_NAME = "JSESSIONID";
    private static final String DEFAULT_SESSION_PARAMETER_NAME = "jsessionid";

java/org/apache/catalina/util/SessionConfig.java lines 28–29

Which tracking modes are on is decided by ApplicationContext. URL rewriting is always on, and cookies are on when <Context cookies="true"> (the default) (core/ApplicationContext.java lines 888–896). You can change this set with <tracking-mode> in web.xml.

Request.doGetSession() — find one, or make one

When a servlet calls request.getSession(), it goes through getSession(true) in connector/Request and reaches doGetSession(true) (lines 2136–2149). Every path to a session meets in this one method.

The flow of getSession. CoyoteAdapter.postParseRequest looks at the URL's jsessionid and then the JSESSIONID cookie and writes the result into requestedSessionId. When the servlet calls getSession, Request.doGetSession first returns the session this request already holds if it is still valid. If there is none, manager.findSession looks it up in the map and checks isValid. If found, access is called and setNew false joins the existing session. If missing or expired, the create argument decides: false returns null, true makes manager.createSession build a new ID and put it in the map, and when cookie tracking is on a Set-Cookie JSESSIONID header is added to the response. Yellow boxes are values already in hand. getSession() searches the map only the first time it is called in a request.

Here is the core of it.

        if (requestedSessionId != null) {
            try {
                session = manager.findSession(requestedSessionId);
            } catch (IOException ioe) {
                if (log.isDebugEnabled()) {
                    log.debug(sm.getString("request.session.failed", requestedSessionId, ioe.getMessage()), ioe);
                } else {
                    log.info(sm.getString("request.session.failed", requestedSessionId, ioe.getMessage()));
                }
                session = null;
            }
            if ((session != null) && !session.isValid()) {
                session = null;
            }
            if (session != null) {
                session.access();
                // The client has chosen to join the session
                session.setNew(false);
                return session;
            }
        }

        // Create a new session if requested and the response is not committed
        if (!create) {
            return null;
        }
        // ... throw if the response is already out; decide whether to reuse the client's ID ...
        session = manager.createSession(sessionId);

        // Creating a new session cookie based on that session
        if (session != null && trackModesIncludesCookie) {
            Cookie cookie =
                    ApplicationSessionCookieConfig.createSessionCookie(context, session.getIdInternal(), isSecure());

            response.addSessionCookieInternal(cookie);
        }

java/org/apache/catalina/connector/Request.java lines 2692–2770

  1. If this request has already fetched a session (the session field), return it (lines 2679–2685). Calling getSession() many times in one request searches the map only once.
  2. If there is a requestedSessionId, look it up in the map and check expiry with isValid(). If it is alive, mark it "in use now" with access() and return it.
  3. If it is missing or expired and create is false, return null. getSession(false) ends here.
  4. If create is true, make a new one with manager.createSession(), and if cookie tracking is on, add Set-Cookie to the response.

A session exists only once someone calls getSession(). JSPs create a session by default (that <%@ page session="true" %> is the default comes from the JSP specification; we look at Jasper's source in part 13), but a web app with only servlets has no session and no cookie unless it asks for one.

The ID the client sends is almost never reused

The elided part (lines 2724–2761) decides "when making a new session, should we use the ID the client sent?". The answer is almost always no (sessionId = null → a fresh ID). It is reused in only two cases.

The reason for blocking this is session fixation. An attacker plants JSESSIONID=ABC123 in the victim's browser, gets the victim to log in with that ID, and then walks in with the same ID. If the server accepted any ID, this would work. We check it in step 5 of example 1.

Example 1: the session ID and the cookie

This example was run in a Docker container (Eclipse Temurin JDK 21) on Tomcat 12.0.0-M1-dev, built as-is from the reference commit (17aa1769b0). One servlet prints the ID the request brought and the state of the session (ex/u11-session-id).

// An example written for this post (not part of Tomcat's source)
// ... package and imports omitted ...
@WebServlet("/s")
public class SessionServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain");
        PrintWriter out = resp.getWriter();

        // The session ID the request brought — CoyoteAdapter already pulled it from the cookie or the URL's ;jsessionid=
        out.println("requested id   : " + req.getRequestedSessionId()
                + " (cookie=" + req.isRequestedSessionIdFromCookie()
                + ", url=" + req.isRequestedSessionIdFromURL()
                + ", valid=" + req.isRequestedSessionIdValid() + ")");

        // With ?create=0 we call getSession(false) — it does not create one
        boolean create = !"0".equals(req.getParameter("create"));
        HttpSession session = req.getSession(create);
        if (session == null) {
            out.println("session        : null");
            return;
        }
        // ... bump the session's count attribute and print id, isNew, maxInactive and the class name ...
        // URL rewriting — adds ;jsessionid= unless the ID came in a cookie
        out.println("encodeURL      : " + resp.encodeURL("/u11/s"));
    }
}

[Shortened from the example ex/u11-session-id/src/ex/u11/SessionServlet.java]

We sent five requests with curl, keeping only the status line, the Set-Cookie header and the body.

===== 1. GET /u11/s?create=0  (getSession(false)) =====
HTTP/1.1 200 
requested id   : null (cookie=false, url=false, valid=false)
session        : null
===== 2. GET /u11/s  (getSession() - first time) =====
HTTP/1.1 200 
Set-Cookie: JSESSIONID=E221CAF5F85FF1E04892CC5FFAC94FAF; Path=/u11; HttpOnly
requested id   : null (cookie=false, url=false, valid=false)
session id     : E221CAF5F85FF1E04892CC5FFAC94FAF (32 chars)
isNew          : true
count          : 1
maxInactive    : 1800 s
class          : org.apache.catalina.session.StandardSessionFacade
encodeURL      : /u11/s;jsessionid=E221CAF5F85FF1E04892CC5FFAC94FAF
===== 3. GET /u11/s  with the cookie =====
HTTP/1.1 200 
requested id   : E221CAF5F85FF1E04892CC5FFAC94FAF (cookie=true, url=false, valid=true)
session id     : E221CAF5F85FF1E04892CC5FFAC94FAF (32 chars)
isNew          : false
count          : 2
maxInactive    : 1800 s
class          : org.apache.catalina.session.StandardSessionFacade
encodeURL      : /u11/s
===== 4. GET /u11/s;jsessionid=<id>  without a cookie =====
HTTP/1.1 200 
requested id   : E221CAF5F85FF1E04892CC5FFAC94FAF (cookie=false, url=true, valid=true)
session id     : E221CAF5F85FF1E04892CC5FFAC94FAF (32 chars)
isNew          : false
count          : 3
maxInactive    : 1800 s
class          : org.apache.catalina.session.StandardSessionFacade
encodeURL      : /u11/s;jsessionid=E221CAF5F85FF1E04892CC5FFAC94FAF
===== 5. GET /u11/s  with a made-up cookie JSESSIONID=ABC123 =====
HTTP/1.1 200 
Set-Cookie: JSESSIONID=C74FE9B92C22274296B4370082622AEA; Path=/u11; HttpOnly
requested id   : ABC123 (cookie=true, url=false, valid=false)
session id     : C74FE9B92C22274296B4370082622AEA (32 chars)
isNew          : true
count          : 1
maxInactive    : 1800 s
class          : org.apache.catalina.session.StandardSessionFacade
encodeURL      : /u11/s

The session ID values differ on every run.

  1. Calling only getSession(false) creates neither a session nor a Set-Cookie.
  2. The first getSession() creates a 32-character ID and a Set-Cookie. Path=/u11 is the web app's path, and HttpOnly tells the browser not to let JavaScript read this cookie (section 3). maxInactive is the 30 minutes = 1800 seconds from conf/web.xml.
  3. Sending the cookie back joins the same session, and isNew becomes false. No new Set-Cookie arrives.
  4. With no cookie and only ;jsessionid= on the URL, it is still the same session (url=true). count continues at 3.
  5. Sending a made-up ID ABC123 gives valid=false, and the session is created with a new ID, not that one. This is the session fixation defense from above.

Easy to get confused

3. Making the session ID and the cookie

createSession()

ManagerBase.createSession() is short.

    @Override
    public Session createSession(String sessionId) {

        if ((maxActiveSessions >= 0) && (getActiveSessions() >= maxActiveSessions)) {
            rejectedSessions++;
            throw new TooManyActiveSessionsException(sm.getString("managerBase.createSession.ise"), maxActiveSessions);
        }

        // Recycle or create a Session instance
        Session session = createEmptySession();

        // Initialize the properties of the new session and return it
        session.setNew(true);
        session.setValid(true);
        session.setCreationTime(System.currentTimeMillis());
        session.setMaxInactiveInterval(getContext().getSessionTimeout() * 60);
        String id = sessionId;
        if (id == null) {
            id = generateSessionId();
        }
        session.setId(id);

        SessionTiming timing = new SessionTiming(session.getCreationTime(), 0);
        synchronized (sessionCreationTiming) {
            sessionCreationTiming.add(timing);
            sessionCreationTiming.poll();
        }
        return session;
    }

java/org/apache/catalina/session/ManagerBase.java lines 715–743

32 characters — 16 bytes of SecureRandom

generateSessionId() hands the work to an ID generator (lines 896–898). If none is configured, the Manager creates a StandardSessionIdGenerator at startup (lines 659–663).

    @Override
    public String generateSessionId(String route) {

        byte[] random = new byte[16];
        int sessionIdLength = getSessionIdLength();

        // Render the result as a String of hexadecimal digits
        // Start with enough space for sessionIdLength and medium route size
        StringBuilder buffer = new StringBuilder(2 * sessionIdLength + 20);

        int resultLenBytes = 0;

        while (resultLenBytes < sessionIdLength) {
            getRandomBytes(random);
            for (int j = 0; j < random.length && resultLenBytes < sessionIdLength; j++) {
                byte b1 = (byte) ((random[j] & 0xf0) >> 4);
                byte b2 = (byte) (random[j] & 0x0f);
                if (b1 < 10) {
                    buffer.append((char) ('0' + b1));
                } else {
                    buffer.append((char) ('A' + (b1 - 10)));
                }
                if (b2 < 10) {
                    buffer.append((char) ('0' + b2));
                } else {
                    buffer.append((char) ('A' + (b2 - 10)));
                }
                resultLenBytes++;
            }
        }

        if (route != null && !route.isEmpty()) {
            buffer.append('.').append(route);
        } else {
            String jvmRoute = getJvmRoute();
            if (jvmRoute != null && !jvmRoute.isEmpty()) {
                buffer.append('.').append(jvmRoute);
            }
        }

        return buffer.toString();
    }

java/org/apache/catalina/util/StandardSessionIdGenerator.java lines 32–73

It takes sessionIdLength random bytes (16 by default, SessionIdGeneratorBase.java line 92) and writes each byte as two characters, 0–9 and A–F, four bits at a time. 16 bytes = 128 bits = 32 upper-case hexadecimal characters. That is the (32 chars) in example 1.

How a session is created and becomes a cookie. ManagerBase.createSession() checks maxActiveSessions (default -1, no limit), creates new StandardSession and sets maxInactiveInterval to sessionTimeout × 60 = 1800 seconds. generateSessionId() hands the work to StandardSessionIdGenerator, which takes a SecureRandom (SHA1PRNG if the JDK has it) from the randoms queue, uses it and puts it back. It gets 16 random bytes (sessionIdLength default 16, 128 bits), for example 0xE2 0x21 0xCA 0xF5 … 0xAF, splits each byte into two 4-bit halves and writes each half as one character 0–9 or A–F. 0xE2 becomes E2 and 0x21 becomes 21, giving the 32 characters E221CAF5F85FF1E04892CC5FFAC94FAF, with a tail such as .node1 if a jvmRoute is set. session.setId(id) puts the session into the sessions map with manager.add(this) and calls HttpSessionListener.sessionCreated() through tellNew(). Finally createSessionCookie() produces Set-Cookie: JSESSIONID=E221CAF5F85FF1E04892CC5FFAC94FAF; Path=/u11; HttpOnly. The ID and cookie are the real output of example 1. The byte values are that ID read back two characters at a time. The cookie attributes are covered in the table at the end of this section.

The .route at the end is for clusters. If you write <Engine jvmRoute="node1"> in server.xml, the ID becomes …AEA.node1 (the value is taken from the Engine at ManagerBase.java lines 925–928). It is a marker so that a load balancer in front can read the part after . and send the user back to the same Tomcat. httpd's mod_proxy_balancer does exactly that (compared at the end of this post).

The random bytes come from SecureRandom.

    protected void getRandomBytes(byte[] bytes) {

        SecureRandom random = randoms.poll();
        if (random == null) {
            random = createSecureRandom();
        }
        random.nextBytes(bytes);
        randoms.add(random);
    }

java/org/apache/catalina/util/SessionIdGeneratorBase.java lines 227–235

If many threads shared one SecureRandom, they would queue on its internal lock. So a queue named randoms (ConcurrentLinkedQueue, line 73) is used as a pool: take one, use it, put it back, and make a new one if the pool is empty. The more threads create sessions at once, the more SecureRandom instances there will be. The algorithm is SHA1PRNG if the JDK has it (lines 56–64). SecureRandom is the JDK class for "unpredictable" random numbers; the point of these 128 bits is to stop someone from guessing a session ID and walking into another user's session.

When the Manager starts, it calls generateSessionId() once in advance (SessionIdGeneratorBase.startInternal(), lines 307–313). The first initialization of a SecureRandom can be slow while it gathers entropy from the operating system, and this keeps the first user's request from paying for it. If creating one takes more than 100 ms, a warning is logged (lines 294–296). The message Creation of SecureRandom instance for session ID generation using [SHA1PRNG] took [...] milliseconds. that you may see in the startup log of an older Linux server is this warning.

Easy to get confused: there is no duplicate check

Neither createSession() nor generateSessionId() has a loop asking "is this ID already in the map?". The design treats a collision of 128 random bits as negligible. If one ever happened, sessions.put() in setId() would overwrite the existing session.

Who decides the cookie's attributes

The single Set-Cookie line is built by ApplicationSessionCookieConfig.createSessionCookie().

        Cookie cookie = new Cookie(SessionConfig.getSessionCookieName(context), sessionId);

        // Just apply the defaults.
        cookie.setMaxAge(scc.getMaxAge());

        if (context.getSessionCookieDomain() == null) {
            // Avoid possible NPE
            if (scc.getDomain() != null) {
                cookie.setDomain(scc.getDomain());
            }
        } else {
            cookie.setDomain(context.getSessionCookieDomain());
        }

        // Always set secure if the request is secure
        if (scc.isSecure() || secure) {
            cookie.setSecure(true);
        }

        // Always set httpOnly if the context is configured for that
        if (scc.isHttpOnly() || context.getUseHttpOnly()) {
            cookie.setHttpOnly(true);
        }

        if (context.getUsePartitioned()) {
            cookie.setAttribute(Constants.COOKIE_PARTITIONED_ATTR, "");
        }

        cookie.setPath(SessionConfig.getSessionCookiePath(context));

java/org/apache/catalina/core/ApplicationSessionCookieConfig.java lines 208–236

This is how it connects to what you see in configuration files.

Cookie attribute Where it is set (earlier wins) Default
Name <Context sessionCookieName> → web.xml <cookie-config><name> JSESSIONID
Max-Age web.xml <cookie-config><max-age> -1 (a cookie that disappears when the browser closes, so it isn't in the example output)
Secure web.xml <secure>, or always when the request is HTTPS absent over HTTP
HttpOnly web.xml <http-only> or <Context useHttpOnly> present (useHttpOnly defaults to true, StandardContext.java line 649)
Path <Context sessionCookiePath> → web.xml <path> → the web app path the web app path such as /u11; / for ROOT (SessionConfig.java lines 82–113)

Note that HttpOnly is an "or". Turning it off in web.xml with <http-only>false</http-only> still leaves it on while <Context useHttpOnly> is at its default true; you have to turn off the Context side too. The SameSite attribute is not set here but by <CookieProcessor sameSiteCookies="…">, whose default is unset (not added) (tomcat/util/http/CookieProcessorBase.java line 56).

4. Expiry — a session dies in two places

isValid() actually carries out the expiry

The expiry check lives in StandardSession.isValid(). The name sounds like "is it valid?", but in fact it removes the session on the spot if its time is up.

    @Override
    public boolean isValid() {

        if (!this.isValid) {
            return false;
        }

        if (this.expiring) {
            return true;
        }

        if (activityCheck && accessCount.get() > 0) {
            return true;
        }

        if (maxInactiveInterval > 0) {
            int timeIdle = (int) (getIdleTimeInternal() / 1000L);
            if (timeIdle >= maxInactiveInterval) {
                expire(true);
            }
        }

        return this.isValid;
    }

java/org/apache/catalina/session/StandardSession.java lines 476–499

So who calls isValid()? Two places.

Three ways a session goes away. First, a request thread calls doGetSession with an old ID, and StandardSession.isValid checks whether the idle time is at least maxInactiveInterval. Second, the Catalina-utility thread calls StandardContext.backgroundProcess, every 10 seconds for the Engine, and ManagerBase.backgroundProcess runs processExpires once every 6 calls, calling isValid on every session. Third, the servlet calls invalidate. All three reach StandardSession.expire, which calls HttpSessionListener.sessionDestroyed in reverse registration order, takes the session out of the map with manager.remove, sets isValid to false and removes the attributes one by one. The gray boxes are the starting points. Whichever way you come, it ends in a single expire().

① When a request arrives. doGetSession() from section 2 calls isValid() right after findSession(). In effect, a request carrying the ID of an expired session is what removes it.

② The housekeeping thread. A session nobody asks for again would never be removed by ①. So all sessions are scanned periodically. It starts in ContainerBase's background task.

            backgroundProcessorFuture = Container.getService(this).getServer().getUtilityExecutor()
                    .scheduleWithFixedDelay(new ContainerBackgroundProcessor(), backgroundProcessorDelay,
                            backgroundProcessorDelay, TimeUnit.SECONDS);

java/org/apache/catalina/core/ContainerBase.java lines 1090–1092

Only containers with backgroundProcessorDelay greater than 0 schedule this task (line 1079). The default is -1 (line 142), and only StandardEngine changes it to 10 in its constructor (StandardEngine.java lines 59–63). So with default settings, one Engine, every 10 seconds, walks down its children (Host → Context → Wrapper) calling backgroundProcess() (processChildren(), lines 1164–1185). The work runs on the Server's utility thread pool (Catalina-utility-N). StandardContext.backgroundProcess() asks the class loader, the Manager and the resources to do their housekeeping in that order (StandardContext.java lines 4961–5001), and the Manager side looks like this.

    @Override
    public void backgroundProcess() {
        count = (count + 1) % processExpiresFrequency;
        if (count == 0) {
            processExpires();
        }
    }

java/org/apache/catalina/session/ManagerBase.java lines 597–603

processExpiresFrequency defaults to 6 (line 181). 10 seconds × 6 = processExpires() runs once every 60 seconds, and all it does is call isValid() on every session (lines 608–629). The removing is done by the expire() inside isValid().

③ invalidate(). This is the web app ending the session itself, as on logout. StandardSession.invalidate() throws IllegalStateException if the session is already invalid, and otherwise calls expire() (lines 904–914).

expire() — the order of removal

            // Mark this session as "being expired"
            expiring = true;

            // ... call each HttpSessionListener's sessionDestroyed() in reverse registration order ...

            // Remove this session from our manager's active sessions
            manager.remove(this, true);

            // ... notify SessionListeners (Tomcat-internal), and logout() if a user is logged in ...

            String[] keys;
            synchronized (attributes) {
                // We have completed expire of this session
                setValid(false);
                expiring = false;

                // Snapshot the attributes before permitting any racing setAttribute() call to observe the invalid state
                keys = keys();
            }

Lines 600–601, 646–647 and 663–671 of java/org/apache/catalina/session/StandardSession.java lines 578–685

  1. expiring = true marks the session "being removed". This stops a listener that calls isValid() or getAttribute() from re-entering expire(). The if (this.expiring) return true; in isValid() is its counterpart.
  2. HttpSessionListener.sessionDestroyed() is called in reverse registration order (lines 614–636, j = (listeners.length - 1) - i). The session is still alive at this point, so listeners can read its attributes. The call is made with the web app's class loader switched in (line 613, context.bind(null)), because the housekeeping thread is not a web app thread.
  3. The session is taken out of the map (manager.remove(this, true); true means "removed by expiry, so count it in the statistics").
  4. isValid becomes false, and attributes are removed one by one (lines 673–682). If an attribute value is an HttpSessionBindingListener, its valueUnbound() is called now.

Example 2: seeing the three paths by thread name

We wrote an HttpSessionListener that prints, when a session is created or destroyed, the name of the thread that did it plus the Tomcat side of the call stack (ex/u11-expire).

// An example written for this post (not part of Tomcat's source)
// ... package and imports omitted ...
@WebListener
public class SessionLog implements HttpSessionListener {
    // ... sessionCreated() prints only the first 8 characters of the ID and the thread name ...

    @Override
    public void sessionDestroyed(HttpSessionEvent e) {
        String thread = Thread.currentThread().getName();
        System.out.println("[u11] " + app(e) + " destroyed " + e.getSession().getId().substring(0, 8)
                + " on " + thread);
        // Who removed it — print at most the top 7 lines of the Tomcat call stack
        int shown = 0;
        for (StackTraceElement f : new Throwable().getStackTrace()) {
            if (f.getClassName().startsWith("org.apache.catalina") && shown++ < 7) {
                System.out.println("[u11]     at " + f.getClassName() + "." + f.getMethodName());
            }
        }
    }
}

[Shortened from the example ex/u11-expire/src/ex/u11/SessionLog.java]

The servlet /touch fetches the session; with ?ttl=2 it makes a 2-second session via setMaxInactiveInterval(2), and with ?invalidate it calls invalidate(). We deployed the same web app under two names.

<Context backgroundProcessorDelay="1">
    <Manager processExpiresFrequency="1" />
</Context>

Giving a Context its own backgroundProcessorDelay makes that Context schedule its own background task, and the Engine's task skips it (the child.getBackgroundProcessorDelay() <= 0 check on line 1182 of processChildren()). The result:

===== /lazy: create a session with a 2 s timeout =====
session E7009DE4 isNew=true ttl=2s
/lazy created   E7009DE4 on http-nio-8080-exec-10
===== /lazy: wait 4 s, then send the old cookie =====
session 97FDB75E isNew=true ttl=1800s
/lazy destroyed E7009DE4 on http-nio-8080-exec-2
    at org.apache.catalina.session.StandardSession.expire
    at org.apache.catalina.session.StandardSession.isValid
    at org.apache.catalina.connector.Request.doGetSession
    at org.apache.catalina.connector.Request.getSessionInternal
    at org.apache.catalina.authenticator.AuthenticatorBase.invoke
    at org.apache.catalina.core.StandardHostValve.invoke
    at org.apache.catalina.valves.ErrorReportValve.invoke
/lazy created   97FDB75E on http-nio-8080-exec-2
===== /bg: create a session with a 2 s timeout, then send nothing for 4 s =====
session 28C1B8F5 isNew=true ttl=2s
/bg created   28C1B8F5 on http-nio-8080-exec-3
/bg destroyed 28C1B8F5 on Catalina-utility-1
    at org.apache.catalina.session.StandardSession.expire
    at org.apache.catalina.session.StandardSession.isValid
    at org.apache.catalina.session.ManagerBase.processExpires
    at org.apache.catalina.session.ManagerBase.backgroundProcess
    at org.apache.catalina.core.StandardContext.backgroundProcess
    at org.apache.catalina.core.ContainerBase$ContainerBackgroundProcessor.processChildren
    at org.apache.catalina.core.ContainerBase$ContainerBackgroundProcessor.run
===== /lazy: invalidate() =====
/lazy created   4CF22D6A on http-nio-8080-exec-5
session 4CF22D6A isNew=false ttl=1800s
invalidated
/lazy destroyed 4CF22D6A on http-nio-8080-exec-7
    at org.apache.catalina.session.StandardSession.expire
    at org.apache.catalina.session.StandardSession.expire
    at org.apache.catalina.session.StandardSession.invalidate
    at org.apache.catalina.session.StandardSessionFacade.invalidate
    at org.apache.catalina.core.ApplicationFilterChain.doFilter
    at org.apache.catalina.core.ApplicationFilterChain.doFilter
    at org.apache.catalina.core.StandardWrapperValve.invoke

Session IDs and thread numbers differ on every run. The stacks keep only lines starting with org.apache.catalina, so the servlet (ex.u11) and jakarta.servlet frames are missing.

Easy to get confused

5. Restarts and sessions — StandardManager's saving

By default, nothing is saved

StandardManager can write sessions to a file when Tomcat stops and read them back when it starts. But pathname, which names the file, defaults to null.

    protected String pathname = null;

java/org/apache/catalina/session/StandardManager.java line 76

With an empty pathname, file() returns null (lines 331–334), and load() and unload() return without doing anything. The conf/context.xml that ships with Tomcat has this setting only as a comment.

    <!-- Uncomment this to enable session persistence across Tomcat restarts -->
    <!--
    <Manager pathname="SESSIONS.ser" />
    -->

conf/context.xml lines 27–30

conf/context.xml holds the default <Context> settings applied to every web app (part 10). Only after you uncomment this do sessions survive a restart. A relative path is resolved under the web app's temporary work folder (work/Catalina/<host>/<webapp>/) (lines 335–343).

What happens to sessions on a restart. catalina.sh stop makes StandardContext.stopInternal call StandardManager.unload. Without a pathname (the default) nothing is written, every remaining session is expired and sessionDestroyed fires. With a pathname, each session is passivated, firing sessionWillPassivate, and written with writeObjectData, dropping attributes that are not Serializable. The result is a SESSIONS.ser file in the work folder, and the written sessions are cleaned up with expire false, without sessionDestroyed. On startup, StandardContext.startInternal starts the Manager after the listeners, StandardManager.load reads the file, activates each session after readObjectData, firing sessionDidActivate, and deletes the file. The red path is the default. A restart logs every user out.

unload() and load()

Here is the core of stopping (stopInternal() → unload()).

                oos.writeObject(Integer.valueOf(sessions.size()));
                for (Session s : sessions.values()) {
                    StandardSession session = (StandardSession) s;
                    list.add(session);
                    session.passivate();
                    session.writeObjectData(oos);
                }
            }
        }

        // Expire all the sessions we just wrote
        if (log.isDebugEnabled()) {
            log.debug(sm.getString("standardManager.expiringSessions", Integer.toString(list.size())));
        }
        for (StandardSession session : list) {
            try {
                session.expire(false);
            } catch (Throwable t) {
                ExceptionUtils.handleThrowable(t);
            } finally {
                session.recycle();
            }
        }

java/org/apache/catalina/session/StandardManager.java lines 225–247

It writes the number of sessions first, then for each session calls passivate() (telling attributes that are HttpSessionActivationListeners via sessionWillPassivate()) and serializes it. Written sessions are cleaned up with expire(false), where false means don't call sessionDestroyed() — the session isn't over, it is just moving to disk for a while. With the default no-save settings, the later part of stopInternal() simply calls expire() on the remaining sessions (lines 303–317), so sessionDestroyed() is called.

During serialization, attributes are filtered like this.

        // Accumulate the names of serializable and non-serializable attributes
        String[] keys = keys();
        List<String> saveNames = new ArrayList<>();
        List<Object> saveValues = new ArrayList<>();
        for (String key : keys) {
            Object value = attributes.get(key);
            if (value == null) {
                // Continue
            } else if (isAttributeDistributable(key, value) && !exclude(key, value)) {
                saveNames.add(key);
                saveValues.add(value);
            } else {
                removeAttributeInternal(key, true);
            }
        }

java/org/apache/catalina/session/StandardSession.java lines 1341–1355

isAttributeDistributable() is value instanceof Serializable (lines 1134–1136). Attributes that are not Serializable are silently removed, with no error.

Starting (startInternal() → load()) is the reverse. It reads the count, revives each session with readObjectData(), puts it in the map and calls activate() (the sessionDidActivate() notification), and then deletes the file whether that succeeded or not (lines 153–178). Sessions that were already invalid when saved are skipped (lines 157–166). The Manager starts after the web app's listeners have started (StandardContext.java lines 4616–4636), so listeners can receive the notifications of revived sessions. When stopping, the order is reversed and the Manager stops before the listeners (lines 4834–4840).

Example 3: stop and start

The servlet /count puts three things in the session: count, a number that can be serialized; plain, an object that is not Serializable; and witness, which implements HttpSessionActivationListener (ex/u11-restart).

// An example written for this post (not part of Tomcat's source)
// ... package and imports omitted. Inside @WebServlet("/count") public class CountServlet extends HttpServlet { ...
    /** Not Serializable — it can't be saved to disk */
    static class NotSerializable {
    }

    /** Gets notified when the session goes out to disk (passivate) and comes back (activate) */
    static class Witness implements HttpSessionActivationListener, Serializable {
        @Override
        public void sessionWillPassivate(HttpSessionEvent e) {
            System.out.println("[u11] sessionWillPassivate " + e.getSession().getId().substring(0, 8));
        }

        @Override
        public void sessionDidActivate(HttpSessionEvent e) {
            System.out.println("[u11] sessionDidActivate   " + e.getSession().getId().substring(0, 8));
        }
    }
    // ... doGet(): on the first call put plain and witness, then bump count and print it ...

[Shortened from the example ex/u11-restart/src/ex/u11/CountServlet.java]

With the default settings (default) and with the comment in conf/context.xml removed (persist), we did: two requests → catalina.sh stop → catalina.sh start → one request with the same cookie.

===== [default] two requests =====
session BF6AC4DB isNew=true count=1 attributes=[witness, plain, count]
session BF6AC4DB isNew=false count=2 attributes=[witness, plain, count]
===== [default] stop Tomcat =====
no SESSIONS.ser
===== [default] start again, same cookie =====
session C928A7E4 isNew=true count=1 attributes=[witness, plain, count]
no SESSIONS.ser
===== [persist] conf/context.xml =====
29:    <Manager pathname="SESSIONS.ser" />
===== [persist] two requests =====
session 67837EDF isNew=true count=1 attributes=[witness, plain, count]
session 67837EDF isNew=false count=2 attributes=[witness, plain, count]
===== [persist] stop Tomcat =====
sessionWillPassivate 67837EDF
336 bytes  /tmp/base/work/Catalina/localhost/u11/SESSIONS.ser
===== [persist] start again, same cookie =====
sessionDidActivate   67837EDF
session 67837EDF isNew=false count=3 attributes=[witness, count]
no SESSIONS.ser

Session IDs differ on every run, and the order of attribute names is the map's order, which may vary.

Easy to get confused

A taste of PersistentManager

<Manager className="org.apache.catalina.session.PersistentManager"> goes one step further than StandardManager. It keeps sessions in a <Store> (FileStore for files, DataSourceStore for a database), and when a request comes with an ID that is not in memory, it brings the session back from the store (swapIn()).

    @Override
    public Session findSession(String id) throws IOException {

        Session session = super.findSession(id);
        // ... another thread may be swapping it out, so lock the session and look it up in the map again ...
        if (session != null) {
            return session;
        }

        // See if the Session is in the Store
        session = swapIn(id);
        return session;
    }

java/org/apache/catalina/session/PersistentManagerBase.java lines 370–398

During housekeeping, processPersistenceChecks() runs after the expiry check and moves long-idle sessions out to the store (maxIdleSwap), moves sessions out when there are too many (maxActiveSessions), or just backs them up (maxIdleBackup) (processExpires() lines 322–348, processPersistenceChecks() lines 355–361). Turning on maxIdleSwap or minIdleSwap forces on the activityCheck we saw in section 4 (lines 292–294), so that a session a request is currently using is not swapped out to disk (lines 793–796). We did not run this one as an example for this post.

What this post leaves out

Summary

Concept One-line summary
StandardManager One per web app. If you don't write <Manager>, StandardContext makes one
Session storage ManagerBase.sessions — a single ConcurrentHashMap keyed by ID
Reading the ID CoyoteAdapter.postParseRequest() reads ;jsessionid= and then the cookie into requestedSessionId. The cookie wins
doGetSession() Find it (findSession + isValid), or make one. The client's ID is almost never reused
Session ID 16 bytes from a SecureRandom pool → 32 upper-case hex characters, plus .route if jvmRoute is set. No duplicate check
Cookie attributes Path is the web app path, HttpOnly is on by default, Secure over HTTPS
URL rewriting encodeURL() adds ;jsessionid= unless the request's ID came in a cookie — even on the first response
Expiry check isValid() looks at idle time and calls expire() on the spot
When expiry happens When a request arrives with that ID, or on the housekeeping thread (every 10 s × 6 = 60 s by default)
expire() Listeners (in reverse) → removed from the map → isValid=false → attributes removed
Saving across restarts Off by default. Turn it on with <Manager pathname="SESSIONS.ser"/>; only on a clean stop, only Serializable attributes

What's next

With sessions, we have seen almost everything Tomcat does for a request while a servlet handles it. So far, one thread has held on to a request from start to finish. Could a servlet let go of that thread while it waits for something slow? Part 12: Async Servlets and Non-Blocking I/O — Letting Go of the Thread follows startAsync() and the AsyncStateMachine.

httpd touches sessions too. Sticky sessions, which httpd part 14 only named at the end, are mod_proxy_balancer reading the part after . in a cookie value such as JSESSIONID as a route and sending the request to the same backend (find_session_route()). Its counterpart is StandardSessionIdGenerator appending .jvmRoute to the ID, as we saw in section 3. Put httpd in front of several Tomcats, and httpd picks the way by reading the tail of the session ID that Tomcat made. Meanwhile, the Basic authentication in httpd part 13 receives the user name and password again in the Authorization header on every request, so the server needs no session. Tomcat's FORM login does the opposite: it keeps the logged-in user in the session from this post, and the AuthenticatorBase we met in section 4 takes it out of the session on each request. The details are in part 14.

More posts