一个线程处理一个客户端连接
在单线程的 Socket 编程中,服务器一次只能处理一个客户端的请求,当服务器与客户端 A 通信时,它就无法响应客户端 B 的请求,这显然不能满足高并发的需求。

多线程 Socket 编程的核心思想是: 每当有一个新的客户端连接请求到达时,服务器就创建一个新的线程来专门处理这个客户端的所有通信。
这样,主线程就可以继续监听新的客户端连接,而其他线程则可以并行地与各自的客户端进行通信,互不干扰。
编程模型与步骤
下面我们通过一个最经典的 “Echo 服务器” 来演示多线程 Socket 编程的模型,Echo 服务器的功能很简单:它接收客户端发来的任何消息,然后将原样返回给客户端。
我们将代码分为三个部分:

- 服务器端
- 客户端
- 为每个客户端服务的任务类
服务器端代码
服务器的主要职责是:
- 在一个固定的端口上启动一个
ServerSocket,监听客户端的连接请求。 - 当一个客户端连接时,
accept()方法会返回一个代表该连接的Socket对象。 - 创建一个新的
Thread,并将处理这个客户端通信的任务交给它。 - 立即返回,继续监听下一个客户端连接。
// EchoServer.java
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
public class EchoServer {
// 服务器监听的端口号
private static final int PORT = 12345;
public static void main(String[] args) {
// 使用 try-with-resources 确保 ServerSocket 被正确关闭
try (ServerSocket serverSocket = new ServerSocket(PORT)) {
System.out.println("服务器已启动,正在监听端口 " + PORT + "...");
// 无限循环,持续监听客户端连接
while (true) {
// accept() 是一个阻塞方法,等待客户端连接
// 当有客户端连接时,它会返回一个 Socket 对象
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
// 创建一个新的线程来处理这个客户端的通信
// 将 clientSocket 和客户端地址信息传递给线程任务
ClientHandler handler = new ClientHandler(clientSocket);
new Thread(handler).start();
}
} catch (IOException e) {
System.err.println("服务器异常: " + e.getMessage());
e.printStackTrace();
}
}
}
客户端任务类
这是多线程模型的核心,这个类实现了 Runnable 接口,它的实例会被每个客户端线程执行,它的职责是:
- 从
Socket获取输入流,读取客户端发来的数据。 - 从
Socket获取输出流,将数据写回给客户端。 - 处理通信结束后,关闭相关的资源。
// ClientHandler.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
public class ClientHandler implements Runnable {
private final Socket clientSocket;
public ClientHandler(Socket socket) {
this.clientSocket = socket;
}
@Override
public void run() {
// 使用 try-with-resources 确保流和 socket 被正确关闭
try (
// 获取客户端的输入流,用于读取数据
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
// 获取客户端的输出流,用于发送数据
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
) {
String inputLine;
// 循环读取客户端发送的数据
// readLine() 也是一个阻塞方法,会等待客户端发送一行数据
while ((inputLine = in.readLine()) != null) {
System.out.println("收到来自 " + clientSocket.getInetAddress().getHostAddress() + " 的消息: " + inputLine);
// 将收到的消息回显给客户端
out.println("服务器回显: " + inputLine);
}
} catch (IOException e) {
// 当客户端断开连接时,会抛出异常,这里捕获并打印
// 正常情况下,客户端关闭连接是 expected behavior,所以这里可以不打印堆栈
System.out.println("客户端 " + clientSocket.getInetAddress().getHostAddress() + " 已断开连接。");
} finally {
// 确保在任务结束时关闭 socket
try {
if (clientSocket != null && !clientSocket.isClosed()) {
clientSocket.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
客户端代码
客户端相对简单,它只需要连接到服务器,然后发送和接收数据即可。
// EchoClient.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;
public class EchoClient {
private static final String HOSTNAME = "localhost";
private static final int PORT = 12345;
public static void main(String[] args) {
// 使用 try-with-resources 确保 socket 和流被正确关闭
try (
Socket socket = new Socket(HOSTNAME, PORT);
// 获取服务器的输入流,用于读取服务器返回的数据
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// 获取服务器的输出流,用于发送数据
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
// 获取控制台输入流,用于从键盘读取用户输入
BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in))
) {
System.out.println("已连接到服务器。");
System.out.println("请输入消息,输入 'exit' 退出:");
String userInput;
// 循环读取用户在控制台的输入
while ((userInput = stdIn.readLine()) != null) {
if ("exit".equalsIgnoreCase(userInput)) {
break;
}
// 将用户输入发送给服务器
out.println(userInput);
// 读取服务器返回的回显消息
String response = in.readLine();
System.out.println("服务器响应: " + response);
}
} catch (UnknownHostException e) {
System.err.println("不知道的主机: " + HOSTNAME);
System.exit(1);
} catch (IOException e) {
System.err.println("无法获取 I/O 针对 " + HOSTNAME + " 的连接");
System.exit(1);
}
}
}
如何运行
-
编译所有 Java 文件:
(图片来源网络,侵删)javac EchoServer.java ClientHandler.java EchoClient.java
-
启动服务器:
java EchoServer
你会看到控制台输出:
服务器已启动,正在监听端口 12345... -
启动第一个客户端: 打开一个新的终端窗口,运行:
java EchoClient
你会看到:
已连接到服务器,请输入消息,输入 'exit' 退出: -
启动第二个客户端: 再打开一个新的终端窗口,运行:
java EchoClient
同样,你会看到连接成功的提示。
-
测试通信:
- 在第一个客户端的窗口输入 "Hello from Client 1",按回车。
- 第一个客户端会收到:
服务器响应: 服务器回显: Hello from Client 1 - 服务器的控制台会打印:
收到来自 127.0.0.1 的消息: Hello from Client 1
- 第一个客户端会收到:
- 在第二个客户端的窗口输入 "Hello from Client 2",按回车。
- 第二个客户端会收到:
服务器响应: 服务器回显: Hello from Client 2 - 服务器的控制台会打印:
收到来自 127.0.0.1 的消息: Hello from Client 2
- 第二个客户端会收到:
- 在第一个客户端的窗口输入 "Hello from Client 1",按回车。
你会发现,两个客户端可以同时与服务器通信,互不影响,这就是多线程的魅力。
进阶与优化
上面的模型虽然简单易懂,但在生产环境中存在一些问题,需要进一步优化。
线程池
问题:如果同时有成千上万的客户端连接,服务器会创建成千上万个线程,这会消耗大量内存和 CPU 资源,导致系统性能急剧下降甚至崩溃。
解决方案:使用线程池。 线程池可以复用已经创建的线程,避免了频繁创建和销毁线程的开销,当有新的任务时,从线程池中获取一个空闲线程来执行;当任务执行完毕后,线程不会销毁,而是返回线程池中等待下一个任务。
优化后的服务器代码:
// EchoServerWithThreadPool.java
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class EchoServerWithThreadPool {
private static final int PORT = 12345;
// 使用固定大小的线程池,50 个线程
private static final int THREAD_POOL_SIZE = 50;
public static void main(String[] args) {
// 创建一个固定大小的线程池
ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
try (ServerSocket serverSocket = new ServerSocket(PORT)) {
System.out.println("服务器已启动,使用线程池,正在监听端口 " + PORT + "...");
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
// 将任务提交给线程池执行,而不是创建新线程
threadPool.execute(new ClientHandler(clientSocket));
}
} catch (IOException e) {
System.err.println("服务器异常: " + e.getMessage());
e.printStackTrace();
} finally {
// 关闭线程池(优雅地)
threadPool.shutdown();
}
}
}
ClientHandler.java 和 EchoClient.java 的代码保持不变。
更健壮的客户端处理
问题:ClientHandler 中的 while ((inputLine = in.readLine()) != null) 循环依赖于客户端在关闭连接时正确地关闭输出流,如果客户端异常崩溃,这个线程可能会一直阻塞。
解决方案:
- 设置超时:可以在
Socket上设置setSoTimeout(),这样readLine()在指定时间内没有数据读取到就会抛出SocketTimeoutException,从而让线程有机会检查连接状态并退出。 - 心跳机制:客户端定期发送一个简单的“心跳”包,服务器收到后就知道客户端还活着,如果一段时间内没收到心跳,就认为客户端已断开,主动关闭连接。
NIO (New I/O) 模型
问题:传统的 I/O (BIO - Blocking I/O) 模式中,每个连接都需要一个线程,即使是空闲的线程也会占用内存,对于连接数非常多但大部分是空闲的场景(如聊天服务器),BIO 效率不高。
解决方案:使用 Java NIO。
NIO 使用非阻塞 I/O和多路复用机制(如 Selector),可以用一个或几个线程来管理成千上万个连接,当某个连接有数据可读或可写时,Selector 会通知线程去处理,否则线程可以去处理其他连接,这是实现高性能网络服务器的关键技术。
| 特性 | 简单多线程模型 | 线程池模型 | NIO 模型 |
|---|---|---|---|
| 核心思想 | 一个客户端一个线程 | 线程复用 | 非阻塞 I/O + 多路复用 |
| 优点 | 编程简单,逻辑清晰 | 资源利用率高,性能稳定 | 高并发,低资源消耗 |
| 缺点 | 资源消耗大,无法处理大量连接 | 线程数需要合理配置 | 编程模型复杂,理解门槛高 |
| 适用场景 | 学习、原型开发、连接数较少的应用 | 连接数较多,但可控的通用应用 | 超高并发,如 IM 服务器、Web 服务器 |
对于初学者来说,掌握 “服务器主线程监听 + 每个客户端一个工作线程” 的模型是至关重要的基础,在此基础上,再学习如何使用线程池进行优化,最后可以挑战更高性能的 NIO 模型。
