Updated Jun 22, 2026

The Servlet Container & Lifecycle

In Phase 1 you wrote a servlet — an object with methods that handle HTTP. But you never called those methods. You never wrote new MyServlet(), never wired it to a socket, never spun up a thread to run it. So who does?

📝 The mental model to carry through this whole phase: the container owns your servlet's life, and it runs one copy of it across many threads at once. Tomcat, Jetty, Undertow — these are the containers. You hand them a class; they decide when to create it, when to call it, when to throw it away, and — the part that bites people — they call it from many threads simultaneously, on a single shared instance. Get that one sentence into your bones and the rest of this phase, plus half of why Spring controllers look the way they do, falls out of it.

Let's take it in three moves: the lifecycle (when your code runs), the one-instance fact (how many copies exist), and the thread-safety consequence (the lesson that actually matters in production).

The lifecycle: init → service → destroy

📝 A servlet's life has exactly three phases, and the container drives all three. When the application starts (or on the first request, depending on config), the container creates your servlet and calls init() once. From then on, every incoming HTTP request makes the container call service() — which routes to doGet, doPost, and friends (Phase 3). When the application shuts down, the container calls destroy() once, and the instance is gone.

flowchart TD
  A[App starts] --> B["container creates ONE instance"]
  B --> C["init() — called once"]
  C --> D["service() — called for EVERY request"]
  D --> D
  D --> E["destroy() — called once"]
  E --> F[App stops]

The shape that matters: init and destroy are bookends — once each, at the edges of the servlet's life. service is the hot loop — called again and again, once per request, for the entire time the app is up.

You override init and destroy when you have setup and cleanup that should happen one time, not on every request — opening a database connection pool, loading a config file, starting a background client.

import jakarta.servlet.*;
import jakarta.servlet.http.*;
import java.io.IOException;

public class ReportServlet extends HttpServlet {

    private DataSource pool;   // expensive to build — do it once

    @Override
    public void init() throws ServletException {
        // runs ONCE, when the container creates this servlet
        this.pool = buildConnectionPool();
        System.out.println("ReportServlet initialised");
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        // runs on EVERY GET — keep it about handling this one request
        resp.getWriter().write("rows: " + queryRowCount(pool));
    }

    @Override
    public void destroy() {
        // runs ONCE, at shutdown — release what init() acquired
        pool.close();
        System.out.println("ReportServlet destroyed");
    }
}

What just happened: The container called init() a single time and we used that one shot to build an expensive connection pool, stashing it in a field. Every doGet after that reuses the same pool instead of rebuilding it per request. At shutdown the container called destroy() once, where we closed the pool. ⚠️ Notice the field pool — it survives across every request because there's only one servlet instance holding it. That's convenient here (the pool is shared and read-only after setup) and a trap in a moment, when the field is mutable. Hold that thought.

ONE instance, MANY requests

Here's the fact that surprises almost everyone the first time, and it's the linchpin of the phase:

📝 The container creates exactly ONE instance of your servlet and reuses it for every single request. It does not do new ReportServlet() per request. It is, in practice, a singleton — one object, living for the whole lifetime of the app, handling thousands or millions of requests.

This is why the connection pool above worked: init() ran once, on the one instance, and every request saw the same pool. If the container made a fresh servlet per request, each would rebuild the pool — slow and pointless. So the container's design is deliberate: build the handler once, keep it warm, route all traffic through it.

That single decision is the entire reason this phase exists. One instance is cheap and fast. One instance is also shared — and shared is where the trouble starts.

Thread-per-request: many threads, one instance

A real server handles many requests at the same time. It can't process them one-at-a-time in a queue; your users would wait in line. So:

📝 The container keeps a pool of worker threads, and runs each incoming request on a thread pulled from that pool — all of them calling service() on the same single servlet instance, concurrently. Request A is being handled by thread 1, request B by thread 2, request C by thread 3 — at the same instant, all three threads executing the same doGet method on the same object.

flowchart TD
  R1[request A] --> T1[pool thread 1]
  R2[request B] --> T2[pool thread 2]
  R3[request C] --> T3[pool thread 3]
  T1 --> S["the ONE servlet instance — service()"]
  T2 --> S
  T3 --> S

If you read Java's concurrency phase, the alarm should already be ringing. This is exactly the dangerous setup from that chapter: multiple threads touching the same object's shared state at the same time. The "thread pool" here is the same ExecutorService idea — Tomcat's default is a couple hundred worker threads — and the "shared mutable state" is any field on your servlet. The container handles the threading for you, which is great, right up until you forget it's happening and write to a field.

The thread-safety consequence — the big lesson

⚠️ Because one instance is shared across many threads, any mutable instance field on a servlet is a data race. This is the single most important thing to take from this phase. Let's see it break.

Imagine you want to count how many requests you've served, so you add a field and increment it:

public class CounterServlet extends HttpServlet {

    private int hits = 0;   // DANGER: mutable field on a shared, multithreaded instance

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        hits++;                                  // read-add-write — NOT atomic
        resp.getWriter().write("hit #" + hits);
    }
}
# 1000 concurrent requests fired at this servlet:
expected final count: 1000
actual final count:   948

What just happened: Exactly the broken counter from the Java concurrency phase, now wearing a web server's clothes. hits++ is three steps — read the value, add one, write it back — and because every request runs on its own thread against the one shared CounterServlet, two threads can read 500 at the same instant, both compute 501, and both write 501. Two requests, one increment; the lost updates pile up and you land at 948 instead of 1000. ⚠️ It's intermittent — under light traffic it might look fine for weeks, then corrupt under load. That's the signature of a race condition, and it's the worst kind of bug to chase in production.

The fix is not to sprinkle locks everywhere. The fix is to not keep request state in fields at all:

public class GreetServlet extends HttpServlet {

    // no mutable fields — nothing shared between requests

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        String name = req.getParameter("name");   // local variable — per request, per thread
        String greeting = "Hello, " + name;       // also local — no sharing possible
        resp.getWriter().write(greeting);
    }
}

What just happened: Every piece of per-request data — name, greeting — is a local variable. Local variables live on each thread's own stack, so thread 1's name and thread 2's name are entirely separate; there is nothing shared to corrupt. The servlet itself holds no changing state, so it's safe to hammer with a thousand concurrent threads. 💡 The rule that drops out of this: keep servlets stateless. Per-request data goes in local variables and the request object; anything genuinely shared (like that read-only connection pool, set up once in init) is fine, and anything that must be mutable and shared needs a real concurrency tool — AtomicInteger, a ConcurrentHashMap, a lock — never a plain field.

💡 And here's the payoff that reaches far beyond raw servlets. Ever wondered why Spring controllers are singletons that you're told to keep stateless? Why "don't store request data in controller fields" is drilled into every Spring tutorial? This is why. A Spring @RestController is, underneath, handled by one shared object inside the DispatcherServlet — which is itself a servlet, running on this exact thread-per-request, one-instance model. The rule isn't a Spring quirk; it's the servlet container's shared-instance reality, inherited. Learn it here, bare, and it stops being a memorized rule and becomes something you actually understand.

Container responsibilities — what you got for free

Step back and notice how much the container did that you never wrote. It managed the thread pool, accepted the network connections and parsed the raw HTTP, created and deployed your servlet, called the lifecycle methods at the right moments, and (you'll see in Phase 5) runs the filters and listeners around your code too. You wrote one method that handles one request; the container handled concurrency, sockets, parsing, and lifetime.

💡 This is the original "inversion of control" in Java web. You don't call the framework — the framework calls you. Your code doesn't run a loop pulling requests off a socket; you hand the container a class and it invokes your methods when requests arrive. That "you write the handler, the runtime drives it" inversion is the deepest pattern in the whole Java web stack — and every framework on top of servlets (Spring, JAX-RS, all of it) is built on this same hand-it-over-and-let-it-call-you arrangement.

Recap

  1. The container owns the lifecycle: initservicedestroy. init() runs once at startup (do expensive setup there), service() runs for every request (routing to doGet/doPost), destroy() runs once at shutdown (clean up what init acquired).
  2. One instance, reused for every request. The container creates a single servlet instance — an effective singleton — not a new one per request. That's why setup in init is worth doing once.
  3. Thread-per-request on that one instance. Each request runs on a worker thread from the container's pool, and they all call service() on the same shared object concurrently — the classic shared-mutable-state danger from Java concurrency.
  4. Mutable instance fields are a data race. A plain int hits counter loses updates under load because hits++ isn't atomic and the field is shared across threads. ⚠️ Intermittent, load-dependent, miserable to debug.
  5. Keep servlets stateless — use local variables and the request object for per-request data. 💡 This is exactly why Spring controllers are stateless singletons: they inherit the servlet container's one-instance, multithreaded model.
  6. The container handles concurrency, networking, parsing, and lifetime for you — the original inversion of control: you write the handler, the runtime calls it.

Quick check

Three questions on the model that explains half of Java web framework behaviour:

[
  {
    "q": "How many instances of a given servlet class does the container create, and how does it serve concurrent requests?",
    "choices": [
      "One instance, reused for every request; concurrent requests each run on their own thread from a pool, all calling service() on that one shared instance",
      "A new instance per request, each on its own thread, so no state is ever shared",
      "One instance, and requests are queued and processed strictly one at a time",
      "One instance per worker thread, so each thread has a private copy"
    ],
    "answer": 0,
    "explain": "The container makes a single servlet instance (effectively a singleton) and runs each request on a pooled thread, all hitting the same instance concurrently. That shared instance is the whole reason thread-safety matters."
  },
  {
    "q": "A servlet has a `private int hits = 0;` field incremented with `hits++` in doGet. Under heavy concurrent traffic the count comes out too low. Why?",
    "choices": [
      "`hits++` is read-add-write (not atomic), and because the one servlet instance is shared across request threads, two threads can read the same value and one increment is lost — a race condition",
      "The container resets the field between requests",
      "Each request gets its own copy of the field, so they never add up",
      "Integers in servlets silently overflow under load"
    ],
    "answer": 0,
    "explain": "One shared instance + many threads + a non-atomic read-add-write = lost updates. The fix is to keep servlets stateless (local variables, the request object) or use a real concurrency tool like AtomicInteger."
  },
  {
    "q": "Why are Spring controllers conventionally kept stateless (no request data in fields)?",
    "choices": [
      "Because they run on the same servlet model: a single shared instance handling requests across many threads, so mutable fields would be a data race — exactly as in a raw servlet",
      "Because Spring creates a brand-new controller per request and fields are wiped anyway",
      "It's purely a style preference with no technical basis",
      "Because Spring controllers can't legally declare fields at all"
    ],
    "answer": 0,
    "explain": "A Spring @RestController is handled by one shared object inside the DispatcherServlet — itself a servlet on the thread-per-request, one-instance model. The 'keep it stateless' rule is the servlet container's shared-instance reality inherited, not a Spring quirk."
  }
]

← Phase 1: What a Servlet Is · Guide overview · Phase 3: Handling Requests with HttpServlet →

Check your understanding

1. How many instances of a given servlet class does the container create, and how does it serve concurrent requests?

2. A servlet has a `private int hits = 0;` field incremented with `hits++` in doGet. Under heavy concurrent traffic the count comes out too low. Why?

3. Why are Spring controllers conventionally kept stateless (no request data in fields)?

Was this page helpful?