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ì khiinit()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
AtomicIntegerhoặcsynchronized. - 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ần | Phiên bản đã kiểm chứng | Ghi chú |
|---|---|---|
| JDK | 17 trở lên | java -version |
| Maven | 3.9 trở lên | mvn -v |
| Apache Tomcat Embed | 11.0.27 | khai báo trong pom.xml, hiện thực Jakarta Servlet 6.1 |
| Docker | không cần | bài này không dùng cơ sở dữ liệu |
Cấu trúc
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 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 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.
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.
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.
| Pha | Gọi bao nhiêu lần | Dù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, doPost | mỗi yêu cầu, từ nhiều luồng cùng lúc | Xử lý yêu cầu | Ghi 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
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
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
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ả:
-- 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ồngmainkhi 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ầndoGetsau đó 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
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):
ordinary exception, request 1: 500
ordinary exception, request 2: 200 ready after 2 init attempts
UnavailableException, request 1: 404
UnavailableException, request 2: 404
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
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):
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
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:
-- 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ách | Khi dùng | Giá phải trả |
|---|---|---|
| Không dùng trường dùng chung | Mặ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ầu | Khô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.concurrent | Bộ đếm, bộ nhớ đệm đơn giản | Phả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óa | Khi cần bảo vệ một đoạn gồm nhiều bước | Yê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ệu | Dữ liệu của từng người dùng hoặc cần bền vững | Cầ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
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
@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 định | Lựa chọn | Đánh đổi |
|---|---|---|
| Thời điểm khởi tạo | loadOnStartup cho servlet phụ thuộc tài nguyên ngoài; muộn cho phần còn lại | Sớ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ắt | Không thể dựa hoàn toàn vào destroy() khi tiến trình bị kill |
| Ghi đè phương thức | doGet, 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 chung | Tránh; nếu cần thì java.util.concurrent | Thiết kế gọn hơn nhưng phải nghĩ về nơi lưu dữ liệu |
Trạng thái static | Không dùng cho dữ liệu cần chia sẻ ngoài ứng dụng | Phải dùng dịch vụ ngoài |
Playbook: gặp sự cố vòng đời thì làm gì
| Triệu chứng | Kiểm tra đầu tiên | Nguyên nhân hay gặp | Cách xử lý |
|---|---|---|---|
| Số liệu dùng chung sai khi tải cao | Có 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ường | init() làm gì | Servlet nạp muộn khởi tạo tài nguyên nặng | loadOnStartup, hoặc khởi tạo ở ServletContextListener |
| 500 ngẫu nhiên sau triển khai, rồi tự hết | Log của init() | Tài nguyên ngoài chưa sẵn sàng; container thử lại ở yêu cầu sau | Kiể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ó servlet | Log có UnavailableException không | init() ném lỗi không phục hồi | Sử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ỗ kia | Lớp được nạp bởi class loader nào | Hai bản của cùng một lớp | Chia 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ừng | destroy() có được gọi không | Tiến trình bị kill, hoặc destroy() ném lỗi | Dừ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ứng | Nguyên nhân | Cách xử lý |
|---|---|---|
Address already in use ở cổng 8080 | Có ứng dụng khác dùng cổng 8080 | PORT=9090 mvn -q compile exec:exec |
/events trả rỗng | Chưa có servlet nào được gọi | Gọi /life/eager hoặc /life/lazy trước |
unsafe ra đúng 2000 | Máy quá nhanh hoặc ít lõi nên không có tranh chấp | Tă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.
- Quan sát vòng đời. Chạy
LifecycleProbes. Tiêu chí:EagerServlet.init()chạy trên luồngmaintrước mọi yêu cầu;LazyServlet.init()chạy đúng một lần trên luồngexec-N. - Đổi thời điểm khởi tạo. Bỏ
loadOnStartup = 1khỏiEagerServletvà chạy lại. Tiêu chí:init()của nó dời sang yêu cầu đầu tiên. - 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. - Gây mất cập nhật. Chạy
LifecycleProbesba lần. Tiêu chí:unsafeluô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ỏiincrementUnsafevà so sánh. - Thêm bộ đếm thứ tư. Thêm đường dẫn
/count/concurrentdùngLongAdder. Tiêu chí: 2000 yêu cầu cho đúng 2000. - Thử HEAD và OPTIONS.
curl -I http://localhost:8080/methodsvàcurl -i -X OPTIONS .... Tiêu chí: thấyAllowđúng với các phương thức bạn đã ghi đè; thêmdoPutvà thấyPUTxuất hiện trongAllow. - 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 testchoTests 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ầndoGetchỉ có mộtinit(). - "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.
- "
synchronizedlà đủ 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ủainit()do container quy định. Kết quả củaUnavailableException(404) lấy từ Tomcat 11.0.27; container khác có thể chọn mã khác.
Tự kiểm tra
- 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? init()némServletExceptionlầ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émUnavailableException?- Vì sao đừng ghi đè
service()chỉ để xử lýGETvàPOST? - Bộ đếm
private int counttăng trongdoGetđếm được 912 trên 2000 yêu cầu. Hai cách sửa, và khác biệt chi phí? - Chương trình khởi chạy đọc một danh sách
staticmà 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ònUnavailableExceptionthì không. HttpServlet.service()cho bạnHEADvà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
- Jakarta Servlet 6.1 Specification, chương về vòng đời servlet.
- Apache Tomcat 11: Class Loader How-To
- Java SE 17: package java.util.concurrent.atomic
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.