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.
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:
# 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:
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
- The container owns the lifecycle:
init→service→destroy.init()runs once at startup (do expensive setup there),service()runs for every request (routing todoGet/doPost),destroy()runs once at shutdown (clean up whatinitacquired). - 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
initis worth doing once. - 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. - Mutable instance fields are a data race. A plain
int hitscounter loses updates under load becausehits++isn't atomic and the field is shared across threads. ⚠️ Intermittent, load-dependent, miserable to debug. - 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.
- 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)?