目录
- TCP/IP 基础回顾
- Java TCP 编程核心类
java.net.ServerSocket(服务器端)java.net.Socket(客户端 & 服务器端连接)java.io.InputStream/java.io.OutputStream(数据流)
- 完整代码示例
-
- TCP 服务器 (
SimpleServer.java)
- TCP 服务器 (
-
- TCP 客户端 (
SimpleClient.java)
- TCP 客户端 (
-
- 代码详细解析
- 服务器端流程
- 客户端流程
- 关键点与最佳实践
- 多线程处理 (服务器如何同时服务多个客户端)
- 异常处理
- 资源关闭 (非常重要!)
- 使用
try-with-resources
- 如何运行示例
- 进阶主题
TCP/IP 基础回顾
在写代码前,快速回顾一下 TCP 的核心特点,这有助于理解代码逻辑:

- 面向连接:通信前必须先通过“三次握手”建立一个稳定的连接。
- 可靠传输:通过序列号、确认应答、重传机制和流量控制,确保数据无差错、不丢失、不重复且按序到达。
- 全双工通信:连接建立后,双方可以同时进行数据的发送和接收。
- 字节流服务:TCP 将应用程序交付的数据看作是一连串的无结构的字节流,它不关心消息的边界,由应用程序自己定义消息的格式(用换行符
\n作为分隔符)。
Java TCP 编程核心类
Java 使用 java.net 包中的类来简化网络编程。
| 类/接口 | 作用 | 关键方法 |
|---|---|---|
ServerSocket |
服务器端使用,用于监听客户端的连接请求。 | int getLocalPort()Socket accept() (阻塞方法,等待连接)void close() |
Socket |
客户端使用,用于发起连接;服务器端 accept() 返回的 Socket 代表与一个客户端的连接。 |
InputStream getInputStream()OutputStream getOutputStream()void close()InetAddress getInetAddress() |
InputStream / OutputStream |
通过 Socket 获取,用于在连接上进行数据的读取和写入。 |
read() / write() (及其重载方法) |
BufferedReader / PrintWriter |
对 InputStream 和 OutputStream 进行包装,提供更方便的字符读写操作。 |
readLine()println() |
完整代码示例
下面是一个简单的“回显服务器”(Echo Server)和客户端的例子,客户端发送一行文本,服务器原样返回这行文本。
TCP 服务器 (SimpleServer.java)
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
/**
* 一个简单的 TCP 服务器。
* 它监听指定端口,等待客户端连接。
* 当客户端连接后,它会读取客户端发送的数据,并将其回显给客户端。
*/
public class SimpleServer {
public static void main(String[] args) {
int port = 8888; // 服务器监听的端口号
// try-with-resources 语句,确保 ServerSocket 在结束后被自动关闭
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("服务器已启动,正在监听端口 " + port + "...");
// accept() 是一个阻塞方法,它会一直等待直到有客户端连接
// 当有客户端连接时,accept() 返回一个 Socket 对象,代表与该客户端的连接
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
// 为这个客户端连接创建输入流和输出流
// 使用 try-with-resources 确保 BufferedReader 和 PrintWriter 也会被关闭
try (
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
) {
String inputLine;
// 循环读取客户端发送的数据
// readLine() 也是阻塞的,当客户端关闭输出流时,它会返回 null
while ((inputLine = in.readLine()) != null) {
System.out.println("收到客户端消息: " + inputLine);
// 将收到的消息回显给客户端
out.println("服务器回复: " + inputLine);
}
}
System.out.println("客户端 " + clientSocket.getInetAddress().getHostAddress() + " 已断开连接。");
} catch (IOException e) {
System.err.println("服务器异常: " + e.getMessage());
e.printStackTrace();
}
}
}
TCP 客户端 (SimpleClient.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;
/**
* 一个简单的 TCP 客户端。
* 它连接到指定的服务器和端口,向服务器发送消息,并接收服务器的回复。
*/
public class SimpleClient {
public static void main(String[] args) {
String hostname = "localhost"; // 或服务器的 IP 地址,如 "192.168.1.100"
int port = 8888;
// 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
BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in));
) {
System.out.println("已连接到服务器 " + hostname + ":" + port);
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);
System.out.println("请输入下一条消息 (输入 'exit' 退出):");
}
} catch (UnknownHostException e) {
System.err.println("找不到主机: " + hostname);
e.printStackTrace();
} catch (IOException e) {
System.err.println("I/O Error: " + e.getMessage());
e.printStackTrace();
}
System.out.println("客户端已关闭。");
}
}
代码详细解析
服务器端流程
-
创建
ServerSocket:ServerSocket serverSocket = new ServerSocket(port);- 这行代码在指定的
port上创建一个服务器套接字,并开始监听该端口,如果端口已被占用,会抛出IOException。
-
等待连接 (
accept):
(图片来源网络,侵删)Socket clientSocket = serverSocket.accept();- 这是服务器端的核心。
accept()方法会阻塞(程序暂停执行),直到有一个客户端尝试连接到这个端口,一旦有连接,它会返回一个新的Socket对象,这个Socket专门用于与这个新连接的客户端进行通信,原来的serverSocket继续监听,等待新的客户端连接。
-
通信:
BufferedReader in = new BufferedReader(...):通过clientSocket.getInputStream()获取输入流,用于读取客户端发送过来的数据。PrintWriter out = new PrintWriter(...):通过clientSocket.getOutputStream()获取输出流,用于向客户端发送数据。true参数表示自动刷新(每次调用println后都会自动调用flush())。while ((inputLine = in.readLine()) != null):循环读取客户端发送的每一行数据。readLine()会一直阻塞,直到客户端发送一行数据(以\n或关闭连接(此时返回null)。out.println(...):将处理后的数据(这里是回显)发送回客户端。
-
关闭连接:
- 当
readLine()返回null时,表示客户端已经关闭了连接,循环结束,try-with-resources会自动调用in和out的close()方法,进而关闭clientSocket。
- 当
客户端流程
-
创建
Socket并连接:Socket socket = new Socket(hostname, port);- 这行代码会尝试创建一个
Socket并连接到指定hostname和port的服务器,如果连接成功,程序才会继续执行;如果连接失败(如服务器未启动、IP错误),会抛出IOException,这个连接过程就是 TCP 的“三次握手”。
-
通信:
(图片来源网络,侵删)BufferedReader in = new BufferedReader(...):通过socket.getInputStream()获取输入流,用于读取服务器的回复。PrintWriter out = new PrintWriter(...):通过socket.getOutputStream()获取输出流,用于向服务器发送数据。BufferedReader stdIn = new BufferedReader(...):这是一个特殊的BufferedReader,它从标准输入(即你的键盘/控制台)读取数据。while ((userInput = stdIn.readLine()) != null):循环读取用户在控制台输入的每一行。out.println(userInput):将用户输入发送给服务器。String response = in.readLine():阻塞,等待并读取服务器发回的一行数据。System.out.println("服务器回复: " + response):将服务器的回复打印到控制台。
-
关闭连接:
- 当用户输入
exit时,循环结束。try-with-resources会按相反的顺序自动关闭stdIn,out,in, 和socket,关闭socket会触发 TCP 的“四次挥手”,断开与服务器的连接。
- 当用户输入
关键点与最佳实践
多线程处理
上面的 SimpleServer 一次只能处理一个客户端,如果另一个客户端在第一个客户端通信期间连接,它必须等待,在生产环境中,服务器必须能够同时处理多个客户端。
解决方案:每当 accept() 返回一个 clientSocket,就启动一个新的线程来处理这个客户端的通信。
// 在 SimpleServer 的 main 方法中,将通信部分放入一个新线程
// ... (serverSocket.accept() 之后)
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
// 启动一个新线程来处理这个客户端
new Thread(new ClientHandler(clientSocket)).start();
// ClientHandler 可以是一个实现 Runnable 的类
class ClientHandler implements Runnable {
private Socket clientSocket;
public ClientHandler(Socket socket) {
this.clientSocket = socket;
}
@Override
public void run() {
// 将原来服务器端通信的代码放在这里
try (BufferedReader in = new BufferedReader(...); PrintWriter out = new PrintWriter(...)) {
// ... 通信逻辑 ...
} catch (IOException e) {
// ...
}
}
}
异常处理
网络编程充满了不确定性(网络延迟、连接中断、端口被占等),所有涉及网络操作的方法(new Socket(), serverSocket.accept(), in.readLine(), out.println())都应该被 try-catch 块包围,妥善处理可能发生的 IOException。
资源关闭
这是 Java 编程中最重要也最容易出错的一点!
每个网络连接(Socket)和每个数据流(InputStream, OutputStream)都是系统资源,如果不关闭,会导致资源泄漏,最终可能耗尽系统资源,使程序崩溃。
最佳实践:使用 try-with-resources
从 Java 7 开始,try-with-resources 是推荐的资源管理方式,只要一个类实现了 AutoCloseable 接口(Socket, ServerSocket, InputStream, OutputStream, Reader, Writer 等都实现了),就可以在 try 语句中声明它,当代码块执行完毕后(无论是正常结束还是发生异常),这些资源都会被自动调用 close() 方法关闭,非常安全和方便。
// 正确方式
try (Socket s = new Socket(...); BufferedReader in = ...) {
// ...
} // s, in 自动关闭
// 错误方式(不要这样做!)
Socket s = null;
BufferedReader in = null;
try {
s = new Socket(...);
in = new BufferedReader(...);
// ...
} finally {
if (in != null) try { in.close(); } catch (IOException e) {}
if (s != null) try { s.close(); } catch (IOException e) {}
}
// 手动关闭代码繁琐且容易遗漏
如何运行示例
-
保存代码:将上面的两个 Java 类 (
SimpleServer.java和SimpleClient.java) 保存到同一个目录下。 -
编译:打开终端或命令提示符,进入该目录,运行
javac命令:javac SimpleServer.java SimpleClient.java
这会生成
SimpleServer.class和SimpleClient.class文件。 -
启动服务器:在一个终端窗口中运行服务器:
java SimpleServer
你会看到输出:
服务器已启动,正在监听端口 8888... -
启动客户端:在另一个终端窗口中运行客户端:
java SimpleClient
你会看到输出:
已连接到服务器 localhost:8888和请输入要发送的消息 (输入 'exit' 退出): -
交互:
- 在客户端的终端输入任意文本,
你好,服务器!,然后按回车。 - 客户端会立即收到服务器的回复:
服务器回复: 你好,服务器! - 服务器的终端也会显示:
收到客户端消息: 你好,服务器! - 你可以继续输入消息进行交互,当客户端输入
exit并回车时,客户端程序会退出,服务器的对应连接也会断开。
- 在客户端的终端输入任意文本,
进阶主题
- NIO (New I/O):对于高并发、高性能的服务器,传统的 I/O(阻塞式)模型效率不高,Java NIO 提供了非阻塞 I/O 和选择器(Selector)机制,允许一个线程管理多个连接,极大地提高了服务器的吞吐量。
java.nio.channels包是其核心。 - Netty:一个成熟的、高性能的异步事件驱动的网络框架,基于 NIO,它极大地简化了网络服务器和客户端的开发,是目前业界最流行的 Java 网络编程框架之一。
- 协议设计:上面的例子使用简单的文本行,在实际应用中,你需要设计自己的应用层协议,例如使用长度前缀(Length-Prefix)来明确消息的边界,或者使用像 Protobuf、JSON、XML 这样的序列化格式来结构化你的数据。
