Socket、HTTP、多线程,这三个词几乎出现在每一份Java工程师的面试题里,但大部分人对它们的理解停留在背概念。平时用Spring Boot写接口,网络层全被Tomcat和框架包住了,一旦线上出现连接被重置、连接数暴涨、请求卡死这种问题,你会发现自己连排查入口都找不到。
这篇文章我不打算讲框架封装好的那套,而是从零开始,用纯Java实现一个多线程HTTP服务器。Socket负责建立连接,HTTP负责定义消息格式,多线程负责并发处理,三件事串起来,你就能真正理解"一个Web服务器到底是怎么工作的"。源码层面的东西我会贴完整实现,也会把我在实际调试中踩过的坑一并写出来。适合正在学Java网络编程或者准备面试的人,也适合在业务代码里被连接问题折磨过的同学。
1. 从"天天调接口"到"自己写服务器":网络编程到底在学什么
1.1 你调用的每个REST接口,底层都是同样的四个动作
很多人以为HTTP接口和Socket是两套完全不相干的网络技术,这是个误解。用HttpClient、RestTemplate、OkHttp发的每一个HTTP请求,底层都是先创建一个Socket,再往Socket的输出流里写HTTP请求文本,最后从输入流里读HTTP响应文本。框架只是把这些动作封装成了好用的API,让你看不到字节流的搬运过程。
Socket的本质是操作系统提供的一个网络通信端点,由IP地址和端口号唯一标识。一次TCP连接由四元组确定:源IP、源端口、目标IP、目标端口。服务端要做的事情很固定:创建一个ServerSocket绑定端口,然后循环等待客户端连接进来,每个连接拿到一个Socket,从里面读数据、写数据,最后关闭。这四个动作对应操作系统层面的bind、listen、accept、connect,Java把这几个系统调用封装成了ServerSocket和Socket两个类。
不理解这一层,你就很难解释一些线上现象:为什么端口被占用起不了服务?为什么连接数突然到了几万?为什么客户端明明断开服务端还显示ESTABLISHED?这些问题全都在Socket层,框架不会帮你回答。
1.2 一个手写HTTP服务器的学习价值在哪里
Tomcat、Jetty、Netty这些成熟产品把网络编程的复杂度全部接管了,日常工作确实不需要重复造轮子。但学习阶段自己写一个,价值非常大。
一是能把协议从"看起来是字符串"变成"可解析的数据结构"。HTTP请求本质就是一段有固定格式的文本,手写解析器能让你彻底看清请求行、请求头、请求体的边界在哪里。
二是能把并发模型从"大概知道"变成"亲手验证"。单线程为什么慢、多线程为什么有线程安全问题、线程池参数怎么定,这些只有跑起来才能形成直觉。
三是面试的时候能讲出实质内容。面试官问Tomcat为什么能同时处理那么多请求,你如果能从ServerSocket accept后丢给线程池处理这个角度回答,比背"Tomcat使用线程池处理请求"有说服力得多。
后面几节我会按照这个路线走:先写一个只能收发字节的Socket服务器,再给它加上HTTP解析能力,最后用线程池改造并发模型。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Socket到底在做什么:从ServerSocket到TCP连接建立
2.1 三次握手藏在哪一行代码里
先看最基础的服务端代码,这是所有Socket服务器的起点。
java复制public class SingleThreadServer {
public static void main(String[] args) throws IOException {
// 第二个参数是backlog,表示操作系统层面允许排队的连接数量
ServerSocket serverSocket = new ServerSocket(8080, 128);
System.out.println("server listening on port 8080");
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("client connected: " + clientSocket.getRemoteSocketAddress());
InputStream in = clientSocket.getInputStream();
OutputStream out = clientSocket.getOutputStream();
byte[] buffer = new byte[1024];
int read = in.read(buffer);
if (read > 0) {
String message = new String(buffer, 0, read, StandardCharsets.UTF_8);
System.out.println("received: " + message);
out.write(("echo: " + message).getBytes(StandardCharsets.UTF_8));
out.flush();
}
clientSocket.close();
}
}
}
很多初学者以为三次握手发生在accept()这一行,这是个常见的误解。实际上,客户端调用new Socket(host, port)的那一刻,操作系统内核就开始进行三次握手了。SYN、SYN-ACK、ACK这三个包的交换完全在内核协议栈里完成,Java代码感知不到。
accept()做的事是:从内核维护的已完成连接队列里取一个连接出来,返回一个Socket对象。如果队列为空,accept()就会阻塞,直到有新的连接完成握手。这个队列的大小就是ServerSocket构造函数的backlog参数,默认值是50。队列满了之后,内核会直接把新的SYN丢弃或者回复RST,客户端表现就是"连接超时"或者"Connection refused"。
这在排查问题的时候很有用。如果你发现服务端accept()逻辑很慢或者被卡住,客户端连接就会在队列里堆积,超过backlog之后新连接连不进来,但已经建立的连接不受影响。很多人遇到"部分客户端连不上"的情况,第一反应是看防火墙,其实先看一眼accept循环是否健康更靠谱。
2.2 InputStream和OutputStream:TCP是字节流,不是消息流
拿到了Socket之后,通信就变成了两个方向的字节流。Socket.getInputStream()用来读对端发来的数据,Socket.getOutputStream()用来向对端写数据。听起来很简单,但这里藏着一个网络编程最核心的概念:TCP是流协议,没有消息边界。
什么意思?你把一个字符串"hello"通过write()写出去,对端read()不一定会一次性拿到完整的5个字节,可能拿到3个字节,剩下的2个字节在下一个read()里。反过来,如果你连续写了两次,对端一次read()也可能把两批数据全部读完。TCP只保证字节的顺序,不保证字节的分组。
这就意味着,应用层协议必须自己定义"一条消息从哪里开始、到哪里结束"。HTTP的做法是用空行分割请求头和请求体,再用Content-Length字段告诉对端请求体有多少字节。自定义二进制协议通常会在包头放一个
