What this post covers
This is the tenth post in the "Digging into Tomcat's Source" series. In part 9 we followed a request through the filter chain until it reached a servlet's service(). Up to that point we simply assumed the web app was already "up". This time we take a step back and look at how that web app comes up in the first place. Copy shop.war into the webapps folder and, a moment later, requests to /shop start working. What happens in between?
There are four questions this post tries to answer.
- Who notices that a new WAR has appeared in
webappswhile Tomcat is running, and when? - How does the file name
shop##v2.warturn into the address/shop, and when does the WAR get unpacked? - When
WEB-INF/web.xml,conf/web.xml,@WebServletannotations and aweb-fragment.xmlinside a jar all exist together, which one wins? - How are
ServletContainerInitializers (SCIs) found, and when are they called compared with listeners, filters and servlets? Spring'sSpringServletContainerInitializeris called along exactly this path.
| Item | Details |
|---|---|
| Target | Apache Tomcat |
| Version | 12.0.0-M1 (main) |
| Commit | 17aa1769b0 (2026-10-23) |
| Main classes | HostConfig, ContextConfig, StandardContext, WebappServiceLoader, WebXml, ExpandWar, ContextName |
All line numbers in this post refer to this commit. Part 1 shows how to get the source at the same commit and build the Docker image used for the examples.
ContextConfig.java is over 3,000 lines long and StandardContext.java over 6,000. This post only picks the main path a first-time reader can follow. Things I left out are listed by name and location in "What this post leaves out" at the end.
Terms to know first
Host and Context. <Host name="localhost" appBase="webapps"> in server.xml is a StandardHost object, and each web app is a StandardContext object. A Host has Contexts as its children. We looked at the parent–child relationship of the four containers (Engine → Host → Context → Wrapper) in part 2.
Deploying means "looking at a web app that exists as files, creating a StandardContext for it, attaching it to the Host as a child, and starting it". Undeploying is the opposite: stopping the Context and detaching it from the Host. Auto deploy is the feature where Tomcat keeps checking the appBase folder while it is running, deploying new files and undeploying ones that have disappeared.
A WAR (Web application ARchive) is a zip file that bundles a whole web app folder (WEB-INF/classes, WEB-INF/lib, WEB-INF/web.xml, static files). Only the extension is .war.
Lifecycle listeners and events. Tomcat's components go through the states init → start → stop → destroy, and each time the state changes they notify their attached listeners with events such as before_start and configure_start (part 3). The two stars of this post, HostConfig and ContextConfig, are both listeners. They are attached to a Host and a Context respectively and do their work when an event arrives. Despite the "Config" in their names, they are not objects that hold settings; they are "workers that do the configuring".
The deployment descriptor is the web app configuration file defined by the Servlet specification, WEB-INF/web.xml. There is also a piece that lives inside a jar, META-INF/web-fragment.xml, and Tomcat adds two of its own: conf/web.xml (defaults for every web app) and WEB-INF/tomcat-web.xml. In the source, each of these is read into its own WebXml object.
A ServletContainerInitializer (SCI) is a Servlet specification interface with a single method, onStartup(Set<Class<?>> classes, ServletContext ctx). If you write the name of an implementing class in a jar's META-INF/services/jakarta.servlet.ServletContainerInitializer file, the container calls it when it starts the web app. If you put @HandlesTypes(SomeType.class) on the class, the container finds "the classes that implement or extend that type" and passes them as the first argument. It is the entry point for frameworks that register servlets and filters in code instead of web.xml.
1. The big picture: two listeners and one start method
Deployment is split among three classes.
| Who | Attached to | What it does |
|---|---|---|
HostConfig |
Listener on the Host | Scans appBase, creates Contexts and attaches them to the Host. Scans again periodically |
ContextConfig |
Listener on the Context | Reads context.xml, the web.xml files, annotations and SCIs to fill in the Context |
StandardContext.startInternal() |
The Context itself | Wakes up what was filled in, in a fixed order (SCI → listeners → filters → servlets) |
The two attributes you know from server.xml, <Host ... unpackWARs="true" autoDeploy="true">, are the switches for this post. Both default to true in StandardHost (StandardHost.java line 109, line 152). The Host also holds the class name of the listener to attach to its Contexts. configClass defaults to "org.apache.catalina.startup.ContextConfig" (line 115).
Blue boxes are HostConfig's work, and the yellow box is the periodic thread that triggers it. deployWAR() is called once per WAR.
2. HostConfig — who watches the webapps folder
It reacts to four events
The events HostConfig receives from the Host, and what it does for each, all fit in one switch in lifecycleEvent().
switch (event.getType()) {
case Lifecycle.PERIODIC_EVENT -> check();
case Lifecycle.BEFORE_START_EVENT -> beforeStart();
case Lifecycle.START_EVENT -> start();
case Lifecycle.STOP_EVENT -> stop();
}
java/org/apache/catalina/startup/HostConfig.java lines 283–288
BEFORE_START_EVENT→beforeStart(): ifcreateDirsis on, it createsappBaseand the configuration folder (conf/Catalina/localhost) (lines 1629–1638).START_EVENT→start(): when the Host starts anddeployOnStartupis set (defaulttrue,StandardHost.javaline 127), it deploys everything once withdeployApps()(lines 1644–1667). This is how the apps inwebappsget deployed when you runcatalina.sh start.PERIODIC_EVENT→check(): called periodically while Tomcat is running. This is auto deploy.
deployApps() — three kinds, in a fixed order
protected void deployApps() {
// Migrate legacy Java EE apps from legacyAppBase
migrateLegacyApps();
File appBase = host.getAppBaseFile();
File configBase = host.getConfigBaseFile();
String[] filteredAppPaths = filterAppPaths(appBase.list());
// Deploy XML descriptors from configBase
deployDescriptors(configBase, configBase.list());
// Deploy WARs
deployWARs(appBase, filteredAppPaths);
// Deploy expanded folders
deployDirectories(appBase, filteredAppPaths);
}
java/org/apache/catalina/startup/HostConfig.java lines 401–413
deployDescriptors—conf/Catalina/localhost/*.xml. One file is one<Context docBase="/somewhere/myapp">. This is how you deploy a web app that lives outsidewebapps.deployWARs—webapps/*.war.deployDirectories— folders insidewebapps. A folder unpacked from a WAR also counts here, but it is skipped if a Context with the same name is already deployed (deploymentExists()).
The migrateLegacyApps() at the top is a feature that takes javax.*-era apps placed in the webapps-javaee folder (legacyAppBase, StandardHost.java line 92), converts them to jakarta.* and moves them into webapps (from line 1138). The 11.0.x branch has the same call.
Every 10 seconds — who fires PERIODIC_EVENT?
PERIODIC_EVENT is fired by the last line of ContainerBase.backgroundProcess() (ContainerBase.java line 956). Before that, it also calls backgroundProcess() on the cluster, the Realm and the valves in the pipeline. The job that calls this method periodically is scheduled by threadStart() on the server's utility executor.
backgroundProcessorFuture = Container.getService(this).getServer().getUtilityExecutor()
.scheduleWithFixedDelay(new ContainerBackgroundProcessor(), backgroundProcessorDelay,
backgroundProcessorDelay, TimeUnit.SECONDS);
java/org/apache/catalina/core/ContainerBase.java lines 1090–1092
backgroundProcessorDelay defaults to -1 (off) (line 142), and only StandardEngine changes it to 10 in its constructor (StandardEngine.java line 62). So only one periodic job exists, on the Engine, and that job (ContainerBackgroundProcessor.processChildren()) walks recursively through the Hosts, Contexts and Wrappers below it whose delay is 0 or less and calls their backgroundProcess() (lines 1164–1194). That is why the Host receives PERIODIC_EVENT every 10 seconds by default. The threads of this executor have names starting with Catalina-utility- (StandardServer.java line 406). The same periodic job also runs the session expiry check of part 11.
The two paragraphs above as one line. There is only one periodic task, on the Engine, and it walks the containers below it recursively.
Here is check().
protected void check() {
if (host.getAutoDeploy()) {
// Check for resources modification to trigger redeployment
DeployedApplication[] apps = deployed.values().toArray(new DeployedApplication[0]);
for (DeployedApplication app : apps) {
if (tryAddServiced(app.name)) {
try {
checkResources(app, false);
} finally {
removeServiced(app.name);
}
}
}
// Check for old versions of applications that can now be undeployed
if (host.getUndeployOldVersions()) {
checkUndeploy();
}
// Hotdeploy applications
deployApps();
}
java/org/apache/catalina/startup/HostConfig.java lines 1693–1715
For every app it has deployed, it remembers a list of watched files (redeployResources, reloadResources) with their modification times. If a time has changed or a file has disappeared, it redeploys or undeploys the app (checkResources(), from line 1369). Then it calls deployApps() again to deploy any files that appeared in the meantime. There is no separate "detect a new WAR" step; it simply rescans from scratch every time and skips names that are already deployed.
tryAddServiced() is a marker that keeps two threads from working on the same app at the same time. The periodic check must not touch an app while, for example, the Manager web app is in the middle of uploading it.
deployWAR() — create a Context and attach it to the Host
deployWAR() (from line 783), which deploys one WAR, first checks whether the WAR contains META-INF/context.xml. If it does, it reads it with the Digester to create the Context object; otherwise it just creates an instance of contextClass (default StandardContext) (lines 817–865). The important part is at the end.
Class<?> clazz = Class.forName(host.getConfigClass());
LifecycleListener listener = (LifecycleListener) clazz.getConstructor().newInstance();
context.addLifecycleListener(listener);
context.setName(cn.getName());
context.setPath(cn.getPath());
context.setWebappVersion(cn.getVersion());
context.setDocBase(cn.getBaseName() + ".war");
host.addChild(context);
java/org/apache/catalina/startup/HostConfig.java lines 916–924
- It creates one instance of the Host's
configClass, i.e.ContextConfig, and attaches it as a listener on the new Context. From this moment on, the Context's lifecycle events go toContextConfig(section 3). - It sets the name, path and version.
cnis aContextNameparsed from the file name (just below). - The
docBase(where the web app's content lives) is set to the WAR file itself for now. Unpacking is done later byContextConfig. host.addChild(context). This single line starts the Context.
The last point is easy to miss. addChild does not just add the child to a list; if the parent is already running, it calls start() right there.
try {
if ((getState().isAvailable() || LifecycleState.STARTING_PREP.equals(getState())) && startChildren) {
child.start();
}
} catch (LifecycleException e) {
throw new IllegalStateException(sm.getString("containerBase.child.start"), e);
}
java/org/apache/catalina/core/ContainerBase.java lines 581–587
So StandardContext.startInternal(), which we look at in section 6, runs inside the call stack of HostConfig.deployWAR(). We confirm this with the stack trace in example 3. Meanwhile, the MapperListener from part 7 hears the ADD_CHILD_EVENT sent by addChild and attaches itself as a listener on the new Context. Then, the moment the Context finishes starting (AFTER_START_EVENT), it registers /shop with the Mapper (MapperListener.java lines 141–143, lines 446–459). That is why requests don't reach the app until it has completely started.
The rule that turns a file name into an address — ContextName
The conversion between file names and Context paths lives in one place, ContextName (ContextName.java lines 49–98). It strips the .war/.xml extension, splits off whatever follows ## as the version, turns # into /, and makes ROOT the empty path.
| File name | Context path (getPath()) |
Version | Context name (getName()) |
|---|---|---|---|
shop.war |
/shop |
(none) | /shop |
ROOT.war |
"" (root) |
(none) | "" |
api#v1.war |
/api/v1 |
(none) | /api/v1 |
shop##v2.war |
/shop |
v2 |
/shop##v2 |
The ## version is used for parallel deployment. If you put both shop##v1 and shop##v2 on the same path /shop, existing sessions stay on the old version and new sessions go to the new one (choosing which version a request goes to is the Mapper's job, and I did not trace it in the source for this post). Once the old version has no sessions left, checkUndeploy() removes it when undeployOldVersions is on (from line 1751). In this post we only look at the naming rule.
Example 1: adding and removing a WAR while Tomcat is running
I checked whether Tomcat really notices on a 10-second cycle. I packed one servlet into shop##v2.war, copied it after a Tomcat with an empty webapps had started, and asked /shop/hello every second until it returned 200. Then I deleted the WAR and asked until it returned 404.
This example 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 core of ex/u10-autodeploy/run.sh looks like this.
# A WAR is a zip. Pack it with the JDK's jar tool.
(cd $B/app && jar cf $B/shop##v2.war .)
# ... start Tomcat and wait for it to come up ...
cp "$B/shop##v2.war" /tmp/base/webapps/
for i in $(seq 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/shop/hello)
[ "$code" = 200 ] && break
sleep 1
done
# ... in the same way, delete the WAR and wait until we get 404 ...
[Abridged from the example ex/u10-autodeploy/run.sh]
===== webapps before copy =====
===== first 200 after about 9 s =====
hello from contextPath=/shop realPath=/tmp/base/webapps/shop##v2/
===== webapps after deploy =====
shop##v2
shop##v2.war
MANIFEST.MF
war-tracker
===== catalina log (deploy) =====
24-Oct-2026 13:42:18.748 INFO [Catalina-utility-1] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive [/tmp/base/webapps/shop##v2.war]
24-Oct-2026 13:42:20.033 INFO [Catalina-utility-1] org.apache.catalina.startup.HostConfig.deployWAR Deployment of web application archive [/tmp/base/webapps/shop##v2.war] has finished in [1,284] ms
===== after removing the WAR: HTTP 404 after about 10 s =====
===== catalina log (undeploy) =====
24-Oct-2026 13:42:30.045 INFO [Catalina-utility-1] org.apache.catalina.startup.HostConfig.undeploy Undeploying context [/shop##v2]
The timestamps, the number of seconds, the deployment time in milliseconds and the number at the end of the thread name change from run to run (other runs took 8, 10 and 12 seconds to deploy, and once Catalina-utility-2 did the deploying). There are four things to read here.
- 9 seconds from copy to response, 10 seconds from deletion to 404. Tomcat waited until the next periodic check. The check interval is 10 seconds and the deployment itself takes a second or so, so it varies from a few seconds to a little over 10 from run to run.
- The thread name in the log is
Catalina-utility-1. The deployment was done by a thread of the utility executor, not by a request-processing threadhttp-nio-8080-exec-N(part 5). - The path is
/shop, and the unpacked folder is namedshop##v2. Exactly the rule in the table above. An empty file calledMETA-INF/war-trackerappeared in the unpacked folder (section 3). - When the WAR was deleted, the unpacked folder disappeared too (the
lsoutput is empty). The unpacked folder is registered as a "watched file" belonging to the WAR, so when the WAR goes away it is deleted along with the undeploy (deleteRedeployResources()).
The output above, laid out in time order. The times and seconds come from this run and change when you run it again.
Easy to get wrong: one app at a time, and on the main thread at startup
deployWARs() hands one task per WAR to host.getStartStopExecutor() (lines 714 and 755). From the name you might expect several apps to be deployed in parallel, but the Host's startStopThreads defaults to 1 (ContainerBase.java line 244), and with 1 it uses an InlineExecutorService that runs the task right away on the calling thread (lines 689–701). So with the default settings apps are deployed one after another; during the periodic check Catalina-utility-1 deploys them, as in example 1, and at startup the main thread deploys them itself (you can see it in example 3's stack). If you have several large apps and startup is slow, you can raise it with something like <Host startStopThreads="4">.
3. ContextConfig — the listener that fills in the Context
The ContextConfig that HostConfig attached reacts to six of the Context's events.
switch (event.getType()) {
case Lifecycle.CONFIGURE_START_EVENT -> configureStart();
case Lifecycle.BEFORE_START_EVENT -> beforeStart();
case Lifecycle.AFTER_START_EVENT -> {
// Restore docBase for management tools
if (originalDocBase != null) {
context.setDocBase(originalDocBase);
}
}
case Lifecycle.CONFIGURE_STOP_EVENT -> configureStop();
case Lifecycle.AFTER_INIT_EVENT -> init();
case Lifecycle.AFTER_DESTROY_EVENT -> destroy();
}
java/org/apache/catalina/startup/ContextConfig.java lines 300–312
In the order they are called while a Context starts:
AFTER_INIT_EVENT→init()(line 997): reads thecontext.xmlfiles.conf/context.xml(shared by all apps),conf/Catalina/localhost/context.xml.default(shared by the apps on that Host), and then this app's own configuration file (context.getConfigFile()— the app'sMETA-INF/context.xmlorconf/Catalina/localhost/<app name>.xml) are read one after another onto the same Context (contextConfig(), from line 608). The defaultconf/context.xmlcontains lines like<WatchedResource>WEB-INF/web.xml</WatchedResource>, so when that file changes the app is reloaded.BEFORE_START_EVENT→beforeStart()(line 1019): unpacks the WAR withfixDocBase()(just below).CONFIGURE_START_EVENT→configureStart()(line 1034): the heart of this post. It merges theweb.xmlfiles, reads annotations and finds SCIs. When it is done, it callscontext.setConfigured(true).
Even when you write a <Context> directly inside server.xml, a Digester rule attaches the same ContextConfig (ContextRuleSet.java line 93). Wherever a Context comes from, there is one worker that fills it in.
Items 1–3 above, placed after deployWAR(). The two blue boxes are this section; the green box is the subject of sections 4 and 5.
The WAR is unpacked just before startup
If docBase is a .war file and unpackWARs is on, fixDocBase() unpacks it with ExpandWar.expand().
if (docBaseAbsolute.toLowerCase(Locale.ENGLISH).endsWith(".war") && !docBaseAbsoluteFile.isDirectory()) {
URL war = UriUtil.buildJarUrl(docBaseAbsoluteFile);
if (unpackWARs) {
docBaseAbsolute = ExpandWar.expand(host, war, pathName);
docBaseAbsoluteFile = new File(docBaseAbsolute);
if (context instanceof StandardContext) {
((StandardContext) context).setOriginalDocBase(originalDocBase);
}
} else {
ExpandWar.validate(host, war, pathName);
}
java/org/apache/catalina/startup/ContextConfig.java lines 883–893
After unpacking, it switches docBase to the unpacked folder (line 940) and records the original value (the WAR path) separately with StandardContext.setOriginalDocBase(). That is why getRealPath("/") pointed at the unpacked folder in example 1. With unpackWARs="false", nothing is unpacked and the app is served straight from the WAR.
The META-INF/war-tracker we saw in example 1 is an empty file that ExpandWar creates after it finishes unpacking. It sets this file's modification time to exactly the WAR's modification time (ExpandWar.java lines 168–175). The next time Tomcat starts and finds the unpacked folder already there, it reuses it if the two times match (or if there is no war-tracker at all); otherwise it deletes the folder and unpacks again (lines 97–112). This is how Tomcat notices a WAR that was replaced while it was shut down.
Easy to get wrong: edit the unpacked folder and the WAR still wins
When webapps/shop.war and webapps/shop/ both exist, deployment is based on the WAR. Even if you edit a JSP in the unpacked folder directly, the moment the WAR's modification time changes the folder is deleted and unpacked again. That is the war-tracker comparison above. If unpackWARs="false" and a folder with the same name exists, HostConfig logs a warning that "the WAR takes priority, so the folder is ignored" (hostConfig.deployWar.hiddenDir) (lines 736–749).
4. Merging web.xml — who wins
configureStart() first calls webConfig() (from line 1288). The source comments number the steps from Step 1 to Step 11, which makes it easy to follow.
The two yellow boxes are the SCI part (section 5). Step 8 (turning JSPs declared with <jsp-file> into servlets) and step 10 (static files inside jars) are left out of the figure.
Here is the middle part as it is.
// Step 3. Look for ServletContainerInitializer implementations
if (ok) {
processServletContainerInitializers();
}
if (!webXml.isMetadataComplete() || !typeInitializerMap.isEmpty()) {
// Steps 4 & 5.
processClasses(webXml, orderedFragments);
}
if (!webXml.isMetadataComplete()) {
// Step 6. Merge web-fragment.xml files into the main web.xml
// file.
if (ok) {
ok = webXml.merge(orderedFragments);
}
// Step 7a
// merge tomcat-web.xml
webXml.merge(tomcatWebXml);
// Step 7b. Apply global defaults
// Have to merge defaults before JSP conversion since defaults
// provide JSP servlet definition.
webXml.merge(defaults);
// Step 8. Convert explicitly mentioned jsps to servlets
if (ok) {
convertJsps(webXml);
}
// Step 9. Apply merged web.xml to Context
if (ok) {
configureContext(webXml);
}
} else {
webXml.merge(tomcatWebXml);
webXml.merge(defaults);
java/org/apache/catalina/startup/ContextConfig.java lines 1343–1380
Priority: the app's web.xml → the app's annotations → fragments → defaults
The ordering is scattered across the source, so here it is gathered in a table.
| Source | Where it is read | Priority |
|---|---|---|
WEB-INF/web.xml |
getContextWebXmlSource() (parsed first, lines 1324–1328) |
Highest |
Annotations in WEB-INF/classes |
processClasses() step 4 — added straight into the app's webXml |
If web.xml has the same name, web.xml wins |
web-fragment.xml in jars and annotations on classes in jars |
steps 1, 2 and 5 → step 6 merge() |
Anything web.xml defines is skipped |
WEB-INF/tomcat-web.xml |
getTomcatWebXmlFragment() (line 1654) |
7a, overridable defaults |
conf/web.xml + conf/Catalina/localhost/web.xml.default |
getDefaultWebXmlFragment() (line 1683) |
7b, lowest |
The same principle is written as a comment inside WebXml.merge() for step 6.
// As per 'clarification' from the Servlet EG, servlet definitions and
// mappings in the main web.xml override those in fragments and those in
// fragments override those in annotations
// Skip servlet definitions and mappings from fragments that are
// defined in web.xml
java/org/apache/tomcat/util/descriptor/web/WebXml.java lines 2281–2285
conf/web.xml and tomcat-web.xml are read with setOverridable(true) (lines 1657 and 1738), so servlets coming from them carry a "step aside if the app defines the same name" mark. Step 7b is why every web app has a default servlet (/) and a jsp servlet (*.jsp), and it is also why the default / is not added when the app maps / itself.
What does @WebServlet give up to web.xml?
Annotations are not read by loading the classes. Steps 4 and 5 parse the bytes of the .class files directly with BCEL (org.apache.tomcat.util.bcel, a class file parser bundled with Tomcat).
protected void processAnnotationsStream(InputStream is, WebXml fragment, boolean handlesTypesOnly,
Map<String,JavaClassCacheEntry> javaClassCache) throws ClassFormatException, IOException {
ClassParser parser = new ClassParser(is);
JavaClass clazz = parser.parse();
checkHandlesTypes(clazz, javaClassCache);
if (handlesTypesOnly) {
return;
}
processClass(fragment, clazz);
}
java/org/apache/catalina/startup/ContextConfig.java lines 2363–2375
Loading thousands of classes into a class loader would be slow, and their static initializers might run when you don't want them to. processClass() picks out the three annotations @WebServlet, @WebFilter and @WebListener and adds them as entries to the WebXml (lines 2384–2402).
processAnnotationWebServlet() (from line 2622), which handles @WebServlet, first decides the name. Without name, the class name (the fully qualified name, package included) becomes the servlet name. If that name already exists in web.xml, it does not create a new definition but adds to web.xml's one, filling in loadOnStartup or init parameters only where web.xml has none. The URL patterns are decided at the end.
if (!isWebXMLservletDef && urlPatterns != null) {
fragment.addServlet(servletDef);
}
if (urlPatterns != null) {
if (!fragment.getServletMappings().containsValue(servletName)) {
for (String urlPattern : urlPatterns) {
fragment.addServletMapping(urlPattern, servletName);
}
}
}
java/org/apache/catalina/startup/ContextConfig.java lines 2702–2711
If web.xml gave that servlet even one mapping, the annotation's URL patterns are ignored entirely. They are not combined.
metadata-complete="true"
<web-app metadata-complete="true"> means "this web.xml is everything; don't look at annotations". In the code above, when this is true the code takes the else branch instead of steps 6–9 and skips merging fragments. But the condition for steps 4 and 5 is !webXml.isMetadataComplete() || !typeInitializerMap.isEmpty(). If there is even one SCI with @HandlesTypes, classes are scanned even when metadata-complete is set. In that case, though, handlesTypesOnly is true, so it only looks for @HandlesTypes matches and does not call processClass() (annotation registration). The comment at the top of the method also says that "JARs are still processed for SCIs" even when metadata-complete is set (lines 1297–1312).
Example 2: when web.xml and @WebServlet describe the same servlet
I made two apps with the same classes and the same web.xml, and switched only one to metadata-complete="true". The servlet Hello carries this annotation.
// An example written for this post (not Tomcat source)
// The annotation says: name "hello", address /anno, init parameter greeting=from-annotation.
// web.xml also declares the same name "hello", so we can see how the two are merged.
@WebServlet(name = "hello", urlPatterns = "/anno",
initParams = @WebInitParam(name = "greeting", value = "from-annotation"))
public class Hello extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain");
resp.getWriter().println("servlet=" + getServletName()
+ " greeting=" + getInitParameter("greeting"));
}
}
[From the example ex/u10-webxml-annotation/Hello.java, imports omitted]
web.xml gives the same name hello the init parameter greeting=from-web-xml and the address /xml. There is also one more class, @WebServlet("/only-anno") public class OnlyAnno, which web.xml never mentions. The /merge app gets <Context logEffectiveWebXml="true"/> through META-INF/context.xml, so the final merged web.xml is printed to the log (lines 1385–1387).
===== /merge (metadata-complete=false) =====
GET /merge/xml servlet=hello greeting=from-web-xml [HTTP 200]
GET /merge/anno [HTTP 404]
GET /merge/only-anno servlet=OnlyAnno [HTTP 200]
===== /complete (metadata-complete=true) =====
GET /complete/xml servlet=hello greeting=from-web-xml [HTTP 200]
GET /complete/anno [HTTP 404]
GET /complete/only-anno [HTTP 404]
===== effective web.xml of /merge: servlet-mapping (url-pattern -> servlet-name) =====
*.jspx -> jsp
*.jsp -> jsp
/only-anno -> OnlyAnno
/xml -> hello
/ -> default
/merge/annois 404. Because web.xml gavehellothe address/xml, the annotation's/annowas dropped (the condition on line 2706). The init parameter is web.xml'sfrom-web-xmltoo./merge/only-annois 200, and the servlet name isOnlyAnno. Nonamewas given, so the class name became the name./complete/only-annois 404. Withmetadata-complete="true", annotations were not registered.- The final mappings include
default(/) andjsp(*.jsp,*.jspx). The app never declared them; they came in fromconf/web.xmlin step 7b.
Easy to get wrong: metadata-complete="true" in the log
The header of /merge's final web.xml printed by logEffectiveWebXml said metadata-complete="true", even though its original web.xml said false (give /complete the same setting in a separate run and it is printed as true too). That is because WebXml.toXml() always writes this value as true unless the version is 2.4 (WebXml.java lines 1422–1426). What is printed is the result with annotations already merged in, so it seems to mean that there is no need to look at annotations again when reading it back. Don't read it as "the original setting was true".
5. ServletContainerInitializer — the door Spring comes in through
Finding them: WebappServiceLoader
Step 3, processServletContainerInitializers(), finds SCIs with WebappServiceLoader.
protected void processServletContainerInitializers() {
List<ServletContainerInitializer> detectedScis;
try {
WebappServiceLoader<ServletContainerInitializer> loader = new WebappServiceLoader<>(context);
detectedScis = loader.load(ServletContainerInitializer.class);
} catch (IOException ioe) {
log.error(sm.getString("contextConfig.servletContainerInitializerFail", context.getName()), ioe);
ok = false;
return;
}
java/org/apache/catalina/startup/ContextConfig.java lines 1833–1843
It reads the same files as the JDK's java.util.ServiceLoader (META-INF/services/<interface name>), but with extra rules for web apps (WebappServiceLoader.load(), from line 98).
- Container ones first, web app ones after. It first collects what it finds through the parent class loader (Tomcat's
lib/), then appends what newly shows up through the web app class loader (line 195). The order lets a web app rely on the container's SCIs. containerSciFilterlets you switch off container SCIs with a regular expression (lines 77–82, 122–125).- If web.xml has an
<absolute-ordering>, it only looks in the jars listed there (ORDERED_LIBS). - The classes it finds are loaded with the web app class loader and created with their no-argument constructor (lines 231–232).
Next, for each SCI it reads @HandlesTypes and records "this type → these SCIs" in typeInitializerMap (lines 1845–1877). The class scan in steps 4 and 5 uses this map to find matches. For each class, checkHandlesTypes() (line 2412) walks up the superclasses and interfaces (populateSCIsForCacheEntry(), lines 2538–2591), and only when one of them is a type listed in @HandlesTypes does it load the class and add it to that SCI's share of initializerClassMap. If @HandlesTypes lists an annotation type, it looks for classes carrying that annotation (lines 2457–2481).
The yellow boxes are the two maps that connect SCIs and classes. The grey box is Spring's side.
Registration and invocation happen in different places
ContextConfig does not call the SCIs. In step 11 it only registers them with the Context.
if (ok) {
for (Map.Entry<ServletContainerInitializer,Set<Class<?>>> entry : initializerClassMap.entrySet()) {
if (entry.getValue().isEmpty()) {
context.addServletContainerInitializer(entry.getKey(), null);
} else {
context.addServletContainerInitializer(entry.getKey(), entry.getValue());
}
}
}
java/org/apache/catalina/startup/ContextConfig.java lines 1407–1415
If no class was found, it passes null, not an empty set. StandardContext keeps these in a LinkedHashMap (StandardContext.java line 229, lines 1329–1331) and later, in startInternal() (section 6), calls onStartup() in registration order.
Where it meets Spring
As we saw in Spring part 20, the spring-web jar contains a META-INF/services/jakarta.servlet.ServletContainerInitializer file whose single line is org.springframework.web.SpringServletContainerInitializer, and that class carries @HandlesTypes(WebApplicationInitializer.class). Spring part 20 noted that "searching for the WebApplicationInitializer implementations and passing them in is the servlet container's job, so it wasn't checked against source there". That search is exactly what this section covers. Seen from Tomcat's side, it goes like this.
WebappServiceLoaderreads the service file inWEB-INF/lib/spring-web-*.jarand createsSpringServletContainerInitializerwith the web app class loader.- It reads
@HandlesTypesand putsWebApplicationInitializer→ this SCI on the map. - It scans the app's classes and jars with BCEL and collects the classes that have
WebApplicationInitializerin their superclass/interface chain. StandardContext.startInternal()callsonStartup(those classes, servletContext). From there on it is the path Spring part 20 read in the source: filter the classes, instantiate them and callonStartup(servletContext)on each in turn.
You can also see from Tomcat's side why Spring's onStartup filters out interfaces and abstract classes: Tomcat passes everything that has the type in its inheritance chain, abstract classes included. We confirm this in example 3. On the other hand, if you use embedded Tomcat's tomcat.addContext() as in the Spring part 20 example, a FixContextListener is attached instead of ContextConfig (Tomcat.java line 680). This listener sets configured to true and processes a few annotations, but it looks for neither web.xml nor SCIs (lines 1112–1141). That is why that example added the SCI by hand with addServletContainerInitializer(). We look at embedded Tomcat in part 16.
6. StandardContext.startInternal() — the order things wake up in
Now for the method that actually starts the Context. Keeping only the major steps of this method of over 300 lines (lines 4390–4701) gives the figure below.
Blue boxes are where the web app's code is called (SCIs, listeners, filters, servlets) or where ContextConfig does its work. Grey boxes are covered in other parts.
| Step | What | Location (StandardContext.java) |
|---|---|---|
| 0 | BEFORE_START_EVENT → ContextConfig.beforeStart() — unpack the WAR |
When LifecycleBase.start() switches to STARTING_PREP |
| 1 | Prepare resources (StandardRoot) |
line 4429 |
| 2 | Start WebappLoader — the web app class loader (part 4) |
line 4476 |
| 3 | CONFIGURE_START_EVENT → ContextConfig.configureStart() |
line 4526 |
| 4 | Start child Wrappers and the pipeline | lines 4529–4539 |
| 5 | Fail if not configured |
lines 4576–4579 |
| 6 | mergeParameters() — context-param |
line 4603 |
| 7 | SCI onStartup() |
lines 4606–4614 |
| 8 | listenerStart() — ServletContextListener.contextInitialized() |
lines 4617–4622 |
| 9 | Start the session Manager (part 11) |
line 4635 |
| 10 | filterStart() — Filter.init() |
line 4644 |
| 11 | loadOnStartup() — init() of load-on-startup servlets (part 9) |
line 4652 |
It matters that step 2 comes before step 3. When ContextConfig finds a @HandlesTypes match it has to load that class, and for that the web app class loader must already exist. Steps 7–11 look like this.
// Call ServletContainerInitializers
for (Map.Entry<ServletContainerInitializer,Set<Class<?>>> entry : initializers.entrySet()) {
try {
entry.getKey().onStartup(entry.getValue(), getServletContext());
} catch (ServletException e) {
log.error(sm.getString("standardContext.sciFail"), e);
ok = false;
break;
}
}
// Configure and call application event listeners
if (ok) {
if (!listenerStart()) {
log.error(sm.getString("standardContext.listenerFail"));
ok = false;
}
}
// ...
// Load and initialize all "load on startup" servlets
if (ok) {
if (!loadOnStartup(findChildren())) {
log.error(sm.getString("standardContext.servletFail"));
ok = false;
}
}
java/org/apache/catalina/core/StandardContext.java lines 4605–4656
There is a reason SCIs come first. An SCI's onStartup() is the place where new servlets, filters and listeners can be registered with ctx.addServlet(), ctx.addFilter() and ctx.addListener(). Only if listeners are called and filters and servlets initialized after all registration is done do the things an SCI registered get the same treatment as everything else. In the Spring part 20 example, the ContextLoaderListener registered in onStartup was called in the very next step (step 8) and refreshed the root context — thanks to this order.
Example 3: a hand-written SCI and the startup order
I packed MySci into a jar and put it in WEB-INF/lib/my-sci.jar. The jar's META-INF/services/jakarta.servlet.ServletContainerInitializer file contains the single line MySci.
// An example written for this post (not Tomcat source)
// An SCI that goes into a jar in WEB-INF/lib. The META-INF/services file tells Tomcat about it.
// Tomcat finds the classes implementing Plugin (named in @HandlesTypes) and passes them as the first argument.
@HandlesTypes(Plugin.class)
public class MySci implements ServletContainerInitializer {
@Override
public void onStartup(Set<Class<?>> classes, ServletContext ctx) {
Set<String> names = new TreeSet<>();
if (classes != null) {
classes.forEach(c -> names.add(c.getName()));
}
System.out.println("1. SCI onStartup, @HandlesTypes classes = " + names);
// Who called me? Print only the org.apache.catalina frames
for (StackTraceElement e : Thread.currentThread().getStackTrace()) {
if (e.getClassName().startsWith("org.apache.catalina")) {
System.out.println(" at " + e.getClassName() + "." + e.getMethodName());
}
}
// Register a servlet in code instead of web.xml (the same API Spring's WebApplicationInitializer uses)
var reg = ctx.addServlet("fromSci", new HelloServlet());
reg.addMapping("/hello");
reg.setLoadOnStartup(1);
}
}
[From the example ex/u10-sci/sci/MySci.java, imports omitted]
Plugin is an empty interface (inside the jar). In the web app's WEB-INF/classes I put PluginA implements Plugin, abstract class AbstractPlugin implements Plugin, PluginB extends AbstractPlugin, and an unrelated NotAPlugin. There is also one @WebListener listener and one @WebFilter("/*") filter, printing "2." and "3." respectively, and the servlet MySci registers prints "4." in its init(). Before all that, run.sh also prints the SCI service files found in Tomcat's lib/*.jar.
===== SCIs that ship with Tomcat itself (lib/*.jar) =====
jasper.jar: org.apache.jasper.servlet.JasperInitializer
tomcat-websocket.jar: org.apache.tomcat.websocket.server.WsSci
hello from a servlet registered by an SCI
===== catalina.out (System.out of the web app) =====
1. SCI onStartup, @HandlesTypes classes = [AbstractPlugin, PluginA, PluginB]
at org.apache.catalina.core.StandardContext.startInternal
at org.apache.catalina.util.LifecycleBase.start
at org.apache.catalina.core.ContainerBase.addChild
at org.apache.catalina.core.StandardHost.addChild
at org.apache.catalina.startup.HostConfig.deployDirectory
at org.apache.catalina.startup.HostConfig$DeployDirectory.run
at org.apache.catalina.startup.HostConfig.deployDirectories
at org.apache.catalina.startup.HostConfig.deployApps
at org.apache.catalina.startup.HostConfig.start
at org.apache.catalina.startup.HostConfig.lifecycleEvent
at org.apache.catalina.util.LifecycleBase.fireLifecycleEvent
at org.apache.catalina.util.LifecycleBase.setStateInternal
at org.apache.catalina.util.LifecycleBase.setState
at org.apache.catalina.core.ContainerBase.startInternal
at org.apache.catalina.core.StandardHost.startInternal
at org.apache.catalina.util.LifecycleBase.start
at org.apache.catalina.core.ContainerBase$StartChild.call
at org.apache.catalina.core.ContainerBase$StartChild.call
at org.apache.catalina.core.ContainerBase.startInternal
at org.apache.catalina.core.StandardEngine.startInternal
at org.apache.catalina.util.LifecycleBase.start
at org.apache.catalina.core.StandardService.startInternal
at org.apache.catalina.util.LifecycleBase.start
at org.apache.catalina.core.StandardServer.startInternal
at org.apache.catalina.util.LifecycleBase.start
at org.apache.catalina.startup.Catalina.start
at org.apache.catalina.startup.Bootstrap.start
at org.apache.catalina.startup.Bootstrap.main
2. ServletContextListener.contextInitialized
3. Filter.init: LogFilter
4. servlet init (load-on-startup): fromSci
- SCI → listener → filter → servlet. Exactly steps 7, 8, 10 and 11 of the table above. "4." is a servlet
MySciregistered in code, yet it got the sameload-on-startuptreatment. - The classes passed in are
AbstractPlugin,PluginAandPluginB.PluginBdoes not implementPlugindirectly, but it was found by walking up to its parent. The abstract class is included too.Pluginitself andNotAPluginare not. A match is only "does one of its superclasses or interfaces equal that type" (lines 2538–2591), so the type itself is not a match. - Tomcat's
lib/also has two jars with SCI service files.JasperInitializer(JSP, part 13) andWsSci(WebSocket, part 15). Both are found along this sameWebappServiceLoaderpath, before the web app's SCIs. - Reading the stack from the bottom up:
Bootstrap.main→ Server → Service → Engine → Host startup (part 3) →HostConfig.start()receiving the Host'sSTART_EVENT→deployApps()→deployDirectories()(this time I put in a folder, not a WAR) →StandardHost.addChild()→start()called byContainerBase.addChild()→StandardContext.startInternal()→MySci.onStartup(). Both claims from section 2, "it starts insideaddChild()" and "at startup themainthread deploys", are visible. The executor calls betweendeployDirectoriesandDeployDirectory.runare not inorg.apache.catalina(InlineExecutorServicelives inorg.apache.tomcat.util.threads), so this example's filter did not print them.
Easy to get wrong: the Host keeps going even when a start fails
If an SCI throws ServletException or a listener fails, ok becomes false and at the end the Context is put into the FAILED state (lines 4690–4700). LifecycleBase.start() treats this as "the component reported its own failure" and just cleans up with stop() without throwing (LifecycleBase.java lines 170–173). So this app does not receive requests, but HostConfig carries on deploying the next app. The log gets a line like Context [/shop##v2] startup failed due to previous errors (standardContext.startFailed), but the real cause is in the error printed above it. If an app only ever returns 404, read the log upwards.
What this post leaves out
- Deploying from
conf/Catalina/localhost/*.xml(deployDescriptor()) and the security meaning ofcopyXMLanddeployXML:HostConfig.javafrom line 545 - The difference between reload and redeploy, and
WatchedResource:checkResources()from line 1369 - Ordering
web-fragment.xmlfiles (<ordering>,<absolute-ordering>):WebXml.orderWebFragments()from line 2653 JarScanner, which narrows down the jars to scan, andjarsToSkipincatalina.properties:java/org/apache/tomcat/util/scan/StandardJarScanFilter.java- Parallel annotation scanning (
parallelAnnotationScanning):ContextConfig.javafrom line 2202 - Generating Java code from
context.xmlahead of time (getGenerateCode(),getUseGeneratedCode()): from line 439 configureContext(), which moves the mergedWebXmlonto the Context object: from line 1463migrateLegacyApps(), which movesjavax.*apps over: from line 1138antiResourceLocking, which copies the web app to a temporary folder to avoid file locking on Windows:antiLocking()from line 947
Summary
| Concept | One-line summary |
|---|---|
HostConfig |
Listener on the Host. At startup and on every PERIODIC_EVENT it deploys xml → WARs → folders |
| Auto-deploy cycle | Only the Engine has backgroundProcessorDelay=10. A Catalina-utility thread visits the children every 10 seconds |
deployWAR() |
Creates a Context, attaches ContextConfig, calls host.addChild() — and start() runs inside it |
ContextName |
# → /, after ## is the version, ROOT is the root |
| Unpacking the WAR | fixDocBase() at BEFORE_START → ExpandWar. The war-tracker time reveals a changed WAR |
ContextConfig |
Listener on the Context. context.xml → unpack the WAR → webConfig() |
| web.xml priority | the app's web.xml > annotations in WEB-INF/classes > jar fragments (and that jar's annotations) > tomcat-web.xml > conf/web.xml |
| Annotation scanning | Reads class files with BCEL. If web.xml gives a mapping, @WebServlet's address is dropped |
metadata-complete |
Turns off annotation registration, but @HandlesTypes matching still happens |
| Finding SCIs | WebappServiceLoader — container jars first, web app jars after |
@HandlesTypes |
Matches through the superclass/interface chain. Abstract classes are passed too; null if nothing found |
startInternal() order |
class loader → configureStart → SCI → listeners → filters → load-on-startup servlets |
What's next
Now the web app is up and taking requests. When a request arrives, the first thing many apps reach for is the session. How a single request.getSession() creates a JSESSIONID, and how the session Manager started as step 9 of section 6's table removes expired sessions in the same 10-second periodic job, continues in part 11: Sessions — How JSESSIONID Is Made and Removed.
Seen from Spring's side, this post is the scene just before Spring part 20. Spring part 20 started at SpringServletContainerInitializer.onStartup(); this post covered how Tomcat finds that SCI in a jar and gathers the WebApplicationInitializer implementations to pass in. How the DispatcherServlet registered that way receives and handles requests is in Spring part 21. The way app settings are merged on top of the conf/web.xml defaults resembles httpd part 7, where per-directory settings were merged on top of the server-wide configuration.
- Part 3: Lifecycle and Startup — What Happens After catalina.sh start
- Part 4: Class Loaders — Each Web App Sees Its Own Classes
- Part 7: CoyoteAdapter and the Mapper — Which Servlet Gets the Request
- Part 9: The Filter Chain and the Servlet Call — Until service() Runs
- Part 16: Embedded Tomcat, JMX and Logging — and Wrapping Up the Series