- 核心概念:简单解释 Socket 是什么。
- 简单代码示例:一个最基础的“一问一答”服务端。
- 代码分步详解:逐行解释核心代码。
- 多线程处理:如何让一个服务端同时为多个客户端服务。
- 完整多线程服务端示例:包含连接管理、优雅关闭等功能的完整代码。
- 进阶话题:NIO、Netty 等简介。
核心概念:什么是 Socket?
你可以把 Socket 想象成一个电话插座。
- 服务端:就像一个总机,它有一个固定的电话号码(IP 地址)和分机号(端口号),它一直开着,等待有人打电话进来。
- 客户端:就像一部电话,它知道总机的号码(IP 和 Port),然后拨打电话发起连接请求。
- 连接:当客户端拨通后,一条通话线路(连接)就建立起来了,这条线路是双向的,双方都可以通过它来发送和接收数据。
- 流:双方通话时说的话,就是通过这条线路传输的数据流,在 Java 中,我们使用
InputStream(读取)和OutputStream(写入)来处理这些数据。
在 Java 中,服务端使用 java.net.ServerSocket 来监听客户端的连接请求,一旦有请求,它会创建一个 java.net.Socket 对象来代表与那个客户端的连接。
简单代码示例(单客户端)
这是一个最简单的服务端,它只能处理一个客户端的连接,然后就退出了。
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
public class SimpleServer {
public static void main(String[] args) {
// 定义服务端要监听的端口号
int port = 8888;
// try-with-resources 语句,确保 ServerSocket 和 Socket 在使用后能被自动关闭
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("服务器已启动,正在监听端口 " + port + "...");
// 1. 阻塞,等待客户端连接,当有客户端连接时,返回一个 Socket 对象
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
// 2. 获取输入流,用于读取客户端发送的数据
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
// 3. 获取输出流,用于向客户端发送数据
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
String inputLine;
// 4. 循环读取客户端发送的数据
while ((inputLine = in.readLine()) != null) {
System.out.println("收到客户端消息: " + inputLine);
// 5. 如果客户端发送 "exit",则关闭连接
if ("exit".equalsIgnoreCase(inputLine)) {
System.out.println("客户端请求关闭连接。");
break;
}
// 6. 将接收到的消息回显给客户端
out.println("服务器: " + inputLine);
}
} catch (IOException e) {
System.err.println("服务器异常: " + e.getMessage());
e.printStackTrace();
}
System.out.println("服务器已关闭。");
}
}
代码分步详解
-
ServerSocket serverSocket = new ServerSocket(port);- 创建一个
ServerSocket实例,并绑定到一个指定的端口号。 - 这就像给总机安装了一个电话插座,并设定了分机号。
- 注意:端口号范围是 0-65535,0-1023 是系统保留端口,建议使用 1024 以上的端口。
- 创建一个
-
Socket clientSocket = serverSocket.accept();- 这是服务端编程最关键的一步。
accept()方法会阻塞程序的执行,直到有一个客户端发起连接请求。 - 一旦有客户端连接,
accept()方法就会返回一个Socket对象,这个对象代表了服务端与那个特定客户端之间的连接通道。 - 服务端和客户端之间建立了一条全双工的通信管道。
- 这是服务端编程最关键的一步。
-
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));- 通过
clientSocket获取输入流 (InputStream)。 - 客户端通过这条流发送过来的数据,我们就可以读取了。
- 使用
BufferedReader可以方便地按行读取文本数据。
- 通过
-
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);- 通过
clientSocket获取输出流 (OutputStream)。 - 服务端通过这条流向客户端发送数据。
PrintWriter可以方便地写入文本数据。true参数表示自动刷新,每次调用println()方法后,数据都会被立即发送出去,而不需要手动调用flush()。
- 通过
-
while ((inputLine = in.readLine()) != null)in.readLine()会读取客户端发送的一行文本(以换行符\n,并返回该字符串。- 当客户端关闭连接时,
readLine()会返回null,循环因此结束。
-
out.println("服务器: " + inputLine);将处理后的消息通过输出流发送回客户端。
多线程处理(核心改进)
上面的简单示例有一个致命的缺点:serverSocket.accept() 和 while 循环都是阻塞的,当一个客户端连接后,服务端会一直处理这个客户端,无法再接受其他客户端的连接。
为了解决这个问题,我们必须引入多线程。
核心思想: 主线程只负责监听和接受新的连接,一旦有新连接到来,就立即创建一个新的线程来处理这个客户端的所有通信,主线程则立刻返回,继续监听下一个连接。
完整多线程服务端示例
这是一个更健壮、更实用的多线程服务端版本。
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 extends Thread {
private final Socket clientSocket;
public ClientHandler(Socket socket) {
this.clientSocket = socket;
}
@Override
public void run() {
try (
// 自动关闭资源
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
) {
System.out.println("处理新客户端连接: " + clientSocket.getInetAddress().getHostAddress());
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.out.println("[" + clientSocket.getInetAddress().getHostAddress() + "] 收到消息: " + inputLine);
if ("exit".equalsIgnoreCase(inputLine)) {
System.out.println("客户端 [" + clientSocket.getInetAddress().getHostAddress() + "] 请求退出。");
break;
}
// 简单回显服务
out.println("Echo: " + inputLine);
}
} catch (IOException e) {
// 如果客户端异常断开,会抛出异常,这里打印日志即可
System.out.println("与客户端 [" + clientSocket.getInetAddress().getHostAddress() + "] 的连接出现异常或已关闭。");
} finally {
try {
// 确保关闭客户端连接
if (clientSocket != null && !clientSocket.isClosed()) {
clientSocket.close();
System.out.println("已关闭与客户端 [" + clientSocket.getInetAddress().getHostAddress() + "] 的连接。");
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
MultiThreadEchoServer.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 MultiThreadEchoServer {
// 使用线程池来管理客户端处理线程,避免频繁创建和销毁线程的开销
private static final int THREAD_POOL_SIZE = 10;
private final ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
public static void main(String[] args) {
int port = 8888;
MultiThreadEchoServer server = new MultiThreadEchoServer();
server.start(port);
}
public void start(int port) {
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("多线程回显服务器已启动,监听端口 " + port + "...");
// 主循环,不断接受客户端连接
while (!serverSocket.isClosed()) {
try {
// 阻塞,等待新连接
Socket clientSocket = serverSocket.accept();
System.out.println("新客户端连接: " + clientSocket.getInetAddress().getHostAddress());
// 将客户端处理任务提交到线程池
threadPool.execute(new ClientHandler(clientSocket));
} catch (IOException e) {
// serverSocket 被关闭,accept() 会抛出 SocketException,这是正常关闭流程的一部分
if (!serverSocket.isClosed()) {
System.err.println("接受客户端连接时出错: " + e.getMessage());
}
}
}
} catch (IOException e) {
System.err.println("服务器启动或运行时出错: " + e.getMessage());
e.printStackTrace();
} finally {
// 关闭线程池
System.out.println("正在关闭服务器线程池...");
threadPool.shutdown();
System.out.println("服务器已关闭。");
}
}
}
这个多线程版本的优势:
- 并发处理:可以同时为多个客户端提供服务。
- 资源复用:使用
ExecutorService线程池,避免了为每个客户端都创建一个新线程,提高了性能和资源利用率。 - 职责分离:
MultiThreadEchoServer只负责监听和分发连接,ClientHandler专注于单个客户端的通信逻辑,代码结构更清晰。 - 健壮性:每个客户端的处理都在独立的线程中,一个客户端的崩溃或错误不会影响其他客户端和主线程的运行。
进阶话题
Java NIO (New I/O)
传统的 Socket 编程基于阻塞 I/O (Blocking I/O),当一个线程调用 read() 或 accept() 时,如果数据还没准备好,这个线程就会被挂起(阻塞),直到数据准备好才能继续执行,这在高并发场景下效率很低,因为线程被大量地浪费在等待上。
Java NIO 提供了一种非阻塞的 I/O 模型,核心组件包括:
- Channel (通道):类似流,但双向的,可以同时读写。
- Buffer (缓冲区):所有数据都读写到 Buffer 中。
- Selector (选择器):这是 NIO 的核心,一个 Selector 可以同时监控多个 Channel 的状态(如:是否有新的连接、是否有数据可读),这样,一个线程就可以管理成百上千个连接,极大地提高了系统的并发能力。
NIO 的实现比传统 BIO 更复杂,但性能更高,适合构建高性能的网络服务。
Netty
Netty 是一个基于 Java NIO 的、高性能、异步事件驱动的网络应用框架,它极大地简化了网络编程的复杂性。
- 为什么用 Netty?
- 高性能:底层基于 NIO,经过了高度优化。
- 易用性:提供了简单、易用的 API,让你不必关心复杂的 NIO 细节。
- 功能丰富:内置了编解码、协议支持(HTTP, WebSocket 等)、心跳检测等大量功能。
- 稳定性:经过了大规模商业项目的验证,非常稳定可靠。
对于绝大多数需要构建高性能网络服务(如 RPC 框架、消息队列、游戏服务器)的 Java 直接使用 Netty 是比从零开始写 NIO 更好的选择。
| 特性 | 传统 BIO Socket | Java NIO | Netty |
|---|---|---|---|
| I/O 模型 | 阻塞 I/O | 非阻塞 I/O | 异步事件驱动 |
| 线程模型 | 一个连接一个线程 | 一个线程管理多个连接 | Reactor 模型,线程池 |
| 性能 | 低,高并发下差 | 高,资源占用少 | 极高,吞吐量大 |
| 易用性 | 简单,API 直观 | 复杂,概念多 | 简单,封装良好 |
| 适用场景 | 学习、简单应用 | 需要自己定制高性能服务 | 构建高性能、高可靠性的商业网络应用 |
对于初学者,从 java.net.ServerSocket 和 Socket 开始是最好的选择,它能帮助你理解网络通信的基本原理,当你需要构建更复杂、性能更高的系统时,再转向 NIO 和 Netty。
