Adetayo Akinsanya unkletayo.dev

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

Adetayo Akinsanya (unkletayo) 2026-09-18

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:

  1. spring-boot-starter-xyz (The Dependency Aggregator): Contains only a pom.xml. It imports the autoconfigure module and third-party libraries.
  2. spring-boot-autoconfigure (The Configuration Engine): Contains the actual Java @Configuration classes, @Conditional evaluations, 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

  1. The JVM loads JarLauncher using standard system classloaders.
  2. JarLauncher parses BOOT-INF/classes/ and opens Virtual File Handles to every nested .jar file inside BOOT-INF/lib/.
  3. JarLauncher constructs an instance of LaunchedURLClassLoader (a custom URLClassLoader subclass capable of reading jar:file:...!/BOOT-INF/lib/xyz.jar!/ URLs).
  4. JarLauncher invokes your application’s actual entry point (Start-Class: PaymentApplication.main(args)) using LaunchedURLClassLoader as 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 WAITING or TIMED_WAITING states. 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:
    server.tomcat.threads.max=400
    server.tomcat.accept-count=500
    server.tomcat.connection-timeout=5000ms
    
    Alternatively, migrate blocking controller endpoints to async execution (CompletableFuture, 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

  1. Spring.io. Spring Boot Reference Guide — Externalized Configuration & Property Sources. Spring Docs.
  2. Wiggins, A. (2012). The Twelve-Factor App: Factor III Config (Store config in the environment). 12factor.net.
  3. Spring.io. Spring API Documentation: org.springframework.core.env.Environment. Spring Docs.

Up Next in Series →

Part 11: The Front Controller Pattern: How DispatcherServlet Routes HTTP Requests

Continue to Part 11 →