做Java网络编程练手我见的第一个“既能练手又接近真实需求”的项目就是TCP做一个文件的上传与下载顺便把三次握手、四次挥手复习一遍。我自己带过不少新同事很多人在这个项目上卡住的点不是不会写Socket而是不理解“TCP是流不是包”也不理解服务端到底什么时候算接收完毕。这篇文章我就把这次核心实践完整写下来三次握手和四次挥手用大实话讲清楚文件上传下载给出可直接跑通的Java源码再从踩坑角度补充几个极其常见的问题。适合刚学网络编程的初学者也适合想找个项目练手的进阶读者。代码层面我尽量保持简单但该处理的边界情况比如半包、文件名编码、缓冲区设置一样不落。1. 三次握手先用“打电话”讲透1.1 一次通话的三次确认三次握手的过程你完全可以当成三个人互相确认“你到底能不能听到我”。我常用的一个类比是打电话第一次A拨号接通后问B“喂你听得到我吗”这个动作在TCP里就是发送一个SYN报文同时带上自己的初始序列号x。第二次B回答“听得到那你能听到我吗”这就是SYNACK报文B带上自己的初始序列号y同时确认已经收到了A的x所以ackx1。第三次A回答“能听到那我们开始说正文。”这就是ACK报文acky1表示A确认了B的序列号。很多人光记住了三次不知道为什么第二次要把SYN和ACK合在一起发。其实TCP把“我收到你的序号了”和“我开始告诉你我的序号”两件事放进同一个包省了一次交互而且第二次报文本身就同时占用“建立连接”和“确认对方”两个语义。这三次做完之后双方才互相信任A确认了B能收能发B也确认了A能收能发而且双方的初始序列号都对齐了。序列号对齐这件事很多人忽略但TCP所有数据都要靠序号来排序、去重、确认比如一个文件被拆成几百个TCP段传输接收方靠的就是序号把它们拼成完整的字节流。1.2 为什么必须是三次不是两次很多人会问既然第一次A说了B回了为什么还要A再回一次举个反面例子。假设只有两次握手A发送SYNB收到后回ACKB就认为连接建立完成。如果网络里有一个很早之前A发过的、已经失效的SYN报文延迟到达了BB会误以为A要新建立连接于是回ACK并傻等A发数据白白占用资源。但如果有第三次握手A收到B的SYNACK后发现这个ack序号根本对不上于是不发确认B超时后就把这个“半吊子连接”清理掉了。还有一个更实际的原因第三次握手是A对B初始序列号的确认。如果只有两次B不知道A是否已经知道自己发送的序列号y。B之后不管发什么数据A可能都没办法正确确认因为A心里没有B的序号。所以第三次不只是礼貌它是在同步双向的序号。平时排查问题时三次握手同样有用。比如第一次SYN丢了客户端会超时重传第二次SYNACK丢了客户端也会超时重传如果第三次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()拿到客户端连接每个连接分到线程池里处理。客户端主动发起连接根据用户输入决定是上传还是下载。上传客户端把本地文件字节流发到服务端服务端保存到指定目录。下载客户端发一个“我要哪个文件”的请求服务端把文件字节流发回客户端客户端保存到本地。为了同时支持上传和下载我在协议最前面放了一个命令字cmd1表示上传2表示下载。所有请求和响应的结构完全统一这样就避免了服务端要去猜对方接下来要干嘛。实际文件传输中最容易出的问题不是Socket用错而是“应用层不知道哪里是边界”。TCP只保证字节顺序不保证你一次write的数据对端一次read就能完整拿到。所以协议设计是文件传输最简单也最关键的一步。3.2 用协议头解决“流没有边界”的问题我设计的报文结构如下字段类型说明cmdint命令字1上传2下载statusint状态0成功-1失败nameLenint文件名的UTF-8字节长度fileNamebyte[]文件名字节fileLengthlong文件大小字节服务端读取时先用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 下载请求和响应如何约定下载的上游逻辑和上传高度对称。客户端先发送一个cmd2的请求头只需要文件名fileLength填0。服务端收到后先检查文件是否存在文件不存在返回一个status-1的响应头客户端收到后直接提示失败。文件存在返回status0的响应头带上文件名和真实文件长度然后紧跟文件字节流。一个值得注意的点是服务端返回的文件名是源文件名客户端本地保存成什么完全由自己决定。所以下载命令里我设计了两个参数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校验值这些都是非常好的扩展练习。
阅读完成 · 觉得有帮助?