
简介面向Java初中级开发者的Socket网络编程入门资料以服务端与客户端的完整可运行代码为主线重点讲解ServerSocket监听5020端口、accept阻塞接收连接、BufferedInputStream与DataInputStream装饰流逐字节读取以及字节数组转十六进制字符串的换算过程。源码中同步展示了IOException处理、available()方法判断一次请求结束、finally中关闭socket等关键细节可有效帮助读者避开资源泄露与数据解析边界不清的常见误区。资源为单个PDF文档共1个文件压缩包仅38KB内容紧凑、便于离线查阅。已有2629人学习下载对想快速理解TCP字节流传输原理并动手实践的网络编程学习者是一份实用且轻量的参考。1. Java socket字节流传输示例解析一个示例背后的三个真正考点做过几年Java服务端的人对socket字节流传输大多有一种“看懂了、写不对”的体感。你问原理人人都知道TCP是面向字节流的你让他手写一个服务端接收不定长消息十个人里有七个会栽在“读不到完整数据”上。这不是基础不牢而是因为Java教程里给的示例大多只演示了“能通”没有讲清字节流传输的本质——调用一次read不等于收到一条完整消息。本文就从一个最小可复现的Java socket字节流传输示例说起把TCP的字节流模型、读写循环、粘包半包处理、参数调优一次讲透。适合刚入门的学生、准备Java面试的求职者以及正在排查线上socket服务端问题的开发工程师。看懂了这篇你至少能回答清楚三个高频问题read返回-1意味着什么、为什么循环里要读多次、消息边界到底怎么界定。2. 先看最小可跑通的示例一个ServerSocket加一个Socket就够了2.1 服务端怎么写ServerSocket的accept与InputStream的阻塞特性先给一个最朴素的版本。这个版本不处理异常细节不搞线程池目的是把“建立连接-读字节流-回写字节流”这条链路展示清楚。常见做法是在main方法里直接跑方便新手调试。import java.io.InputStream; import java.io.OutputStream; import java.net.ServerSocket; import java.net.Socket; public class TcpServerMinimal { public static void main(String[] args) throws Exception { // 监听本机 9000 端口第二个参数是 backlog ServerSocket serverSocket new ServerSocket(9000, 50); System.out.println(server started on port 9000); while (true) { // accept 会阻塞直到有客户端连进来 Socket socket serverSocket.accept(); System.out.println(client connected: socket.getRemoteSocketAddress()); // 字节流输入读客户端发来的数据 InputStream in socket.getInputStream(); // 字节流输出回写数据给客户端 OutputStream out socket.getOutputStream(); byte[] buffer new byte[1024]; int len; // read 会阻塞等待数据读到 -1 表示对端关闭 while ((len in.read(buffer)) ! -1) { String received new String(buffer, 0, len, UTF-8); System.out.println(received: received); // 回显给客户端 out.write(buffer, 0, len); out.flush(); } socket.close(); System.out.println(client disconnected: socket.getRemoteSocketAddress()); } } }这段代码只做了一个完整闭环accept接连接read读字节write写字节。逻辑上分为三层。第一层是ServerSocket本身它只负责监听和accept真正收发数据的是accept返回的Socket对象。第二层是getInputStream()和getOutputStream()这两个流是双向独立存在的读取和写入互不干扰。第三层是read的阻塞语义——in.read(buffer)会一直阻塞到有数据可读、连接关闭或异常发生读到-1的唯一场景是对端正常关闭了输出方向这是判断连接是否结束的关键信号。参数上值得留意的是new ServerSocket(9000, 50)里的50。这个backlog表示内核为该监听端口维护的连接队列长度当accept处理速度跟不上连接到达速度时队列满了之后新的连接会直接被拒绝。生产环境通常设在128到1024之间视业务峰值连接数而定。如果设得太大会白白占用内核内存设得太小高峰期容易丢连接。2.2 客户端怎么写connect、getOutputStream与read的配合客户端同样简单。先拿到远程地址和端口然后建立连接、写数据、读回显。这里故意用“只写一次、分两段写”的方式方便后面解释粘包现象。import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; public class TcpClientMinimal { public static void main(String[] args) throws Exception { // 连接本机 9000 端口默认连接超时时间由系统控制 Socket socket new Socket(127.0.0.1, 9000); OutputStream out socket.getOutputStream(); InputStream in socket.getInputStream(); // 第一次写4 个字节 String firstMessage hello; out.write(firstMessage.getBytes(UTF-8)); // 第二次写5 个字节中间不 sleep用来观察粘包 String secondMessage world; out.write(secondMessage.getBytes(UTF-8)); // 读完服务端回显的数据 byte[] buffer new byte[1024]; int len; while ((len in.read(buffer)) ! -1) { System.out.println(echo from server: new String(buffer, 0, len, UTF-8)); } // 关闭输出方向通知服务端“我发完了” socket.shutdownOutput(); socket.close(); } }客户端的核心逻辑在于两个细节。第一个是out.write()只是把数据写到了本机操作系统的发送缓冲区真正发往网络的时机由TCP协议栈决定所以连续两次write完全可能在同一个TCP段里到达服务端。第二个是shutdownOutput()的位置——它关闭的是输出方向而不是整个Socket这样服务端的read才能感知到“对端数据发完了”。如果用socket.close()直接关闭服务端的read同样会收到-1但此时整个连接都断了回显数据也无法送达。关于new Socket(127.0.0.1, 9000)还有一个隐式参数连接超时。这个构造函数默认会阻塞在TCP三次握手阶段超时时间由操作系统内核决定通常超过一分钟。生产环境建议改用new Socket()再调用connect(SocketAddress endpoint, int timeout)这样能把超时控制在业务容忍范围内比如3秒。超时后抛出SocketTimeoutException业务方可以根据异常类型决定是重试还是降级。2.3 跑通之后立刻会撞上的问题read到的数据对不上号如果你把上面的示例原样跑一遍输出很可能长这样received: helloworld两条独立的消息被合并成了一条。更糟的情况是服务端只收到hel剩下的loworld要到下一次read才读到。这不是代码写错了而是TCP字节流的基本性质——它不保证一次write对应一次read也不保证消息边界。字节流只是一个有序的、可靠的双向管道你的应用层协议必须自己定义“一条消息从哪里开始、在哪里结束”。这时就该停下来思考一个问题socket编程里真正需要设计的是什么答案是消息的“帧格式”术语叫framing。最简单的方案有两类一类是固定长度比如每条消息都是4字节读满4个字节算一条另一类是长度前缀先读4个字节的int代表消息体长度再读对应长度的字节作为消息体。下一章就基于这两种方案把示例升级到能用的程度。3. 处理粘包与半包自定义消息边界是socket开发的分水岭3.1 为什么TCP是“字节流”而不是“消息流”这个问题在面试里被反复问到实际写代码时也是理解各种bug的前提。TCP的传输模型可以这样理解你的应用把数据块交给内核的发送缓冲区TCP协议栈把这些数据切成一个个TCP段每个段的大小受MSS最大报文段长度约束默认通常是1460字节。一个段里可能装着你应用层两次write的数据也可能只装了半次write的数据。接收端的内核缓冲区按照到达顺序拼装字节应用层的read只是从这个缓冲区里取字节它根本不知道“你原本打算分几次写”。举个例子你调用out.write(bytes1)和out.write(bytes2)如果两次写入间隔极短TCP很可能把它们合并到一个TCP段中发送如果这对端延迟确认打开了接收方可能会等一小段时间把多个段的字节合并到一次read返回。反之如果你一次write了10MB数据TCP会把它拆成多个段发送接收方的内核缓冲区装不下应用层就必须多次调用read才能读完整。前者叫粘包后者叫半包。本质上都是“应用层消息”和“TCP段”之间的映射关系不确定。这也解释了为什么in.read(buffer)返回的字节数不一定是buffer的长度甚至每一次返回值都可能不同。常见的错误就是只用一次read去接收一条消息然后拿new String(buffer, 0, len)去解析。粘包时你会得到两条消息拼接后的脏数据半包时你会得到一条被截断的消息两种情况都会让业务解析直接崩溃。3.2 用长度前缀解决边界问题writeInt与readFully的配合实际工程中长度前缀是最通用的帧协议方案。常见做法是定一个4字节的整数作为长度字段网络字节序大端后面跟着消息体。发送端先写长度再写消息体接收端先读4字节得到长度N再循环读取N字节的消息体。Java的DataOutputStream和DataInputStream天然支持这种模式writeInt用大端写readFully保证读满指定字节数。import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.net.ServerSocket; import java.net.Socket; public class TcpServerFrame { public static void main(String[] args) throws Exception { ServerSocket serverSocket new ServerSocket(9000, 50); while (true) { Socket socket serverSocket.accept(); // 用 DataInputStream 包装字节流方便读整数和读取指定长度 DataInputStream in new DataInputStream(socket.getInputStream()); DataOutputStream out new DataOutputStream(socket.getOutputStream()); while (true) { int messageLength; try { // readFully 读不满 4 字节会抛 EOFException messageLength in.readInt(); } catch (java.io.EOFException e) { // 客户端关闭输出正常退出循环 System.out.println(client closed: socket.getRemoteSocketAddress()); break; } byte[] messageBody new byte[messageLength]; // readFully 会循环读取直到读满 messageLength 字节或连接关闭 in.readFully(messageBody); String message new String(messageBody, UTF-8); System.out.println(message: message); // 回显时也按长度前缀写 byte[] response (echo: message).getBytes(UTF-8); out.writeInt(response.length); out.write(response); out.flush(); } socket.close(); } } }这个版本的核心改进是DataInputStream.readFully。普通read只保证“尽量读读多少算多少”而readFully会阻塞循环直到读满指定字节数或者流关闭。它内部其实就是在循环调用read但从调用方的视角看语义变成了“这条消息没读完我就不会返回”。这就是消息边界落实的第一步先定长度再定消息体。参数上要注意长度字段的字节序。DataOutputStream.writeInt固定按大端写如果你和C或Python的socket程序互通必须确认对方也用大端。绝大多数网络协议都约定大端但有些私有协议喜欢用本机字节序跨语言联调时这就是典型的坑。还有一点readInt在没有数据可读时也会阻塞只有读到EOF才会抛EOFException所以用它来判断客户端关闭是可行的但不能在有超时配置的场景下忽略SocketTimeoutException。3.3 长度值的合法性校验防止内存被打爆的一行守卫长度前缀方案引入了一个新的风险点如果对方是恶意客户端或者网络数据被篡改了长度字段可能是一个巨大的值比如2的31次方减1。你的程序如果直接new byte[messageLength]会立刻抛出OutOfMemoryError。这是很多socket新手完全没意识到的安全漏洞。// 在 readFully 之前加一个长度校验 int maxMessageSize 1024 * 1024; // 限制单条消息最大 1MB if (messageLength 0 || messageLength maxMessageSize) { System.err.println(illegal message length: messageLength); socket.close(); break; }我一般在处理readInt返回后、分配字节数组前都会做这个校验。messageLength 0可以拦截掉脏数据因为合法长度不可能小于等于0messageLength maxMessageSize则是一个业务约定比如文件传输协议可以把上限设得很大但普通RPC调用1MB绰绰有余。上限的具体数值取决于你的业务传输日志行64KB够了传图片可能要设到10MB。这个值本身应该做成配置项而不是硬编码。还有一个值得注意的地方即使做了长度校验如果客户端发送速度极快且每次都声明一个边界长度接收方仍然可能因为分配大量缓冲区而触发GC压力。更稳妥的方案是用ByteArrayOutputStream按需扩容或者使用堆外内存池。但对于大多数中小型项目先做上限校验就足够挡住最粗暴的攻击了。4. 参数与边界TCP缓冲区、MSS与读写循环的配合4.1 读写缓冲区的默认值怎么影响传输性能很多人以为socket的性能瓶颈在网络带宽实际上一半以上的问题出在应用层读写缓冲区设置上。Java的Socket有几个关键参数setSendBufferSize、setReceiveBufferSize、setTcpNoDelay。前两个控制内核socket缓冲区大小默认值在Linux下通常分别是16KB和128KB左右但实际生效值可能因为操作系统上限被截断。后一个控制是否禁用Nagle算法。Socket socket new Socket(); // 连接前设置 TCP 层参数 socket.setTcpNoDelay(true); // send buffer 影响发送吞吐receive buffer 影响接收窗口 socket.setSendBufferSize(64 * 1024); socket.setReceiveBufferSize(64 * 1024); socket.connect(new InetSocketAddress(127.0.0.1, 9000), 3000);setTcpNoDelay(true)禁用Nagle算法。Nagle算法的目的是减少小包数量它会在有未确认数据时把小包攒起来一起发这对交互式场景是灾难——一次请求等40毫秒才发出去用户体验就是卡顿。如果业务对时延敏感比如RPC调用或实时消息推送务必开启TCP_NODELAY。如果你在做的是吞吐优先的批量数据传输而且客户端和服务端都明确知道消息边界关闭它反而更省带宽。缓冲区大小对传输速率的影响更隐蔽。TCP的接收窗口直接受receive buffer大小限制窗口小意味着对端能发送但未被确认的数据量小传输速率上不去。对于高带宽长链路建议把发送和接收缓冲区都调到1MB以上但要验证实际生效值。Java提供了getReceiveBufferSize方法你设置后可以读取确认。4.2 为什么read循环体里不能用单次read拼业务消息这个话题在3.2节其实已经触及了但值得单独强调一次。很多人在面试或实际项目中写了这样的代码// 错误写法只读一次认为读完了一条消息 byte[] buffer new byte[1024]; int len in.read(buffer); String message new String(buffer, 0, len, UTF-8);这段代码在“本地调试小数据量”时往往能跑通因为操作系统缓冲区里恰好有完整的一条消息。但一旦进入真实网络环境出现半包的概率会急剧上升。原因在于in.read(buffer)只保证缓冲区内有数据就返回不保证填满buffer也不保证刚好是一条完整消息。实际的返回长度取决于内核缓冲区当前可读的字节数、MSS、以及对端是否已经发送完毕。一次read返回的数据可能只有消息的四分之一也可能是三条消息的拼接。正确的处理方式是维持一个接收缓冲区的累积状态ByteArrayOutputStream accumulated new ByteArrayOutputStream(); byte[] buffer new byte[4096]; int len; while ((len in.read(buffer)) ! -1) { accumulated.write(buffer, 0, len); byte[] allBytes accumulated.toByteArray(); // 检查是否已经凑够一条消息长度前缀4字节 if (allBytes.length 4) { // 这里应该解析长度再决定是否继续读 // 核心原则没有凑齐消息体之前绝不能去解析业务字段 } }这种写法背后的原则是socket读取是一个无限循环你永远不知道下一条数据什么时候到只能靠“攒够一条消息就处理一条”的节奏推进。ByteArrayOutputStream负责暂存未消费的字节每轮read后检查总字节数是否足以解析出消息体。专业框架比如Netty里对应的概念叫Cumulation实现为Cumulator。理解了这个看任何socket框架的源码都能快速抓住主线。4.3 write的陷阱成功返回不代表对方已经收到OutputStream.write(byte[])返回void它不表示“写入成功”只表示“数据已经复制到了内核发送缓冲区”。真正的网络发送由TCP协议栈异步完成。如果你的应用需要知道对方确实收到了数据必须自己在业务协议里做确认应答比如客户端收到消息后回一条ACK。TCP层的ACK只是表示“字节到达了对端内核”不能替代业务层的确认。还有一点关于flush()。ByteArrayOutputStream和FileOutputStream的flush是无害的空操作但BufferedOutputStream和DataOutputStream不是。它们内部有缓冲区不flush的话数据会滞留在应用层缓冲区迟迟不进入内核发送队列。在socket编程里每写完一条完整消息后调用out.flush()是基本习惯代价很低收益是避免“消息发不出去”的诡异问题。4.4 半关闭与TCP连接的四次挥手shutdownOutput和shutdownInput最佳实践是在不需要发送数据时调用shutdownOutput()而不是直接close()。原因在于close()会立即关闭整个Socket如果此时还有未读完的接收数据它们会被直接丢弃而shutdownOutput()只关闭输出方向表示“我不会再写数据了”但仍可以继续读取对端发来的剩余数据。这在请求-响应协议里很常见客户端发完请求后调用shutdownOutput()服务端读到EOF就知道客户端请求已经完整发送处理完后把响应写回客户端再完整读到响应后关闭连接。参数上需要注意的是对端如果持续向你发送数据而你调用了shutdownInput()这些数据会被直接丢弃TCP层还会回RST可能导致对端报错。所以shutdownInput()是一个危险操作除非你确定不需要读数据了否则别碰。5. 避坑字节流socket的5个常见翻车现场5.1 端口被占用bind时抛出BindException现象启动服务端时报java.net.BindException: Address already in use: bind。原因有两种一是另一个进程确实占用了同一端口二是上一个服务端进程异常退出后连接还处于TIME_WAIT状态端口没被释放。排查方法是先netstat -tlnp | grep 9000看谁在占用。如果确认是TIME_WAIT导致可以在服务端ServerSocket创建前设置setReuseAddress(true)ServerSocket serverSocket new ServerSocket(); serverSocket.setReuseAddress(true); serverSocket.bind(new InetSocketAddress(9000), 50);setReuseAddress允许内核把处于TIME_WAIT状态的连接地址重用。这个参数必须在bind之前设置否则不生效。需要说明的是它解决的是“主动关闭方”的TIME_WAIT问题如果你的进程是被kill -9的被动关闭方这个参数无效。5.2 read返回0不是没数据而是buffer长度为0现象循环里int len in.read(buffer)发现len等于0进入死循环。原因你的byte[] buffer数组长度声明为0比如new byte[0]此时read方法不读数据直接返回0。解决办法是检查缓冲区初始化代码确保长度大于0。这个坑在初学者代码里出镜率很高通常是复用了某个空数组或者从配置文件读了一个错误的长度。5.3 粘包后解析错乱长度前缀做对了消息还是乱码现象服务端收到的消息内容偶尔会有乱码特别是在高并发压测时。原因接收方的消息长度字段与消息体没有正确对齐比如半包时长度字段本身没读完整把长度值解析错了。解决办法是严格使用DataInputStream.readFully读长度字段不要用单次read。另外检查字符串编码是否一致——客户端用UTF-8发送、服务端用GBK解码必然出现乱码。5.4 并发下Socket关闭导致Connection reset现象客户端报了java.net.SocketException: Connection reset。原因一端在数据还在路上时直接close()另一端的read会收到RST而不是正常的EOF。最常见的触发场景是服务端在处理线程中读写同一个Socket但某个异常分支直接调用了close()而没有先shutdownOutput()。解决习惯关闭连接前先尝试优雅半关闭或者确保没有未读数据。5.5 读循环拿线程阻塞当异常没有处理超时和时间戳现象服务端线程卡在read()不回话也不知道客户端是否还活着。原因TCP是“长连接”客户端掉线但不主动发FIN服务端的read会一直阻塞不会返回-1。这是TCP设计如此——没有心跳机制就无法发现死连接。解决做法在Socket上设置读超时socket.setSoTimeout(15000);设置了setSoTimeout之后如果15秒内没有数据到达read会抛出SocketTimeoutException捕获到这个异常就可以判断连接可能已经死了然后主动清理。但要注意这个超时一旦触发可能误杀“业务处理时间本来就超过15秒”的连接所以超时值要给足余量或者只在空闲检测场景使用而不是全局统一设置。6. 从示例到生产心跳、编码与可读性改造的落地习惯现在你已经能把一个能跑通的socket字节流示例升级成能处理粘包半包、设置了基本参数的版本。再往前走一步代码要能在生产环境站得住还需要补三类能力心跳机制、字符编码统一、以及读写逻辑的可观测性。心跳这里给一个常见的双端约定应用层每30秒发送一个特殊帧比如长度为0的消息。接收端在setSoTimeout(60)的循环里读数据读到长度为0的帧就更新“最后活跃时间”超过90秒没有心跳就关闭连接。这个做法比依赖TCP的keepalive强太多了——TCP keepalive默认要两个小时起跳而且只检测连接存活性不关心应用层是否假死。// 定时任务发送心跳帧在独立的调度线程中执行 byte[] heartbeatFrame new byte[0]; out.writeInt(heartbeatFrame.length); out.write(heartbeatFrame); out.flush(); // 接收端循环中检测长度 0 int messageLength in.readInt(); if (messageLength 0) { // 心跳帧不需要解析业务字段 continue; }字符编码方面我的习惯是进出网络边界全部用byte[]业务层才转String且统一指定UTF-8绝不使用平台默认编码。在Java中new String(bytes)不指定编码时使用的是Charset.defaultCharset()运行在不同操作系统上可能得到不同的结果。有一次在Linux服务器上调试一个Windows客户端传过来的数据乱码问题查了很久最后发现是客户端用了GBK、服务端用了UTF-8。可观测性这一点最常被忽略。socket程序调试困难因为你不知道网络层到底发生了什么。我的做法是在每个read循环的入口和出口加日志记录本次读取的字节数和累计字节数。生产环境用日志级别控制开关线上出了问题再打开DEBUG。如果日志量太大就用采样比如每100条消息打一条。这比事后抓包要快得多。// 循环内的可观测性日志 if (log.isDebugEnabled()) { log.debug(read once: length{}, totalReceived{}, len, totalReceived.getAndAdd(len)); }最后说一个我自己的习惯在socket示例代码里我总会在文件头写清楚“消息格式定义”。比如“4字节大端长度前缀 消息体UTF-8”这行注释看起来不起眼但是它让后来接手的同事不需要去逐行读代码猜协议。而且当跨语言联调时这行注释就是最简单的联调文档。从最小示例到生产级代码差距往往不在某个复杂框架而在于你是否把字节流的边界、编码、超时和日志这四个维度都落实了。如果这一篇能帮你在面试或排障时少走一次弯路那就够了。希望帮到你。本文还有配套的精品资源点击获取