做Java网络编程练手,我见的第一个“既能练手又接近真实需求”的项目,就是TCP做一个文件的上传与下载,顺便把三次握手、四次挥手复习一遍。我自己带过不少新同事,很多人在这个项目上卡住的点不是不会写Socket,而是不理解“TCP是流不是包”,也不理解服务端到底什么时候算接收完毕。这篇文章我就把这次核心实践完整写下来:三次握手和四次挥手用大实话讲清楚,文件上传下载给出可直接跑通的Java源码,再从踩坑角度补充几个极其常见的问题。适合刚学网络编程的初学者,也适合想找个项目练手的进阶读者。代码层面我尽量保持简单,但该处理的边界情况,比如半包、文件名编码、缓冲区设置,一样不落。
1. 三次握手,先用“打电话”讲透
1.1 一次通话的三次确认
三次握手的过程,你完全可以当成三个人互相确认“你到底能不能听到我”。我常用的一个类比是打电话:
- 第一次:A拨号接通后问B:“喂,你听得到我吗?”这个动作在TCP里就是发送一个SYN报文,同时带上自己的初始序列号x。
- 第二次:B回答:“听得到,那你能听到我吗?”这就是SYN+ACK报文,B带上自己的初始序列号y,同时确认已经收到了A的x,所以ack=x+1。
- 第三次:A回答:“能听到,那我们开始说正文。”这就是ACK报文,ack=y+1,表示A确认了B的序列号。
很多人光记住了三次,不知道为什么第二次要把SYN和ACK合在一起发。其实TCP把“我收到你的序号了”和“我开始告诉你我的序号”两件事放进同一个包,省了一次交互,而且第二次报文本身就同时占用“建立连接”和“确认对方”两个语义。
这三次做完之后,双方才互相信任:A确认了B能收能发,B也确认了A能收能发,而且双方的初始序列号都对齐了。序列号对齐这件事很多人忽略,但TCP所有数据都要靠序号来排序、去重、确认,比如一个文件被拆成几百个TCP段传输,接收方靠的就是序号把它们拼成完整的字节流。
1.2 为什么必须是三次,不是两次
很多人会问:既然第一次A说了,B回了,为什么还要A再回一次?
举个反面例子。假设只有两次握手:A发送SYN,B收到后回ACK,B就认为连接建立完成。如果网络里有一个很早之前A发过的、已经失效的SYN报文延迟到达了B,B会误以为A要新建立连接,于是回ACK并傻等A发数据,白白占用资源。但如果有第三次握手,A收到B的SYN+ACK后发现这个ack序号根本对不上,于是不发确认,B超时后就把这个“半吊子连接”清理掉了。
还有一个更实际的原因:第三次握手是A对B初始序列号的确认。如果只有两次,B不知道A是否已经知道自己发送的序列号y。B之后不管发什么数据,A可能都没办法正确确认,因为A心里没有B的序号。所以第三次不只是礼貌,它是在同步双向的序号。
平时排查问题时,三次握手同样有用。比如第一次SYN丢了,客户端会超时重传;第二次SYN+ACK丢了,客户端也会超时重传;如果第三次ACK丢了,服务端收不到确认,过一会儿会主动发RST或者关闭半连接。表现到应用层,就是客户端连接成功后第一次发数据,却报Connection reset。遇到这种诡异现象,可以先怀疑握手阶段的第三次ACK在网络上丢了。
1.3 跟文件传输直接相关的握手细节
讲完理论,还得说点跟实际文件服务强相关的东西。
服务端用ServerSocket监听时,操作系统内核里其实维护了两个队列:一个是半连接队列,里面放的是SYN到了但三次握手还没完成的连接;另一个是全连接队列,里面放的是握手已经完成、等待应用调用accept()取走的连接。如果应用处理不过来,accept()取连接的速度跟不上,全连接队列就会慢慢被占满。这时候客户端connect()仍然能成功,但应用层会感觉“连接建立慢”或者“半天没响应”。
所以做文件传输服务时,千万不要每来一个连接就new Thread,线程过多会导致CPU上下文切换剧烈,accept()速度更慢。稳妥的做法是用固定大小的线程池。我项目里一般开16个线程,处理量远超绝大多数个人项目需求。
另一个细节是SYN Flood,攻击者伪造大量SYN报文塞满半连接队列,导致正常客户端的连接请求进不了队列。Linux内核有syncookies机制可以做一定程度的缓解,这属于网络调优范畴,普通文件服务了解即可。
2. 四次挥手:断开比建立更讲究
2.1 断开连接是一场双向告别
三次握手建立双向连接,四次挥手是两个方向各自告别。还是用打电话来比喻:
- A说:“我要说的说完了,准备挂了。”对应发送FIN。
- B说:“收到,但我这边还没说完。”对应返回ACK。
- B又说:“我也说完了,现在可以挂了。”对应发送FIN。
- A说:“好的,收到。”对应返回ACK,然后A进入TIME_WAIT状态。
很多人不理解为什么不能像握手一样三次搞定。其实TCP连接是两条独立的通道:A到B是一条,B到A是一条。A发送FIN只表示A不再往B发送数据,但B仍然可以往A发送数据。B收到FIN后先回ACK,是因为它还要把手头的数据发完。等B的数据发完了,再发FIN告诉A“我这边也关了”。如果B恰好没有剩余数据,理论上有机会把ACK和FIN合在同一个报文里,但大多数情况下还是能看到四次过程。
这个设计有一个很实用的推论:半关闭。Java的Socket.shutdownOutput()就是只关闭发送方向,保留接收方向,可以做到“我数据发完了,但还能继续读你的响应”。很多文件协议里,客户端发完请求后调用shutdownOutput(),服务端读到EOF就知道客户端不会再发数据,可以专心响应了。
2.2 TIME_WAIT:理论最绕,实战最常遇到
四次挥手里,初学者最容易栽跟头的就是最后一步的TIME_WAIT。
主动关闭连接的一方,发出最后一个ACK之后不会立刻把连接销毁,而是进入TIME_WAIT状态,等待2倍MSL时间才彻底消失。MSL是报文在网络里的最大生存时间,常见值是几十秒到两分钟。
为什么要等这么久?两个原因。
第一个,最后一个ACK可能会丢。如果被动方没收到这个ACK,它会重发FIN,主动方需要还能用这个连接继续回ACK。如果主动方直接销毁连接,被动方会一直重发,或者返回异常。等2MSL,就是为了保证被动方能收到最后的ACK。
第二个,防止“历史报文”污染新连接。一个TCP连接的四元组(源IP、源端口、目的IP、目的端口)释放后可能很快被复用,如果旧连接里的迟到报文还在网络中游荡,新连接收到这些脏数据就会错乱。等2MSL基本上能让旧的报文都消失在网络中。
文件传输程序对TIME_WAIT的感受非常明显。如果客户端每上传一个文件就主动断开连接,就会堆积大量TIME_WAIT,短时间内频繁连接时可能出现Cannot assign requested address: connect这种端口分配失败。解决办法不是简单调内核参数,而是改应用:尽量复用长连接,或者在协议层面让客户端发完数据后由服务端主动关闭,把TIME_WAIT留在能承受的一端。
2.3 挥手阶段和文件程序的映射关系
四次挥手映射到代码里,就是close()和shutdownOutput()的区别。
很多刚开始写文件传输的人会这样写客户端:
out.write(data); socket.close();服务端如果还在while ((len = in.read(buffer)) != -1),正常情况下能读到EOF然后退出。但如果客户端在写完数据后立刻close,而服务端处理得比较慢,内核缓冲区里还有数据没被读走,关闭操作可能触发Connection reset。原因不是数据丢了,而是关闭时序不对。
我在下面给出的源码里不用EOF判断文件结束,而是在协议头里直接带上文件总长度,服务端严格按长度读。这样客户端写完数据后不着急关闭,服务端也不需要依赖EOF,只要把fileLength个字节数读够,就知道数据处理完了。这个思路和HTTP里的Content-Length是一样的,理解了它,你后面看Netty或者WebSocket协议都会轻松很多。
3. 文件上传与下载的整体协议设计
3.1 客户端/服务端各管一头
这个项目的架构并不复杂,但首先要讲清楚角色分配:
- 服务端:
ServerSocket监听固定端口,accept()拿到客户端连接,每个连接分到线程池里处理。 - 客户端:主动发起连接,根据用户输入决定是上传还是下载。
- 上传:客户端把本地文件字节流发到服务端,服务端保存到指定目录。
- 下载:客户端发一个“我要哪个文件”的请求,服务端把文件字节流发回客户端,客户端保存到本地。
为了同时支持上传和下载,我在协议最前面放了一个命令字cmd:1表示上传,2表示下载。所有请求和响应的结构完全统一,这样就避免了服务端要去猜对方接下来要干嘛。
实际文件传输中最容易出的问题,不是Socket用错,而是“应用层不知道哪里是边界”。TCP只保证字节顺序,不保证你一次write的数据对端一次read就能完整拿到。所以协议设计是文件传输最简单也最关键的一步。
3.2 用协议头解决“流没有边界”的问题
我设计的报文结构如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| cmd | int | 命令字,1=上传,2=下载 |
| status | int | 状态,0=成功,-1=失败 |
| nameLen | int | 文件名的UTF-8字节长度 |
| fileName | byte[] | 文件名字节 |
| fileLength | long | 文件大小(字节) |
服务端读取时,先用DataInputStream连续读4个int和1个long,这个头是“固定结构”的,无论网络怎么粘包、拆包,readInt()和readLong()都会自动按字节拼出结果。文件名是变长的,所以必须在前面加一个nameLen,接收方先读长度,再按长度读取指定字节数。
文件体部分就更好处理了:知道了fileLength,接收方就在while循环里反复读取,直到累计读够这个字节数为止。我代码里用的是Math.min(buffer.length, remaining),最后一次如果不足8KB,就只读剩余那么长,这样既不会多读,也不会漏读。
这里必须强调一下DataInputStream.readFully()。它是“读满指定字节才返回”的方法,内部会循环补齐,天然解决了半包问题。如果流提前结束,它会抛EOFException,这对我们判断客户端异常断开很有帮助。
3.3 为什么不推荐ObjectOutputStream传文件
网上很多教程喜欢把文件名、大小封装成一个对象,用ObjectOutputStream写,然后再写文件字节流。我强烈不建议照搬这种做法。
第一个问题,ObjectOutputStream写出来的数据自带Java序列化魔数和头信息,如果对端用ObjectInputStream去读,顺序稍微不对就报StreamCorruptedException。第二个问题,序列化对象没法很好表达大文件的流式传输,文件如果很大,你不可能把一个文件对象完整塞进内存。第三个问题,序列化流对字节流的边界控制是黑盒的,出了问题很难排查。
用DataOutputStream加自定义头部,每一个字节都看得见、控得住。而且这种“先写长度、再写内容”的设计思路,以后做RPC协议、消息队列、文件分块上传都能直接复用。
4. 文件上传核心代码:源码级拆解
4.1 协议头类:所有包统一格式
先看协议头类。这个类承担了所有请求和响应的序列化与反序列化,是整套代码的地基。
import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; public class FileHeader { public static final int CMD_UPLOAD = 1; public static final int CMD_DOWNLOAD = 2; public int cmd; public int status; public String fileName; public long fileLength; public FileHeader(int cmd, int status, String fileName, long fileLength) { this.cmd = cmd; this.status = status; this.fileName = fileName; this.fileLength = fileLength; } public void write(DataOutputStream out) throws IOException { byte[] nameBytes = fileName.getBytes(StandardCharsets.UTF_8); out.writeInt(cmd); out.writeInt(status); out.writeInt(nameBytes.length); out.write(nameBytes); out.writeLong(fileLength); } public static FileHeader read(DataInputStream in) throws IOException { int cmd = in.readInt(); int status = in.readInt(); int nameLen = in.readInt(); if (nameLen <= 0 || nameLen > 1024 * 1024) { throw new IOException("非法的文件名长度: " + nameLen); } byte[] nameBytes = new byte[nameLen]; in.readFully(nameBytes); String fileName = new String(nameBytes, StandardCharsets.UTF_8); long fileLength = in.readLong(); return new FileHeader(cmd, status, fileName, fileLength); } }这里有个关键细节:读取nameLen后我加了一个上限校验。如果不校验,恶意客户端可以发送一个超大的nameLen,服务端直接new byte[nameLen]就可能把内存耗尽,这是典型的安全漏洞。合法文件名最多几百字节,限制在1MB以内完全合理。
4.2 服务端接收上传的核心方法
接下来是服务端主类。它先启动一个ServerSocket监听,然后用线程池处理每个连接。每个连接内部先读协议头,根据cmd分发到上传或下载逻辑。
import java.io.*; import java.net.ServerSocket; import java.net.Socket; import java.nio.file.Paths; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class FileServer { static final int PORT = 9000; static final String BASE_DIR = "D:/tcp-file-server/"; public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(PORT); ExecutorService pool = Executors.newFixedThreadPool(16); System.out.println("文件服务已启动,端口: " + PORT); while (true) { Socket socket = serverSocket.accept(); pool.execute(() -> handleClient(socket)); } } private static void handleClient(Socket socket) { try (Socket s = socket; BufferedInputStream bis = new BufferedInputStream(s.getInputStream()); DataInputStream in = new DataInputStream(bis); BufferedOutputStream bos = new BufferedOutputStream(s.getOutputStream()); DataOutputStream out = new DataOutputStream(bos)) { FileHeader header = FileHeader.read(in); if (header.cmd == FileHeader.CMD_UPLOAD) { receiveFile(in, out, header); } else if (header.cmd == FileHeader.CMD_DOWNLOAD) { sendFile(out, header); } else { System.out.println("未知命令: " + header.cmd); } } catch (EOFException e) { // 客户端没发完整数据就断开,正常现象,不打印堆栈 } catch (IOException e) { e.printStackTrace(); } } private static void receiveFile(DataInputStream in, DataOutputStream out, FileHeader header) throws IOException { String safeName = sanitizeFileName(header.fileName); File target = new File(BASE_DIR, safeName); long totalRead = 0; try (FileOutputStream fos = new FileOutputStream(target)) { byte[] buffer = new byte[8192]; long remaining = header.fileLength; while (remaining > 0) { int len = in.read(buffer, 0, (int) Math.min(buffer.length, remaining)); if (len == -1) { break; } fos.write(buffer, 0, len); totalRead += len; remaining -= len; } } // 写回执给客户端 header.status = 0; header.fileLength = totalRead; header.write(out); out.flush(); System.out.println("上传完成: " + safeName + ",大小: " + totalRead + " 字节"); } private static String sanitizeFileName(String fileName) { // 去掉可能的路径部分,防止客户端传入 ../../xxx 覆盖服务端其他目录 String name = fileName.replace('\\', '/'); int idx = name.lastIndexOf('/'); if (idx >= 0) { name = name.substring(idx + 1); } // 替换Windows/Unix下常见的非法字符 return name.replaceAll("[\\\\/:*?\"<>|]", "_"); } }receiveFile里最关键的一行是in.read(buffer, 0, (int) Math.min(buffer.length, remaining))。它保证了最后一次读取不会超过文件剩余大小,防止连下一个请求的数据也一起读进来。写完文件之后,服务端会写一个状态回执,客户端拿到回执才知道这次上传到底成没成功。
4.3 客户端发送文件与接收回执
客户端的上传方法核心很直接:连接服务器,写协议头,然后循环读本地文件并写入Socket。写完之后一定要flush(),再等服务端的回执。
import java.io.*; import java.net.Socket; public class FileClient { static final String SERVER_HOST = "127.0.0.1"; static final int SERVER_PORT = 9000; public static void main(String[] args) throws IOException { if (args.length < 1) { System.out.println("用法:"); System.out.println(" java FileClient upload <本地文件路径>"); System.out.println(" java FileClient download <服务端文件名> <本地保存路径>"); return; } if ("upload".equalsIgnoreCase(args[0])) { upload(args[1]); } else if ("download".equalsIgnoreCase(args[0])) { download(args[1], args.length >= 3 ? args[2] : "download_" + args[1]); } } public static void upload(String filePath) throws IOException { File file = new File(filePath); if (!file.exists() || !file.isFile()) { throw new IOException("文件不存在: " + filePath); } try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT); BufferedOutputStream bos = new BufferedOutputStream(socket.getOutputStream()); DataOutputStream out = new DataOutputStream(bos); DataInputStream in = new DataInputStream(new BufferedInputStream(socket.getInputStream())); FileInputStream fis = new FileInputStream(file)) { FileHeader header = new FileHeader(FileHeader.CMD_UPLOAD, 0, file.getName(), file.length()); header.write(out); out.flush(); byte[] buffer = new byte[8192]; long total = 0; int len; while ((len = fis.read(buffer)) != -1) { out.write(buffer, 0, len); total += len; } out.flush(); FileHeader resp = FileHeader.read(in); if (resp.status == 0) { System.out.println("上传成功,服务端保存为: " + resp.fileName); } else { System.out.println("上传失败,服务端状态: " + resp.status); } } } }有人会担心客户端写完数据后立即去读回执,会不会和服务端形成死锁。这个担心有道理,但前提是发送缓冲区被写满并且服务端停止读取。我们代码里服务端一直在循环读文件数据,读够fileLength后立刻写回执,客户端写完文件并flush()之后再读回执,两个方向是异步推进的,不会卡死。
4.4 上传流程里三个不能偷懒的细节
第一,文件名必须用UTF-8字节数组。直接用String.getBytes()默认使用系统编码,Windows和Linux互相传文件时很容易乱码。
第二,不要在循环里频繁flush()。flush()只是把Java层缓冲区的数据推给操作系统,而不是直接发到对端。每写一个小块就flush()会产生大量小包,白白增加网络开销。真正合适的是写完全部数据后flush()一次,让底层去决定怎么分段。
第三,服务端保存文件前一定要做sanitizeFileName。如果客户端传入一个../../etc/crontab或者Windows下的路径分隔符,服务端不做过滤就直接拼接路径,后果不堪设想。学习项目也要养成这个习惯。
5. 文件下载:思路反着来,坑却不一样
5.1 下载请求和响应如何约定
下载的上游逻辑和上传高度对称。客户端先发送一个cmd=2的请求头,只需要文件名,fileLength填0。服务端收到后先检查文件是否存在:
- 文件不存在:返回一个
status=-1的响应头,客户端收到后直接提示失败。 - 文件存在:返回
status=0的响应头,带上文件名和真实文件长度,然后紧跟文件字节流。
一个值得注意的点是:服务端返回的文件名是源文件名,客户端本地保存成什么,完全由自己决定。所以下载命令里我设计了两个参数:remoteName是服务端文件名,localPath是本地保存路径,两者可以不同。
5.2 服务端发送文件、客户端保存
服务端的sendFile方法和上面的receiveFile对应。它被handleClient里的Cmd_DOWNLOAD分支调用。
private static void sendFile(DataOutputStream out, FileHeader req) throws IOException { String safeName = sanitizeFileName(req.fileName); File file = new File(BASE_DIR, safeName); if (!file.exists() || !file.isFile()) { FileHeader resp = new FileHeader(FileHeader.CMD_DOWNLOAD, -1, req.fileName, 0); resp.write(out); out.flush(); return; } FileHeader resp = new FileHeader(FileHeader.CMD_DOWNLOAD, 0, req.fileName, file.length()); resp.write(out); out.flush(); try (FileInputStream fis = new FileInputStream(file)) { byte[] buffer = new byte[8192]; int len; while ((len = fis.read(buffer)) != -1) { out.write(buffer, 0, len); } out.flush(); } }这里我特意在写完响应头之后先flush()一次,再去发文件体。这么做的目的是让客户端尽早拿到头部信息,尤其是文件很大时,客户端可以先创建好本地文件,同时服务端继续读磁盘发送,两边并行起来,IO整体效率更高。
客户端的下载代码如下:
public static void download(String remoteName, String localPath) throws IOException { try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT); BufferedOutputStream bos = new BufferedOutputStream(socket.getOutputStream()); DataOutputStream out = new DataOutputStream(bos); DataInputStream in = new DataInputStream(new BufferedInputStream(socket.getInputStream()))) { FileHeader req = new FileHeader(FileHeader.CMD_DOWNLOAD, 0, remoteName, 0); req.write(out); out.flush(); FileHeader resp = FileHeader.read(in); if (resp.status != 0) { System.out.println("下载失败: 文件不存在或状态异常"); return; } File targetFile = new File(localPath); if (targetFile.getParentFile() != null) { targetFile.getParentFile().mkdirs(); } try (FileOutputStream fos = new FileOutputStream(targetFile)) { byte[] buffer = new byte[8192]; long remaining = resp.fileLength; long total = 0; long lastPrint = 0; while (remaining > 0) { int len = in.read(buffer, 0, (int) Math.min(buffer.length, remaining)); if (len == -1) { break; } fos.write(buffer, 0, len); remaining -= len; total += len; if (total - lastPrint >= 10 * 1024 * 1024) { System.out.println("已下载 " + total + " / " + resp.fileLength + " 字节"); lastPrint = total; } } } System.out.println("下载完成,本地文件: " + targetFile.getAbsolutePath()); } }这段代码里有一个细节:mkdirs()创建父目录。如果用户指定了一个不存在的保存路径,程序不会因为FileNotFoundException直接崩溃,而是先把目录建出来再写文件,实用性高了不少。
5.3 传输方法统一化的思路
细看你会发现,上传的接收逻辑和下载的接收逻辑几乎一样,都是“按指定长度循环读、循环写”。完整源码里我特意保留了这两个方法,方便初学者对照理解。但实际工程里,这种重复代码应该抽出公共方法。
给一个参考实现:
private static long transferWithLength(InputStream in, OutputStream out, long length, long printStep) throws IOException { byte[] buffer = new byte[8192]; long total = 0; long nextPrint = 0; while (total < length) { int len = in.read(buffer, 0, (int) Math.min(buffer.length, length - total)); if (len == -1) { break; } out.write(buffer, 0, len); total += len; if (printStep > 0 && total >= nextPrint) { System.out.println("进度: " + total + " / " + length); nextPrint += printStep; } } return total; }上传时调用transferWithLength(new FileInputStream(file), socketOut, file.length(), step),下载时调用transferWithLength(socketIn, new FileOutputStream(target), resp.fileLength, step)。参数化之后,两个方向只保留“数据从哪来、到哪去”的差异,其余逻辑完全复用。这也是一种很好的抽象思维训练。
6. 实战踩坑:这些排查经验帮你少熬夜
6.1 异常与报错快速排查速查表
以下都是我实际调试或帮人排查时遇到过的高频问题,整理成一张表:
| 异常或现象 | 可能原因 | 处理建议 |
|---|---|---|
StreamCorruptedException | 协议不匹配,序列化流头错乱 | 统一用DataInputStream/DataOutputStream,别混用序列化流 |
EOFException | 对端没发完整头部就断开 | 服务端捕获并忽略,不算致命错误 |
SocketException: Connection reset | 对端已关闭连接,本端还在读写 | 检查关闭顺序,不要提前close,必要时用shutdownOutput半关闭 |
SocketException: Broken pipe | 管道已断裂,继续写数据 | 写入前确认对端状态,捕获后按业务重试 |
Cannot assign requested address | 大量短连接触发TIME_WAIT,源端口耗尽 | 改用长连接复用,减少主动关闭 |
服务端read卡住不动 | 客户端没关闭输出流,或没有按约定长度发送 | 协议头里带fileLength,服务端按长度读取 |
| 文件名乱码 | 收发两端默认编码不一致 | 统一用UTF-8字节数组 |
6.2 粘包和半包:从原理到规避
粘包和半包是TCP新手最痛的两个词。先解释一下,TCP是面向字节流的,它只保证字节顺序,不保证每次write对应对方一次read。两个小write可能被内核合并成一个TCP段发给对端,这是粘包;一个大write可能被拆成多个TCP段,这是半包。
解决思路永远是:应用层自己定义消息边界。我这套代码就做出了三个示范。
第一,头部是固定结构,四个整数加一个long,用readInt()读,无论如何粘包都不会错位。第二,文件名是变长的,所以前面加nameLen,并用readFully()保证攒够字节再返回。第三,文件内容变长,所以前面加fileLength,接收端用剩余长度控制循环。
如果你以后接触Netty,会发现它里面的LengthFieldBasedFrameDecoder就是同一个套路:先读长度字段,再按长度读内容。理解了今天这套手写协议,后面看Netty源码就不会发怵了。
6.3 性能优化:缓冲、线程池与长连接
这个项目虽然小,但性能优化的方向是和大系统一致的。
首先是缓冲,Socket流一定要包BufferedInputStream和BufferedOutputStream,否则每个readInt()或writeInt()都可能触发一次底层网络IO,性能天差地别。缓冲区默认8KB,正好和文件读写缓冲区对齐,不需要额外调大。
其次是线程池。示例代码里我用的是固定16线程的Executors.newFixedThreadPool。如果你一定要用并行流或者虚拟线程(Java 21+),也要注意控制并发量,别让所有大文件同时涌入内存。
第三是连接复用。当前代码每个连接只处理一次请求,适合教学但不适合高频率传输。真正的高频上传下载场景,客户端应该建立一个长连接,连续传输多个文件,或者至少减少主动断开次数。这样既能避开频繁三次握手带来的延迟,也能减少TIME_WAIT堆积。
还有一个反直觉的经验:不要为同一个文件开多线程分片传输。很多人觉得多线程能加速,实际单个TCP流已经有滑动窗口和拥塞控制,它会自动利用可用带宽。多线程分片反而可能引起大量乱序重传,速度不升反降。真正需要分片的时候,通常是为了断点续传或服务端存储限制,而不是单纯为了“更快”。
这段项目的最大价值,其实就是把一个抽象的“TCP是流不是包”落到代码里,让你亲眼看到自定义协议头是怎么解决边界问题的。跑通一次之后,你可以试着把服务端的处理逻辑改成“一条连接连续接收多个文件”,或者给协议头加一个MD5校验值,这些都是非常好的扩展练习。