☰
Java Socket字节流传输示例:从ServerSocket到半包粘包避坑
2026/10/9 10:50:39 网站建设 项目流程

简介:一份面向Java学习者的Socket字节流传输示例解析,通过服务端与客户端完整代码演示TCP端口监听、accept等待连接、装饰流读取数据及字节转十六进制的实现思路,适合正在学习网络编程或需要快速上手Socket通信的开发者参考。资源压缩包含1个PDF文件,大小仅38KB,内容精炼便携,可随时打开查看。已有2629人浏览学习,受到一定关注。文档不仅包含TalkServer4Byte与TalkClient4Byte的完整源码,还逐段解释ServerSocket初始化、BufferedInputStream与DataInputStream组合、available判断请求结束、finally关闭连接等关键细节,并附有字节数组转十六进制字符串的工具方法。通过该文档,读者既能理解字符流之外更通用的字节流通信方式,也能规避资源泄露等常见问题,为后续处理文本、图片或二进制协议数据提供基础。

1. 一段 5020 端口的字节流折腾:这个示例到底解决什么问题

做 Java 服务端开发的人,迟早会碰上一次"用 socket 裸传字节"的需求。不管是接硬件设备的指令上报,还是两个系统之间约定了一个二进制协议,你都会发现 HTTP 那一套在这时候派不上用场——对方不认 JSON,只认字节序。我拆这个Java socket 字节流传输示例的时候,第一反应是它确实够原始:服务端监听 5020 端口,客户端从键盘读一个字节发一个字节,服务端收到后转成 hex 字符串打印出来。它不涉及 Netty、不涉及线程池、不涉及协议解析框架,就是一个最朴素的 TCP 字节流收发闭环。对于想理解 Socket 底层行为、或者准备 java 面试时被问"TCP 字节流到底怎么读"的人来说,这个示例是很好的破冰材料。它把 Java 网络编程里最容易被忽略的几个细节——流包装、逐字节读取、请求边界判定——全部暴露在了不到两百行代码里。

2. 服务端 TalkServer4Byte:ServerSocket 与逐字节 hex 转换的微循环

2.1 服务端的整体骨架:从构造器到 accept() 阻塞

先看服务端的主结构。TalkServer4Byte类里有一个ServerSocket成员变量和一个端口常量5020,构造器里直接new ServerSocket(port)。这里有个很容易被忽略的点:ServerSocket的构造过程不只是创建一个对象,它会在操作系统层面真实占用 5020 端口。如果这个端口被别的进程占着,构造器会抛出IOException,而代码里把这个异常给吞了——catch 块是空的。

public TalkServer4Byte() { try { server = new ServerSocket(port); } catch (IOException e) { // 端口被占用时,这里什么都不会打印 } }

这段代码说明了ServerSocket构造器的一个特性:绑定失败时不是返回 null,而是抛异常。所以你在自己写的时候,catch 块里至少应该打一行日志,否则端口冲突时你看到的只有"连不上",根本不知道是服务端没起来还是客户端没连对。talk()方法里是主循环:while (true)里调用server.accept(),这个调用会一直阻塞,直到有客户端连上来。每 accept 到一个连接,就从socket.getInputStream()取流开始读数据。

2.2 流包装链路:为什么是 BufferedInputStream 套 DataInputStream

服务端拿到Socket之后,做的第一件事不是直接读,而是先包两层流。

BufferedInputStream bis = new BufferedInputStream(socket.getInputStream()); DataInputStream dis = new DataInputStream(bis); byte[] bytes = new byte[1]; while (dis.read(bytes) != -1) { ret += bytesToHexString(bytes) + " "; if (dis.available() == 0) { doSomething(ret); } }

这里BufferedInputStream的作用是减少底层read的系统调用次数。虽然代码里每次只读一个字节,但BufferedInputStream会预读一批数据到内存缓冲区里,后续的read直接从缓冲区取,不会每次 read 都触发一次内核态切换。DataInputStream则提供了一系列读基本类型的方法,比如readByte()、readInt(),以及对read(byte[])的包装。需要注意一点:DataInputStream的read(byte[])也是不保证一次读满整个数组的,它继承自InputStream的语义,可能只读一部分。这个示例里数组长度是 1,所以不存在"读不满"的问题,但这个细节在后面改造时是要命的地方。

2.3 bytesToHexString:把字节变成可读的十六进制

bytesToHexString方法是这个示例里最值得抄的一段工具代码。它处理了 Java 里 byte 转 hex 的经典问题——byte 是有符号的,直接Integer.toHexString(src[i])会出问题,因为负数会被转成 8 位的补码字符串。

public static String bytesToHexString(byte[] src) { StringBuilder stringBuilder = new StringBuilder(""); if (src == null || src.length <= 0) { return null; } for (int i = 0; i < src.length; i++) { int v = src[i] & 0xFF; String hv = Integer.toHexString(v); if (hv.length() < 2) { stringBuilder.append(0); } stringBuilder.append(hv); } return stringBuilder.toString(); }

关键就是src[i] & 0xFF这一步。& 0xFF会把 byte 转成 0 到 255 之间的 int,这样Integer.toHexString输出的就是两位以内的十六进制,再补个前导 0 就整齐了。比如收到的字节是0x0A,如果不做& 0xFF,转出来可能是a或者因为符号扩展变成fffffffa。示例里还有一个BytesHexString方法,逻辑类似但输出大写,两个方法实际上重复了,项目里保留一个就行。

2.4 doSomething:请求边界的判定逻辑

doSomething方法只是把拼好的 hex 字符串打印出来。真正的边界判定逻辑在dis.available() == 0这个条件上。available() 返回的是当前流里可读的字节数,为 0 意味着数据暂时读完了。这个写法在局域网、数据量小、客户端发完就停的场景下勉强能工作,但它有先天缺陷,后面避坑章节我会专门展开。这里要理解的是作者的意图:客户端发来一串字节,服务端不知道一个"请求"从哪里结束到哪里结束,所以只能用"读不到数据了"来推断。这在 TCP 流里是不可靠的——TCP 是流式协议,没有消息边界,可读字节数为 0 可能只是数据还在路上,不等于一个请求结束了。

3. 客户端 TalkClient4Byte:DataInputStream 读键盘、DataOutputStream 发单字节

3.1 客户端连接建立的两种姿势

客户端的构造器里,连接建立的写法比直接new Socket("127.0.0.1", 5020)多绕了一步。

socket = new Socket(); address = new InetSocketAddress("127.0.0.1", 5020); socket.connect(address, 1000);

先创建无连接的Socket对象,再通过InetSocketAddress指定目标地址和端口,最后调用connect并传入超时时间 1000 毫秒。这种写法的好处是连接超时可控——直接new Socket(host, port)的方式,如果目标 IP 不可达,可能会卡在操作系统默认的超时时间上(通常是几十秒甚至更久)。这里 1000 毫秒的意思是,如果 1 秒内没连上,connect会抛SocketTimeoutException。在真实联调时,我一般会把超时时间放长一点到 3000 毫秒,因为局域网里服务端如果刚好在 GC 或者负载高,1 秒真不一定够。

3.2 键盘读入与 socket 写出的流转

客户端的talk()方法有一个非常典型的"字节透传"写法:从系统输入流读一个字节,立刻写到 socket 输出流。

InputStream os = new DataInputStream(System.in); byte[] b = new byte[1]; DataOutputStream dos = new DataOutputStream(socket.getOutputStream()); while (-1 != os.read(b)) { dos.write(b); } dos.flush(); dos.close();

这里有个容易看花眼的地方:new DataInputStream(System.in)被赋值给了InputStream类型的变量,其实它包装的就是标准键盘输入流。os.read(b)每次从键盘读一个字节,dos.write(b)把这个字节写到 socket 输出流发给服务端。这个循环会在键盘输入 Ctrl+D 或者 Ctrl+C 时结束——read返回 -1 表示输入流结束。注意DataInputStream的read(byte[])在这里同样存在"可能没读满"的问题,但因为数组长度是 1,所以不会有歧义。

3.3 这个客户端设计里藏着的两个问题

第一个问题:dos.flush()和dos.close()在循环外面,如果os.read(b)一直不返回 -1(就是不终止输入),数据是边读边写的,write本身会把数据送进 socket 的发送缓冲区,不需要等 flush——TCP 的发送缓冲区有自己的刷出机制。flush更多是针对有内部缓冲区的流(比如BufferedOutputStream),对DataOutputStream这种直写型流意义不大。第二个问题:从键盘读字节,读到的是你按下每个字符的原始字节,如果输入中文,一个汉字会占多个字节,服务端按单字节转 hex 打印,你会看到一串e4 b8 ad e6 96 87这样的输出,这就是 UTF-8 编码后的中文。想验证 socket 传二进制数据,最方便的办法不是手敲键盘,而是用printf配合管道把文件内容送进去。

4. 把示例跑起来:编译、双端启动与三种联调姿势

4.1 编译与启动步骤

这个示例是纯 Java 标准库实现,不需要引入任何第三方依赖。把服务端和客户端两个类分别保存为TalkServer4Byte.java和TalkClient4Byte.java,目录结构保持package com.yuan.socket对应的路径即可。

# 在 src 目录下编译 javac -d . TalkServer4Byte.java TalkClient4Byte.java # 先启动服务端(前台运行,保持终端不退出) java com.yuan.socket.TalkServer4Byte # 再开一个终端启动客户端 java com.yuan.socket.TalkClient4Byte

有一个小坑:javac编译时如果当前目录不是com/yuan/socket所在的根目录,直接编译单个文件会报"类文件在错误的目录中"。我一般会在src目录下执行javac -d . 源码路径,让 class 文件按包结构输出。-d .的意思是输出到当前目录,并且自动创建com/yuan/socket的目录层级。服务端启动后日志会打印监控端口:5020,此时服务端阻塞在accept(),客户端连上后会在服务端控制台输出连接客户端地址:/127.0.0.1:53987,后面的端口是客户端的临时端口,每次连接都会变。

4.2 三种联调姿势:从键盘输入到二进制文件探测

第一种是直接跑客户端在键盘上打字,适合验证基本收发流程。第二种是构造一个带原始字节的数据源来测试 hex 转换是否正确,这是验证二进制协议时最常用的办法。比如用 Python 生成一段带非打印字符的数据,通过管道喂给客户端程序。

# 发送测试字节序列:0x01 0x02 0xFF 0x10 printf '\x01\x02\xff\x10' | java com.yuan.socket.TalkClient4Byte

这段命令把四个十六进制字节通过管道送到了客户端的System.in,客户端read到之后逐个发给服务端。服务端应输出01 02 ff 10。这里有个值得注意的现象:管道输入不会像键盘输入那样"一行一行"地到来,而是可能一次把四个字节全部推给客户端,所以服务端的available() == 0判定的"请求边界"会变得很随机——可能四个字节拆成两次到达,也可能一次全到。第三种姿势是用nc直接连服务端测试 TCP 层是否通,排除 Java 代码的干扰:

nc 127.0.0.1 5020 # 连接后直接输入字符并回车,服务端应立即打印对应 hex

4.3 端口占用与防火墙的排查顺序

如果客户端连不上服务端,先别急着改代码。我在拆这个示例时遇到的第一个坑是端口起不来——ServerSocket构造异常被吞掉了,服务端进程还在跑但实际是死状态。排查方法很简单:先启动服务端,然后在另一个终端执行netstat -ano | grep 5020,如果没有任何输出,说明ServerSocket绑定失败了。常见原因有三个:第一个是 5020 被别的进程占用了,Windows 上可以用netstat -ano | findstr 5020看 PID,再用任务管理器结束进程;第二个是 IPv6 和 IPv4 监听冲突,如果代码里没指定,new ServerSocket(port)默认监听的是0.0.0.0(通配地址),一般不会和 IPv6 冲突;第三个是 Linux 下端口范围限制,但 5020 在默认范围内,概率很低。另外,如果服务端和客户端不在同一台机器上,必须确认防火墙放行了 5020 端口,这个跟代码无关,纯环境问题。

5. 避坑实录:单线程阻塞、available() 误判与 finally 链的隐性风险

5.1 单线程模型:第二个连接永远等不到服务

现象:客户端 A 连接上服务端并保持连接不关闭,此时客户端 B 发起连接,服务端控制台没有任何反应,B 一直卡在connect超时后报错。

原因:talk()方法里的while (true) accept()循环是单线程的。accept 到一个连接后,程序就进入内层的读取循环处理这个连接的数据。只要这个连接没断开,内层循环就不会退出,accept()根本不会被调用到,后续的连接全被堵在操作系统的连接队列里。

解决:这是这个示例最大的结构性缺陷。想同时处理多个连接,至少要把每个连接的处理逻辑丢进独立线程。常见做法是accept到 socket 后立刻new Thread(() -> handleSocket(socket)).start()。但要注意,这个示例里的socket变量是定义在while循环内部的,多线程化时要把它作为参数传进线程,不能共享同一个变量。我也见过有人在这种场景直接引入 Netty,但如果只是内部工具,手写线程池就够了。

5.2 available() 判边界:数据还在路上就被当成请求结束

现象:用printf一次发送四个字节,服务端有时候输出01 02后就把doSomething打出来了,有时候输出01 02 ff 10才打出来,结果不稳定。

原因:dis.available() == 0判断的是"当前缓冲区里没有可读数据",不是"一个完整请求到达了"。TCP 是流协议,字节到达的时间受网络、内核缓冲、对端发送时机影响。客户端多次write的数据可能被 TCP 粘在一起到达,一次write的数据也可能被拆成多个 TCP 分段。用 available 判断业务边界,本质上是拿系统的缓冲区水位当协议边界,这属于在赌概率。

解决:判定请求边界有三个可靠方案。第一个是约定结束标志,比如帧尾用0x0D 0x0A,读到这个序列就认为一个帧结束。第二个是定长协议,比如每个请求固定 8 字节。第三个是长度前缀,前两个字节表示后续负载长度。在没改协议的情况下,至少应该把available() == 0改成"读取到 -1 再结算",也就是等对端关闭连接再统一处理收到的数据。不过 TCP 连接复用场景下这个方案不适用,所以还是得走向协议设计。

5.3 finally 空指针:socket.close() 在 accept 失败时直接 NPE

现象:服务端在accept()阶段抛出异常(比如连接被对端重置),控制台打印异常信息后,程序继续循环,但某次会突然抛NullPointerException把整个进程带崩。

原因:socket变量是在try块里声明的,accept()抛异常时它还没被赋值,是 null。finally 块里无条件调用socket.close(),就触发了空指针。

Socket socket = null; while (true) { try { socket = server.accept(); // ... } catch (IOException e) { System.out.println(e.getMessage()); } finally { socket.close(); // socket 可能为 null } }

解决:finally 块里必须先判空再关闭。更稳妥的写法是if (socket != null && !socket.isClosed()) { socket.close(); }。另外,socket.close()本身会抛IOException,示例里在 catch 里只是打印,没有区分"业务异常"和"关闭异常",信息会被混在一起。我写这类代码时习惯把关闭逻辑单独抽一个方法,内部吞掉所有异常,避免关闭时的异常干扰主流程判断。

5.4 读循环的退出条件:客户端断开后服务端卡死不退出

现象:客户端正常关闭后,服务端并没有回到accept()等待下一个连接,而是卡在读取循环里一动不动,CPU 占用 0。

原因:dis.read(bytes)在对端正常关闭(发送 FIN)时会返回 -1,循环退出没问题。但如果对端是异常断开(比如网络断开),read 会一直阻塞,直到 TCP 超时。而这个示例里显然没有处理 read 返回 -1 的情况——循环条件是!= -1,但循环体内没有任何 break 或 return,如果 read 返回 -1 后配合 available 的判断又恰好不成立,就会陷入空转。

解决:读循环里要把-1当作连接关闭的信号显式处理。常见做法是int n; while ((n = dis.read(bytes)) != -1) { ... },然后在循环结束后打日志并关闭 socket。这个示例只用了!= -1控制循环,但包裹在 while 内部的逻辑没有对连接关闭后的收尾动作做处理,实战中要补上。这也是 Java socket 编程里最经典的一个八股考点:InputStream.read返回 -1 究竟意味着什么——对端关闭了输出流,而不是没数据。

5.5 字符串拼接的隐患:长连接下 ret 无限膨胀

现象:连续发送几百个字节后,服务端doSomething打印出的字符串越来越长,内存占用持续上涨,GC 开始频繁。

原因:ret += bytesToHexString(bytes) + " "在循环里用字符串拼接,Java 里String是不可变对象,每次+=都会创建新字符串。短连接数据量小看不出问题,长连接下这个字符串会无限累积,直到触发一次大对象 GC,然后再次累积。

解决:短示例无所谓,但给这个代码做生产化改造时,第一件事是把ret从String改成StringBuilder,或者在每次请求结算后把ret清空。这个示例里doSomething(ret)调用后没有重置ret,所以它本身不是按"每个请求"独立输出的,而是把整个连接内收到的所有字节拼成一个大字符串。如果你期望的是"每个请求打印一行",那必须在doSomething之后加一行ret = ""。

6. 从示例到可用:缓冲区改造、半包粘包处理与连接复用

这个示例跑通之后,如果你要把它改造成一个能处理真实业务的服务端,核心动作可以归结为三个:把单字节读出改成批量读出、把available()判边界改成自定义协议判边界、把单线程改成线程池。我以自己常用的改造模板为例,说明怎么动刀。

public void handleSocket(Socket socket) { try (BufferedInputStream bis = new BufferedInputStream(socket.getInputStream())) { byte[] buffer = new byte[4096]; int n; while ((n = bis.read(buffer)) != -1) { // 完整读到了一批数据,交给协议解析层 doSomething(bytesToHexString(buffer, n)); } } catch (IOException e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException e) { // 关闭异常不影响主流程 } } }

这里bis.read(buffer)一次最多读 4096 字节,返回的n是实际读取的字节数。注意bytesToHexString如果要按实际长度转换,方法签名必须带上n,否则会把 buffer 里没被填满的尾部也转成 00。这是改造时最容易翻车的地方——很多人把read(buffer)的返回值忽略了,导致 hex 输出串进一堆无意义的 0。改成read带返回值后,半包的问题是"一次 read 可能只读到半个业务消息",这需要协议层去拼包,常见做法是维护一个ByteArrayOutputStream累积数据,每次攒够一个完整的帧再消费。粘包的问题则相反,一次 read 可能读到两个消息,协议层要能按帧长切分。

连接复用是另一个值得留意的点。示例里的客户端每次发完数据就把 socket 关了,这在一次性测试场景没问题,但真实场景下频繁建连会大量消耗 TIME_WAIT 状态的连接,并且每次握手都增加延迟。改造方法是把dos和socket都提升为成员变量,write之后只flush不close,需要关闭连接时显式调用一个shutdown方法。

拆完这个示例,我最深的一个体会是:Java socket 编程里真正难的不是 API 调用,而是对"TCP 是字节流、没有消息边界"这件事的敬畏。那段时间我每次写完收发逻辑,都会强制自己走一遍"长连接持续发 10 万条小消息、断网重连、多客户端并发接入"这三个测试,过了才敢交给联调。你现在看这个示例觉得它简单,但把它吃透再去碰 Netty 这类框架,理解深度是完全不一样的。希望这篇拆解帮到你,也希望你在自己的工程里少踩我踩过的坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询