Vòng đời của Servlet

Xem thử

Một trang đích của chiến dịch khuyến mãi có dòng chữ "Đã có 4.812 lượt xem", lấy từ một bộ đếm trong servlet. Đêm đó chiến dịch lên kệ, lưu lượng tăng mạnh, nhưng bộ đếm tăng chậm một cách kỳ lạ: nhật ký truy cập của máy chủ ghi hơn 9.000 yêu cầu, trong khi con số trên trang chưa qua nổi 5.000. Không có lỗi trong log, không yêu cầu nào thất bại. Code chỉ có một dòng: visitCount++, và trường visitCount là biến instance của servlet.

Lỗi này chỉ lộ ra khi có nhiều hơn một yêu cầu cùng lúc, nên nó qua mặt mọi lần thử một mình trên máy lập trình. Hiểu vòng đời của servlet không chỉ là thuộc ba tên phương thức, mà là biết ai gọi chúng, khi nào, và bằng luồng nào. Bài này đo cả ba, sau đó đi tới đúng lỗi trên: lab chạy 2000 yêu cầu từ 16 luồng client và cho thấy bộ đếm không an toàn đếm sai ra sao.

Sau bài này, bạn có thể

  • Nêu đúng ba pha của servlet: init() một lần, service() mỗi yêu cầu, destroy() một lần, và biết thời điểm cùng luồng nào gọi từng pha.
  • Chọn giữa khởi tạo sớm (loadOnStartup) và muộn, và biết container làm gì khi init() ném lỗi.
  • Giải thích vì sao đừng ghi đè service(), và OPTIONS, HEAD đến từ đâu.
  • Tái hiện lỗi mất cập nhật trên trường của servlet và sửa bằng AtomicInteger hoặc synchronized.
  • Giải thích vì sao một biến static của ứng dụng web không dùng chung với chương trình khởi chạy server.

Chuẩn bị môi trường và dự án

Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, Tomcat 11.0.27 nhúng, Jakarta Servlet 6.1). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 03-servlet/02-vong-doi-servlet, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.

Yêu cầu môi trường

Thành phầnPhiên bản đã kiểm chứngGhi chú
JDK17 trở lênjava -version
Maven3.9 trở lênmvn -v
Apache Tomcat Embed11.0.27khai báo trong pom.xml, hiện thực Jakarta Servlet 6.1
Dockerkhông cầnbài này không dùng cơ sở dữ liệu

Cấu trúc

text
02-vong-doi-servlet/
  pom.xml
  src/main/webapp/WEB-INF/web.xml       deployment descriptor tối thiểu (Servlet 6.1)
  src/main/java/dev/canhdominich/servlet/lifecycle/
    EmbeddedServer.java       dựng Tomcat nhúng, quét lớp trong target/classes
    ServerMain.java           chạy server ở cổng 8080 (đổi bằng biến PORT)
    EventLog.java             ghi và in các sự kiện vòng đời
    EventsServlet.java        trả danh sách sự kiện qua HTTP (/events)
    LifecycleServlets.java    EagerServlet (loadOnStartup=1) và LazyServlet
    CounterServlet.java       ba bộ đếm: không an toàn luồng, synchronized, AtomicInteger
    FailingInitServlet.java   hai servlet có init() ném lỗi
    MethodsServlet.java       servlet chỉ có doGet và doPost, để xem OPTIONS và HEAD
    LifecycleProbes.java      chạy toàn bộ thí nghiệm và in kết quả

Khai báo thư viện: pom.xml

pom.xml

Dự án Maven Java 17 với tomcat-embed-core (Tomcat 11 chạy trong chương trình, đã gồm Jakarta Servlet 6.1) và tomcat-embed-jasper, cùng JUnit và AssertJ cho test. Không có cơ sở dữ liệu nên không cần Docker.

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>dev.canhdominich.javabackend</groupId>
    <artifactId>servlet-vong-doi</artifactId>
    <version>1.0.0</version>
    <name>Servlet - Vong doi Servlet</name>

    <properties>
        <maven.compiler.release>17</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <main.class>dev.canhdominich.servlet.lifecycle.ServerMain</main.class>
        <main.arg>none</main.arg>
        <tomcat.version>11.0.27</tomcat.version>
        <junit.version>6.0.3</junit.version>
        <assertj.version>3.27.7</assertj.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-core</artifactId>
            <version>${tomcat.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-jasper</artifactId>
            <version>${tomcat.version}</version>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>${assertj.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.6</version>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.6.3</version>
                <configuration>
                    <executable>java</executable>
                    <arguments>
                        <argument>-classpath</argument>
                        <classpath/>
                        <argument>${main.class}</argument>
                        <argument>${main.arg}</argument>
                    </arguments>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Cấu hình ứng dụng web: web.xml

src/main/webapp/WEB-INF/web.xml

Deployment descriptor tối thiểu theo Servlet 6.1; chỉ cần có mặt để Tomcat coi src/main/webapp là một ứng dụng web. Các servlet được khai báo bằng annotation.

xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_1.xsd" version="6.1">
    <display-name>servlet-vong-doi</display-name>
</web-app>

src/main/java/dev/canhdominich/servlet/lifecycle/EmbeddedServer.java

Dựng và điều khiển Tomcat nhúng: đặt cổng (0 là chọn cổng trống), nghe ở 127.0.0.1, đăng ký ứng dụng web từ src/main/webapp, nối target/classes vào WEB-INF/classes để quét annotation, và gọn log. Thực hiện AutoCloseable nên đóng server bằng try-with-resources, lúc đó container gọi destroy() của mọi servlet.

java
package dev.canhdominich.servlet.lifecycle;

import java.io.File;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.WebResourceRoot;
import org.apache.catalina.connector.Connector;
import org.apache.catalina.startup.Tomcat;
import org.apache.catalina.webresources.DirResourceSet;
import org.apache.catalina.webresources.StandardRoot;

public final class EmbeddedServer implements AutoCloseable {

    private final Tomcat tomcat;

    private EmbeddedServer(Tomcat tomcat) {
        this.tomcat = tomcat;
    }

    public static EmbeddedServer start(int port) throws LifecycleException {
        Logger root = Logger.getLogger("");
        root.setLevel(Level.WARNING);
        for (Handler handler : root.getHandlers()) {
            handler.setLevel(Level.WARNING);
        }
        Tomcat tomcat = new Tomcat();
        tomcat.setBaseDir(new File("target/tomcat").getAbsolutePath());
        tomcat.setPort(port);
        Connector connector = tomcat.getConnector();
        connector.setProperty("address", "127.0.0.1");
        if (System.getProperty("lab.hardenErrors") != null) {
            org.apache.catalina.valves.ErrorReportValve valve = new org.apache.catalina.valves.ErrorReportValve();
            valve.setShowReport(false);
            valve.setShowServerInfo(false);
            tomcat.getHost().getPipeline().addValve(valve);
        }
        Context context = tomcat.addWebapp("", new File("src/main/webapp").getAbsolutePath());
        if (context instanceof org.apache.catalina.core.StandardContext standard) {
            standard.setClearReferencesRmiTargets(false);
            standard.setClearReferencesThreadLocals(false);
        }
        WebResourceRoot resources = new StandardRoot(context);
        resources.addPreResources(new DirResourceSet(resources, "/WEB-INF/classes", new File("target/classes").getAbsolutePath(), "/"));
        context.setResources(resources);
        tomcat.start();
        return new EmbeddedServer(tomcat);
    }

    public int port() {
        return tomcat.getConnector().getLocalPort();
    }

    public Tomcat tomcat() {
        return tomcat;
    }

    @Override
    public void close() throws LifecycleException {
        tomcat.stop();
        tomcat.destroy();
    }
}

src/main/java/dev/canhdominich/servlet/lifecycle/ServerMain.java

Chạy server thật ở cổng lấy từ biến môi trường PORT (mặc định 8080) và chờ cho tới khi bạn dừng.

java
package dev.canhdominich.servlet.lifecycle;

import org.apache.catalina.LifecycleException;

public final class ServerMain {

    private ServerMain() {
    }

    public static void main(String[] args) throws LifecycleException {
        int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));
        EmbeddedServer server = EmbeddedServer.start(port);
        System.out.println("Listening on http://localhost:" + server.port() + "/life/eager");
        server.tomcat().getServer().await();
    }
}

Ba pha của vòng đời

Container tạo đối tượng servlet bằng constructor không tham số, rồi gọi init(). Sau đó mỗi yêu cầu HTTP tới servlet đó dẫn tới một lời gọi service(), và HttpServlet chuyển nó thành doGet, doPost... tùy phương thức. Khi ứng dụng bị gỡ hoặc server dừng, container gọi destroy() rồi bỏ đối tượng.

PhaGọi bao nhiêu lầnDùng đểĐừng làm
init()một lần cho mỗi servletĐọc cấu hình, mở tài nguyên tốn kém (pool, bộ nhớ đệm)Việc lâu mà không có giới hạn thời gian: nó chặn yêu cầu đầu tiên hoặc cả việc khởi động
service() rồi doGet, doPostmỗi yêu cầu, từ nhiều luồng cùng lúcXử lý yêu cầuGhi vào trường dùng chung mà không đồng bộ
destroy()một lầnĐóng tài nguyên đã mở ở init()Giả định nó luôn chạy: tiến trình bị kill đột ngột thì không

Cách đọc hình: hàng trên là đường đi của servlet nạp sớm: triển khai, init() một lần ngay lúc khởi động, rồi service() cho mọi yêu cầu, và cuối cùng destroy() một lần khi dừng. Hàng dưới là servlet nạp muộn: không có loadOnStartup nên init() bị hoãn tới yêu cầu đầu tiên, chạy trên luồng xử lý yêu cầu đó, rồi các yêu cầu sau vào thẳng service().

Bằng chứng: ai gọi init và khi nào

Để thấy tận mắt, bài dùng hai servlet cùng ghi lại ba phương thức vòng đời vào một danh sách sự kiện. EventLog ghi sự kiện đồng thời in ra màn hình với tiền tố event:, nên thứ tự các dòng in ra chính là thứ tự container gọi chúng:

src/main/java/dev/canhdominich/servlet/lifecycle/EventLog.java

java
package dev.canhdominich.servlet.lifecycle;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public final class EventLog {

    private static final List<String> EVENTS = Collections.synchronizedList(new ArrayList<>());

    private EventLog() {
    }

    public static void record(String event) {
        EVENTS.add(event);
        System.out.println("  event: " + event);
    }

    public static List<String> snapshot() {
        synchronized (EVENTS) {
            return new ArrayList<>(EVENTS);
        }
    }

    public static void clear() {
        EVENTS.clear();
    }
}

Hai servlet dùng chung lớp cha Recording: EagerServlet khai báo loadOnStartup = 1 (khởi tạo ngay khi ứng dụng triển khai), còn LazyServlet không khai báo gì (chỉ khởi tạo khi có yêu cầu đầu tiên):

src/main/java/dev/canhdominich/servlet/lifecycle/LifecycleServlets.java

java
package dev.canhdominich.servlet.lifecycle;

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

public final class LifecycleServlets {

    private LifecycleServlets() {
    }

    abstract static class Recording extends HttpServlet {

        @Override
        public void init() {
            EventLog.record(getClass().getSimpleName() + ".init() on " + Thread.currentThread().getName().replaceAll("\\d+$", "N"));
        }

        @Override
        protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
            EventLog.record(getClass().getSimpleName() + ".doGet()");
            response.setContentType("text/plain;charset=UTF-8");
            response.getWriter().println(getClass().getSimpleName() + " served");
        }

        @Override
        public void destroy() {
            EventLog.record(getClass().getSimpleName() + ".destroy()");
        }
    }

    @WebServlet(urlPatterns = "/life/eager", loadOnStartup = 1)
    public static class EagerServlet extends Recording {
    }

    @WebServlet(urlPatterns = "/life/lazy")
    public static class LazyServlet extends Recording {
    }
}

Chương trình LifecycleProbes khởi động server, gọi servlet muộn hai lần, servlet sớm một lần, rồi dừng server. Nó còn làm các thí nghiệm ở những mục sau của bài nên bạn sẽ gặp lại nó; đây là toàn bộ mã:

src/main/java/dev/canhdominich/servlet/lifecycle/LifecycleProbes.java

java
package dev.canhdominich.servlet.lifecycle;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public final class LifecycleProbes {

    static final int INCREMENTS = 2000;

    private LifecycleProbes() {
    }

    public static void main(String[] args) throws Exception {
        System.out.println("-- starting the server");
        EmbeddedServer server = EmbeddedServer.start(0);
        HttpClient client = HttpClient.newHttpClient();
        String base = "http://127.0.0.1:" + server.port();
        System.out.println("-- server started; requests to the lazy servlet twice, then the eager servlet once");
        get(client, base + "/life/lazy");
        get(client, base + "/life/lazy");
        get(client, base + "/life/eager");
        System.out.println("-- failing init");
        System.out.println("  ordinary exception, request 1: " + status(get(client, base + "/bad-init/ordinary")));
        System.out.println("  ordinary exception, request 2: " + status(get(client, base + "/bad-init/ordinary")) + " " + get(client, base + "/bad-init/ordinary").body().trim());
        System.out.println("  UnavailableException, request 1: " + status(get(client, base + "/bad-init/unavailable")));
        System.out.println("  UnavailableException, request 2: " + status(get(client, base + "/bad-init/unavailable")));
        System.out.println("-- methods the container adds");
        HttpResponse<String> options = client.send(HttpRequest.newBuilder(URI.create(base + "/methods")).method("OPTIONS", HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString());
        System.out.println("  OPTIONS /methods -> " + options.statusCode() + " Allow=" + options.headers().firstValue("Allow").orElse("none"));
        HttpResponse<String> head = client.send(HttpRequest.newBuilder(URI.create(base + "/methods")).method("HEAD", HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString());
        System.out.println("  HEAD /methods    -> " + head.statusCode() + " Content-Length=" + head.headers().firstValue("Content-Length").orElse("none") + " body length=" + head.body().length());
        System.out.println("-- " + INCREMENTS + " increments from 16 client threads");
        for (String kind : new String[]{"unsafe", "synchronized", "atomic"}) {
            System.out.printf("  %-12s final value %s%n", kind, hammer(client, base, kind));
        }
        System.out.println("-- stopping the server");
        server.close();
    }

    static String hammer(HttpClient client, String base, String kind) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(16);
        List<Future<?>> futures = new ArrayList<>();
        for (int i = 0; i < INCREMENTS; i++) {
            futures.add(pool.submit(() -> get(client, base + "/count/" + kind)));
        }
        for (Future<?> future : futures) {
            future.get();
        }
        pool.shutdown();
        return get(client, base + "/count/" + kind + "?peek=1").body().trim();
    }

    private static String status(HttpResponse<String> response) {
        return String.valueOf(response.statusCode());
    }

    private static HttpResponse<String> get(HttpClient client, String url) throws Exception {
        return client.send(HttpRequest.newBuilder(URI.create(url)).build(), HttpResponse.BodyHandlers.ofString());
    }
}

Bài này không dùng cơ sở dữ liệu nên không cần Docker; chỉ cần chạy mvn -q compile exec:exec -Dmain.class=dev.canhdominich.servlet.lifecycle.LifecycleProbes (xem mục Chuẩn bị môi trường và dự án ở đầu bài để có pom.xml và các lớp khởi động server). Phần đầu của kết quả:

text
-- starting the server
  event: EagerServlet.init() on main
-- server started; requests to the lazy servlet twice, then the eager servlet once
  event: LazyServlet.init() on http-nio-127.0.0.1-auto-1-exec-N
  event: LazyServlet.doGet()
  event: LazyServlet.doGet()
  event: EagerServlet.doGet()
...
-- stopping the server
  event: LazyServlet.destroy()
  event: EagerServlet.destroy()

Từ kết quả:

  • EagerServlet.init() chạy trên luồng main khi server khởi động, trước yêu cầu đầu tiên. Lỗi cấu hình lộ ra ngay lúc triển khai, không phải khi người dùng đầu tiên tới.
  • LazyServlet.init() chạy trên một luồng xử lý yêu cầu (exec-N), đúng một lần dù có hai yêu cầu, và người dùng đầu tiên phải chờ nó xong. Phần doGet sau đó chạy lặp lại.
  • destroy() của cả hai chạy khi server dừng, thứ tự do container quyết định (trong lần chạy này servlet muộn trước servlet sớm); đừng dựa vào thứ tự.

init() ném lỗi thì sao

init() hay lỗi nhất: cơ sở dữ liệu chưa sẵn sàng, tệp cấu hình thiếu. Lớp FailingInitServlet có hai servlet để thử hai loại lỗi: một cái ném ServletException ở lần khởi tạo đầu rồi thành công ở lần sau, một cái luôn ném UnavailableException:

src/main/java/dev/canhdominich/servlet/lifecycle/FailingInitServlet.java

java
package dev.canhdominich.servlet.lifecycle;

import jakarta.servlet.ServletException;
import jakarta.servlet.UnavailableException;
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.util.concurrent.atomic.AtomicInteger;

public final class FailingInitServlet {

    private static final AtomicInteger TEMPORARY_ATTEMPTS = new AtomicInteger();

    private FailingInitServlet() {
    }

    @WebServlet("/bad-init/ordinary")
    public static class Ordinary extends HttpServlet {

        @Override
        public void init() throws ServletException {
            if (TEMPORARY_ATTEMPTS.incrementAndGet() == 1) {
                throw new ServletException("database not ready yet");
            }
        }

        @Override
        protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
            response.setContentType("text/plain;charset=UTF-8");
            response.getWriter().println("ready after " + TEMPORARY_ATTEMPTS.get() + " init attempts");
        }
    }

    @WebServlet("/bad-init/unavailable")
    public static class Unavailable extends HttpServlet {

        @Override
        public void init() throws ServletException {
            throw new UnavailableException("configuration missing");
        }

        @Override
        protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
            response.getWriter().println("never reached");
        }
    }
}

Kết quả đo được (phần "failing init" của LifecycleProbes):

text
ordinary exception, request 1: 500
ordinary exception, request 2: 200 ready after 2 init attempts
UnavailableException, request 1: 404
UnavailableException, request 2: 404
Hình: hai kiểu lỗi trong init() và cách container phản ứng

Cách đọc hình: hàng trên là lỗi thường (ServletException): yêu cầu đầu nhận 500, container thử init() lại ở yêu cầu thứ hai và lần này thành công nên trả 200. Hàng dưới là UnavailableException không thời hạn: servlet bị đánh dấu không khả dụng và cả hai yêu cầu nhận 404 mà init() không được gọi lại.

Với lỗi thường, container không đưa servlet vào hoạt động, trả 500 cho yêu cầu hiện tại và thử khởi tạo lại ở yêu cầu kế tiếp: lần hai thành công (đã có hai lần gọi init()). Với UnavailableException không có thời hạn, lab thấy cả hai yêu cầu đều nhận 404 và servlet không bao giờ phục vụ; đó là cách đánh dấu một servlet hỏng vĩnh viễn thay vì để nó thử lại mãi. Hai hệ quả thực tế: init() phải rẻ khi gọi lại được, và lỗi ở init() của servlet nạp muộn biểu hiện thành 500 của một người dùng thật chứ không làm ứng dụng không khởi động được. Với servlet nạp sớm, bạn muốn lỗi như vậy xuất hiện lúc triển khai, nên đặt loadOnStartup cho servlet phụ thuộc tài nguyên ngoài.

service(), OPTIONS và HEAD: những phương thức bạn không viết

Cách đọc hình: service() đọc phương thức HTTP rồi phân phối: GET tới doGet và POST tới doPost bạn đã viết, HEAD chạy doGet nhưng bỏ thân phản hồi, OPTIONS trả tiêu đề Allow liệt kê các phương thức lớp của bạn hỗ trợ. Phương thức nào bạn không ghi đè đều nhận 405.

Đừng ghi đè service(): lớp HttpServlet đã cài sẵn nó để chọn doGet, doPost... theo phương thức HTTP, đồng thời xử lý hai phương thức mà bạn không viết. Servlet MethodsServlet chỉ ghi đè doGet và doPost:

src/main/java/dev/canhdominich/servlet/lifecycle/MethodsServlet.java

java
package dev.canhdominich.servlet.lifecycle;

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet("/methods")
public class MethodsServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("text/plain;charset=UTF-8");
        response.getWriter().println("hello from doGet");
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("text/plain;charset=UTF-8");
        response.getWriter().println("hello from doPost");
    }
}

Gửi OPTIONS và HEAD tới nó (phần "methods the container adds" của LifecycleProbes):

text
OPTIONS /methods -> 200 Allow=GET, HEAD, POST, OPTIONS
HEAD /methods    -> 200 Content-Length=17 body length=0

HttpServlet đọc xem lớp của bạn đã ghi đè phương thức nào để dựng tiêu đề Allow, nên bạn có HEAD và OPTIONS miễn phí. Với HEAD, nó chạy doGet của bạn nhưng bỏ thân phản hồi: tiêu đề Content-Length: 17 cho biết độ dài thân thật (hello from doGet cộng ký tự xuống dòng), còn thân thì rỗng. Một phương thức bạn không ghi đè (ví dụ PUT) nhận lỗi 405, như bài trước đã đo.

Thread safety: trường của servlet là dữ liệu dùng chung

Quay lại câu chuyện bộ đếm. Servlet CounterServlet có ba bộ đếm với ba cách khác nhau, ánh xạ vào ba đường dẫn; mỗi lần gọi tăng một đơn vị và trả giá trị mới, còn ?peek đọc giá trị mà không tăng:

src/main/java/dev/canhdominich/servlet/lifecycle/CounterServlet.java

java
package dev.canhdominich.servlet.lifecycle;

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.util.concurrent.atomic.AtomicInteger;

@WebServlet({"/count/unsafe", "/count/atomic", "/count/synchronized"})
public class CounterServlet extends HttpServlet {

    private int unsafeCount;
    private int synchronizedCount;
    private final AtomicInteger atomicCount = new AtomicInteger();

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String kind = request.getServletPath().substring("/count/".length());
        boolean peek = request.getParameter("peek") != null;
        int value = switch (kind) {
            case "unsafe" -> peek ? unsafeCount : incrementUnsafe();
            case "atomic" -> peek ? atomicCount.get() : atomicCount.incrementAndGet();
            default -> peek ? synchronizedCount : incrementSynchronized();
        };
        response.setContentType("text/plain;charset=UTF-8");
        response.getWriter().print(value);
    }

    private int incrementUnsafe() {
        int current = unsafeCount;
        Thread.yield();
        unsafeCount = current + 1;
        return unsafeCount;
    }

    private synchronized int incrementSynchronized() {
        int current = synchronizedCount;
        Thread.yield();
        synchronizedCount = current + 1;
        return synchronizedCount;
    }
}

Cách đọc hình: cột trái là hai luồng cùng đọc bộ đếm và cùng thấy 5. Cột giữa là cả hai cùng ghi 5 + 1 = 6. Kết quả là bộ đếm chỉ tăng một lần cho hai yêu cầu. Ô nét đứt ghi số đo thật: 2000 yêu cầu từ 16 luồng chỉ còn khoảng 900 ở bộ đếm không an toàn.

Chú ý incrementUnsafe gọi Thread.yield() giữa lúc đọc và ghi. Dòng đó không tạo ra lỗi; nó chỉ mở rộng khoảng hở vốn luôn tồn tại giữa đọc và ghi, để lỗi lộ ra gần như chắc chắn thay vì chờ tải cao. Không có dòng đó, lỗi vẫn có, chỉ ít xảy ra hơn nên còn nguy hiểm hơn. Chương trình LifecycleProbes gửi 2000 yêu cầu tăng từ 16 luồng client tới mỗi bộ đếm rồi đọc giá trị cuối:

text
-- 2000 increments from 16 client threads
  unsafe       final value 912
  synchronized final value 2000
  atomic       final value 2000

Bộ đếm không an toàn chỉ đếm được khoảng 900 trên 2000 (hai lần đo cho 898 và 912; con số thay đổi mỗi lần chạy nhưng luôn nhỏ hơn 2000). Hai bộ còn lại đúng 2000.

Đang tải Animation…

Các cách xử lý, từ tốt nhất tới ít tốt nhất:

CáchKhi dùngGiá phải trả
Không dùng trường dùng chungMặc định cho dữ liệu của từng yêu cầu: dùng biến cục bộ trong doGet, tham số, đối tượng tạo mới mỗi yêu cầuKhông có
Trường bất biến, gán một lần ở init()Cấu hình, DataSource, các đối tượng chỉ đọcĐối tượng được gán phải an toàn luồng
AtomicInteger, ConcurrentHashMap, các lớp trong java.util.concurrentBộ đếm, bộ nhớ đệm đơn giảnPhải chọn đúng thao tác nguyên tử, không ghép hai thao tác nguyên tử rời
synchronized hoặc khóaKhi cần bảo vệ một đoạn gồm nhiều bướcYêu cầu xếp hàng qua khóa nên tắc ở tải cao
Lưu trong HttpSession hoặc cơ sở dữ liệuDữ liệu của từng người dùng hoặc cần bền vữngCần bộ nhớ hoặc truy cập dữ liệu; session cũng cần cẩn thận khi nhiều yêu cầu của cùng người dùng chạy song song

Quy tắc thực dụng: mọi private không final trong một lớp servlet là nghi phạm cho tới khi chứng minh được là vô tội.

Một cái bẫy phụ: biến static của ứng dụng web

Cách đọc hình: hàng trên là chương trình khởi chạy với bản EventLog riêng của nó, luôn rỗng. Hàng dưới là class loader của ứng dụng web với bản EventLog thứ hai, nơi các servlet ghi sự kiện. Cách duy nhất để đọc bản thứ hai từ bên ngoài là đi qua một servlet, ở đây là /events.

Khi viết lab, LifecycleProbes ban đầu đọc EventLog.snapshot() trực tiếp để in sự kiện, và nhận về danh sách rỗng, dù servlet đã ghi sự kiện (chúng hiện ra trên màn hình). Nguyên nhân: Tomcat nạp lớp của ứng dụng web bằng một class loader riêng, và ở đây target/classes được nối vào WEB-INF/classes nên lớp EventLog mà servlet dùng là một bản khác với lớp EventLog trong chương trình khởi chạy. Hai bản, hai trường static riêng biệt. Lab bổ sung EventsServlet để đọc sự kiện từ bên trong ứng dụng web:

src/main/java/dev/canhdominich/servlet/lifecycle/EventsServlet.java

java
package dev.canhdominich.servlet.lifecycle;

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet("/events")
public class EventsServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("text/plain;charset=UTF-8");
        for (String event : EventLog.snapshot()) {
            response.getWriter().println(event);
        }
    }
}

Một test ghim đúng điều này (trích từ LifecycleBehaviourTest): /events có sự kiện còn EventLog.snapshot() trong chương trình khởi chạy thì rỗng.

Phương thức webappClassesAreIsolatedFromTheLauncherClasspath trong LifecycleBehaviourTest.java

java
@Test
void webappClassesAreIsolatedFromTheLauncherClasspath() throws Exception {
    get("/life/eager");
    assertThat(get("/events").body()).contains("EagerServlet.doGet()");
    assertThat(EventLog.snapshot()).isEmpty();
}

Hệ quả ngoài đời: trạng thái static của một ứng dụng web không chia sẻ được với thứ nằm ngoài ứng dụng (kể cả một ứng dụng web khác trên cùng Tomcat), và khi triển khai lại ứng dụng thì class loader cũ bị bỏ, trạng thái static mất theo. Muốn chia sẻ giữa các ứng dụng hay giữ qua lần triển khai, hãy dùng cơ sở dữ liệu, bộ nhớ đệm ngoài hoặc tài nguyên do container cung cấp.

Quyết định thiết kế và đánh đổi

Quyết địnhLựa chọnĐánh đổi
Thời điểm khởi tạoloadOnStartup cho servlet phụ thuộc tài nguyên ngoài; muộn cho phần còn lạiSớm: khởi động chậm hơn nhưng lỗi lộ sớm; muộn: yêu cầu đầu tiên trả giá
Dọn dẹpĐóng tài nguyên ở destroy() và cả ở nơi có thể nhận tín hiệu tắtKhông thể dựa hoàn toàn vào destroy() khi tiến trình bị kill
Ghi đè phương thứcdoGet, doPost, không ghi đè service()Mất quyền tự xử lý mọi phương thức, nhưng có HEAD, OPTIONS đúng chuẩn
Trạng thái dùng chungTránh; nếu cần thì java.util.concurrentThiết kế gọn hơn nhưng phải nghĩ về nơi lưu dữ liệu
Trạng thái staticKhông dùng cho dữ liệu cần chia sẻ ngoài ứng dụngPhải dùng dịch vụ ngoài

Playbook: gặp sự cố vòng đời thì làm gì

Triệu chứngKiểm tra đầu tiênNguyên nhân hay gặpCách xử lý
Số liệu dùng chung sai khi tải caoCó trường thay đổi được trong servlet khôngĐọc, tính, ghi không nguyên tửAtomicInteger, synchronized, hoặc bỏ trường dùng chung
Yêu cầu đầu tiên chậm bất thườnginit() làm gìServlet nạp muộn khởi tạo tài nguyên nặngloadOnStartup, hoặc khởi tạo ở ServletContextListener
500 ngẫu nhiên sau triển khai, rồi tự hếtLog của init()Tài nguyên ngoài chưa sẵn sàng; container thử lại ở yêu cầu sauKiểm tra phụ thuộc, nạp sớm để lỗi lộ lúc triển khai
404 vĩnh viễn cho một đường dẫn có servletLog có UnavailableException khônginit() ném lỗi không phục hồiSửa nguyên nhân gốc, triển khai lại
Biến static luôn rỗng ở chỗ này nhưng có dữ liệu ở chỗ kiaLớp được nạp bởi class loader nàoHai bản của cùng một lớpChia sẻ qua dịch vụ ngoài hoặc truyền tường minh
Tài nguyên không được dọn khi dừngdestroy() có được gọi khôngTiến trình bị kill, hoặc destroy() ném lỗiDừng server êm (SIGTERM), bọc destroy() bằng try/catch từng tài nguyên

Lỗi thường gặp khi chạy lab

Triệu chứngNguyên nhânCách xử lý
Address already in use ở cổng 8080Có ứng dụng khác dùng cổng 8080PORT=9090 mvn -q compile exec:exec
/events trả rỗngChưa có servlet nào được gọiGọi /life/eager hoặc /life/lazy trước
unsafe ra đúng 2000Máy quá nhanh hoặc ít lõi nên không có tranh chấpTăng INCREMENTS hoặc số luồng client trong LifecycleProbes

Bài thực hành

Làm trong dự án của bài (thư mục 03-servlet/02-vong-doi-servlet). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.

  1. Quan sát vòng đời. Chạy LifecycleProbes. Tiêu chí: EagerServlet.init() chạy trên luồng main trước mọi yêu cầu; LazyServlet.init() chạy đúng một lần trên luồng exec-N.
  2. Đổi thời điểm khởi tạo. Bỏ loadOnStartup = 1 khỏi EagerServlet và chạy lại. Tiêu chí: init() của nó dời sang yêu cầu đầu tiên.
  3. Gây lỗi init. Đổi điều kiện trong FailingInitServlet.Ordinary để lỗi xảy ra ba lần đầu. Tiêu chí: ba yêu cầu đầu trả 500, yêu cầu thứ tư trả 200.
  4. Gây mất cập nhật. Chạy LifecycleProbes ba lần. Tiêu chí: unsafe luôn nhỏ hơn 2000 và thay đổi giữa các lần; hai bộ còn lại luôn 2000. Sau đó bỏ Thread.yield() ra khỏi incrementUnsafe và so sánh.
  5. Thêm bộ đếm thứ tư. Thêm đường dẫn /count/concurrent dùng LongAdder. Tiêu chí: 2000 yêu cầu cho đúng 2000.
  6. Thử HEAD và OPTIONS. curl -I http://localhost:8080/methods và curl -i -X OPTIONS .... Tiêu chí: thấy Allow đúng với các phương thức bạn đã ghi đè; thêm doPut và thấy PUT xuất hiện trong Allow.
  7. Chạy bộ test (tùy chọn). Bộ test tự động của dự án nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone, mvn test cho Tests run: 6, Failures: 0, Errors: 0.

Hiểu lầm và giới hạn

  • "init() chạy mỗi lần có yêu cầu." Chỉ một lần cho mỗi lần khởi tạo thành công; lab thấy hai lần doGet chỉ có một init().
  • "Servlet mới được tạo cho mỗi người dùng." Một đối tượng phục vụ mọi người dùng.
  • "synchronized là đủ cho mọi trường hợp." Nó đúng nhưng làm yêu cầu xếp hàng; chỉ dùng khi thật cần.
  • "destroy() luôn chạy." Không khi tiến trình bị kill.
  • Bài chưa đo hành vi dưới tải rất cao, chưa thử SingleThreadModel (đã bị bỏ khỏi Servlet API), và không đo thời gian chờ tối đa của init() do container quy định. Kết quả của UnavailableException (404) lấy từ Tomcat 11.0.27; container khác có thể chọn mã khác.

Tự kiểm tra

  1. Servlet nạp muộn và servlet nạp sớm khác nhau thế nào về luồng gọi init(), và điều đó ảnh hưởng gì tới người dùng?
  2. init() ném ServletException lần đầu: yêu cầu hiện tại và yêu cầu kế tiếp nhận gì? Còn nếu ném UnavailableException?
  3. Vì sao đừng ghi đè service() chỉ để xử lý GET và POST?
  4. Bộ đếm private int count tăng trong doGet đếm được 912 trên 2000 yêu cầu. Hai cách sửa, và khác biệt chi phí?
  5. Chương trình khởi chạy đọc một danh sách static mà servlet vừa ghi vào nhưng thấy rỗng. Nguyên nhân?

Gợi ý đáp án. (1) Nạp sớm: init() chạy trên luồng khởi động, trước yêu cầu đầu tiên; nạp muộn: chạy trên luồng xử lý yêu cầu đầu tiên và người đó chờ. (2) Lần đầu 500; yêu cầu sau container thử init() lại (lab thành công ở lần hai); với UnavailableException thì 404 liên tục. (3) Mất HEAD và OPTIONS tự động, và dễ làm sai cách phân phối. (4) AtomicInteger (không khóa, rẻ) hoặc synchronized (khóa, yêu cầu xếp hàng). (5) Hai bản của cùng một lớp do hai class loader nạp, nên hai trường static riêng.

Điều cần nhớ

  • init() một lần, service() mỗi yêu cầu từ nhiều luồng, destroy() một lần khi dừng êm.
  • Nạp sớm đưa lỗi cấu hình ra lúc triển khai; init() lỗi thường được thử lại ở yêu cầu sau, còn UnavailableException thì không.
  • HttpServlet.service() cho bạn HEAD và OPTIONS; đừng ghi đè nó.
  • Trường của servlet là dữ liệu dùng chung: không có khóa thì mất cập nhật mà không có lỗi.
  • Biến static của ứng dụng web nằm trong class loader riêng của ứng dụng.

Đọc thêm

Bài tiếp theo đi vào request, response và session: đọc dữ liệu từ yêu cầu, ghi phản hồi đúng bộ ký tự, và giữ trạng thái giữa các yêu cầu bằng HttpSession.