这是「Java 从基础到实战」系列的第 13 篇。Tomcat、Netty 再复杂,最底层也是 Socket。本文先用 30 行代码跑通 TCP 通信,再升级成一个能被浏览器直接访问的多线程 HTTP 服务器——全部代码不到 200 行,没有任何依赖。JDK 17 下可直接运行。
前言
「浏览器输入 URL 后发生了什么」是面试经典题,但纸上谈兵不如亲手接一次浏览器的请求:你会亲眼看到浏览器发来的原始报文,也就真正理解了 HTTP 只是「按格式约定的字符串」。
环境准备
| 软件 |
版本 |
| JDK |
17(Eclipse Temurin) |
| IntelliJ IDEA |
Community 社区版 |
| 浏览器 |
任意(Chrome/Edge) |
在 java-basics 项目中新建包 net。
步骤 1:最小 TCP 通信 —— Echo 服务器
Socket 通信模型:服务端 ServerSocket.accept() 等电话,客户端 new Socket(host, port) 打电话,接通后双方各有一对输入/输出流对讲。
新建 EchoServer.java:
package net;
import java.io.*; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets;
public class EchoServer { public static void main(String[] args) throws IOException { try (ServerSocket server = new ServerSocket(9000)) { System.out.println("Echo 服务器启动,监听 9000 端口……"); while (true) { Socket socket = server.accept(); try (socket; var reader = new BufferedReader(new InputStreamReader( socket.getInputStream(), StandardCharsets.UTF_8)); var writer = new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), StandardCharsets.UTF_8), true)) { String line = reader.readLine(); System.out.println("收到: " + line); writer.println("Echo: " + line); } } } } }
|
新建 EchoClient.java,先运行服务器再运行它:
package net;
import java.io.*; import java.net.Socket; import java.nio.charset.StandardCharsets;
public class EchoClient { public static void main(String[] args) throws IOException { try (Socket socket = new Socket("127.0.0.1", 9000); var writer = new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), StandardCharsets.UTF_8), true); var reader = new BufferedReader(new InputStreamReader( socket.getInputStream(), StandardCharsets.UTF_8))) { writer.println("你好,Socket!"); System.out.println("服务器回复: " + reader.readLine()); } } }
|
客户端预期输出:
TCP 通信就这么多东西:建连接、读写流、关连接。
步骤 2:偷看浏览器发来的 HTTP 请求
把 Echo 服务器的读取部分改成循环打印,然后用浏览器访问 http://localhost:9000/hello:
String line; while ((line = reader.readLine()) != null && !line.isEmpty()) { System.out.println(line); }
|
控制台会打印出浏览器的原始请求报文:
GET /hello HTTP/1.1 Host: localhost:9000 Connection: keep-alive User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Accept: text/html,application/xhtml+xml,...
|
看清楚了:HTTP 请求 = 请求行 + 头部字段 + 空行 + 可选 body 的纯文本。所谓 HTTP 服务器,就是解析这段文本、按格式回一段文本。
步骤 3:手写 HTTP 服务器(核心 130 行)
功能目标:支持 GET 路由分发、返回 HTML/JSON、404 兜底、多线程处理并发。新建 MiniHttpServer.java:
package net;
import java.io.*; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Function;
public class MiniHttpServer { private static final Map<String, Function<String, String>> ROUTES = Map.of( "/", path -> "<h1>欢迎来到迷你服务器</h1><p>试试 <a href='/time'>/time</a></p>", "/time", path -> "<h1>当前时间: " + java.time.LocalTime.now() + "</h1>", "/api/user", path -> "{\"id\": 1, \"name\": \"张三\"}" );
public static void main(String[] args) throws IOException { ExecutorService pool = Executors.newFixedThreadPool(10); try (ServerSocket server = new ServerSocket(8080)) { System.out.println("HTTP 服务器启动: http://localhost:8080"); while (true) { Socket socket = server.accept(); pool.execute(() -> handle(socket)); } } }
private static void handle(Socket socket) { try (socket; var reader = new BufferedReader(new InputStreamReader( socket.getInputStream(), StandardCharsets.UTF_8)); var out = new BufferedOutputStream(socket.getOutputStream())) {
String requestLine = reader.readLine(); if (requestLine == null) return; String[] parts = requestLine.split(" "); String method = parts[0]; String path = parts[1]; System.out.printf("[%s] %s %s%n", Thread.currentThread().getName(), method, path);
String line; while ((line = reader.readLine()) != null && !line.isEmpty()) {}
Function<String, String> handler = ROUTES.get(path); if (handler != null) { String body = handler.apply(path); String type = path.startsWith("/api") ? "application/json" : "text/html"; write(out, 200, "OK", type, body); } else { write(out, 404, "Not Found", "text/html", "<h1>404 页面不存在</h1>"); } } catch (IOException e) { System.err.println("处理请求出错: " + e.getMessage()); } }
private static void write(OutputStream out, int code, String reason, String contentType, String body) throws IOException { byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); String headers = "HTTP/1.1 " + code + " " + reason + "\r\n" + "Content-Type: " + contentType + "; charset=utf-8\r\n" + "Content-Length: " + bodyBytes.length + "\r\n" + "Connection: close\r\n" + "\r\n"; out.write(headers.getBytes(StandardCharsets.UTF_8)); out.write(bodyBytes); out.flush(); } }
|
步骤 4:验证
运行 MiniHttpServer,依次用浏览器访问:
| URL |
预期效果 |
http://localhost:8080/ |
显示欢迎页,带链接 |
http://localhost:8080/time |
显示当前时间,刷新会变 |
http://localhost:8080/api/user |
显示 JSON:{"id": 1, "name": "张三"} |
http://localhost:8080/xxx |
显示 404 页面不存在 |
控制台同时打印每个请求由哪个线程处理:
[pool-1-thread-1] GET / [pool-1-thread-2] GET /time [pool-1-thread-3] GET /api/user
|
恭喜,你写的服务器已经具备了 Web 框架的雏形:监听 → 解析 → 路由 → 响应 → 并发。Spring Boot 内嵌的 Tomcat 做的是同一件事,只是解析更完整、线程模型更精巧。
常见坑
坑 1:响应缺少空行或 Content-Length,浏览器一直转圈
错误示范 ❌:头部和 body 之间漏掉 \r\n 空行,或不写 Content-Length——浏览器不知道 body 从哪开始、到哪结束,一直等待。
正确写法:严格按「状态行 → 头部 → 空行 → body」格式输出,Content-Length 用字节数(bodyBytes.length),不是字符串长度 ✅(中文字符 UTF-8 下占 3 字节,用 body.length() 必错)。
坑 2:端口被占用 BindException: Address already in use
原因:上一个进程还占着 8080(常见于上次运行没停掉)。
解法:IDEA 中先点红色方块停掉旧进程;查不到就用命令行 ✅:
netstat -ano | findstr :8080 taskkill /F /PID <PID>
|
坑 3:单线程 accept 循环里直接处理请求
错误示范 ❌:不用线程池,handle(socket) 直接在主循环调用——一个慢请求会卡住所有后续连接(用两个浏览器标签同时访问 /time 即可复现排队现象)。
正确写法:accept 后立刻把 socket 丢给线程池 ✅(见步骤 3),这正是第 7 篇线程池知识的实战应用。
小结
- ✅ TCP 通信三件事:建连接、读写流、关连接
- ✅ HTTP 报文 = 按约定格式的纯文本,空行是头与体的分界
- ✅ 手写 HTTP 服务器五要素:监听 → 解析 → 路由 → 响应 → 线程池并发
- ✅ Content-Length 按字节算,端口冲突用 netstat 排查
下一篇是阶段二收官:《JDK 17/21 新特性实战:Record、密封类、虚拟线程》,虚拟线程 vs 线程池压测对比,敬请期待。
上一篇:《Java 反射与注解实战:手写一个迷你 @Autowired》
本系列完整目录见博客「技术博文」分类。
💬 评论