请求线程、异步 Servlet 与非阻塞读写
同步 Servlet 在 service() 和 Filter 调用栈退出后交还执行线程,请求随之进入完成处理。异步 Servlet 把这两个时刻分开:初始调用可以先返回,请求和响应继续存在,直到异步重新分派结束或调用 complete()。
同步请求 [Filter → service → 返回] → 完成
执行线程 [ 被请求占用 ] → 可处理其他任务
异步请求 [startAsync → 初始返回] ─────── [dispatch / complete] → 完成
初始线程 [ 被占用 ] → 可处理其他任务把普通工作提交到线程池,只改变工作的执行位置;调用 startAsync() 才改变 Servlet 请求的生命周期。使用 ReadListener、WriteListener 又解决另一件事:等待 socket 可读或可写时,不阻塞执行线程。这三个操作可以组合,也可以独立存在。
原调用返回后,请求怎样继续存在
startAsync、dispatch 和 complete
startAsync() 返回当前请求的 AsyncContext。应用可使用 start(Runnable) 让容器调度任务,使用 dispatch() 把请求重新交给某个 Servlet,或使用 complete() 表示异步处理完成。调用的合法时机与超时规则见 AsyncContext API。
两个时序条件尤其重要:
在当前容器分派尚未返回时调用 dispatch,目标分派会延后到当前分派返回之后。此时调用 complete,完成处理及相关通知也要等到该分派返回。
因此,startAsync() 不会把正在执行的 Java 调用栈从线程上拿走。随后继续执行十秒阻塞调用,当前线程仍被占用十秒;在调用末尾 future.get() 等待后台任务,也会保留原线程的等待成本。
用重新分派完成一次请求
下载实验工程,解压进入 servlet-async/。Java 17 编译目标、Temurin 25 运行,容器固定 Tomcat 11.0.25 / Servlet 6.1。项目包括 AsyncServlet、ReadServlet、StreamServlet、启动类和五个真实 HTTP 集成测试。
以下 AsyncServlet 完整展示“后台计算结果→异步分派”。后台任务只修改独立的原子引用,不并发读写原始 request/response;目标 Servlet 在重新分派中读取结果并写响应。
package lab;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicReference;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
public final class AsyncServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
static final String RESULT = AsyncServlet.class.getName() + ".result";
@Override protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws IOException {
AtomicReference<String> result = new AtomicReference<>();
request.setAttribute(RESULT, result);
AsyncContext async = request.startAsync();
async.setTimeout(3000);
async.addListener(new CompletionEvents());
Server.events.add("startAsync");
async.start(() -> {
// Only detached result data is touched by this task.
result.set("work-done");
async.dispatch("/result");
});
}
public static final class CompletionEvents implements AsyncListener {
@Override public void onComplete(AsyncEvent event) { Server.events.add("onComplete"); }
@Override public void onTimeout(AsyncEvent event) throws IOException {
Server.events.add("onTimeout");
HttpServletResponse response=(HttpServletResponse)event.getAsyncContext().getResponse();
if(!response.isCommitted()){
response.setStatus(503);
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().println("async-timeout");
}
event.getAsyncContext().complete();
}
@Override public void onError(AsyncEvent event) { Server.events.add("onError"); }
@Override public void onStartAsync(AsyncEvent event) {
event.getAsyncContext().addListener(this);
}
}
}目标 /result 在 Server 中注册,核心处理为:
Object result = request.getAttribute(AsyncServlet.RESULT);
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().println(
((AtomicReference<?>) result).get() + ":" + request.getDispatcherType());原分派前放入的容器请求属性保存一个结果槽;槽内的计算结果具有跨线程可见性。异步任务通过 AsyncContext 发起分派,不接触 writer。目标 Servlet 返回时没有再次调用 startAsync,请求按本次分派结果结束。
Linux 宿主账号需有 Docker 权限。解压目录和 Maven 缓存由该账号拥有,构建与运行容器均显式使用宿主 UID/GID:
BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25
RUN_IMAGE=eclipse-temurin:25.0.4_7-jdk
mkdir -p .m2
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/cache -v "$PWD/.m2:/cache" \
-v "$PWD:/work" -w /work "$BUILD_IMAGE" \
mvn -B -Dmaven.repo.local=/cache/repository clean verify
docker run --detach --name servlet-async \
--user "$(id -u):$(id -g)" --read-only \
--cap-drop ALL --security-opt no-new-privileges \
--tmpfs /tmp:rw,nosuid,nodev,size=64m,mode=1777 \
-p 127.0.0.1:18088:8080 \
-v "$PWD/target:/app:ro" -w /app \
"$RUN_IMAGE" java -cp 'classes:dependency/*' lab.Server 8080
docker logs servlet-async
curl -q --noproxy '*' --fail-with-body --max-time 6 \
http://127.0.0.1:18088/async日志出现 LISTENING 8080 后,响应应为 work-done:ASYNC。首次依赖下载受限时使用企业批准的 Maven 仓库和 Docker 镜像仓库;离线运行要先准备依赖缓存与镜像,不能只带源码。
测试在真实 Filter 回程中记录 return:REQUEST,在目标 Servlet 中记录 result:ASYNC,断言前者先于后者,并观察一次 onComplete。这个实验验证的是容器分派顺序,不依赖“后台线程大概晚一点运行”的 sleep 假设。
asyncSupported 和对象线程安全
当前链路中的 Servlet 和 Filter 必须允许异步。实验 /unsupported 使用同一 AsyncServlet,却没有把注册的 asyncSupported 设为 true,因而 startAsync 抛 IllegalStateException,Tomcat 返回 500。
code=$(curl -q --noproxy '*' --silent --show-error --max-time 6 \
-o unsupported.body -w '%{http_code}' \
http://127.0.0.1:18088/unsupported)
transport=$?
test "$transport" -eq 0 && test "$code" = 500
docker logs servlet-async实际应用应查完整参与链,而不只看目标 Servlet 的注解;一个不支持异步的 Filter 也能阻止 startAsync。Filter 是否参与 ASYNC 分派,则由 dispatcher 映射决定,两种配置互不替代。
Servlet 请求和响应一般没有供任意多线程并发访问的保证。让两个回调同时写 writer,或让初始线程和后台线程一起解析参数,都会制造数据竞争。可把必要输入复制为不可变值,后台任务只产生结果,最后由一个明确的响应处理路径完成写出。
超时、错误与完成通知
超时从哪个时刻开始
AsyncContext.setTimeout() 的时间从启动异步的当前容器分派返回后起算,单位毫秒;非正数表示不设超时。它不包含此前排队、参数解析或同步业务的全部耗时。数据库、HTTP 客户端、业务队列和入口代理还需要各自的时限,必要时共享一个端到端 deadline。
实验 /timeout 启动异步后不安排工作,设置 100 毫秒超时。容器触发 onTimeout,监听器写入 503 和 async-timeout,再调用 complete。定时检查具有调度粒度,响应不承诺恰好在第 100 毫秒到达。
code=$(curl -q --noproxy '*' --silent --show-error --max-time 6 \
-o timeout.body -w '%{http_code}' \
http://127.0.0.1:18088/timeout)
transport=$?
test "$transport" -eq 0 && test "$code" = 503
cat timeout.body测试另外断言 onTimeout 先于 onComplete,且最终完成通知一次。onComplete 是请求完成通知,不能和业务结果、超时一起争抢“第一个回调就是唯一终点”的标志,否则超时路径抢到标志后可能把真正的最终清理跳过。
如果所有 timeout/error 监听器都没有通过 dispatch 或 complete 接管处理,容器会按规范继续错误处理。目标错误页、响应是否已提交和后续分派共同决定实际结果,完整分支见 Servlet 异步规范。
每次异步周期都有自己的监听器集合
AsyncListener 包含 onComplete、onTimeout、onError 和 onStartAsync。重新分派的目标再次调用 startAsync,会开始新的异步周期;旧监听器可在 onStartAsync 中向新周期重新注册。不能假设首次注册后会自动覆盖任意后续周期。接口定义见 AsyncListener API。
业务完成与超时接近时,应把“选择哪种结果”和“最终释放资源”分开处理。前者可由原子状态确保只有一个响应动作;后者放到幂等关闭函数或最终通知中。若结果到达时已经失去响应处理权,就只释放任务资源,不再调用失效的 request/response。
取消还必须作用于真正工作的对象:Future 中断请求、数据库 statement 取消、远程 deadline 或订阅关闭。HTTP 响应已经 503,后台事务仍可能继续提交;写操作需要幂等键和状态查询处理结果未知。普通 Servlet 超时不会自动替所有依赖做这些动作。
非阻塞读取与写出
readiness 控制可以推进多少字节
ReadListener 在有可读数据时收到 onDataAvailable,在请求数据全部读完时收到 onAllDataRead;错误走 onError。isReady() 为 false 时应返回,等待容器之后通知,而不是循环空转或阻塞等待。输入流需要在异步处理或支持的升级场景中注册非阻塞监听,见 ServletInputStream API。
实验的完整 ReadServlet 使用 4 KiB 缓冲,累计字节但不保存正文,超过 64 KiB 返回 413:
package lab;
import java.io.IOException;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
public final class ReadServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override protected void doPost(HttpServletRequest request,
HttpServletResponse response) throws IOException {
AsyncContext async=request.startAsync();
async.setTimeout(3000);
ServletInputStream input=request.getInputStream();
input.setReadListener(new ReadListener(){
final byte[] chunk=new byte[4096];
int count;
boolean finished;
@Override public void onDataAvailable()throws IOException{
while(!finished && input.isReady() && !input.isFinished()){
int n=input.read(chunk);
if(n<0)break;
count+=n;
if(count>65536){
finished=true;
response.sendError(413);
async.complete();
}
}
}
@Override public void onAllDataRead()throws IOException{
if(finished)return;
finished=true;
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().println("bytes="+count);
async.complete();
}
@Override public void onError(Throwable error){
if(!finished){finished=true;async.complete();}
}
});
}
}64 KiB 是演示上限。实际应用应把累计大小与业务允许值绑定,并正确处理未知 Content-Length、提前 EOF 和取消。传输块也不对应一个完整字符或业务消息:UTF-8 字符、JSON token、multipart boundary 都可能跨两次回调。需要增量解码器或协议解析状态,不能把每块直接转 String 后拼接当作通用解析器。
写出也需要保存偏移
WriteListener.onWritePossible() 表示可以继续尝试输出;每次写前仍需检查 isReady()。客户端接收缓慢时,容器缓冲可能暂时不能接收更多数据,应用必须保存偏移并返回。相关契约见 ServletOutputStream API。
实验 StreamServlet 输出有限的 64 KiB 内容:
package lab;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
public final class StreamServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws IOException {
byte[] data = "x".repeat(65536).getBytes(StandardCharsets.US_ASCII);
AsyncContext async = request.startAsync();
async.setTimeout(3000);
async.addListener(new AsyncServlet.CompletionEvents());
response.setContentType("application/octet-stream");
response.setContentLength(data.length);
ServletOutputStream output = response.getOutputStream();
output.setWriteListener(new WriteListener() {
int offset;
boolean finished;
@Override public void onWritePossible() throws IOException {
while (!finished && output.isReady()) {
int length = Math.min(512, data.length - offset);
if (length > 0) {
output.write(data, offset, length);
offset += length;
}
if (offset == data.length) {
finished = true;
async.complete();
}
}
}
@Override public void onError(Throwable error) {
finished = true;
async.complete();
}
});
}
}offset 是已交给输出流的字节数,finished 避免完成后继续推进。一个回调可能发送多块,也可能只推进很少;测试按最终响应长度和全部内容核对,不硬编码回调次数。
curl -q --noproxy '*' --fail-with-body --max-time 6 \
-o stream.bin http://127.0.0.1:18088/stream
wc -c < stream.bin预期是 65536。它说明客户端拿到了完整实验内容,不能据此推导生产慢客户端性能。有限数组也没有演示无限事件流:持续生产的上游需要有界队列,在待写数据达到上限时暂停生产、合并允许合并的事件,或结束连接。
非阻塞读写只消除等待 socket 就绪时的线程阻塞。在这些回调里执行 JDBC 查询、等待锁或做长时间文件解析,仍会占用调用回调的容器线程;有阻塞依赖时应使用受控任务执行方式,并明确并发配额。
线程、在途请求与停止
同步请求平均到达率为 λ,平均驻留时间为 W,在稳定条件下平均在途数量约为 λW。异步可以降低等待阶段占用的平台线程数量,却不会消除请求状态、连接、缓冲或下游占用。数据库只有 40 条连接时,允许数万请求一起异步等待会把问题移到连接等待队列。
虚拟线程让阻塞式代码具备另一种较低线程成本的实现方式;是否仍需 Servlet async,取决于流式通信、回调接口、框架支持与编程复杂度。无论线程类型,都要按下游容量限制并发,不能把线程创建便宜理解为资源无限。
排障时把以下观察放在同一时间窗口内:
| 观察组合 | 应继续检查 |
|---|---|
| 工作线程繁忙,异步请求很少 | 同步阻塞调用、锁、下游连接池等待 |
| 工作线程较空闲,异步请求年龄持续增长 | 漏 dispatch/complete、回调未到、任务排队 |
| timeout 增多,后台任务数量不下降 | 取消是否传播到任务和真实 I/O |
| 已提交后出现写异常 | 客户端断开、慢消费、上游代理时限 |
| 初始调用返回很慢 | startAsync 后仍执行阻塞代码或等待 Future |
计时应区分本次分派耗时与整个请求耗时;线程上下文按每次执行恢复,请求级资源按最终完成清理。对应状态可以通过应用计数、AsyncListener 事件、容器指标和线程 dump 联合观察,不能只看线程池 active。
停止服务时先停止接收新业务,给同步与异步在途处理一定时间;随后取消剩余任务,关闭其持有资源,再结束执行器。完整停止策略要考虑部署平台和依赖超时,示例容器可以这样结束:
docker stop --timeout 15 servlet-async
docker rm servlet-async
rm -- unsupported.body timeout.body stream.bin强制终止后不能要求监听器一定执行;生产恢复应能处理未完成任务和结果未知。需要从线程栈、端口和日志继续定位时,使用 Java Web 常见故障中的实际命令。
权威资料与规范地址
完整契约、配置选项和实现细节可按下列地址查阅。
