Spring Boot Starters & Embedded Web Servers: How Tomcat Runs Inside an Executable JAR
Dissecting starter POM structure, ServletWebServerFactory boot sequences, and LaunchedURLClassLoader fat JAR execution
Part 10 in Series — Catch up on the previous article: Spring Boot Auto-Configuration Under the Hood: @EnableAutoConfiguration and Conditional Annotations (Part 9) before diving into this post.
Why You Need This in Real Life
Deploying a high-throughput microservice during a Friday night release generates a 65MB executable JAR file containing business logic, dependencies, and an embedded Apache Tomcat web server. You execute java -jar payment-service.jar on an EC2 instance.
Within 4 seconds, the application boots, but suddenly every HTTP request fails with a ClassNotFoundException: org.apache.juli.logging.LogFactory or java.lang.IllegalStateException: No ServletWebServerFactory bean found. Even worse, in production load tests, memory consumption skyrockets because Tomcat worker threads stall while processing long-polling WebSocket requests, exhausting your thread pool of 200 default workers (server.tomcat.threads.max).
To debug application boot failures, classpath leaks, and web server thread bottlenecks, you cannot treat Spring Boot as a magic black box. You must understand how Starters pull in dependencies, how ServletWebServerFactory programmatically initializes Tomcat, and how LaunchedURLClassLoader executes nested JAR files inside a single executable archive.
Part 1: Deconstructing Spring Boot Starter POMs
A Spring Boot Starter is not a JAR containing Java code. It is an empty transitive dependency aggregator (a POM-only artifact) that bundles a curated set of dependencies required for a specific capability.
Starter Architecture: Split Pattern
Spring Boot splits starter functionalities into two distinct artifacts:
spring-boot-starter-xyz(The Dependency Aggregator): Contains only apom.xml. It imports the autoconfigure module and third-party libraries.spring-boot-autoconfigure(The Configuration Engine): Contains the actual Java@Configurationclasses,@Conditionalevaluations, and auto-configuration manifests.
+-----------------------------------------------------------------------------+
| Spring Boot Starter Anatomy |
| |
| +-----------------------------------------------------------------------+ |
| | spring-boot-starter-web (POM Artifact) | |
| | |-- spring-boot-starter (Core logging, YAML, auto-config base) | |
| | |-- spring-web (RestTemplate, HttpMessageConverters) | |
| | |-- spring-webmvc (DispatcherServlet, Controller annotations) | |
| | \-- spring-boot-starter-tomcat (Embedded Tomcat Web Server JARs) | |
| +-----------------------------------+-----------------------------------+ |
| | |
| v Transitive Dependency |
| +-----------------------------------------------------------------------+ |
| | spring-boot-autoconfigure.jar | |
| | \-- META-INF/spring/org.springframework.boot.autoconfigure. | |
| | AutoConfiguration.imports | |
| | |-- ServletWebServerFactoryAutoConfiguration.class | |
| | \-- DispatcherServletAutoConfiguration.class | |
| +-----------------------------------------------------------------------+ |
+-----------------------------------------------------------------------------+
Swapping Web Servers via Starter Exclusions
Because web servers are pulled in via starter dependencies, swapping Tomcat for Eclipse Jetty or Undertow requires zero code changes. You simply exclude spring-boot-starter-tomcat and import spring-boot-starter-undertow:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
When Spring Boot boots, @ConditionalOnClass(Servlet.class) and @ConditionalOnMissingBean detect Undertow on the classpath instead of Tomcat, dynamically instantiating UndertowServletWebServerFactory.
Part 2: Programmatic Embedded Tomcat Initialization
In legacy Java web applications, Tomcat managed the lifecycle of your application: Tomcat booted first, read web.xml, loaded your WAR file, and instantiated Spring’s DispatcherServlet.
Spring Boot inverts this lifecycle. Spring boots first, creates an ApplicationContext, and then programmatically creates and launches Tomcat as a child process during container initialization.
The ServletWebServerFactory Hierarchy
Spring Boot uses ServletWebServerFactory to abstract web server creation:
+--------------------------------+
| ServletWebServerFactory |
+---------------+----------------+
|
+------------------------+------------------------+
| | |
v v v
+------------------+ +-------------------+ +-------------------+
| TomcatServlet | | JettyServlet | | UndertowServlet |
| WebServerFactory| | WebServerFactory | | WebServerFactory |
+------------------+ +-------------------+ +-------------------+
Step-by-Step Programmatic Tomcat Bootstrap
Below is the conceptual Java code executed internally by TomcatServletWebServerFactory.getWebServer():
package com.example.internals;
import org.apache.catalina.Context;
import org.apache.catalina.startup.Tomcat;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
public class ProgrammaticTomcatBootstrap {
public static void main(String[] args) throws Exception {
// 1. Instantiate Apache Tomcat engine
Tomcat tomcat = new Tomcat();
// 2. Set temporary work directory and port
String baseDir = new File(System.getProperty("java.io.tmpdir")).getAbsolutePath();
tomcat.setBaseDir(baseDir);
tomcat.setPort(8080);
tomcat.getConnector(); // Forces connector creation
// 3. Create context root "/"
Context context = tomcat.addContext("", baseDir);
// 4. Programmatically add a Servlet (e.g., DispatcherServlet)
HttpServlet customServlet = new HttpServlet() {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.getWriter().write("Hello from Programmatic Embedded Tomcat!");
}
};
String servletName = "customDispatcherServlet";
Tomcat.addServlet(context, servletName, customServlet);
context.addServletMappingDecoded("/*", servletName);
// 5. Start Tomcat server engine
tomcat.start();
System.out.println("Embedded Tomcat started programmatically on port 8080");
// 6. Keep main thread alive
tomcat.getServer().await();
}
}
When Does Embedded Server Start in Spring’s Lifecycle?
When you invoke SpringApplication.run(), Spring creates a AnnotationConfigServletWebServerApplicationContext. During the container refresh phase (AbstractApplicationContext.refresh()), Spring calls the onRefresh() hook:
// Inside ServletWebServerApplicationContext.java
@Override
protected void onRefresh() {
super.onRefresh();
try {
createWebServer(); // Finds ServletWebServerFactory bean and calls getWebServer()
} catch (Throwable ex) {
throw new ApplicationContextException("Unable to start web server", ex);
}
}
createWebServer() retrieves the TomcatServletWebServerFactory bean, initializes Catalina, binds HTTP connectors, attaches Spring’s DispatcherServlet, and starts listening on port 8080 before the container fires ApplicationReadyEvent.
Part 3: The Executable Fat JAR & Classloading Magic
Standard Java JVM classloaders (AppClassLoader) only support loading loose .class files from directories or JAR files located directly on the filesystem classpath. Standard JVMs cannot load nested JAR files inside a JAR archive (e.g., app.jar!/BOOT-INF/lib/spring-core-6.0.0.jar).
Spring Boot solved this using a custom archive format and launcher classloader.
Layout of a Spring Boot Fat JAR
When you execute mvn package with spring-boot-maven-plugin, Maven restructures your compiled JAR into three main subdirectories:
payment-service.jar
├── META-INF
│ └── MANIFEST.MF
├── org
│ └── springframework
│ └── boot
│ └── loader <-- Custom Spring Boot ClassLoader classes
│ ├── JarLauncher.class
│ └── LaunchedURLClassLoader.class
└── BOOT-INF
├── classes <-- Your compiled application classes (.class)
└── lib <-- Nested dependency JARs (.jar)
├── spring-core-6.1.0.jar
├── spring-webmvc-6.1.0.jar
└── tomcat-embed-core-10.1.0.jar
The Manifest File (MANIFEST.MF)
Inspect META-INF/MANIFEST.MF of a compiled Spring Boot fat JAR:
Manifest-Version: 1.0
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.example.payment.PaymentApplication
Spring-Boot-Version: 3.2.0
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Notice that Main-Class is NOT your application’s PaymentApplication. It is Spring Boot’s JarLauncher.
How LaunchedURLClassLoader Resolves Nested JARs
- The JVM loads
JarLauncherusing standard system classloaders. JarLauncherparsesBOOT-INF/classes/and opens Virtual File Handles to every nested.jarfile insideBOOT-INF/lib/.JarLauncherconstructs an instance ofLaunchedURLClassLoader(a custom URLClassLoader subclass capable of readingjar:file:...!/BOOT-INF/lib/xyz.jar!/URLs).JarLauncherinvokes your application’s actual entry point (Start-Class:PaymentApplication.main(args)) usingLaunchedURLClassLoaderas the Thread Context ClassLoader.
+-----------------------------------------------------------------------------+
| Fat JAR Classloading Boot Flow |
| |
| 1. java -jar application.jar |
| | |
| v |
| 2. JVM AppClassLoader reads MANIFEST.MF |
| - Main-Class = org.springframework.boot.loader.JarLauncher |
| | |
| v |
| 3. JarLauncher initializes LaunchedURLClassLoader |
| - Inspects BOOT-INF/classes/ and BOOT-INF/lib/*.jar |
| - Index nested URL handlers for embedded JAR archives |
| | |
| v |
| 4. LaunchedURLClassLoader invokes Start-Class via Reflection |
| - Start-Class = com.example.payment.PaymentApplication |
| - Invokes main(String[] args) |
+-----------------------------------------------------------------------------+
Part 4: Production Gotchas & Edge Cases
Gotcha 1: File Descriptor Exhaustion on Fat JARs
Because LaunchedURLClassLoader opens file handles to dozens of nested JAR files in BOOT-INF/lib/, applications with high classloading churn or frequent file system calls can run out of file descriptors on Linux.
- Symptom:
java.io.FileNotFoundException: (Too many open files) - Solution: Increase OS file descriptor limits (
ulimit -n 65536) in systemd service definitions or Docker container configs.
Gotcha 2: Tomcat Thread Pool Starvation
By default, Spring Boot configures Tomcat with 200 maximum worker threads (server.tomcat.threads.max=200) and a minimum spare threads pool of 10 (server.tomcat.threads.min-spare=10).
- Symptom: Under heavy concurrent I/O (e.g., calling slow downstream microservices synchronously inside HTTP request threads), all 200 worker threads get blocked in
WAITINGorTIMED_WAITINGstates. Incoming requests get queued in Tomcat’s accept queue (server.tomcat.accept-count=100), resulting in HTTP 504 Gateway Timeouts. - Solution: Tunings in
application.properties:
Alternatively, migrate blocking controller endpoints to async execution (server.tomcat.threads.max=400 server.tomcat.accept-count=500 server.tomcat.connection-timeout=5000msCompletableFuture,DeferredResult) or Spring WebFlux (Netty reactive I/O).
Next Steps
Now that we understand how embedded web servers boot and package dependencies inside fat JARs, we will explore the front controller pattern: dissecting Spring MVC’s DispatcherServlet request routing pipeline from first principles.
References & Further Reading
- Spring.io. Spring Boot Reference Guide — Externalized Configuration & Property Sources. Spring Docs.
- Wiggins, A. (2012). The Twelve-Factor App: Factor III Config (Store config in the environment). 12factor.net.
- Spring.io. Spring API Documentation:
org.springframework.core.env.Environment. Spring Docs.
Part 11: The Front Controller Pattern: How DispatcherServlet Routes HTTP Requests
Continue to Part 11 →