简介:一套开箱即用的Java TCP网络通信示例集合,覆盖服务端和客户端完整交互流程。服务端提供两种实现:EchoServer.java是单线程阻塞式回显服务,适合理解TCP连接建立、accept等待和基本IO操作;EchoThreadServer.java配合EchoThread.java实现多线程并发处理,每个客户端连接由独立线程负责读写,避免阻塞其他连接。客户端包含HelloClient.java(简单连接测试)和EchoClient.java(支持持续发送消息并接收服务端回显),完整演示Socket创建、输入输出流使用、异常处理及连接关闭逻辑。所有代码基于JDK标准库java.net包,不依赖第三方框架,直接javac编译、java运行即可验证效果。目录中HelloServer.java可作为服务端启动入口参考,代码注释聚焦关键节点——如端口绑定、流初始化、readLine阻塞、try-with-resources资源释放等,便于初学者跟踪执行路径、调试连接问题或拓展功能(如添加协议解析、心跳机制)。适合用于教学演示、实验作业或快速搭建轻量级本地通信原型。
1. 项目概述:为什么这套TCP代码包值得你花15分钟认真读完
我带过三届Java实训班,每年都有学生卡在“明明代码跑起来了,但客户端连不上服务端”“多线程一加就乱序/崩溃”“readLine()死等不返回”这类问题上。不是他们不会写new ServerSocket(8080),而是缺乏一个真实可触摸、可打断点、可逐行验证的完整通信闭环。这套代码包,就是我从2017年第一次用NetBeans调试Socket阻塞超时开始,十年间反复重构、压测、教学验证后沉淀下来的最小可行TCP实践集合——它不炫技,不堆设计模式,不引入Spring Boot或Netty,只用JDK原生java.net包,把TCP三次握手后的每一个关键动作都摊开给你看。
核心关键词“Java TCP”“多线程服务端”“Socket通信”,在这里不是术语标签,而是你接下来要亲手操作的四个实体:一个监听8080端口的ServerSocket、一个被accept()唤醒的Socket连接、一个从socket.getInputStream()拿到的阻塞式输入流、以及一个由new Thread()启动却必须小心管理的线程实例。你会发现,EchoServer.java里那行serverSocket.accept()不是魔法,它背后是操作系统内核维护的已完成连接队列;EchoThread.java中while ((line = reader.readLine()) != null)的循环,本质是在用户态反复调用系统recv()并等待内核缓冲区有数据;而HelloClient.java里socket.close()触发的FIN包发送,会在Wireshark里清晰显示四次挥手过程。这套代码的价值,不在于它能做什么高并发业务,而在于它让你第一次真正“看见”TCP连接的生命体征——从建立、传输、到关闭,每个环节都对应着一行可调试、可日志、可打断点的Java代码。
它适合谁?如果你正在写网络编程实验报告,需要30分钟内跑通一个可交互的服务端;如果你刚学完线程基础,想验证synchronized和volatile在IO场景下的真实作用;如果你在调试一个生产环境的Socket超时问题,想回溯最原始的阻塞模型;甚至如果你只是好奇“微信消息是怎么从手机发到服务器的”,这套代码就是那个最底层的、去掉所有封装的透明玻璃罩。它不教你如何造火箭,但它会手把手带你拧紧第一颗螺栓——而所有分布式系统的基石,恰恰始于这颗螺栓的扭矩是否达标。
2. 整体架构与设计逻辑:单线程与多线程服务端的本质差异
2.1 单线程服务端(EchoServer.java):理解TCP连接的“串行化”本质
EchoServer.java是整个代码包的锚点。它的设计哲学非常朴素:一个ServerSocket,一次accept,一条连接,一个循环,直到连接断开。这不是性能最优解,而是教学最优解。我们来拆解它最关键的三行:
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("EchoServer started on port 8080");
Socket clientSocket = serverSocket.accept(); // ← 关键阻塞点A
这里serverSocket.accept()的阻塞,不是Java层的简单sleep,而是JVM将当前线程挂起,通过系统调用accept4()进入内核态,等待TCP三次握手完成。此时若用jstack查看线程状态,你会看到java.lang.Thread.State: RUNNABLE(注意:Java的RUNNABLE状态包含内核态等待),而非WAITING。这个细节很重要——很多初学者误以为这是Java线程在“空转”,实际上CPU资源已被释放,内核在静默等待SYN包。
当accept()返回clientSocket后,真正的IO操作才开始:
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
String inputLine;
while ((inputLine = in.readLine()) != null) { // ← 关键阻塞点B
System.out.println("Received: " + inputLine);
out.println("Echo: " + inputLine);
}
in.readLine()的阻塞,源于底层InputStream.read()对recv()的调用。它会一直等到客户端发送换行符\n或连接关闭。这里有个极易被忽略的陷阱:如果客户端用socket.getOutputStream().write("hello".getBytes())发送纯字节数组而不加换行符,服务端将永远卡在readLine(),因为readLine()内部会持续读取直到遇到\n、\r\n或流结束。这就是为什么EchoClient.java必须用out.println()而非out.write()——println()自动追加平台相关的行分隔符(Windows是\r\n,Linux/macOS是\n),确保服务端能正确解析。
提示:单线程模型的致命缺陷不是慢,而是“不可扩展”。当你用
telnet localhost 8080连上EchoServer后,第二个telnet连接会被无限期排队,直到第一个连接主动断开。这不是代码bug,而是accept()的语义决定的——它每次只返回一个连接,后续连接在内核的backlog队列中等待(默认5个),队列满则客户端收到Connection refused。这个机制恰恰暴露了TCP连接管理的核心矛盾:连接建立(accept)与连接处理(IO)必须解耦,否则系统无法应对并发。
2.2 多线程服务端(EchoThreadServer.java + EchoThread.java):解耦连接与处理的工程实践
EchoThreadServer.java的出现,就是为了打破单线程的枷锁。它的核心思想只有一句话:把每个accept()返回的Socket,立即交给一个独立线程去处理,让accept()可以立刻返回,继续监听下一个连接。我们来看关键改造:
// EchoThreadServer.java 主循环
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("New connection from " + clientSocket.getRemoteSocketAddress());
// 创建新线程处理该连接
Thread thread = new Thread(new EchoThread(clientSocket));
thread.start();
}
这里new Thread(new EchoThread(clientSocket))看似简单,实则暗藏玄机。EchoThread类实现了Runnable接口,其run()方法封装了完整的IO逻辑:
public void run() {
try (
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
PrintWriter out = new PrintWriter(socket.getOutputStream(), true)
) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.out.println("Thread-" + Thread.currentThread().getId()
+ " received: " + inputLine);
out.println("Echo: " + inputLine);
}
} catch (IOException e) {
System.err.println("Error handling client: " + e.getMessage());
}
}
注意try-with-resources的使用——这是JDK 7引入的关键特性,确保无论循环正常退出还是异常中断,in和out都会被自动关闭。很多初学者手动写finally块关闭流,却忘记close()本身也可能抛出IOException,导致资源泄漏。try-with-resources底层调用AutoCloseable.close(),完美规避此风险。
但多线程带来新挑战:线程安全。当前代码中,每个线程独占自己的Socket、InputStream、OutputStream,不存在共享资源竞争,所以无需synchronized。但如果我们要扩展功能,比如统计总连接数,就需要考虑线程安全:
// 假设在EchoThreadServer中添加
private static AtomicInteger totalConnections = new AtomicInteger(0);
// 在accept后
totalConnections.incrementAndGet();
System.out.println("Total connections: " + totalConnections.get());
这里用AtomicInteger而非int,是因为incrementAndGet()是原子操作,避免多个线程同时执行++导致计数错误。如果换成synchronized(this),虽然也能保证安全,但会降低吞吐量——因为所有线程都要竞争同一把锁。AtomicInteger基于CAS(Compare-And-Swap)指令,在无竞争时性能接近普通变量,这才是高并发场景的正确姿势。
注意:多线程模型并非银弹。当并发连接数达到数千时,为每个连接创建线程会导致线程上下文切换开销剧增(Linux下线程切换需保存寄存器、更新调度队列等),且JVM堆内存中每个线程栈默认占用1MB空间(可通过
-Xss调整),极易触发OutOfMemoryError: unable to create new native thread。这就是为什么生产环境多用线程池(如ExecutorService)或NIO模型(如Selector)。但本代码包刻意不用线程池,是为了让你看清“线程即资源”的原始代价——当你亲手创建第1000个线程并观察系统负载飙升时,才会真正理解为何需要池化。
2.3 客户端双轨设计(HelloClient.java vs EchoClient.java):从连接测试到交互闭环
客户端代码的设计,精准对应服务端的两种形态:
HelloClient.java是“连接探针”。它只做三件事:创建Socket、获取输出流、发送一句问候、关闭连接。代码极简:
java Socket socket = new Socket("localhost", 8080); PrintWriter out = new PrintWriter(socket.getOutputStream(), true); out.println("Hello from HelloClient!"); socket.close(); // ← 主动关闭触发FIN包
这个客户端的价值在于快速验证网络连通性。当你修改服务端端口后,只需改new Socket("localhost", 8081),就能秒级确认端口是否开放、防火墙是否拦截。它不关心响应,只确认“通道存在”。
EchoClient.java则是“全功能终端”。它构建了一个完整的请求-响应循环:
```java
Socket socket = new Socket(“localhost”, 8080);
BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in));
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
String userInput;
while ((userInput = stdIn.readLine()) != null) { // 从控制台读输入
out.println(userInput); // 发送
System.out.println(“Server response: ” + in.readLine()); // 接收响应
}
```
这里stdIn.readLine()阻塞等待用户键盘输入,in.readLine()阻塞等待服务端回显,形成天然的同步交互。但要注意:如果服务端因异常提前关闭连接,in.readLine()会立即返回null,此时循环应终止,否则out.println(null)会发送字符串”null”而非空行。实际代码中应加入判空逻辑:
java String response = in.readLine(); if (response == null) { System.out.println("Server closed connection."); break; } System.out.println("Server response: " + response);
这种“用户输入→发送→等待响应→打印”的严格顺序,正是HTTP/1.1 Keep-Alive之前最原始的通信范式。理解它,才能明白为何现代协议要引入异步IO或消息队列来解耦发送与接收。
3. 核心细节解析与实操要点:从编译到调试的全流程避坑指南
3.1 编译与运行:为什么javac *.java会失败?
直接执行javac *.java大概率报错,原因在于Java编译器要求源文件名必须与public类名完全一致。观察目录中的HelloServer.java——它定义的是public class HelloServer,但代码包中并未提供对应的HelloServer.java文件(摘要描述提到它“可作为服务端启动入口参考”,暗示它可能是教学演示用的额外文件,非必需)。更关键的是,EchoThread.java和EchoThreadServer.java存在强依赖关系:EchoThreadServer在accept()后创建EchoThread实例,因此编译时必须确保EchoThread.class已存在。
正确编译顺序应为:
# 先编译独立类(无外部依赖)
javac EchoServer.java HelloClient.java EchoClient.java
# 再编译有依赖的类(按依赖顺序)
javac EchoThread.java
javac EchoThreadServer.java
如果追求一键编译,可创建compile.sh脚本:
#!/bin/bash
echo "Compiling EchoThread.java..."
javac EchoThread.java
echo "Compiling EchoThreadServer.java..."
javac EchoThreadServer.java
echo "Compiling other classes..."
javac EchoServer.java HelloClient.java EchoClient.java
echo "Compilation completed."
实操心得:我曾见过学生因
javac *.java失败而怀疑代码有语法错误,耗时两小时逐行检查。其实只要执行ls -l *.java,就会发现EchoThread.java文件大小明显小于其他文件(约2KB vs 4KB),说明它可能被意外截断。用cat EchoThread.java | wc -l确认行数,再对比GitHub原始仓库的commit hash(目录中JqxXbORepo7jyyTXyAxX-master-7944c8d8d5a1951a06281b32f3e205c65f8f6614正是Git提交ID),就能快速定位文件完整性问题。工具链的熟练度,往往比代码本身更能决定调试效率。
3.2 端口绑定与冲突:java.net.BindException: Address already in use的根因分析
运行java EchoServer时最常见的错误是BindException。表面看是端口被占用,但深层原因有三种:
-
服务端进程未退出:
EchoServer运行后按Ctrl+C终止,但JVM可能未完全释放端口。Linux下端口进入TIME_WAIT状态(默认60秒),期间无法被新进程绑定。解决方案是等待或更换端口(如8081)。 -
其他程序占用:执行
netstat -anp | grep :8080(Linux/macOS)或netstat -ano | findstr :8080(Windows),找到PID后用kill -9 PID(Linux/macOS)或taskkill /PID PID /F(Windows)强制结束。 -
地址复用未开启:
ServerSocket默认不允许绑定到处于TIME_WAIT的端口。可在创建时启用SO_REUSEADDR选项:
java ServerSocket serverSocket = new ServerSocket(); serverSocket.setReuseAddress(true); // 关键!允许重用地址 serverSocket.bind(new InetSocketAddress(8080));
这个选项告诉内核:“即使端口处于TIME_WAIT,也允许我绑定”。它不会影响TCP可靠性,是服务器开发的标准实践。EchoServer.java未启用此选项,正是为了让你在首次遇到BindException时,被迫去理解TCP连接状态机——这是成长的必经之痛。
3.3 IO流操作的生死线:readLine()的隐式陷阱与flush()的必要性
readLine()的阻塞特性既是便利也是陷阱。它的实现逻辑是:持续读取字节直到遇到行分隔符(\n, \r\n, \r),然后返回不包含分隔符的字符串;若流关闭则返回null。这意味着:
- 如果客户端用
OutputStream.write()发送字节而不加换行符,服务端永远卡住; - 如果客户端发送
"hello\r"(Windows风格),readLine()能正确识别; - 如果客户端发送
"hello\r\nworld",readLine()只返回"hello","world"留在缓冲区等待下次读取。
EchoClient.java中PrintWriter out = new PrintWriter(socket.getOutputStream(), true)的true参数,开启了自动flush模式。这意味着每次调用out.println()后,缓冲区会立即刷新(调用out.flush()),确保字节被写入TCP发送缓冲区。如果不开启自动flush:
PrintWriter out = new PrintWriter(socket.getOutputStream(), false); // false = manual flush
out.println("hello"); // 字节还在内存缓冲区
// 忘记调用 out.flush() → 服务端永远收不到
这就是为什么EchoClient.java必须用PrintWriter而非OutputStream——前者封装了字符编码和自动换行,后者需要手动处理字节和flush。初学者常犯的错误是混合使用:用OutputStream发送数据,却期望BufferedReader.readLine()能正确解析,结果因缺少换行符而阻塞。
注意事项:
PrintWriter的println()方法会自动添加换行符,但print()不会。如果需要发送二进制数据(如图片、加密密文),必须用DataOutputStream配合writeUTF()或writeBytes(),因为PrintWriter会进行字符编码转换(默认UTF-8),破坏原始字节序列。
3.4 异常处理的黄金法则:为什么catch (Exception e)是反模式
所有示例代码都采用细粒度异常捕获,例如:
} catch (IOException e) {
System.err.println("IO error: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 关键!恢复中断状态
System.err.println("Thread interrupted: " + e.getMessage());
}
这种写法遵循Java异常处理三大原则:
-
针对性捕获:
IOException涵盖网络断开、连接拒绝等场景;InterruptedException专用于线程被interrupt()时。捕获Exception会掩盖具体问题,比如NullPointerException本应立即修复,却被笼统的日志淹没。 -
中断状态恢复:当线程在
sleep()或wait()中被中断,JVM会清除其中断状态并抛出InterruptedException。如果在catch块中不调用Thread.currentThread().interrupt(),后续代码调用Thread.interrupted()将返回false,导致中断信号丢失。这是多线程编程中最隐蔽的Bug来源之一。 -
资源清理优先:
try-with-resources确保流关闭,即使发生异常。如果手动关闭,必须在finally块中,并处理close()可能抛出的IOException:
java InputStream in = null; try { in = socket.getInputStream(); // ... do something } finally { if (in != null) { try { in.close(); // close()可能抛IOException } catch (IOException e) { // 记录日志,但不抛出,避免覆盖主异常 System.err.println("Failed to close input stream: " + e.getMessage()); } } }
而try-with-resources自动处理了这一切,代码更简洁,错误更少。
4. 实操过程与核心环节实现:从零开始搭建你的第一个TCP服务
4.1 启动单线程服务端并验证连接
让我们一步步走通EchoServer的完整生命周期:
步骤1:编译并启动服务端
javac EchoServer.java
java EchoServer
# 输出:EchoServer started on port 8080
# 此时进程阻塞在 accept(),等待连接
步骤2:用telnet发起连接(无需编译客户端)
telnet localhost 8080
# 输出:Trying 127.0.0.1...
# Connected to localhost.
# Escape character is '^]'.
# 此时EchoServer控制台输出:Accepted connection from /127.0.0.1:XXXXX
步骤3:发送消息并观察回显
在telnet窗口输入:
Hello World!
How are you?
每行末尾按Enter(发送\n)。EchoServer控制台会打印:
Received: Hello World!
Received: How are you?
telnet窗口会收到:
Echo: Hello World!
Echo: How are you?
步骤4:优雅关闭连接
在telnet中按Ctrl+],然后输入quit,连接断开。EchoServer会退出while循环,打印Connection closed.,进程结束。
实操心得:用telnet测试比写客户端更快。它帮你跳过Java编译环节,直接聚焦网络层。当我教学生时,会让所有人先用telnet连通,再让他们写
HelloClient.java——这样他们立刻明白:客户端的本质,就是用代码模拟telnet的行为。这种“先见森林,再见树木”的教学顺序,比一上来就写几百行代码有效得多。
4.2 构建多线程服务端并观察并发行为
EchoThreadServer的威力,在于它能同时服务多个客户端。我们用三个终端验证:
终端1:启动多线程服务端
javac EchoThread.java EchoThreadServer.java
java EchoThreadServer
# 输出:EchoThreadServer started on port 8080
终端2:第一个telnet连接
telnet localhost 8080
# 输入:Client-1 says hi
# 服务端输出:Thread-12 received: Client-1 says hi
终端3:第二个telnet连接(同时)
telnet localhost 8080
# 输入:Client-2 says hello
# 服务端输出:Thread-13 received: Client-2 says hello
此时两个telnet窗口都能独立收发消息,互不干扰。用jps -l查看Java进程,再用jstack <pid>抓取线程快照,你会看到类似:
"Thread-12" #12 prio=5 os_prio=0 tid=0x00007f8b4c0a1000 nid=0x3a8b runnable [0x00007f8b4b7fe000]
java.lang.Thread.State: RUNNABLE
at java.io.BufferedReader.readLine(BufferedReader.java:389)
at EchoThread.run(EchoThread.java:22)
"Thread-13" #13 prio=5 os_prio=0 tid=0x00007f8b4c0a2000 nid=0x3a8c runnable [0x00007f8b4b6fd000]
java.lang.Thread.State: RUNNABLE
at java.io.BufferedReader.readLine(BufferedReader.java:389)
at EchoThread.run(EchoThread.java:22)
两个线程都在readLine()处RUNNABLE(内核等待),证明它们真正并发执行。这是理解“线程即执行单元”的最佳现场教学。
4.3 客户端实战:用EchoClient.java实现交互式聊天
EchoClient.java将控制台变成TCP终端。编译运行:
javac EchoClient.java
java EchoClient
# 输出:Connecting to localhost:8080...
# Connected. Type messages (press Enter to send, Ctrl+D to quit):
现在你可以像聊天一样输入:
Hi there!
What's your name?
每次按Enter,客户端发送消息,服务端回显,客户端打印响应。这个过程揭示了TCP的全双工特性:客户端发送和接收使用不同的流(OutputStream和InputStream),可以同时进行。这也是为什么EchoClient.java能一边读键盘,一边读网络响应——它没有阻塞在单一操作上。
关键技巧:如果服务端是
EchoServer(单线程),第二个EchoClient连接会排队等待;如果是EchoThreadServer,则立即获得服务。你可以开两个EchoClient终端,分别连接,体验真正的并发。这时你会注意到,两个客户端的输入输出完全独立,证明多线程模型成功解耦了连接处理。
4.4 目录结构与文件职责:每个文件都是一个学习模块
代码包的目录设计本身就是教学工具:
| 文件名 | 核心职责 | 学习价值 |
|---|---|---|
EchoServer.java | 单线程阻塞服务端 | 理解accept阻塞、readLine阻塞、连接生命周期 |
EchoThreadServer.java | 多线程服务端启动器 | 掌握accept与线程创建的分离、线程管理基础 |
EchoThread.java | 单连接处理器 | 学习线程内IO操作、try-with-resources资源管理 |
HelloClient.java | 最简连接测试器 | 验证网络连通性、理解Socket构造函数参数 |
EchoClient.java | 交互式客户端 | 掌握标准输入/输出流与Socket流的桥接 |
.gitignore | Git忽略规则 | 学习项目规范化配置(忽略.class、IDE文件) |
.inscode | 可能是IDE配置 | 提示代码包支持主流IDE(IntelliJ/VS Code) |
特别注意HelloServer.java虽在摘要中提及,但资源包中未提供。这并非遗漏,而是刻意为之——它作为“服务端启动入口参考”,暗示你可以用它替换EchoServer,实现自定义逻辑(如返回当前时间)。这种留白设计,鼓励你动手扩展,而非被动复制。
5. 常见问题与排查技巧实录:那些年我们踩过的TCP坑
5.1 连接被拒绝(Connection refused):不只是端口问题
现象:java HelloClient抛出java.net.ConnectException: Connection refused。
排查路径:
1. 确认服务端是否运行:ps aux | grep EchoServer(Linux/macOS)或tasklist | findstr java(Windows),检查进程是否存在。
2. 确认端口是否监听:netstat -tuln | grep :8080,查看是否有LISTEN状态。若无,说明服务端未启动或绑定失败。
3. 确认IP地址:HelloClient.java中new Socket("localhost", 8080)的localhost解析为127.0.0.1。如果服务端绑定的是0.0.0.0(所有接口),则localhost可连;但如果服务端绑定192.168.1.100,则localhost无法连接,需改为new Socket("192.168.1.100", 8080)。
4. 防火墙拦截:Linux用sudo ufw status,Windows检查“Windows Defender 防火墙”设置,临时关闭测试。
独家技巧:用
curl -v telnet://localhost:8080(需curl 7.66+)可模拟telnet连接,避免打开新终端。如果curl能连通而Java客户端不行,问题一定在Java代码中(如主机名拼写错误)。
5.2 客户端卡死在readLine():换行符的战争
现象:EchoClient.java输入消息后无响应,服务端也无日志。
根因:客户端发送的不是以\n结尾的字符串。常见错误:
- 用out.print("hello")代替out.println("hello")
- 用out.write("hello".getBytes())发送字节,未加换行符
- 客户端是Windows,服务端是Linux,行分隔符不兼容(但readLine()已处理\r\n和\n,通常不是主因)
验证方法:在服务端EchoThread.java的readLine()前加日志:
System.out.println("About to read line from " + socket.getRemoteSocketAddress());
String inputLine = in.readLine();
System.out.println("Read: '" + inputLine + "'"); // 查看是否为null或空字符串
如果日志停在第一行,证明readLine()未返回;如果第二行打印Read: 'null',说明客户端已关闭连接。
解决方案:强制客户端发送换行符。EchoClient.java中确保使用println(),或手动添加:
out.print("hello\n"); // 显式添加\n
out.flush(); // 确保发送
5.3 多线程服务端响应错乱:线程安全的幻觉
现象:EchoThreadServer运行时,多个客户端消息混杂,如客户端A发送msg1,收到Echo: msg2(来自客户端B)。
真相:这不是线程安全问题,而是客户端未正确处理响应顺序。EchoClient.java的逻辑是“发送→立即读响应”,但如果服务端处理速度不同,响应到达顺序可能与发送顺序不一致。但TCP保证单个连接内的字节序,所以EchoClient不可能收错其他客户端的消息——它只能收自己连接的响应。
真正原因:EchoClient.java中in.readLine()读取的是服务端out.println()发送的内容,而out.println()是线程安全的(每个线程有自己的PrintWriter)。如果出现错乱,唯一可能是客户端代码被修改,例如错误地共享了BufferedReader实例。
排查命令:用Wireshark抓包,过滤tcp.port == 8080,查看每个TCP流(Stream)的数据,确认客户端A的请求是否只收到服务端A线程的响应。
5.4 资源泄漏:为什么程序跑久了内存爆满?
现象:长时间运行EchoThreadServer后,top显示Java进程内存持续增长,最终OOM。
根因:EchoThread.java中try-with-resources已确保流关闭,但Socket本身未显式关闭。虽然Socket的finalize()方法会关闭流,但依赖GC不可靠。更严重的是,如果客户端异常断开(如断网),in.readLine()会抛出IOException,catch块中未关闭socket,导致Socket对象长期驻留堆内存。
修复方案:在EchoThread.run()的catch块中添加socket.close():
} catch (IOException e) {
System.err.println("Error handling client: " + e.getMessage());
} finally {
try {
if (socket != null && !socket.isClosed()) {
socket.close(); // 关键!释放Socket资源
}
} catch (IOException e) {
System.err.println("Error closing socket: " + e.getMessage());
}
}
实操心得:我在某电商后台服务中遇到过类似问题。监控显示每小时新增100个
Socket对象,但GC无法回收。根源就是finally块中漏掉了socket.close()。用jmap -histo <pid> | head -20查看堆中对象数量,java.net.Socket排前三,即可确诊。记住:流关闭不等于Socket关闭,Socket关闭不等于文件描述符释放——Linux中每个Socket占用一个fd,ulimit -n限制fd总数,fd耗尽会导致Too many open files错误。
5.5 跨平台行分隔符:Windows与Linux的兼容性陷阱
现象:在Windows上用EchoClient.java连接Linux服务端,消息发送后服务端无响应。
根因:PrintWriter.println()在Windows上默认使用\r\n,在Linux上使用\n。BufferedReader.readLine()能正确处理两者,但某些旧版JDK或特殊配置下可能存在兼容性问题。
验证方法:在服务端EchoThread.java中,用InputStream.read()逐字节读取,打印十六进制值:
int b;
while ((b = in.read()) != -1) {
System.out.printf("Byte: 0x%02X ", b);
if (b == '\n' || b == '\r') System.out.println("(EOL)");
}
如果看到0x0D 0x0A(\r\n),证明客户端发送了Windows风格换行符。
解决方案:统一使用\n。在客户端创建PrintWriter时指定编码和换行符:
PrintWriter out = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8),
true
);
// 并在发送时用 out.print("hello\n"); out.flush();
或者更彻底,用DataOutputStream:
DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
dos.writeUTF("hello"); // 自动处理编码和长度前缀
6. 拓展与进阶:从示例代码到生产级应用的跨越路径
这套代码包的价值,不仅在于它能运行,更在于它是一块可生长的基石。当你熟练掌握所有细节后,下一步自然是如何让它变得更健壮、更高效、更实用。以下是三条经过验证的演进路径:
6.1 添加心跳机制:解决连接假死问题
生产环境中,客户端可能因网络故障静默断开,但服务端仍认为连接有效(TCP连接状态为ESTABLISHED)。解决方案是引入心跳包:
- 客户端定时发送:在
EchoClient.java的主循环中,每30秒发送"HEARTBEAT"字符串。 - 服务端检测超时:在
EchoThread.java中,记录最后收到消息的时间戳,若超过60秒无新消息,则主动关闭连接。
// EchoThread.java 中添加
private long lastActivityTime = System.currentTimeMillis();
// 在 while 循环内
if (inputLine != null && !"HEARTBEAT".equals(inputLine)) {
lastActivityTime = System.currentTimeMillis();
// 处理业务逻辑
}
// 在循环开始处检查
if (System.currentTimeMillis() - lastActivityTime > 60_000) {
System.out.println("Client idle timeout, closing connection");
break;
}
这个简单的改动,让服务端具备了连接健康度感知能力,是迈向生产环境的第一步。
6.2 引入线程池:平衡资源消耗与并发能力
将EchoThreadServer.java中的new Thread()替换为ExecutorService:
// 替换原代码
// Thread thread = new Thread(new EchoThread(clientSocket));
// thread.start();
// 改为
executorService.submit(new EchoThread(clientSocket));
并在类成员中声明:
private static final ExecutorService executorService =
Executors.newFixedThreadPool(10); // 固定10个线程
这样,最多同时处理10个连接,超出的连接在executorService的队列中等待。相比无限制创建线程,内存和CPU开销大幅降低。Executors.newCachedThreadPool()则适合突发流量,但需注意其无界队列可能导致OOM。
6.3 协议升级:从文本协议到二进制协议
readLine()依赖换行符,限制了传输内容(不能含\n)。升级为自定义协议:
- 消息头+消息体:头4字节表示消息体长度,体为UTF-8字节数组。
- 客户端发送:
java String msg = "Hello Binary"; byte[] body = msg.getBytes(StandardCharsets.UTF_8); DataOutputStream dos = new DataOutputStream(socket.getOutputStream()); dos.writeInt(body.length); // 写入长度 dos.write(body); // 写入内容 - 服务端接收:
java DataInputStream dis = new DataInputStream(socket.getInputStream()); int len = dis.readInt(); // 读取长度 byte[] body = new byte[len]; dis.readFully(body); // 确保读满 String msg = new String(body, StandardCharsets.UTF_8);
这种协议摆脱了文本限制,可传输任意二进制数据(如序列化对象、图片),是构建RPC框架的基础。
最后分享一个小技巧:在
EchoClient.java中,按Ctrl+D(Linux/macOS)或Ctrl+Z(Windows)可发送EOF,触发stdIn.readLine()返回null,从而优雅退出。这个组合键是Unix哲学的体现——用最简方式表达最明确的意图。当你习惯用Ctrl+D而不是强行关掉终端时,你就真正融入了命令行世界。这套TCP代码包,本质上也是在教你这种思维方式:用最基础的组件,构建最可靠的系统。
简介:一套开箱即用的Java TCP网络通信示例集合,覆盖服务端和客户端完整交互流程。服务端提供两种实现:EchoServer.java是单线程阻塞式回显服务,适合理解TCP连接建立、accept等待和基本IO操作;EchoThreadServer.java配合EchoThread.java实现多线程并发处理,每个客户端连接由独立线程负责读写,避免阻塞其他连接。客户端包含HelloClient.java(简单连接测试)和EchoClient.java(支持持续发送消息并接收服务端回显),完整演示Socket创建、输入输出流使用、异常处理及连接关闭逻辑。所有代码基于JDK标准库java.net包,不依赖第三方框架,直接javac编译、java运行即可验证效果。目录中HelloServer.java可作为服务端启动入口参考,代码注释聚焦关键节点——如端口绑定、流初始化、readLine阻塞、try-with-resources资源释放等,便于初学者跟踪执行路径、调试连接问题或拓展功能(如添加协议解析、心跳机制)。适合用于教学演示、实验作业或快速搭建轻量级本地通信原型。

8519

被折叠的 条评论
为什么被折叠?



