我将从最基础的 单线程服务端 开始,逐步演进到更实用的 多线程服务端 和 线程池服务端,并解释其中的关键概念和最佳实践。

核心概念
在开始编码前,我们先理解几个核心概念:
- IP 地址 (InetAddress): 网络上设备的唯一标识,
168.1.100或localhost(代表本机)。 - 端口号 (Port): 应用程序在网络上的“门牌号”,一个 IP 地址上的不同应用通过不同的端口号来区分,范围是 0-65535,0-1023 是系统保留端口。
- Socket (套接字): 是网络编程的 API,它封装了底层的 TCP/IP 协议,我们可以把它想象成一个“电话插座”,服务器和客户端通过这个插座来建立连接、发送和接收数据。
- ServerSocket: 服务端使用的 Socket,它负责在指定的 IP 和端口上“监听”客户端的连接请求,当有客户端连接时,它会创建一个新的
Socket与该客户端进行通信。 - TCP (Transmission Control Protocol): 我们这里讨论的是基于 TCP 的 Socket,TCP 是面向连接的、可靠的协议,在通信前,必须先建立一个连接(三次握手),数据传输完成后,需要断开连接(四次挥手)。
服务端的工作流程
一个标准的 TCP Socket 服务端遵循以下步骤:
- 创建 ServerSocket 实例:指定一个端口号,开始监听客户端的连接。
- 等待并接受连接:调用
accept()方法,这个方法是阻塞的,程序会在这里暂停,直到有客户端连接上来,一旦有客户端连接,accept()方法会返回一个新的Socket对象,用于与这个特定的客户端通信。 - 通信:
- 通过返回的
Socket对象获取InputStream和OutputStream。 - 通过
InputStream从客户端读取数据。 - 通过
OutputStream向客户端发送数据。
- 通过返回的
- 关闭连接:与当前客户端的通信结束后,关闭与该客户端相关的
Socket流和Socket本身。 - 循环监听:服务端通常会返回第 2 步,继续等待下一个客户端的连接。
示例代码
示例 1:基础单线程服务端
这个版本最简单,但有一个致命缺点:一次只能服务一个客户端,当与第一个客户端通信时,其他客户端必须等待。
SimpleServer.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class SimpleServer {
public static void main(String[] args) {
// 定义服务端要监听的端口号
int port = 8888;
try ( // 使用 try-with-resources 语句,确保资源自动关闭
// 1. 创建 ServerSocket,并绑定到指定端口
ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("服务器已启动,等待客户端连接...");
System.out.println("服务器地址: " + InetAddress.getLocalHost().getHostAddress() + ":" + port);
// 2. 调用 accept() 方法,等待客户端连接,这是一个阻塞方法。
// 当有客户端连接时,accept() 返回一个 Socket 对象,代表与该客户端的连接。
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
// 3. 获取输入流和输出流
// BufferedReader 用于按行读取客户端发送的文本数据
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
// PrintWriter 用于向客户端发送文本数据,autoFlush=true 表示自动刷新缓冲区
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
String inputLine;
// 4. 循环读取客户端发送的数据
while ((inputLine = in.readLine()) != null) {
System.out.println("收到客户端消息: " + inputLine);
// 如果客户端发送 "exit",则结束通信
if ("exit".equalsIgnoreCase(inputLine)) {
System.out.println("客户端请求关闭连接。");
break;
}
// 5. 向客户端发送响应
String response = "服务器回复: " + inputLine.toUpperCase();
out.println(response);
}
System.out.println("与客户端的连接已关闭。");
} catch (IOException e) {
System.err.println("服务器异常: " + e.getMessage());
e.printStackTrace();
}
}
}
如何测试:
- 运行
SimpleServer.java。 - 打开一个命令行窗口,使用
telnet命令连接:telnet localhost 8888。 - 在
telnet窗口中输入任意文本,然后按回车,你会在服务端的控制台看到收到的消息,telnet窗口会显示服务器的回复。 - 输入
exit并回车,客户端连接会断开,服务端程序也会结束。
示例 2:多线程服务端
为了解决单线程服务端一次只能处理一个客户端的问题,我们引入多线程,每当有新的客户端连接时,服务端就创建一个新的线程来专门处理这个客户端的通信,主线程则继续返回 accept() 状态,等待下一个客户端。
MultiThreadServer.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class MultiThreadServer {
public static void main(String[] args) {
int port = 8888;
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("多线程服务器已启动,等待客户端连接...");
System.out.println("服务器地址: " + InetAddress.getLocalHost().getHostAddress() + ":" + port);
// 无限循环,持续接受客户端连接
while (true) {
// accept() 阻塞,等待新连接
Socket clientSocket = serverSocket.accept();
System.out.println("新客户端连接: " + clientSocket.getInetAddress().getHostAddress());
// 为每个客户端连接创建一个新的线程来处理
ClientHandler clientHandler = new ClientHandler(clientSocket);
new Thread(clientHandler).start();
}
} catch (IOException e) {
System.err.println("服务器异常: " + e.getMessage());
}
}
}
/**
* 一个静态的内部类,用于处理单个客户端的通信逻辑
*/
class ClientHandler implements Runnable {
private final Socket clientSocket;
public ClientHandler(Socket socket) {
this.clientSocket = socket;
}
@Override
public void run() {
// try-with-resources 确保每个客户端的流都被正确关闭
try (BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true)) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.out.println("[" + Thread.currentThread().getName() + "] 收到来自 " +
clientSocket.getInetAddress().getHostAddress() + " 的消息: " + inputLine);
if ("exit".equalsIgnoreCase(inputLine)) {
System.out.println("客户端请求关闭连接。");
break;
}
String response = "服务器回复: " + inputLine.toLowerCase();
out.println(response);
}
} catch (IOException e) {
// 如果客户端异常断开,会收到 SocketException,这是正常情况
if (e instanceof java.net.SocketException) {
System.out.println("客户端 " + clientSocket.getInetAddress().getHostAddress() + " 已断开连接。");
} else {
System.err.println("处理客户端时发生错误: " + e.getMessage());
}
} finally {
try {
clientSocket.close();
System.out.println("与客户端 " + clientSocket.getInetAddress().getHostAddress() + " 的连接已关闭。");
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
改进点:
- 并发处理:主线程
main专注于接受连接,每个客户端的通信任务被分配给一个独立的线程。 - 职责分离:
ClientHandler类封装了与单个客户端交互的所有逻辑,使代码结构更清晰。 - 资源管理:每个
ClientHandler线程都管理自己的Socket和I/O流,确保在通信结束后能正确关闭。
示例 3:使用线程池优化 (最佳实践)
虽然多线程服务端解决了并发问题,但如果同时有成千上万的客户端连接,创建和销毁线程会消耗大量系统资源,并可能导致服务器不稳定。
解决方案:使用线程池,线程池可以复用已创建的线程,避免了频繁创建和销毁线程的开销,从而提高了系统的响应速度和稳定性。
ThreadPoolServer.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolServer {
// 定义线程池,核心线程数为10,最大线程数为200,当任务队列满时,新任务会等待
private static final ExecutorService threadPool = Executors.newFixedThreadPool(10);
public static void main(String[] args) {
int port = 8888;
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("线程池服务器已启动,等待客户端连接...");
System.out.println("服务器地址: " + InetAddress.getLocalHost().getHostAddress() + ":" + 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());
} finally {
// 当服务器需要关闭时,优雅地关闭线程池
System.out.println("服务器正在关闭,线程池正在关闭...");
threadPool.shutdown();
}
}
}
// ClientHandler 类与 MultiThreadServer 中的完全相同
class ClientHandler implements Runnable {
// ... (代码同上) ...
}
改进点:
- 资源控制:通过
Executors.newFixedThreadPool(10)我们限制了同时处理客户端请求的线程数量为 10,防止因客户端过多而导致系统资源耗尽。 - 性能提升:线程复用减少了线程创建和销毁的开销,提高了系统吞吐量。
- 可管理性:线程池提供了统一的线程管理机制,可以方便地调整线程数量、监控线程状态等。
关键点总结与最佳实践
- 使用
try-with-resources:对于所有实现了AutoCloseable接口的对象(如Socket,ServerSocket,InputStream,OutputStream),都应该使用try-with-resources语句,可以确保它们在使用完毕后自动关闭,避免资源泄漏。 - 处理阻塞方法:
serverSocket.accept()和inputStream.read()都是阻塞方法,在多线程或线程池模型中,必须将它们放在各自的线程任务中执行,否则会阻塞主线程或其他任务。 - 区分
ServerSocket和Socket:ServerSocket:只有一个,负责“监听”和“接纳”连接。Socket:每有一个客户端连接,就会创建一个,负责与该客户端“通信”。
- I/O 流的选择:
- 对于文本数据,使用
InputStreamReader+BufferedReader和OutputStreamWriter+PrintWriter非常方便。 - 对于二进制数据(如图片、文件),直接使用
Socket的getInputStream()和getOutputStream()。
- 对于文本数据,使用
- 编码问题:在处理文本时,最好明确指定字符集,
new InputStreamReader(socket.getInputStream(), "UTF-8"),以避免因客户端和服务端编码不一致导致的乱码问题。 - 优雅关闭:在服务端关闭时,除了关闭
ServerSocket,还应该关闭线程池(threadPool.shutdown()),并通知所有客户端连接关闭。
希望这个从基础到高级的讲解能帮助你全面理解 Java Socket 服务端的实现!
