Java Web & Servlets Fundamentals

The Servlet Lifecycle

18 min Lesson 4 of 13

The Servlet Lifecycle

Every servlet you write lives inside a container — Tomcat, Jetty, WildFly, or any other Jakarta EE-compliant engine. The container owns the object: it decides when to create your servlet, when to destroy it, and how many threads may call it simultaneously. Understanding that contract in depth is what separates developers who debug concurrency problems in five minutes from those who chase them for days.

The Three Lifecycle Methods

The Jakarta Servlet specification defines three methods that mark the stages of a servlet's existence. The container calls them — you override them.

  • init(ServletConfig config) — called once, right after the container instantiates the servlet. Use it for expensive one-time setup: opening a database connection pool, loading a configuration file, or initialising a cache.
  • service(ServletRequest req, ServletResponse res) — called for every request, potentially by many threads at the same time. HttpServlet dispatches each call to doGet, doPost, etc., so you usually override those convenience methods rather than service directly.
  • destroy() — called once, when the container is shutting down or the servlet is being un-deployed. Release whatever init acquired: close the pool, flush a write buffer, cancel background threads.
The lifecycle guarantee: init completes before the first service call. destroy is called only after all in-progress service calls finish. The container enforces this — you get clear boundaries without writing synchronisation code for the phase transitions themselves.

A Fully Annotated Lifecycle Example

The following servlet demonstrates all three methods in context. It opens an imaginary connection pool in init, queries it per request, and closes it in destroy.

package com.example.web; import jakarta.servlet.ServletConfig; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; import java.util.concurrent.atomic.AtomicInteger; @WebServlet(name = "LifecycleServlet", urlPatterns = "/lifecycle") public class LifecycleServlet extends HttpServlet { // ---- state that is safe to share across threads ---- private static final long serialVersionUID = 1L; private final AtomicInteger requestCount = new AtomicInteger(0); // Simulate an expensive resource (e.g. a connection pool wrapper) private AppDataSource dataSource; // ------------------------------------------------------- // 1. INIT — called once, before the first request arrives // ------------------------------------------------------- @Override public void init(ServletConfig config) throws ServletException { super.init(config); // always call super — it stores the config String dbUrl = config.getInitParameter("dbUrl"); dataSource = new AppDataSource(dbUrl); dataSource.open(); log("LifecycleServlet initialised, dbUrl=" + dbUrl); } // ------------------------------------------------------- // 2. SERVICE — called on every request (multiple threads!) // ------------------------------------------------------- @Override protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException { int count = requestCount.incrementAndGet(); // thread-safe counter res.setContentType("text/html;charset=UTF-8"); try (PrintWriter out = res.getWriter()) { out.println("<p>Request #" + count + " handled by thread " + Thread.currentThread().getName() + "</p>"); } } // ------------------------------------------------------- // 3. DESTROY — called once, when the servlet is removed // ------------------------------------------------------- @Override public void destroy() { if (dataSource != null) { dataSource.close(); } log("LifecycleServlet destroyed after " + requestCount.get() + " requests."); super.destroy(); } }

Notice the call to super.init(config). HttpServlet.init stores the ServletConfig so that getServletConfig() and the helper method getInitParameter() work later. If you forget super.init, those calls return null and you will see a NullPointerException far from the actual cause.

The Single-Instance, Multi-Threaded Model

The container creates exactly one instance of your servlet class (by default) and routes all concurrent requests through that one object. Ten simultaneous users means ten threads all executing your doGet — on the same instance, reading and writing the same fields.

This design maximises throughput: object creation is expensive; thread creation is cheaper; sharing one servlet avoids both. But it shifts the thread-safety burden entirely onto you.

Instance fields are shared across all threads. A naive counter like private int count = 0; with count++ inside doGet is a data race. Two threads can read the same value, both increment it, and write back the same result — silently losing one increment. Use AtomicInteger, LongAdder, or synchronisation as appropriate.

What Is and Is Not Safe in a Servlet

The critical rule is simple: distinguish between data that belongs to the servlet and data that belongs to a single request.

  • Safe to store as instance fields: immutable objects (strings, primitive wrappers, final references set once in init), thread-safe objects (AtomicInteger, ConcurrentHashMap), stateless helper objects (a Jackson ObjectMapper configured at startup).
  • Never store as instance fields: per-request data such as a user's input, a HttpServletRequest or HttpServletResponse reference, a result set, or any local computation. Declare those as local variables inside the method — each thread gets its own stack frame.
// WRONG — req and res are stored in fields accessible to all threads public class BrokenServlet extends HttpServlet { private HttpServletRequest currentRequest; // NEVER do this @Override protected void doGet(HttpServletRequest req, HttpServletResponse res) { currentRequest = req; // race condition processRequest(); } } // CORRECT — pass request data as method parameters or local variables public class CorrectServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse res) { String userId = req.getParameter("id"); // local — lives on this thread's stack String result = processRequest(userId); // ... } }

Lazy vs Eager Initialisation with load-on-startup

By default the container initialises a servlet on the first request it receives — lazy initialisation. That first caller pays the cost of init. For servlets with heavy startup work (building a cache, warming a connection pool) that delay is undesirable. The load-on-startup attribute forces eager initialisation at deploy time:

@WebServlet( name = "SearchServlet", urlPatterns = "/search", loadOnStartup = 1 // positive integer = initialise at startup; lower runs first ) public class SearchServlet extends HttpServlet { @Override public void init() throws ServletException { buildSearchIndex(); // runs at deploy time, not on first request } }

The integer value controls initialisation order when multiple servlets use loadOnStartup: servlet with value 1 inits before value 2, and so on. Negative values (or omitting the attribute) keep the default lazy behaviour.

Prefer the no-arg init() override. HttpServlet provides a convenience no-arg init() that the container calls after the full init(ServletConfig) has stored the config. Overriding it means you do not have to remember super.init(config) — the config is already stored by the time your code runs.

The destroy Method in Practice

destroy is your cleanup hook, but it has limits. The container gives no hard guarantee on how long it will wait for destroy to complete before killing the JVM during an abrupt shutdown (SIGKILL, OOM kill). For critical shutdown work — flushing messages, completing financial transactions — use a JVM shutdown hook or a framework-level graceful shutdown mechanism as a second line of defence.

Also note: if init throws a ServletException, the container marks the servlet as unavailable and never calls destroy. Clean up anything partially initialised inside the same init method using a try/finally block.

@Override public void init(ServletConfig config) throws ServletException { super.init(config); try { connectionPool = buildPool(config.getInitParameter("dbUrl")); searchIndex = loadIndex(); } catch (Exception e) { // Clean up whatever was partially initialised if (connectionPool != null) connectionPool.close(); throw new ServletException("Servlet init failed", e); // destroy() will NOT be called after this throw } }

Summary

The servlet lifecycle gives you three well-defined hooks: init (once, at startup), service/doGet/doPost (per request, on many threads), and destroy (once, at shutdown). The single-instance model means every instance field is shared across all concurrent requests — only thread-safe objects or final initialisation-time data belong there. Request-scoped data must live in local variables. Understanding this model is fundamental to writing correct, high-throughput web applications in Java.