namalzi

← All posts

Digging into Tomcat's Source 1: How I Read It with AI, and the Map

Tomcat · Published

The first post of a series that reads the Tomcat source — it starts by showing what Tomcat is, how it works and what it is for, with diagrams and a 3-minute hands-on run. It covers which version is read (the main branch, 12.0.0-M1) and how it differs from the stable 11.0.x, which Servlet spec version it implements, how to get the same source and build it with Ant in Docker, a folder map of the roughly 740,000-line codebase, the big road one request travels from the Acceptor to your servlet, and how AI-written notes are re-checked against the source.

What this post covers

"Digging into Tomcat's Source" is a series that reads the source code of Apache Tomcat (the server that runs Java web applications) a little at a time, with help from AI, and writes up what is there. It follows the same method as the two series already finished: Digging into Spring's Source (27 posts) and Digging into Apache httpd's Source (16 posts). Tomcat sits right between them. Spring MVC is a framework that runs on top of a servlet container such as Tomcat, and httpd is a web server written in C that does the same job as Tomcat (take an HTTP request, send back a response).

This is the first post, and it gets things ready before any deep reading starts. It tries to answer five questions.

If Tomcat is new to you, start with the "What is Tomcat" section right below. Two diagrams and a 3-minute hands-on run show what Tomcat does, and a short list shows what questions this series answers, before the main part begins. From there on, the reader I have in mind has downloaded and run Tomcat (dropped a WAR into webapps/, changed a port in server.xml, maybe used embedded Tomcat through Spring Boot) but has never opened its source. Java classes, interfaces and inheritance, knowing that threads exist, and having written a servlet or two is all you need.

Item Details
Project Apache Tomcat (apache/tomcat)
Version 12.0.0-M1 (main), which builds as 12.0.0-M1-dev
Commit 17aa1769b0 (2026-10-23)
Language Java (Java 21 or later)
Specs implemented Servlet 6.2, JSP 4.1, EL 6.1, WebSocket 2.3 (all drafts — see section 1)
Files this post mainly reads build.properties.default, build.xml, catalina/core/Constants.java, conf/server.xml, catalina/connector/CoyoteAdapter.java, tomcat/util/net/AbstractEndpoint.java

Every line number and detail in these posts refers to that commit. In other versions the line numbers will differ, and some of the details may too. Since this post is the map for the whole series, it stays with the big picture and leaves the depth to the individual posts.

The series has 16 posts in all.

  1. How I Read It with AI, and the Map (this post)
  2. The Big Picture — Server, Service, Connector and the Four Containers
  3. Lifecycle and Startup — What Happens After catalina.sh start
  4. Class Loaders — Each Web App Sees Its Own Classes
  5. The Connector and the NIO Endpoint — Which Threads Take a Connection
  6. Reading an HTTP/1.1 Request — From Bytes to a coyote Request
  7. CoyoteAdapter and the Mapper — Which Servlet Gets the Request
  8. Pipelines and Valves — The Processing Chain on Every Container
  9. The Filter Chain and the Servlet Call — Until service() Runs
  10. Deploying a Web App — From a WAR to a Running Context
  11. Sessions — How JSESSIONID Is Made and Removed
  12. Async Servlets and Non-Blocking I/O — Letting Go of the Thread
  13. Jasper — How a JSP Becomes a Servlet
  14. Authentication and Realms — How security-constraint Is Enforced
  15. HTTP/2 and WebSocket — Protocols Reached by Upgrading
  16. Embedded Tomcat, JMX and Logging — and Wrapping Up the Series

What is Tomcat — how it works and what it is for

Before opening the source, let's see what Tomcat does, with pictures and a quick hands-on run. If you have already deployed web apps to Tomcat, you can just skim "Questions this series answers" at the end of this section and move on.

In one sentence — an "interpreter and concierge" between HTTP and your Java code

Tomcat is a server that turns incoming HTTP requests into Java objects and calls your code with them. What a browser sends is a few lines of text like GET /shop/hello?name=alice HTTP/1.1, but the Java code we write never reads that text directly. Tomcat accepts the connection, reads the text, builds an object called HttpServletRequest from it, picks which piece of code should handle the request, and calls its method. Whatever our code writes into the response object, Tomcat turns back into HTTP text and sends back. It is an interpreter, and also a concierge that shows each guest (request) to the right person (code).

A Java class that Tomcat calls this way is called a servlet, and a server that hosts and runs servlets is called a servlet container. This whole series is really the story of what happens inside Tomcat between a connection arriving and a request reaching a method in your servlet.

Why you would want this

Imagine building a web service in Java by hand. Without something like Tomcat, you would have to do all of this yourself:

With Tomcat, Tomcat does all of that, and we only write servlets that say "when a request comes to this path, answer like this." The contract a servlet follows (its interfaces) is fixed by a standard called Jakarta Servlet, so the same web app can run on any other servlet container that follows the standard. Spring MVC runs on top of this too. The heart of Spring MVC, DispatcherServlet, is just one servlet: it extends HttpServlet through FrameworkServlet → HttpServletBean (HttpServletBean.java line 82, Spring Framework commit f447f3c310 — the base commit of the Spring series). The Spring series on this blog also ran its web examples with Tomcat placed inside the program (embedded Tomcat) (Spring part 20). It is widely said that a Spring Boot app starts with a single java -jar because it carries Tomcat inside it, but this series did not check that in Spring Boot's source (part 16).

On the left, those who send requests (browsers, mobile apps, other servers, curl) send HTTP requests to Tomcat. Sometimes a web server such as nginx or httpd sits in front. In the middle, Tomcat is one JVM process: the connector on port 8080 accepts connections, reads HTTP and puts one worker thread on each request, and the container picks which web app and which servlet should handle it and hands over the request and response as Java objects. On the right, the web apps (webapps/) are your code: /shop has servlets and JSP, /blog is a Spring MVC app (DispatcherServlet is a servlet too). Tomcat calls their service(request, response), and the web apps in turn call a DB or other services Where Tomcat sits. Yellow boxes are Tomcat, green boxes are the code we write. The front web servers are the same ones read in this blog's nginx and httpd series.

How it works — in five words

Five words are enough to describe how Tomcat works. The hands-on run and the diagram below show exactly these five.

  1. Servlet — a Java class that takes a request and produces a response. It usually extends HttpServlet and fills in methods like doGet(). Tomcat creates the object, and Tomcat calls it.
  2. Web app (called a Context inside Tomcat) — one bundle of servlets, settings and static files. Each one is identified by the first part of the address (the context path, e.g. /shop), and one Tomcat can host many. Drop a folder or a WAR file (a web app packed into an archive) into the webapps/ folder, and its name becomes the path.
  3. Connector — the part that opens a port, accepts connections, and reads and writes HTTP. For each request it hands the work to one thread from a thread pool (a set of worker threads made ahead of time).
  4. Request and response objects — the HTTP the connector read, turned into HttpServletRequest and HttpServletResponse objects. A servlet reads the request through object methods like req.getParameter("name") and writes into the response object.
  5. Session — server-side storage that ties together many requests from the same visitor. Tomcat makes a random ID for each visitor, sends it as a cookie named JSESSIONID, and when that cookie comes back with the next request, it hands back the same session.

A 3-minute hands-on — one servlet, deployed as two web apps

Words alone don't make it click, so let's run it. I used the Tomcat built by this series (the Docker image made in section 2), but since it only uses javac, catalina.sh and curl, the same steps should work with the official download too (I did not run it with the official download). The example folder is ex/u01-first-look, and this is the only servlet.

// Servlet for the hands-on in part 1, "What is Tomcat" (not part of Tomcat's source)
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.*;
import java.io.IOException;
import java.io.PrintWriter;

@WebServlet("/hello")
public class Hello extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        HttpSession session = req.getSession();          // this visitor's session (created if missing)
        Integer n = (Integer) session.getAttribute("visits");
        n = (n == null) ? 1 : n + 1;
        session.setAttribute("visits", n);

        resp.setContentType("text/plain");
        PrintWriter out = resp.getWriter();
        out.println("app     : " + req.getContextPath());
        out.println("request : " + req.getMethod() + " " + req.getRequestURI()
                + "  name=" + req.getParameter("name"));
        out.println("thread  : " + Thread.currentThread().getName());
        out.println("session : " + session.getId() + "  visit #" + n);
    }
}

The single line @WebServlet("/hello") says "requests to /hello inside this web app are handled by this class" (it is registered with just this annotation, no web.xml settings file; where Tomcat looks for annotations is in part 10). Here is what run.sh does.

  1. Compile Hello.java with javac. The classpath has only Tomcat's lib/servlet-api.jar.
  2. Put the compiled Hello.class in two places: webapps/shop/WEB-INF/classes/ and webapps/blog/WEB-INF/classes/. In effect, one class makes two web apps.
  3. Start Tomcat with catalina.sh start and check in the log which web apps Tomcat found.
  4. Call /shop/hello for the first time, with curl -i to see the response headers too.
  5. Call it twice more, sending the received cookie back.
  6. Call /blog/hello with the same cookie jar.
  7. Send five requests at the same time and see which threads handled them.
  8. Call a path that no web app owns (/nothing/hello).

Here are just the key commands.

javac -d /tmp/build -cp $CATALINA_HOME/lib/servlet-api.jar Hello.java
for app in shop blog; do                      # put the same class into two web apps
  mkdir -p /tmp/base/webapps/$app/WEB-INF/classes
  cp /tmp/build/Hello.class /tmp/base/webapps/$app/WEB-INF/classes/
done
catalina.sh start

curl -si -c /tmp/jar 'http://127.0.0.1:8080/shop/hello?name=alice'   # keep the received cookie in /tmp/jar
curl -s  -b /tmp/jar 'http://127.0.0.1:8080/shop/hello?name=alice'   # send the kept cookie

The output:

=== 1. what Tomcat found in webapps/ (from logs/catalina.out)
Deploying web application directory [/tmp/base/webapps/blog]
Deploying web application directory [/tmp/base/webapps/shop]
=== 2. first visit to /shop/hello  (curl -i: response headers too)
HTTP/1.1 200 
Set-Cookie: JSESSIONID=B1BF925D8048991331B2018E2B4FEA2D; Path=/shop; HttpOnly
Content-Type: text/plain;charset=UTF-8

app     : /shop
request : GET /shop/hello  name=alice
thread  : http-nio-8080-exec-2
session : B1BF925D8048991331B2018E2B4FEA2D  visit #1
=== 3. two more visits, sending the cookie back
app     : /shop
request : GET /shop/hello  name=alice
thread  : http-nio-8080-exec-4
session : B1BF925D8048991331B2018E2B4FEA2D  visit #2
app     : /shop
request : GET /shop/hello  name=alice
thread  : http-nio-8080-exec-5
session : B1BF925D8048991331B2018E2B4FEA2D  visit #3
=== 4. same class, other app: /blog/hello with the same cookie jar
app     : /blog
request : GET /blog/hello  name=alice
thread  : http-nio-8080-exec-6
session : 935E2B4A4886E2C0D1A180E81849687E  visit #1
=== 5. five requests at the same time: which threads ran them?
thread  : http-nio-8080-exec-1
thread  : http-nio-8080-exec-7
thread  : http-nio-8080-exec-8
thread  : http-nio-8080-exec-9
thread  : http-nio-8080-exec-10
=== 6. a path no app owns
404

(In step 2's response headers, the Date, Keep-Alive, Connection and Content-Length lines are removed. The session IDs and the numbers after exec- change on every run. Step 7 is sorted for readability. The output's === 1.–6. are steps 3–8 of the list above.)

curl sends GET /shop/hello?name=alice with the cookie JSESSIONID=B1BF…. Inside Tomcat, the connector http-nio-8080 accepts the connection, reads HTTP and hands it to one of the worker threads exec-1 to exec-10. It passes through the Engine and Host localhost to the web app matching the start of the path: /shop goes to shop, /blog goes to blog, and if no app owns it the answer is 404. In web app /shop, the Hello servlet's session B1BF… counts visits 1, 2, 3; in web app /blog, the Hello servlet makes a separate session 935E… at visit 1. The response goes back to curl with Set-Cookie: JSESSIONID, Path=/shop What the hands-on showed, drawn as is. Yellow boxes are what Tomcat does, green boxes are our servlets. How a web app is picked by path is covered in part 7.

There are five things to notice in the output.

  1. The folder name becomes the address. In output 1, Tomcat found the two folders under webapps/ and deployed each one as a web app: the shop folder became /shop, and blog became /blog. The folder a Host looks in for web apps defaults to webapps (StandardHost.java line 85), and for each folder Tomcat makes a Context named after it and prints this log line (HostConfig.java lines 984–986, line 1037). How just dropping in a folder turns it into an app is in part 10.
  2. HTTP text arrives at your method as Java objects. The servlet never touched a socket or a byte, yet req.getContextPath() returned /shop and req.getParameter("name") returned alice. To the text the servlet wrote with println(), Tomcat added the HTTP/1.1 200 status line and headers before sending it. The OK you may be used to seeing at the end of the status line is missing because Tomcat deliberately doesn't send the reason phrase (Http11OutputBuffer.java lines 372–374). How bytes become a request object is in part 6.
  3. Each request is handled by one worker thread. Even back-to-back requests from the same visitor (same cookie) ran on different threads, exec-2, exec-4 and exec-5, and in step 7 the five simultaneous requests were split across five threads. A thread's name is the connector's name (http-nio plus the port, Http11NioProtocol.java lines 97–103, AbstractProtocol.java lines 659–685) followed by -exec- and a number (AbstractEndpoint.java line 1967). Tomcat keeps 10 ready at the start (line 1459) and grows to 200 by default when requests pile up (line 1499). That is why many threads can call one servlet object at the same time, and why you must not keep per-request values in a servlet's fields. Connections and threads are in part 5.
  4. A session is carried by the JSESSIONID cookie. The first response's Set-Cookie: JSESSIONID=B1BF…; Path=/shop; HttpOnly is the session ID Tomcat made. The second and third requests, which sent the cookie back, got the same ID and became visit #2 and #3. The cookie name defaults to JSESSIONID (SessionConfig.java line 28), and the ID is 16 random bytes written as 32 hexadecimal characters (SessionIdGeneratorBase.java lines 91–92, StandardSessionIdGenerator.java lines 44–61). Unless set otherwise, the cookie's Path is the web app's path (SessionConfig.java lines 89–95), and HttpOnly (a flag that keeps the browser's JavaScript from reading the cookie) is on by default (StandardContext.java line 649). How sessions are made and removed is in part 11.
  5. The two web apps don't mix. In step 6 we called /blog/hello with the same cookie jar, but the cookie's Path was /shop, so curl didn't send it, and blog made a new session (935E…, visit #1). It's not only the cookie: the store that holds sessions (the Manager) is separate per web app too. When a web app (Context) starts and nothing else has been configured, it makes its own StandardManager (StandardContext.java lines 4542–4566). Why the two web apps each load their own copy of the same Hello.class is covered with class loaders in part 4. A path no web app owns, like step 8, becomes a 404 at the Host stage (StandardHostValve.java lines 80–87).

What it is used for

Use What it does Example
Java web app server Runs web apps built with servlets and JSP, packed as WARs and dropped into webapps/. One server can host several apps, split by path /shop and /blog in the hands-on above
Running Spring MVC apps A single servlet called DispatcherServlet takes requests and hands them to controllers. Tomcat just calls that servlet The web examples in the Spring series
A server inside your program (embedded Tomcat) Instead of installing it separately, start it inside main() with new Tomcat() Part 16
REST API servers APIs that exchange JSON with no screens also run on servlets A backend that a mobile app calls
Live connections Keeps a connection open with WebSocket to exchange messages, and carries many requests over one connection with HTTP/2 Part 15

There are also places where it doesn't fit. For a site that only serves static files like HTML and images, Tomcat can do it (a DefaultServlet that serves static files is registered in conf/web.xml), but there's no need for Java, so nginx or httpd is simpler. Also, of Jakarta EE, Tomcat implements only the web-side specs such as Servlet, JSP, EL, WebSocket and Authentication (Jakarta Authentication). The *-api.jar files in section 2's lib/ list (annotations, el, jaspic, jsp, servlet, websocket) are the APIs of those specs. An app that needs other Jakarta EE specs, like EJB or messaging (JMS), needs an application server that implements those as well.

Questions this series answers

Running the hands-on raises questions. This series answers them one by one from the source. If a question grabs you, feel free to start with that post.

What you might wonder Where the answer is
What's inside Tomcat, and in how many layers? Why are the connector and the container separate? Part 2: The Big Picture
When you type catalina.sh start, what gets built first, and in what order does it switch on? Part 3: Lifecycle and Startup
The same Hello.class went into two apps, so why don't they mix? Part 4: Class Loaders
Who makes the http-nio-8080-exec-N threads, and who accepts the connection? Part 5: The Connector and the NIO Endpoint
How does the text GET /shop/hello?name=alice become a request object? Part 6: Reading an HTTP/1.1 Request
How does /shop/hello find its way to Hello in the shop app? Part 7: CoyoteAdapter and the Mapper
Where do the access log and the gray error page step in along a request's way? Part 8: Pipelines and Valves
How do filters line up in front of a servlet, and who calls doGet()? Part 9: The Filter Chain and the Servlet Call
We only dropped a folder into webapps/, so how did it become an app? Who reads @WebServlet? Part 10: Deploying a Web App
How is JSESSIONID made, and when is it removed? Part 11: Sessions
How do you keep a slow request from holding on to a worker thread? Part 12: Async Servlets and Non-Blocking I/O
How does a .jsp file become a servlet? Part 13: Jasper
How are pages that require a login protected? Part 14: Authentication and Realms
How does an HTTP/1.1 connection switch to HTTP/2 or WebSocket? Part 15: HTTP/2 and WebSocket
How do you start Tomcat inside main() instead of installing it separately? Part 16: Embedded Tomcat, JMX and Logging

The rest of this post gets ready to follow these questions through the source. It looks at which version is read (section 1), how to get and build the source (section 2), the folder map (section 3), and which classes the request from the hands-on above actually passes through (section 4), then closes with how to read (section 5) and the series map (section 6). If you don't plan to build it yourself, you can skip section 2 without missing anything you need later.

Words to know first

Material about Tomcat keeps using a handful of names that look odd the first time. Pin down these six and the rest reads much more easily.

A servlet container is a server program that takes HTTP requests off the network and calls the service() method of the servlet objects you wrote. Tomcat is a servlet container. Creating a servlet (init()), calling it for every request, and cleaning it up when it is taken down (destroy()) are all the container's job.

Jakarta EE and jakarta.* are the names of the Java web standards such as Servlet and JSP. They used to be called Java EE, with packages like javax.servlet; when the standards moved to the Eclipse Foundation the packages became jakarta.servlet. The Tomcat in this series uses only jakarta.*. A standard is a specification (a spec document), and Tomcat is an implementation of that spec.

Catalina is the name of the part of Tomcat that acts as the servlet container. The <Engine>, <Host> and <Context> in server.xml become classes from this part. In the source it is the org.apache.catalina package, and the name survives in the start script, catalina.sh.

Coyote is the name of the network side, the connector. It reads bytes from a socket and turns them into an HTTP request, then writes the response back out as bytes. The <Connector port="8080" …> line in server.xml is what starts Coyote. Why Catalina and Coyote are kept apart is the topic of post 2.

Jasper is the engine that turns a JSP file into Java servlet source and compiles it. It is the org.apache.jasper package, covered in post 13.

main and 11.0.x are branch names in the Tomcat repository. main is the development line that builds the next release (12.x), while the stable releases we download come from branches such as 11.0.x. This series reads main. The next section looks at the difference.

1. Which version — main and 11.0.x

The version lives in a build settings file

Tomcat's version is written in build.properties.default at the top of the repository. Tomcat is built with Ant (an older build tool for Java projects; the steps are written in build.xml), and Ant reads the values in this file to put the version string together.

version.major=12
version.minor=0
version.build=0
version.patch=0
version.suffix=-M1
version.dev=-dev

build.properties.default lines 31–36

12.0.0 gets -M1 (Milestone 1, a preview released before the final version) and, because it is still in development, -dev. That is why the Tomcat built from the reference commit calls itself 12.0.0-M1-dev. The example below confirms this with version.sh.

How main differs from 11.0.x

Checked directly in this repository:

Branch / tag Version What it is
main (commit 17aa1769b0, 2026-10-23) 12.0.0-M1-dev The development line for the next major release. What this series reads
11.0.x branch (commit 62983467e4, 2026-10-23) 11.0.28-dev The line where the stable release keeps getting fixed
tag 11.0.27 (2026-10-06) — The most recent 11.0 stable tag in this repository

Listing the tags with git tag -l '11.0.*' | sort -V puts 11.0.27 last, and there is not a single 12.* tag yet. In other words, 12.0 has never been released. The repository also keeps older stable branches such as 10.1.x and 9.0.x.

Why read main at all? For the same reason the httpd series read trunk. Fixes and new features land on the development line first, and the big structure (connectors, containers, valves, the filter chain) is the same on both branches. In return, whenever something a post states is different on 11.0.x, the post says so. Comparing the lists of .java files under java/ on the two branches already shows these differences, just among the things this post touches:

Java 21 is required

Which Java you build and run with is set in build.xml.

  <!-- Jakarta EE 12 platform requires Java 21+ -->
  <!-- Keep in sync with webapps/docs/tomcat-docs.xsl -->
  <property name="compile.release" value="21"/>
  <property name="min.java.version" value="21"/>
  <property name="build.java.version" value="21"/>
  <property name="release.java.version" value="25"/>

build.xml lines 107–112

As the comment says, "the Jakarta EE 12 platform requires Java 21+", so the source is compiled against the Java 21 language and API (compile.release) and needs Java 21 or later to run (min.java.version). Lines 115–122 right below stop the build with "Java version 21 or newer is required" if the JDK doing the build is older than 21. On the 11.0.x branch the same three values are all 17. So 11.0 starts at Java 17, 12.0 at Java 21. (The 25 in release.java.version is the JDK used to make official releases. This series' examples were built with JDK 21.)

Which Servlet spec — two places give different numbers

Each Tomcat release line implements a fixed Servlet spec version. That is in build.xml too.

  <!-- Exact spec versions (for the manifests etc.) -->
  <property name="servlet.spec.version"    value="6.2" />
  <property name="servlet.revision"        value="DRAFT" />
  <property name="jsp.spec.version"        value="4.1" />
  <property name="jsp.revision"            value="DRAFT" />
  <property name="el.spec.version"         value="6.1" />
  <property name="el.revision"             value="DRAFT" />
  <property name="websocket.spec.version"  value="2.3" />
  <property name="websocket.revision"      value="DRAFT" />
  <property name="jaspic.spec.version"     value="3.2" />
  <property name="jaspic.revision"         value="DRAFT" />
  <property name="annotation.spec.version" value="3.1" />
  <property name="annotation.revision"     value="DRAFT" />

build.xml lines 59–71

Servlet 6.2, JSP 4.1, EL 6.1, WebSocket 2.3, all marked DRAFT, meaning the Jakarta EE 12 specs are not final yet. On the 11.0.x branch the same spot says Servlet 6.1, JSP 4.0, EL 6.0, WebSocket 2.2, with an empty revision (final).

The servlet API source does contain methods that are new in 6.2. HttpServlet has doQuery() for HTTP's new QUERY method (HttpServlet.java line 294), and HttpServletResponse has sendEarlyHints() for sending a 103 response (HttpServletResponse.java line 224). Both carry @since Servlet 6.2 in their javadoc, and neither is in the same files on the 11.0.x branch.

But when a running web app asks the container which Servlet spec it implements (ServletContext.getMajorVersion() and getMinorVersion()), the answer is different. The two methods are implemented in ApplicationContext like this:

    @Override
    public int getMajorVersion() {
        return Constants.MAJOR_VERSION;
    }


    @Override
    public int getMinorVersion() {
        return Constants.MINOR_VERSION;
    }

java/org/apache/catalina/core/ApplicationContext.java lines 290–299

The constants they return live in Constants in the same package:

    /**
     * The major version of the Servlet specification implemented by this container.
     */
    public static final int MAJOR_VERSION = 6;

    /**
     * The minor version of the Servlet specification implemented by this container.
     */
    public static final int MINOR_VERSION = 1;

java/org/apache/catalina/core/Constants.java lines 30–38

6.1. The last version-related commit to this file is 9609c385cd ("Update version constant for Servlet 6.1"); no commit has bumped it to 6.2 yet. When the example below actually asks, it prints servlet spec: 6.1.

Easy to get confused

"Which Servlet version is Tomcat 12?" has two answers. The build settings (build.xml) and the API source (the @since Servlet 6.2 methods in jakarta.servlet, and version="6.2" in conf/web.xml) are heading for 6.2, yet the number the container reports about itself at run time (Constants.MINOR_VERSION) is still 6.1. It looks like the kind of mismatch you get in a development build while the spec is still a draft, and my guess is that it will be lined up before a final release (what the source can confirm is only "right now they differ"). This series will call it "a release that is in the middle of implementing Servlet 6.2 (draft)."

12.0.0-M1 is not a final release. "M1" means the first milestone (a preview), and this repository does not even have a 12.0.0-M1 tag yet. What people run in production is a stable 11.0.x (or older) release. The examples in post 20 of the Spring series also ran on a stable embedded Tomcat (tomcat-embed-core 11.0.15).

2. Getting the same source

This section is for readers who want to run the examples themselves. If you only want to read, feel free to skip to section 3 (the folder map).

Clone the source and pin the commit

To open the line numbers in these posts yourself, you need the source at the same commit. With Git, the steps are:

  1. Clone the repository. The history is long, so it takes a while.
  2. Check out the reference commit separately. I left the cloned folder (tomcat) alone and used git worktree to put just the reference commit in another folder (tomcat-17aa1769b0). A worktree lets one repository have different commits checked out in different folders at the same time. That way, pulling the latest source into the original folder with git pull never changes the line numbers in the reference folder.
  3. Check that it worked. You should see the full commit hash.
git clone https://github.com/apache/tomcat.git
cd tomcat
git worktree add --detach ../tomcat-17aa1769b0 17aa1769b0fa32713b7b36b10a33155b63a40bbc
git -C ../tomcat-17aa1769b0 rev-parse HEAD

If a worktree feels like too much, git checkout 17aa1769b0fa32713b7b36b10a33155b63a40bbc moves the cloned folder itself to that commit. Git will mention a "detached HEAD"; that only means you checked out a specific commit rather than a branch, and it is fine for reading. When you want to compare with 11.0.x, git show origin/11.0.x:<path> prints a file from that branch without touching your checkout.

You can also just read on the web. This link opens the commit on GitHub; you will see the same files, and every code link in this series points at that commit.

Building in Docker

If you only want to read, you do not need to build. But this series actually runs every example it shows, so it needs a Tomcat built from the reference commit. A downloaded stable release (11.0.x) would have different line numbers and even a different set of classes. So I built the reference commit as-is inside a Docker container. Here is the Dockerfile that is actually used. (Its comments are in Korean; they say: an image for the "Digging into Tomcat's Source" examples; it builds the reference commit 17aa1769b0 (main, 12.0.0-M1) with Ant, unchanged; the source is tomcat-17aa1769b0.tar.gz made with git archive (LF line endings, untouched original); and base.path is where the build puts the libraries it downloads, such as ecj and bnd.)

# "Tomcat 소스 파헤치기" 예제용 이미지.
# 기준 커밋 17aa1769b0 (main, 12.0.0-M1) 의 소스를 그대로 Ant 로 빌드한다.
# 소스는 git archive 로 뽑은 tomcat-17aa1769b0.tar.gz (LF 줄 끝, 손대지 않은 원본).
FROM eclipse-temurin:21-jdk

RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
      ant curl procps unzip \
    && rm -rf /var/lib/apt/lists/*

COPY tomcat-17aa1769b0.tar.gz /usr/src/
WORKDIR /usr/src
# base.path: 빌드가 내려받는 의존 라이브러리(ecj, bnd 등)를 둘 곳
RUN tar xzf tomcat-17aa1769b0.tar.gz && cd tomcat \
    && echo "base.path=/usr/src/tomcat-deps" > build.properties \
    && ant -q deploy \
    && cp -r output/build /opt/tomcat

ENV CATALINA_HOME=/opt/tomcat PATH=/opt/tomcat/bin:$PATH
WORKDIR /work
CMD ["version.sh"]

Step by step, it does this:

  1. Start from an image with JDK 21. eclipse-temurin:21-jdk is the OpenJDK 21 image made by Eclipse Adoptium. As shown above, main needs Java 21 to build.
  2. Install Ant and a few tools. ant is the build tool; curl and ps (procps) are there so the examples can poke at the server. jstack and jcmd (thread dump tools) already come with the JDK.
  3. Copy in the reference commit's source as an archive. It is the original, extracted from the repository with git archive. The comment says "LF line endings" because of what happened in the httpd series: files checked out by Git on Windows (core.autocrlf=true) get CRLF line endings, and a shell script then refused to run on Linux, claiming the file did not exist (see "where I got stuck" in httpd post 1). So this time the archive was made without that conversion from the start.
  4. Write base.path into build.properties. Tomcat's build downloads its dependencies from the internet while it builds, for example ecj (Eclipse's Java compiler, used for JSPs). base.path is the folder they go into. The repository's BUILDING.txt also recommends always creating this file because the default value is awkward (BUILDING.txt lines 126–148).
  5. Build with ant deploy. deploy is the default target in build.xml, and when it finishes, a ready-to-run Tomcat sits in output/build (BUILDING.txt lines 164–173). That gets copied to /opt/tomcat, which becomes CATALINA_HOME (the environment variable pointing at the folder Tomcat is installed in).

The resulting image is called tomcat-lab:17aa1769b0. /opt/tomcat ends up with the same layout you get from unpacking a downloaded release (bin/, conf/, lib/, webapps/ …).

How the examples are run

Each post has one example folder (like ex/u01-reading-plan) containing a run.sh, and a small script (run-example.sh) runs it inside a container. The heart of it is one line:

MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd -W 2>/dev/null || pwd)/$dir:/work" -w /work \
  tomcat-lab:17aa1769b0 bash ./run.sh

It mounts the example folder at /work in the container, runs run.sh, and deletes the container right away (--rm). Every run starts from a clean server, so web apps or logs left by an earlier example cannot interfere. pwd -W is how Git Bash gets a Windows path (D:/…), and MSYS_NO_PATHCONV=1 stops Git Bash from "helpfully" rewriting arguments like /work into Windows paths (the same approach as the httpd series).

Inside run.sh, the installation folder (CATALINA_HOME=/opt/tomcat) is left untouched. The script creates separate conf/, logs/ and webapps/ folders under /tmp/base and starts Tomcat with CATALINA_BASE=/tmp/base. CATALINA_HOME is where the program (jars and scripts) lives; CATALINA_BASE is where the configuration, web apps and logs live. Keeping them apart lets one installation run several instances. How the two values are used is in post 3.

Example: asking the built server directly

Every example in this series was run with Tomcat 12.0.0-M1-dev, built unchanged from the reference commit (17aa1769b0), in a Docker container (Eclipse Temurin JDK 21). The example folder is ex/u01-reading-plan. First, version.sh (the Server built time differs per build, and OS Version per machine):

Server version: Apache Tomcat/12.0.0-M1-dev
Server built:   Oct 24 2026 13:31:50 UTC
Server number:  12.0.0.0
OS Name:        Linux
OS Version:     5.15.146.1-microsoft-standard-WSL2
Architecture:   amd64
JVM Version:    21.0.12.1+1-LTS
JVM Vendor:     Eclipse Adoptium
APR loaded:     false

Third-party libraries:
  ecj-4.41.jar:                          3.46.100.v20260826-1225
  jakartaee-migration-1.0.13-shaded.jar: 1.0.13

The numbers read from build.properties.default come straight back. APR loaded: false means Tomcat Native (a C library that uses OpenSSL) is not loaded; every example in this series runs in pure Java. The last two lines are libraries the build downloaded and bundled. ecj compiles JSPs (post 13), and jakartaee-migration is the "Apache Tomcat Migration Tool for Jakarta EE" (the tool bin/migrate.sh calls), which converts old Java EE web apps written against javax.* to jakarta.*.

Next, the jars in the lib/ folder, with size in bytes and name:

35
    13731  annotations-api.jar
    60127  catalina-ant.jar
   132150  catalina-ha.jar
    67464  catalina-ssi.jar
    97889  catalina-storeconfig.jar
   368904  catalina-tribes.jar
  1769887  catalina.jar
  3384534  ecj-4.41.jar
    97854  el-api.jar
  1466818  jakartaee-migration-1.0.13-shaded.jar
   184793  jasper-el.jar
   551772  jasper.jar
    27816  jaspic-api.jar
    72851  jsp-api.jar
   445522  servlet-api.jar
    12039  tomcat-api.jar
     9200  tomcat-coyote-ffm.jar
   900823  tomcat-coyote.jar
   344745  tomcat-dbcp.jar
    70245  tomcat-i18n-cs.jar
    77508  tomcat-i18n-de.jar
   106939  tomcat-i18n-es.jar
   186025  tomcat-i18n-fr.jar
   211770  tomcat-i18n-ja.jar
   190389  tomcat-i18n-ko.jar
    52780  tomcat-i18n-pt-BR.jar
    71663  tomcat-i18n-ru.jar
   177889  tomcat-i18n-zh-CN.jar
   154827  tomcat-jdbc.jar
    16750  tomcat-jni.jar
   231698  tomcat-util-scan.jar
   214154  tomcat-util.jar
   260099  tomcat-websocket.jar
    14064  websocket-api.jar
    31767  websocket-client-api.jar

There are 35. The names alone point back to source folders: the *-api.jar files are the standard APIs under java/jakarta/, catalina.jar is Catalina, tomcat-coyote.jar is Coyote, and jasper.jar is Jasper. Jars and packages are not one-to-one, though. The packaging rules in build.xml put JNDI (org/apache/naming) into catalina.jar and leave clustering (ha, tribes) out (build.xml lines 493–502). And org/apache/tomcat/util/net, home of NioEndpoint, which accepts connections, goes into tomcat-coyote.jar, not tomcat-util.jar (build.xml lines 767–776). Good to know when you see a class name in a stack trace and wonder which jar it is in.

3. Folder map — where are the 740,000 lines?

Top-level folders

At the reference commit, git tracks 5,036 files. Of those, 2,984 are Java sources (.java), with 741,389 lines in total (blank lines and comments included). Counted per top-level folder (files picked with git ls-files, lines counted with wc -l):

Folder What's in it .java files Lines
java/ The server itself. Standard APIs (jakarta/) and the implementation (org/apache/) 1,779 503,838
test/ Unit and integration tests 1,019 192,251
modules/ Extra modules built separately (jdbc-pool, manager2, stuffed and others) 116 38,012
webapps/ Web apps that ship with Tomcat (ROOT, docs, examples, manager, host-manager) 70 7,288
conf/ Default configuration files (server.xml, web.xml, context.xml, tomcat-users.xml, catalina.properties, logging.properties and others, 9 in all) 0 0
bin/ Scripts (catalina.sh, startup.sh, version.sh and others, each with a .bat twin) 0 0
res/ Build resources (META-INF files to put inside jars, the Windows installer install-win, settings for checking tools and so on) 0 0

conf/ and bin/ are exactly the folders you saw when you unpacked a release. The build copies them to output/build, compiles java/, and packages it into the jars in lib/. When reading the source you can treat java/ as everything. It is also striking that the tests come to 190,000 lines. When some behavior is confusing, finding the test with the matching name in test/ (for example TestCoyoteAdapter) often tells you what was intended.

Inside java/

Split by package, java/ looks like this:

Package What's in it .java files Lines Posts
jakarta/* Standard APIs — servlet (117 files, 18,666 lines), el, websocket, security, annotation and others, 10 in all 262 33,109 9, 12
org/apache/catalina Catalina, the servlet container 611 205,367 2–4, 7–11, 14, 16
org/apache/coyote Coyote, the connectors — http11, http2, ajp 92 30,746 5, 6, 15
org/apache/tomcat Shared tools — util (358 files, 107,414 lines), dbcp, websocket and others 577 167,199 5, 6, 15, 16
org/apache/jasper Jasper, the JSP engine 109 42,371 13
org/apache/el EL (${…} expressions) implementation 82 16,690 13
org/apache/naming JNDI (java:comp/env name lookup) implementation 32 4,739 —
org/apache/juli Logging (JULI, java.util.logging split up so each web app gets its own) 14 3,617 16

Catalina is the biggest, but not all of its 200,000 lines are about handling requests. The clustering code that ties several servers together, catalina/tribes (30,578 lines) and catalina/ha (10,651 lines), is over 40,000 lines by itself. Inside org/apache/tomcat, the database connection pool tomcat/dbcp (39,906 lines) is Apache Commons DBCP and Pool (dbcp2, pool2) with their package names changed, so this series does not read it. Here are the sizes of the files this series will spend the most time in:

File Lines Posts
tomcat/util/net/AbstractEndpoint.java 2,576 5
tomcat/util/net/NioEndpoint.java 2,274 5
coyote/http11/Http11Processor.java 1,452 6
catalina/connector/CoyoteAdapter.java 1,323 7
catalina/mapper/Mapper.java 1,882 7
catalina/core/StandardWrapperValve.java 337 9
catalina/core/ApplicationFilterChain.java 248 9
catalina/startup/ContextConfig.java 3,032 10
catalina/core/StandardContext.java 6,019 2, 10

The Spring code read in the first series was about 840,000 lines of main code alone (tests excluded), and httpd about 350,000 lines of .c and .h, so Tomcat lands in between. A quarter of Tomcat's 740,000 lines are tests, though; without them, the server itself (java/) is a little over 500,000.

Seen as layers

At the top, web apps call the jakarta API, and Catalina and Jasper implement that API. Coyote hands the requests it receives to Catalina through an Adapter. Catalina and Coyote both use tomcat.util, and tomcat.util wraps the JDK's sockets and threads. The Catalina and Coyote boxes are marked as the place to start reading. Tomcat's layers. Start reading in the blue boxes (Catalina, Coyote); the gray boxes (web apps, the JDK) are outside.

Two things from this picture are worth remembering.

First, web apps do not know Tomcat's classes. The servlet you wrote only sees interfaces such as jakarta.servlet.http.HttpServletRequest; the real object behind it (Catalina's RequestFacade) is supplied by Tomcat. That is why the same WAR runs on Tomcat and on Jetty. Why it is a RequestFacade rather than a Request is in post 7.

Second, Coyote and Catalina are joined by a single Adapter. Coyote (org.apache.coyote) knows nothing about servlets; it simply passes the request to the Adapter interface in its own package. The class implementing that interface is CoyoteAdapter on the Catalina side. Post 2 looks at this design in detail.

Easy to get confused

org/apache/tomcat/websocket and jakarta/websocket are different things. The first is Tomcat's WebSocket implementation; the second is the standard API. Servlets work the same way (jakarta/servlet ↔ org/apache/catalina). Remember: "starts with jakarta means the standard, starts with org/apache means Tomcat's implementation." The standard API's source sits in the Tomcat repository only because Tomcat builds and ships servlet-api.jar itself.

TOMCAT-NEXT.txt is empty. At the top of the repository there is a file titled "notes of things to consider for the next major Tomcat release (12.x)", so you would expect 12.0's plans in it. At the reference commit it holds only the license header and that one title line, with no items at all. The differences between main and 11.0.x have to be read straight from the source and the git history, as above.

4. The big road one request travels

Starting from the config file you know

Before going into the source, look at the familiar conf/server.xml. With the comments stripped, the skeleton is:

<Server port="8005" shutdown="SHUTDOWN">
  <!-- ... -->
  <Service name="Catalina">
    <!-- ... -->
    <Connector port="8080" protocol="HTTP/1.1"
               connectionTimeout="20000"
               redirectPort="8443" />
    <!-- ... -->
    <Engine name="Catalina" defaultHost="localhost">
      <!-- ... -->
      <Host name="localhost"  appBase="webapps"
            unpackWARs="true" autoDeploy="true">
        <!-- ... -->
        <Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs"
               prefix="localhost_access_log" suffix=".txt"
               pattern="%h %l %u %t &quot;%r&quot; %s %b" />

      </Host>
    </Engine>
  </Service>
</Server>

conf/server.xml lines 22–154 (omitted parts marked <!-- ... -->)

<Connector> is Coyote; everything from <Engine> inward is Catalina. Contexts (one per web app) and Wrappers (one per servlet) are not written here, but they are created at run time for each folder under webapps/ and each servlet inside it (a <Context> can be written by hand, but there is no server.xml element for a Wrapper at all). So the container has four layers: Engine → Host → Context → Wrapper. The four layers are in post 2, and how server.xml becomes objects is in post 3.

On one page

Posts 5 through 9 follow the road one request travels, in order. Drawn on one page, the main road looks like this:

The Acceptor takes the connection, the Poller picks sockets with data to read, a SocketProcessor is handed to a worker thread, Http11Processor reads the request line and headers, CoyoteAdapter turns it into a Catalina request, the Mapper picks the Host, Context and Wrapper, the request passes the Engine, Host and Context valves, StandardWrapperValve gets the servlet, and ApplicationFilterChain calls the filters in order before reaching Servlet.service(). With keep-alive, it goes back to the Poller to wait for the next request on the same connection. Each box shows the post that covers it. The main road of one request. Each box names the post that covers that step. The dotted line is the way back to the next request on a keep-alive connection.

Matched up with what you know from the config files:

Step What you saw in config files Post
Acceptor · Poller · worker threads <Connector port maxThreads maxConnections acceptCount>, <Executor> 5
Reading HTTP/1.1 connectionTimeout, maxHttpHeaderSize, keepAliveTimeout 6
CoyoteAdapter · Mapper <Host name>, <Context path>, <servlet-mapping> in web.xml 7
Valves <Valve className="…AccessLogValve">, <Realm> 8, 14
Filter chain · servlet <filter> and <servlet> in web.xml, @WebFilter and @WebServlet 9

A sneak peek at two spots in the code

One: where a connection is handed to a worker thread. When the Poller notices "data has arrived on this socket", it calls AbstractEndpoint.processSocket().

            SocketProcessorBase<S> sc = null;
            if (processorCache != null) {
                sc = processorCache.pop();
            }
            if (sc == null) {
                sc = createSocketProcessor(socketWrapper, event);
            } else {
                sc.reset(socketWrapper, event);
            }
            Executor executor = getExecutor();
            if (dispatch && executor != null) {
                executor.execute(sc);
            } else {
                sc.run();
            }

java/org/apache/tomcat/util/net/AbstractEndpoint.java lines 2136–2150

It makes a unit of work (a SocketProcessor; a leftover one from the cache is reused if available) and hands it to the thread pool (executor). From then until the response is fully written, one worker thread from the pool owns this request, while the Poller thread goes straight back to watching other sockets. Post 5 covers this structure.

Two: where Coyote hands over to Catalina. The middle of CoyoteAdapter.service():

        try {
            // Parse and set Catalina and configuration specific
            // request parameters
            postParseSuccess = postParseRequest(req, request, res, response);
            if (postParseSuccess) {
                // check valves if we support async
                request.setAsyncSupported(connector.getService().getContainer().getPipeline().isAsyncSupported());
                // Calling the container
                connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
            }

java/org/apache/catalina/connector/CoyoteAdapter.java lines 339–348

req and res are the request and response Coyote built; request and response are the Catalina-side objects. First, postParseRequest() cleans up the URI and uses the Mapper to decide which Host, Context and servlet this request goes to (connector.getService().getMapper().map(…) on line 700 of the same file). Once that is settled, line 347 calls the first valve in the pipeline (a chain of valves lined up in order) of the top container, the Engine. From there, each valve calls the next one, and the last valve calls the pipeline of the container one layer in, all the way down to the Wrapper (post 8).

Example: does a request really take this road?

To check the picture, I printed the call stack (the list of currently running methods, from the innermost back out to whoever called them) from inside a servlet. The httpd series needed gdb for this; in Java, new Throwable().getStackTrace() is a single line. Here is the example servlet:

// An example written for this post (not part of Tomcat's source)
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

@WebServlet("/where")
public class WhereAmI extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain");
        PrintWriter out = resp.getWriter();
        // The server name, and the Servlet spec version the container reports about itself
        out.println("server info : " + getServletContext().getServerInfo());
        out.println("servlet spec: " + getServletContext().getMajorVersion()
                + "." + getServletContext().getMinorVersion());
        // The thread handling this request
        out.println("thread      : " + Thread.currentThread().getName());
        // The call stack right now — top is this method, bottom is where the thread started
        out.println("stack (top = this method):");
        for (StackTraceElement e : new Throwable().getStackTrace()) {
            out.println("  at " + e.getClassName() + "." + e.getMethodName()
                    + "(" + e.getFileName() + ":" + e.getLineNumber() + ")");
        }
    }
}

run.sh compiles it with javac, with only servlet-api.jar on the classpath, drops it into /tmp/base/webapps/u01/WEB-INF/classes, starts Tomcat with catalina.sh start, and calls it with curl http://127.0.0.1:8080/u01/where. It is registered by the @WebServlet annotation alone, with no web.xml (where annotations are found is in post 10). The response:

server info : Apache Tomcat/12.0.0-M1-dev
servlet spec: 6.1
thread      : http-nio-8080-exec-2
stack (top = this method):
  at WhereAmI.doGet(WhereAmI.java:23)
  at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:673)
  at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:762)
  at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
  at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:59)
  at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
  at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:164)
  at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:77)
  at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:540)
  at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115)
  at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:82)
  at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:777)
  at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:71)
  at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:347)
  at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:404)
  at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:71)
  at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:1317)
  at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:2218)
  at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:74)
  at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:968)
  at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:481)
  at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:74)
  at java.lang.Thread.run(Thread.java:1583)

(The number at the end of the thread name differs from run to run.) The first two lines confirm the versions from section 1: the server is 12.0.0-M1-dev, and the Servlet spec the container reports is 6.1.

Read the stack from the bottom up. Every line number in parentheses was checked against the reference commit's source. For example, CoyoteAdapter.java:347 is the "Calling the container" line just shown above.

  1. A worker thread from the thread pool (ThreadPoolExecutor → SocketProcessorBase.run()) is the starting point. It is not the JDK's pool but Tomcat's own org.apache.tomcat.util.threads.ThreadPoolExecutor (why Tomcat keeps its own is in post 5).
  2. Coyote: NioEndpoint$SocketProcessor.doRun() → AbstractProtocol$ConnectionHandler.process() → Http11Processor.service(). This is where the request line and headers are read (post 6).
  3. The boundary: CoyoteAdapter.service() crosses over into Catalina.
  4. Valves: the Engine's StandardEngineValve → the Host's AbstractAccessLogValve, ErrorReportValve and StandardHostValve → the Context's AuthenticatorBase and StandardContextValve → the Wrapper's StandardWrapperValve. You can see it walk down the four container layers one by one.
  5. Filter chain: ApplicationFilterChain.doFilter() calls a filter (WsFilter), the filter calls back into the chain, and the chain finally reaches the servlet's service() (post 9).
  6. The servlet: HttpServlet.service() sees a GET and calls doGet().

Finally, run.sh takes a thread dump with jcmd <pid> Thread.print and keeps only the threads whose names start with http-nio-8080:

"http-nio-8080-Acceptor"
"http-nio-8080-Poller"
"http-nio-8080-exec-1"
"http-nio-8080-exec-2"
"http-nio-8080-exec-3"
"http-nio-8080-exec-4"
"http-nio-8080-exec-5"
"http-nio-8080-exec-6"
"http-nio-8080-exec-7"
"http-nio-8080-exec-8"
"http-nio-8080-exec-9"
"http-nio-8080-exec-10"

The top three boxes of the picture show up as threads: one Acceptor that takes connections, one Poller that watches sockets, and worker threads exec-1 to exec-10. The names are given in the source like this: the Acceptor gets getName() + "-Acceptor" (AbstractEndpoint.java line 2331), the Poller getName() + "-Poller" (NioEndpoint.java line 542), and the worker threads getName() + "-exec-" followed by a number (AbstractEndpoint.java line 1967). There are 10 worker threads because the default minSpareThreads is 10 (AbstractEndpoint.java line 1459). Under load the pool grows up to the default maxThreads of 200 (line 1499).

Easy to get confused

The Mapper is not on the stack. In the picture the Mapper sits between CoyoteAdapter and the valves, but the stack above has no Mapper line at all. That is because a call stack shows only the methods that are open at this very moment. The Mapper is called inside postParseRequest(), writes down "this request goes to this servlet in this Context" (request.getMappingData()), and has already returned. The valves simply read what it wrote down (as StandardEngineValve does with request.getHost()). It is the same reason ap_read_request() was missing from the stack in httpd post 1.

Filters and valves show up that nobody added. The example web app has no filters and no login configuration, yet the stack shows WsFilter and AuthenticatorBase. Following the source shows that all three of the following were put in by Tomcat itself.

One stack trace reveals things you could never tell from the config files alone. That is why every post in this series prints a stack or thread names.

5. How to read it — with AI, but checked against the source

The reading loop

A loop: pin the reference commit, AI writes notes and a map, re-check against the pinned source and fix what is wrong, run a small example in a Docker container, turn it into a post for first-time readers, double-check before publishing, then go back for the next post The order in which one post is read and written. Blue boxes are steps; red boxes are where mistakes in what the AI wrote get filtered out.

The principle is the same as in the Spring and httpd series. Do not read everything at once; pick one question from the map and read deeply only the code needed to answer it. In this series, one question is one post.

  1. Pin the reference commit. That is the worktree from section 2. Line numbers must not move, or the evidence an AI cites cannot be verified.
  2. AI writes the notes and the map. The layout of the 16 posts and the list of starting classes for each post were made this way. Each post was handed to its own AI task with the same instructions (what to read, in what format, with what evidence).
  3. Re-check against the reference commit's source. The classes named in the instructions are only starting points; what goes into a post is only what was confirmed directly in the source. Code is copied from the files as-is, and only line numbers found directly are written down.
  4. Actually run the examples. As in section 4, a small example that confirms one claim of the post is run in the container, and its output is shown as-is.
  5. Check once more before publishing. When a post is done, a separate AI task compares its code blocks, line numbers, stated facts and example output against the source from the beginning. A post that has not been through this check does not get published.

What the checking caught in this post

Why step 3 is needed showed up while writing this very post. The instructions said only, "Post 1: confirm the Servlet spec version by finding the constants that ServletContext's major/minor point to." Writing "Servlet 6.2" from build.xml alone, or "Servlet 6.1" from the constants alone, would each have been half right. Only after opening both places and asking a running server could it be written up as in the "Easy to get confused" part of section 1.

The same goes for WsFilter and AuthenticatorBase. People usually draw the request path as "valves → filters → servlet" and stop there. Without actually printing the stack, "a web app with no filters still has a filter in its chain" would never have made it into the post.

AI guidance inside the repository

At the top of the Tomcat repository there is a file called AGENTS.md. The git history shows it was committed on 2026-05-14 (11043b325a, "Add AGENTS.md + SECURITY.md linking the project's security model"), and its first paragraph reads:

This file is read by automated agents (security scanners, code analyzers, AI assistants) operating on this repository. It points them at the human-authored references they should consult before producing output.

It tells automated agents (security scanners, code analyzers, AI assistants) to consult human-written references before producing output, and its content is a link to the security model document (SECURITY.md). As more AI tools read the source and report "security issues", the project wrote down that they should first read what the project does and does not consider a problem. It is the same principle as this series: check what an AI wrote against a human-written baseline (here, the reference commit's source) before writing it down.

There is more AI-related material in res/llm/: two files, repomix.config.json5 (2025-06-25, commit 385b898bff, "Add basic config for building repository data") and repomix-full.config.json5 (the next day, commit 016413da69, "Add a config for java + testsuite"), which are settings for a tool called Repomix. As their comments say, running npx repomix gathers .java files (the first leaves out some packages under java/; the second also takes in the tests) into a single markdown file (output/repomix/repomix-tomcat.md). It looks meant for handing the whole source to an AI in one go. But these settings include "removeEmptyLines": true and "showLineNumbers": false. An AI that reads a file with blank lines removed and no line numbers cannot know the original file's line numbers. That is one more reason not to trust an AI's "line N" as-is, and to look it up again in the reference commit's file.

6. The series map

Four groups

Four groups in a row: getting ready (posts 1–2: getting the source and the map, the big picture), then the skeleton (posts 3–4: Lifecycle and startup, class loaders), then the road of a request (posts 5–9: connector and endpoint, reading HTTP/1.1, Adapter and Mapper, pipelines and valves, filter chain and servlet), then web apps and features (posts 10–16: deployment, sessions, async, Jasper, authentication, HTTP/2 and WebSocket, embedded Tomcat, JMX and logging) The four groups of the series. The blue boxes are the core of the series; the gray boxes are the preparation before it and the features built on top of it.

Group Post The question it answers
Getting ready 1 Which source, and how to read it (this post)
2 Which classes do the elements of server.xml become, and why are Catalina and Coyote separate?
The skeleton 3 What starts, in what order, after catalina.sh start?
4 What do the class loaders that give each web app its own classes look like?
The road of a request 5 Which thread takes a connection, and how is work split among worker threads?
6 How do bytes from a socket become a request object?
7 Which Host, Context and servlet does a request go to?
8 How does the chain of valves on each container work?
9 Through the filter chain until service() is called
Web apps and features 10 How is a WAR deployed, and how is web.xml read?
11 How is a session ID made, and when are sessions removed?
12 How does an async servlet let go of its thread?
13 How does a JSP become a servlet?
14 How is security-constraint enforced?
15 How do HTTP/2 and WebSocket get upgraded to?
16 How are embedded Tomcat, JMX and logging put together?

Reading order

Just read them in numerical order. Like the httpd series, this one was numbered from the start to follow the big road from section 4, for three reasons.

What this post leaves out

Summary

Concept One-line summary
Source being read Apache Tomcat 12.0.0-M1 (main), commit 17aa1769b0 (2026-10-23). 2,984 .java files, about 740,000 lines (the server itself, java/, is about 500,000)
main and 11.0.x main is the development line for the next major release (12); the stable line is 11.0.x (latest tag 11.0.27). main has dropped the NIO2 connector, among other things
Java main needs Java 21 or later (build.xml); 11.0.x needs Java 17 or later
Servlet spec build.xml and the API say Servlet 6.2 (draft); getMinorVersion() at run time still says 6.1
Getting the same source git clone, then pin the commit with git worktree add --detach … 17aa1769b0…
Build In Docker (Temurin JDK 21), write base.path into build.properties and run ant deploy → output/build
Folders java/jakarta (standard APIs) · org/apache/catalina (container) · coyote (connectors) · tomcat/util (shared tools) · jasper and el (JSP)
Main road Acceptor → Poller → worker thread → Http11Processor → CoyoteAdapter → Mapper → valves → filter chain → servlet
How to read Pin the commit → AI notes → check against the source → run examples → write → double-check

What's next

The next post is the big picture. In this post we glanced at <Server>, <Service>, <Connector>, <Engine> and <Host> in server.xml, and saw the Engine → Host → Context → Wrapper valves being called in turn in the stack trace. The next post looks at which class each of those elements becomes (StandardServer, StandardService, Connector, StandardEngine …), how the four container layers form parents and children through a single Container interface, and why Catalina and Coyote are split apart with just one Adapter between them.

There are also earlier series read the same way. Post 1 of the httpd series started reading a web server written in C with the same principles. In httpd, taking connections and handing them to threads is the job of a module called the MPM (httpd post 8); in Tomcat it is the Acceptor, Poller and thread pool seen above. Post 5 compares how both arrived at the same answer: separate the threads that watch connections from the threads that process requests. Post 1 of the Spring series is the first post of the series on a framework that runs on top of Tomcat. In Spring MVC, the HttpServlet.service() spot at the top of the stack trace above is taken by DispatcherServlet (Spring post 21). In a sense, this series reads what lies underneath — the part Spring skips over with "the servlet container calls it."

More posts