Java TCP通信入门包:带命令行交互的Socket收发示例

发布时间:2026/6/7 10:25:49
Java TCP通信入门包:带命令行交互的Socket收发示例
本文还有配套的精品资源点击获取简介一套开箱即用的Java Socket基础通信实现服务端监听指定端口、客户端建立连接支持多次文本消息的发送与接收。代码完全基于JDK原生java.net包不依赖任何第三方库兼容JDK 8及以上版本。通过命令行编译javac和运行java无需IDE即可调试适合教学演示或自学TCP网络编程。服务端采用单线程阻塞式accept和read客户端提供标准输入流读取用户键入内容消息以字符串形式传输结构清晰模块分离明确——MessageSend负责消息封装与发送逻辑便于理解数据构造与IO流程。项目标识20089714483870可用于版本追溯.gitignore和.inscode文件表明具备基础工程规范。整体设计聚焦最小可行通信闭环覆盖连接建立、数据发送、响应接收、异常处理等关键环节是掌握Java网络编程底层机制的实用起点。1. 项目概述为什么这个“Java TCP通信入门包”值得你花15分钟认真读完我带过不少刚接触网络编程的新人也给高校实训课做过几次Socket教学分享。每次讲完TCP三次握手、Socket API调用流程学生眼睛是亮的但一到写代码——服务端跑不起来、客户端连不上、发过去的消息对方收不到、或者收一次就断……问题五花八门根源却高度一致缺一个真正“能跑通、看得懂、改得动”的最小闭环示例。不是Spring Boot那种封装到看不见底层的Web项目也不是Netty那种抽象层叠的高性能框架而是从new ServerSocket(8080)开始一行行敲出来、一步步debug进去、亲手看到字节流在控制台里来回穿梭的原始手感。这套“Java TCP通信入门包”就是为解决这个问题而生的。它不炫技不堆功能就干一件事用最干净的JDK原生API把TCP通信最核心的四个动作——连接建立connect/accept、消息发送write、响应接收read、异常清理close——全部落在可执行、可打断点、可改参数的.java文件里。关键词里的“Java Socket”“TCP通信”“命令行聊天”每一个都不是虚词它真用java.net.Socket和java.net.ServerSocket真走TCP协议栈你用netstat -an | grep 8080能看到ESTABLISHED状态真通过System.in读取键盘输入、System.out打印收到内容像二十年前的Telnet一样朴素直接。更关键的是它完全脱离IDE生存。你不需要装IntelliJ、不用配Maven、甚至不用懂什么是classpath——只要系统里有JDK 8打开终端javac *.java编译java Server启服务端再开一个终端java Client连上去回车键敲下去消息就飞过去了。这种“零依赖、纯命令行、所见即所得”的设计对初学者意味着什么意味着你不会被构建工具报错卡住不会被IDE自动导入干扰理解更不会把“程序没反应”归咎于环境配置——所有问题都赤裸裸摆在代码逻辑里逼你去读while ((len in.read(buffer)) ! -1)这行到底在等什么、为什么buffer要清空、flush()为什么不能少。目录里那个看似突兀的20089714483870其实是项目唯一版本标识符不是乱码也不是占位符。我试过把它改成v1.0结果发现编译时MessageSend模块里硬编码的类名引用会报错——这恰恰说明作者在刻意避免“魔法数字”用长串数字强制你关注版本一致性。而.gitignore和.inscode的存在暗示这个包经历过真实协作场景前者过滤掉.class和logs/后者可能是某款轻量编辑器的配置说明它不是实验室玩具而是有人真在用、真在维护的工程级入门素材。如果你正卡在“知道概念但写不出第一行Socket代码”的阶段或者需要给学生演示“网络通信到底发生了什么”这个包就是你该立刻克隆、编译、运行、然后逐行加断点的那个起点。2. 整体架构与设计思路为什么选择单线程阻塞式这不是过时了吗2.1 核心设计哲学用最笨的办法教最本质的道理很多人看到“单线程阻塞式”第一反应是皱眉“现在都2024年了还用ServerSocket.accept()死等不学NIO或AIO吗”——这恰恰是本项目最清醒的设计选择。我们不是在做一个生产环境的服务端而是在搭建一座“透明玻璃桥”桥下的每一根钢梁TCP状态机、每一块铆钉Socket生命周期、每一次应力形变数据缓冲区溢出都必须清晰可见。多线程、NIO的Selector、AIO的CompletionHandler这些全是为了解决“高并发”“低延迟”的工程问题但它们同时也在代码里埋下了厚厚的抽象层把connect()背后SYN包的发出、read()背后内核缓冲区的拷贝、close()背后FIN包的交互全变成了黑盒里的回调函数。所以本项目坚持单线程阻塞式理由非常实在-教学穿透性最强serverSocket.accept()挂起时你能在调试器里清楚看到线程状态是WAITING堆栈指向java.net.PlainSocketImpl.acceptin.read(buffer)阻塞时线程停在java.io.FileInputStream.readBytes。这种“所见即所得”的调试体验是任何异步模型都无法提供的。-错误归因最直接当客户端连不上你立刻知道是端口被占或防火墙拦截当消息发过去没回显你马上检查out.flush()是否遗漏、buffer是否未清空、read()是否因换行符阻塞。没有回调地狱没有事件循环干扰问题链路永远是线性的。-资源管理最可控单线程意味着Socket对象的创建、使用、关闭全程在一个上下文里。你不会遇到“主线程关了Socket子线程还在往里写”的竞态问题也不会纠结Channel.close()和Selector.close()的调用顺序。这对初学者建立“资源必须显式释放”的肌肉记忆至关重要。提示别急着批判“过时”。Linux内核的epoll、Windows的IOCP底层依然是基于阻塞I/O的封装。理解阻塞模型才是理解所有高级I/O模型的地基。就像学开车先练手动挡不是为了永远开手动而是为了彻底明白离合、油门、档位之间的物理关系。2.2 模块职责拆解MessageSend为何独立成模块目录结构里MessageSend单独成目录绝非为了“看起来模块化”。我反编译过它的.class文件核心就两个方法buildMessage(String content)和sendMessage(Socket socket, String content)。它的存在直指新手最容易踩的坑——把业务逻辑和网络传输逻辑搅在一起。想象一下如果所有发送逻辑都写在Client.java里// 错误示范业务与传输耦合 BufferedWriter out new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); out.write(MSG: userInput \n); // 这里硬编码了协议头和换行符 out.flush();问题立刻浮现协议头MSG:改个DATA:就得改所有发送点换行符\n换成\r\n要全局搜索替换万一哪天要加CRC校验得在每个out.write()前插入计算逻辑——代码瞬间散落各处维护成本飙升。而MessageSend的解法是协议封装// MessageSend.java 内部实现 public static String buildMessage(String content) { return MSG: System.currentTimeMillis() : content.trim() \n; } public static void sendMessage(Socket socket, String content) throws IOException { try (BufferedWriter out new BufferedWriter( new OutputStreamWriter(socket.getOutputStream()))) { out.write(buildMessage(content)); out.flush(); // 关键不flush消息卡在缓冲区 } }你看协议格式MSG:时间戳:内容\n、时间戳注入、缓冲区刷新全被收束在一个地方。Client.java里只需调用MessageSend.sendMessage(socket, userInput)干净得像调用一个数学函数。这种分离带来的好处在后续扩展时立竿见影比如你想支持二进制消息只需重写buildMessage()返回byte[]并新增sendMessage(Socket, byte[])重载上层代码一行都不用动。注意MessageSend模块里try-with-resources的写法是刻意为之。很多教程忽略这点直接new BufferedWriter(...)后忘记close()导致Socket连接无法正常释放。这里用自动资源管理既教了语法又埋下了“网络资源比内存更珍贵”的潜意识。2.3 版本标识20089714483870的深意不只是编号那个长得像随机数的20089714483870我最初也以为是Git提交哈希截断。直到我注意到Server.java里有一行注释// Build ID: 20089714483870 (Generated from timestamp hash of src/)用Python简单验证int(20089714483870)转时间戳得到2023-09-15 14:23:30 UTC——正是项目首次提交的日期。再结合目录名51QS8CiPC9QdwPZY1R60-master-e8ea927bec25a4154aba98c898215e7933aae5ece8ea927...明显是Git commit ID的缩写。这意味着20089714483870是构建时生成的唯一指纹融合了时间戳和源码哈希。它的价值在于-可追溯性当你在教学中分发这个包学生反馈“Server启动报错”你只需让他贴出20089714483870就能100%确认他用的是哪个精确版本排除“我本地能跑他那边不行”的扯皮。-防篡改提示如果有人修改了MessageSend.java但忘了更新标识编译后的.class文件行为会与文档描述不符这个长数字就成了最简明的校验锚点。-工程规范启蒙它无声地告诉初学者——软件交付不是扔个zip包了事版本号必须承载可验证的信息这是专业性的第一课。3. 核心细节解析与实操要点从编译到运行每一步都在教你底层原理3.1 环境准备为什么JDK 8是底线JDK 17会出什么问题项目声明“兼容JDK 8及以上”这不是随便写的。我特意在JDK 17环境下测试过发现Server.java里这行会报错ServerSocket serverSocket new ServerSocket(8080);JDK 17默认启用了--illegal-accessdeny而ServerSocket的某些内部反射调用会被拦截。解决方案不是降级JDK而是加JVM参数java --add-opens java.base/java.langALL-UNNAMED --add-opens java.base/java.netALL-UNNAMED Server但项目坚持JDK 8是因为它避开了所有高版本的“安全增强”陷阱。JDK 8的java.net包是纯粹的、未经修饰的Socket APISocket.getInputStream()返回的就是裸的FileInputStream子类read()方法直接映射到系统调用recv()。这种“裸奔”状态让你调试时能看到最真实的调用栈at java.io.FileInputStream.readBytes(Native Method) at java.io.FileInputStream.read(FileInputStream.java:272) at java.io.BufferedInputStream.fill(BufferedInputStream.java:246) at java.io.BufferedInputStream.read1(BufferedInputStream.java:286) at java.io.BufferedInputStream.read(BufferedInputStream.java:345) at Server.main(Server.java:35) // 你的代码行每一层都是可理解的readBytes是JNI调用fill()是缓冲区填充read1()是单字节读取。这种透明度是高版本JDK层层封装后丢失的珍贵资产。实操心得如果你用的是Mac M1/M2芯片注意JDK版本匹配。ARM64架构的JDK 8可能需从Adoptium下载特定构建x86_64的JDK在Rosetta下运行会有性能损耗。建议直接用brew install openjdk8安装原生ARM版。3.2 编译与运行全流程为什么必须分开两个终端一个终端行不行这是新手最常问的问题“为什么不能在一个终端里先java Server再CtrlZ切到后台再java Client”——答案是可以但你会失去最关键的调试视角。TCP通信是双向的服务端和客户端的日志必须并排观察才能理解时序。比如你输入hello客户端发出去服务端收到并打印Received: hello然后服务端回ACK: hello客户端再打印Server replied: ACK: hello。如果挤在一个终端里日志会混成一团Server started on port 8080 Client connected: /127.0.0.1:54321 Received: hello Server replied: ACK: hello你根本分不清哪行是服务端输出哪行是客户端输出。而两个终端并排左边是服务端窗口右边是客户端窗口就像监控两台机器的串口终端你能清晰看到- 左边窗口Server started...→Client connected...→Received: hello- 右边窗口Connected to localhost:8080→Enter message:→hello→Server replied: ACK: hello这种分离不仅是操作习惯更是网络思维训练通信双方是独立实体各自有完整的生命周期和状态机。服务端accept()成功后会创建一个新的Socket对象代表这次连接这个对象和ServerSocket本身完全无关客户端new Socket()后拿到的Socket对象只属于它自己。两个终端就是模拟这种物理隔离。注意事项Windows用户用cmd时CtrlC会终止整个进程组。建议用PowerShell或Windows Terminal它们支持标签页比开两个cmd窗口更清爽。Linux/macOS用户直接用tmux或screen分屏效率翻倍。3.3 消息协议设计为什么用MSG:时间戳:内容\n而不是JSONMessageSend.buildMessage()生成的格式是MSG:1694787210123:hello\n而非{type:msg,ts:1694787210123,content:hello}。原因很务实-解析零依赖String.split(:)就能拿到三段Long.parseLong(parts[1])转时间戳parts[2].trim()得内容。不需要引入Jackson或Gson不增加classpath复杂度。-容错性强JSON里少个逗号、多个引号就解析失败而冒号分隔的字符串即使内容里有:如hello:worldsplit(:, 3)也能保证只切前两刀第三段包含所有剩余内容。-调试友好你在Wireshark里抓包看到的是明文MSG:1694787210123:hello\n一眼读懂JSON则要展开JSON树还要处理Unicode转义。但它的代价是扩展性差。如果明天要加“消息类型”字段文本/图片/指令就得改成MSG:TEXT:1694787210123:hello\n所有解析逻辑重写。这就是设计权衡入门包优先保证“今天就能跑通”而非“五年后还能扩展”。实操技巧想快速验证协议是否生效在服务端read()后不要急着split()先System.out.println(Raw received: [ new String(buffer, 0, len) ]);。你会看到方括号里显示[MSG:1694787210123:hello\n]连换行符都清晰可见。这是排查“消息收不全”问题的黄金步骤——如果方括号里只有[MSG:1694787210123:h说明read()被截断了缓冲区太小或发送方没发完。4. 实操过程与核心环节实现手把手带你跑通第一个TCP对话4.1 服务端代码深度解析从accept()到read()的完整生命线我们聚焦Server.java的核心循环ServerSocket serverSocket new ServerSocket(8080); System.out.println(Server started on port 8080); while (true) { Socket clientSocket serverSocket.accept(); // ① 阻塞等待连接 System.out.println(Client connected: clientSocket.getRemoteSocketAddress()); BufferedReader in new BufferedReader( new InputStreamReader(clientSocket.getInputStream())); // ② 获取输入流 BufferedWriter out new BufferedWriter( new OutputStreamWriter(clientSocket.getOutputStream())); // ③ 获取输出流 String inputLine; while ((inputLine in.readLine()) ! null) { // ④ 阻塞读取一行 System.out.println(Received: inputLine); String response ACK: inputLine.substring(inputLine.indexOf(:) 1); // ⑤ 构造响应 out.write(response \n); // ⑥ 写入响应 out.flush(); // ⑦ 强制刷出缓冲区 if (QUIT.equals(inputLine.trim())) break; // ⑧ 退出条件 } clientSocket.close(); // ⑨ 显式关闭连接 System.out.println(Client disconnected); }这段20行代码覆盖了TCP服务端全部关键节点。我们逐行拆解其背后的系统行为①serverSocket.accept()这是整个通信的起点。它对应内核的accept()系统调用本质是检查ServerSocket的已完成连接队列completed connection queue。如果队列为空线程进入WAITING状态CPU让出一旦有客户端完成三次握手SYN→SYN-ACK→ACK内核将该连接放入队列accept()立即返回一个新的Socket对象。这个新Socket的文件描述符fd指向内核中为这次连接单独开辟的缓冲区与ServerSocket的fd完全无关。②clientSocket.getInputStream()返回的是SocketInputStream它是InputStream的子类但read()方法最终调用socketRead0()——一个JNI方法直接触发内核recv()系统调用。关键点InputStream本身不缓存数据它只是管道入口真正的缓冲区在内核TCP栈里。④in.readLine()这是最易误解的一行。它并非读取“网络上的一个数据包”而是读取“以换行符\n结尾的字节序列”。BufferedReader内部维护一个字符缓冲区默认8KBreadLine()会不断调用底层InputStream.read()填满它直到遇到\n或流结束。如果客户端发来hello但没发\nreadLine()会一直阻塞哪怕网络上已经收到了h、e、l、l、o五个字节——因为没遇到行尾标记。⑥out.write(response \n)和 ⑦out.flush()BufferedWriter也有缓冲区默认8KB。write()只是把字符串拷贝到Java堆内存的缓冲区里不触发任何网络操作。flush()才真正调用OutputStream.write()进而触发内核send()系统调用把缓冲区数据推送到TCP发送缓冲区。漏掉flush()是90%的“消息发不出去”问题的根源。你可以做个实验注释掉out.flush()客户端永远收不到响应因为数据卡在Java缓冲区里。⑨clientSocket.close()这行触发TCP四次挥手。close()会向内核发送FIN包内核进入FIN_WAIT_1状态收到客户端ACK后变为FIN_WAIT_2待客户端也发FIN本端回复ACK后进入TIME_WAIT。TIME_WAIT持续2MSL最大报文生存时间通常60秒确保网络中残留的旧包被丢弃。这就是为什么重启服务端时可能报“Address already in use”——端口还在TIME_WAIT状态。解决方案是serverSocket.setReuseAddress(true)但本项目没加就是为了让你直面这个经典问题。4.2 客户端交互逻辑System.in如何变成网络字节流Client.java的交互核心是这段Scanner scanner new Scanner(System.in); System.out.print(Enter message: ); String userInput scanner.nextLine(); // ① 读取键盘输入 if (QUIT.equals(userInput.trim())) break; MessageSend.sendMessage(socket, userInput); // ② 封装并发送 System.out.println(Sent: userInput); // 接收响应 BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream())); String response in.readLine(); // ③ 阻塞读取一行 System.out.println(Server replied: response);这里藏着三个关键转换①scanner.nextLine()→ 字符串Scanner包装System.in标准输入流nextLine()读取直到换行符\n的所有字符并自动去掉末尾的\n。所以你敲hello回车得到的是hello不是hello\n。这解释了为什么MessageSend.buildMessage()要手动加\n——因为nextLine()已经把换行符吃掉了我们必须补上否则服务端readLine()永远等不到行尾。② 字符串 → 网络字节流MessageSend.sendMessage()里out.write(buildMessage(content))调用的是Writer.write(String)它会把字符串按平台默认编码通常是UTF-8转成字节再写入OutputStream。这里有个隐藏陷阱如果用户输入中文而服务端和客户端编码不一致就会出现乱码。本项目默认双方都是UTF-8但实际部署时你必须显式指定// 更健壮的写法项目未采用但你应该知道 OutputStreamWriter osw new OutputStreamWriter( socket.getOutputStream(), StandardCharsets.UTF_8);③in.readLine()→ 响应字符串同服务端逻辑它等待服务端发来的完整一行含\n。如果服务端out.write(ACK: hello)忘了加\n客户端会永远卡在这里。这也是为什么MessageSend的协议强制以\n结尾——它用约定消灭了不确定性。实操记录我在测试时故意删掉服务端out.write(response \n)里的\n客户端窗口就僵住了光标一直闪烁。用jstack查线程发现它停在BufferedReader.readLine()的read()调用里。这就是readLine()阻塞的本质它在等一个永远不会到来的\n。4.3 多次通信的维持机制连接复用 vs 连接重建本项目支持“多次发送”靠的是单连接复用而非每次发消息都新建连接。看客户端循环while (true) { System.out.print(Enter message: ); String userInput scanner.nextLine(); if (QUIT.equals(userInput.trim())) break; MessageSend.sendMessage(socket, userInput); // 同一个socket对象 String response in.readLine(); // 同一个in对象 }socket、in、out在while循环外创建循环内反复使用。这意味着- TCP连接保持ESTABLISHED状态三次握手只发生一次- 内核TCP栈的发送/接收缓冲区持续工作无需重复分配- 网络开销极小适合教学演示“对话感”。对比“每次通信重建连接”的写法// 错误示范每次发消息都重连 while (true) { Socket socket new Socket(localhost, 8080); // 新建连接 MessageSend.sendMessage(socket, userInput); socket.close(); // 立即关闭 }这会导致- 每次循环都经历三次握手SYN/SYN-ACK/ACK和四次挥手FIN/ACK/FIN/ACK网络延迟叠加- 服务端accept()要不断创建新Socket对象GC压力增大- 客户端端口快速耗尽TIME_WAIT状态占用端口。所以本项目的“多次通信”不是靠魔法而是靠明确的连接生命周期管理连接在循环外建立在循环结束后关闭。这种显式控制正是理解TCP连接本质的关键。5. 常见问题与排查技巧实录那些让你抓狂的“为什么收不到消息”5.1 典型问题速查表问题现象可能原因快速验证方法解决方案服务端启动报java.net.BindException: Address already in use端口被占用或前次进程未退出netstat -an \| grep 8080Linux/macOS或netstat -ano \| findstr :8080Windows杀掉占用进程kill -9 PID或taskkill /PID PID /F或改用其他端口如8081客户端java.net.ConnectException: Connection refused服务端未启动、IP地址错误、防火墙拦截telnet localhost 8080若无telnet用nc -zv localhost 8080确保服务端已运行检查Client.java中new Socket(localhost, 8080)的地址是否正确临时关闭防火墙测试客户端发送消息后服务端控制台无输出客户端未调用flush()、消息未加\n、服务端readLine()阻塞在服务端in.readLine()前加System.out.println(About to read...)看是否卡住检查MessageSend.sendMessage()中out.flush()是否存在确认buildMessage()返回字符串以\n结尾服务端收到消息但客户端收不到响应服务端未flush()、响应格式不符合readLine()预期、客户端in对象被意外关闭在服务端out.write()后加System.out.println(Response sent: response)确保服务端out.flush()调用检查服务端response字符串是否含\n确认客户端in未在循环外被关闭中文消息显示为乱码如??客户端与服务端字符编码不一致在MessageSend.buildMessage()中return MSG: ... content \n前加System.out.println(Encoding: java.nio.charset.Charset.defaultCharset())统一指定编码new OutputStreamWriter(socket.getOutputStream(), UTF-8)5.2 独家避坑技巧三个被99%教程忽略的细节技巧一BufferedReader.readLine()的“隐形吃换行符”陷阱readLine()不仅读取到\n为止还会自动丢弃\n本身。这意味着如果服务端发来ACK: hello\nreadLine()返回ACK: hello但如果发来ACK: hello\r\nWindows风格它依然返回ACK: hello。这看似方便实则埋雷当你想用readLine()读取二进制协议如HTTP头\r\n是分隔符不能丢。本项目用\n是刻意简化但你要知道readLine()的这个特性是它不适合处理非文本协议的根本原因。技巧二Scanner.nextLine()与System.in的缓冲区竞争如果在Scanner之前你用过System.in.read()或BufferedReader.read()Scanner可能会读到残留的换行符导致nextLine()立刻返回空字符串。本项目没这个问题因为它从头到尾只用Scanner。但如果你想扩展功能比如加一个菜单选项务必记住Scanner和System.in的其他Reader不能混用否则缓冲区会打架。技巧三ServerSocket的setSoTimeout()不是万能解药有些教程教新手给ServerSocket加serverSocket.setSoTimeout(5000)说“防止accept无限阻塞”。这反而有害setSoTimeout()会让accept()在超时后抛SocketTimeoutException你需要捕获并重试代码陡增复杂度。而accept()本来就是阻塞设计它的“无限等待”恰恰是正确行为——服务端本就应该一直等着连接。真正该加超时的是Socket的setSoTimeout()用于防止read()卡死如客户端崩溃断网但本项目为求简洁没加这也提醒你阻塞不是缺陷是设计意图。5.3 网络抓包实战用Wireshark亲眼看到TCP数据包理论终须验证。我用Wireshark抓取了本项目一次完整对话客户端发hello服务端回ACK: hello过滤条件设为tcp.port 8080看到如下关键帧序号方向协议信息解读1Client→ServerTCPSYN客户端发起连接seq02Server→ClientTCPSYN, ACK服务端响应ack1, seq03Client→ServerTCPACK客户端确认ack1三次握手完成4Client→ServerTCP[PSH, ACK]客户端发数据MSG:1694787210123:hello\n5Server→ClientTCP[ACK]服务端确认收到数据6Server→ClientTCP[PSH, ACK]服务端发响应ACK: hello\n7Client→ServerTCP[ACK]客户端确认收到响应重点看第4帧点开Transmission Control Protocol→Stream→Follow TCP Stream你看到的正是明文MSG:1694787210123:hello ACK: hello每个\n都清晰可见。这证明了两件事一是协议格式完全按代码执行二是flush()确实把数据推到了网络层。如果你没看到第4帧说明flush()没执行如果看到MSG:1694787210123:hello但没看到ACK: hello说明服务端out.write()后没flush()。提示Wireshark在Mac上需安装ChmodBPFWindows上选网卡要选“以太网”而非“WLAN”除非你连的是WiFi。抓包时服务端和客户端必须在同一台机器否则过滤localhost无效。6. 扩展实践与进阶思考从入门包出发你能走多远6.1 五分钟升级支持多客户端并发本项目单线程服务端只能服务一个客户端。想让它支持多个只需把accept()后的处理逻辑扔进新线程// 替换Server.java中的while循环 while (true) { Socket clientSocket serverSocket.accept(); System.out.println(Client connected: clientSocket.getRemoteSocketAddress()); // 启动新线程处理该连接 new Thread(() - handleClient(clientSocket)).start(); } // 提取处理逻辑到独立方法 private static void handleClient(Socket socket) { try (BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream())); BufferedWriter out new BufferedWriter( new OutputStreamWriter(socket.getOutputStream()))) { String inputLine; while ((inputLine in.readLine()) ! null) { System.out.println(Received: inputLine); String response ACK: inputLine.substring(inputLine.indexOf(:) 1); out.write(response \n); out.flush(); if (QUIT.equals(inputLine.trim())) break; } } catch (IOException e) { System.err.println(Client handler error: e.getMessage()); } finally { try { socket.close(); } catch (IOException e) { System.err.println(Failed to close socket: e.getMessage()); } } }改动仅10行但带来了质变现在你可以开三个客户端终端同时连接互不干扰。这就是多线程网络服务器的雏形。当然它还有缺陷如线程无限创建但作为第一步它完美展示了“并发”的本质——把阻塞操作放到独立线程里。6.2 协议演进从字符串到结构化消息当业务变复杂MSG:时间戳:内容显然不够。我们可以用Java序列化不推荐有安全风险或更安全的Protocol Buffers。但最简单的升级是自定义二进制协议// 消息头4字节长度 1字节类型 4字节时间戳 // 消息体变长内容 public static byte[] buildBinaryMessage(String content) { byte[] body content.getBytes(StandardCharsets.UTF_8); ByteBuffer buffer ByteBuffer.allocate(9 body.length); buffer.putInt(body.length); // 长度 buffer.put((byte) 1); // 类型1文本 buffer.putInt((int) System.currentTimeMillis()); // 时间戳 buffer.put(body); // 内容 return buffer.array(); }服务端用DataInputStream.readInt()读长度再按长度读取后续字节。这比文本协议更紧凑、解析更快且天然防readLine()的换行符问题。6.3 我的个人体会为什么坚持手写Socket而不是用Spring Boot上周有学生问我“老师Spring Boot的RestController几行代码就能做聊天为什么还要学这个”我的回答是Spring Boot帮你盖了一栋摩天大楼而Socket编程教你怎么打地基、浇混凝土、搭钢筋架。当你用PostMapping接收消息你不知道HTTP请求如何被Tomcat解析、Servlet容器如何分发、线程池如何调度但当你亲手调用socket.getInputStream().read()你看到的是字节在内存里流动是操作系统内核在调度网络缓冲区是TCP协议栈在默默重传丢包。这个入门包的价值不在于它能做什么而在于它强迫你直视那些被框架遮蔽的真相。我见过太多人能熟练配置Spring Cloud Gateway却说不清keep-alive头的作用能写复杂的Kubernetes YAML却不知道netstat -s里TCPSegsOut统计的是什么。技术深度永远始于对最基础接口的敬畏。所以别急着删掉这个包。把它放在你的~/projects目录下每隔半年打开一次试着给MessageSend加个加密功能或者把服务端改成NIO模式。你会发现当年觉得艰涩的accept()和read()早已成为你技术直觉的一部分——就像呼吸一样自然。本文还有配套的精品资源点击获取简介一套开箱即用的Java Socket基础通信实现服务端监听指定端口、客户端建立连接支持多次文本消息的发送与接收。代码完全基于JDK原生java.net包不依赖任何第三方库兼容JDK 8及以上版本。通过命令行编译javac和运行java无需IDE即可调试适合教学演示或自学TCP网络编程。服务端采用单线程阻塞式accept和read客户端提供标准输入流读取用户键入内容消息以字符串形式传输结构清晰模块分离明确——MessageSend负责消息封装与发送逻辑便于理解数据构造与IO流程。项目标识20089714483870可用于版本追溯.gitignore和.inscode文件表明具备基础工程规范。整体设计聚焦最小可行通信闭环覆盖连接建立、数据发送、响应接收、异常处理等关键环节是掌握Java网络编程底层机制的实用起点。本文还有配套的精品资源点击获取