
简介面向Java初、中级开发者的Socket编程实战示例解决在分布式或网络应用中服务端与客户端传输文件的需求。源码包共9个文件以3个Java源文件与3个编译后的class文件为核心直观展示ServerSocket监听、accept()建立连接、输入/输出流读写文件等关键步骤另含Eclipse工程配置文件.project、.classpath、.prefs导入IDE即可运行。压缩包仅9KB结构精简适合快速学习目前已有679人学习/下载。资源价值在于给出完整的文件传输示例涵盖字节流与缓冲流配合使用、文件大小预通知、read()判断传输结束以及try-catch-finally异常处理和防资源泄露的规范写法代码还涉及NIO与线程池的优化方向可帮助读者理解单线程通信到并发处理的扩展思路。通过阅读和调试这份代码能掌握Java文件传输的基础框架为今后在真实项目中加入加密、身份验证等安全机制打下基础。 前阵子做一个内部小工具需要在两台Linux服务器之间传一批配置文件和日志环境比较干净不想为一次临时需求去搭FTP、起Nginx就顺手用Java写了个极简的Socket文件传输demo。整套代码走下来发现从协议设计到粘包处理都有不少可以抠的细节今天把这套实现和踩坑经历整理出来希望对正在研究Java服务端和客户端传输文件的朋友有一点帮助。这篇文章不会讲太虚的理论核心就是围绕“Java中实现服务端和客户端传输文件”这一件事把消息边界、代码实现、并发处理、常见坑位一次说清楚。如果你只是临时传文件可以直接用我给的代码如果你想自己封装一个文件传输工具那我建议把协议设计和踩坑记录看两遍。1. 先搞清楚需求再选传输方案1.1 不只是“传文件”先看你的场景是什么很多人在搜索引擎里输入“Java传输文件”往往是因为遇到了某个具体场景给客户端推送升级包、从服务器拉日志、内网机器之间同步配置、或者做一个教学演示。不同场景对传输的要求差别很大。我自己的场景是内网两台机器之间传几个几十MB的文件偶尔还有日志增量同步不需要常驻服务也不想依赖外部组件。这时候如果搭FTP需要配置用户和目录权限如果用HTTP还要起个Web容器用现成的scp又要确认两边有没有装OpenSSH而且不好在Java程序里集成控制。所以我最后选了Java原生Socket服务端和客户端都是同一个Jar包里的两个入口一条命令启动传完就退干净利落。如果你要长期服务大量客户端那建议直接走Netty或者成熟的HTTP文件服务别用我这种简易实现。但如果目标是快速落地一个可控的小工具或者想弄懂TCP传输文件的底层逻辑原生Socket是性价比最高的选择。1.2 主流方案对比选型前先看这张表关于“Java服务端和客户端传输文件”市面常见方案无非这几种优缺点其实都很明显方案优点缺点适合场景FTP / SFTP成熟稳定支持断点续传、权限控制需要额外搭服务依赖外部组件长期固定文件交换HTTP文件上传下载能过防火墙天然支持浏览器交互需要Web容器协议重量Web系统内嵌文件功能Java原生Socket依赖最少协议可以自己定义最容易理解需要处理粘包、半包、断线等细节轻量工具、教学、内网传输Netty高性能、高并发内置编解码器学习曲线陡代码量相对大生产级大文件、高并发传输我最终选原生Socket核心原因只有一条我要传输的文件大小、数量、频次都可控而且我想完全掌握协议内容。自己写的协议虽然简单但出了问题我能直接定位到字节级不用去翻别人的源码。2. 协议边界问题粘包半包怎么破2.1 TCP是“水管”不是“信封”刚开始写Socket传输文件的同学十有八九会踩这个坑。你以为客户端write一次、服务端就能read一次两边数据是“一一对应”的实际上完全不是这么回事。TCP是字节流协议它就像一根水管你倒进去多少水字节数据接收端拧开水龙头接水时接到的水量和你倒进去的水量并不一定完全一致。可能你倒了两次水对方一次就全接走了这叫粘包也可能你倒了一次水对方慢慢接分两次接完这叫半包。放到文件传输里就更明显了。如果你直接把文件名和文件内容都塞进Socket流里服务端拿着read()傻等很容易出现文件名长度不对、内容被截断、甚至把下一个文件的数据都读进来。2.2 解决方式先定边界再传数据业内解决粘包半包的常见方式有三类固定消息长度、分隔符分隔、长度字段头。文件传输场景最合理的是“长度字段头”方案因为你本来就要传文件名和文件大小顺手就把边界定了。我的协议设计非常简单一共就四步客户端发送文件名长度占用4个字节int。客户端发送文件名的UTF-8字节数组。客户端发送文件大小占用8个字节long。客户端发送文件内容长度就是上面传的fileSize。服务端接收时反过来读先readInt()拿到文件名长度再按这个长度读文件名字节接着readLong()拿到文件大小最后按照这个大小循环读取内容。这样每一步该读多少字节双方约定得明明白白不会乱。2.3 为什么不建议用writeUTF传文件名DataOutputStream确实有个writeUTF()可以一次性写入字符串和它的长度读取时用readUTF()直接还原很多教程都这么教。但它有一个隐藏限制writeUTF内部用UTF-8的变体编码字符串长度上限是65535字节。普通文件名当然不会超过这个数但我设计协议时会想一个问题哪天我想传文件路径、文件MD5、附加JSON元数据这些都可能很短也可能很长如果依赖writeUTF等于把协议卡死在一个早期选择上。所以更稳妥的办法是writeInt(length) write(bytes)自解释又不受长度限制。提示自己定义协议时尽量把“长度字段”和“数据内容”分开别把所有东西绑在一个API里后面扩展会舒服很多。3. 服务端完整实现接收文件、落盘、并发处理3.1 ServerSocket监听与线程池接入服务端核心就两件事监听端口、为每个连接分配处理线程。直接上代码我加了详细注释。import java.io.*; import java.net.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.concurrent.*; public class FileServer { private static final int PORT 9000; private static final ExecutorService POOL Executors.newFixedThreadPool(8); public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(PORT); System.out.println(服务端已启动监听端口 PORT); while (true) { Socket socket serverSocket.accept(); POOL.execute(() - handle(socket)); } } private static void handle(Socket socket) { String saveDir /tmp/files; try { Path dir Paths.get(saveDir); if (!Files.exists(dir)) { Files.createDirectories(dir); } DataInputStream in new DataInputStream(socket.getInputStream()); // 1. 读取文件名长度和文件名 int nameLen in.readInt(); byte[] nameBytes new byte[nameLen]; in.readFully(nameBytes); String fileName new String(nameBytes, StandardCharsets.UTF_8); // 安全过滤去掉路径分隔符防止目录穿越 fileName Paths.get(fileName).getFileName().toString(); // 2. 读取文件大小 long fileSize in.readLong(); System.out.println(接收文件 fileName 大小 fileSize 字节); // 3. 循环读取文件内容并落盘 Path target dir.resolve(fileName); try (FileOutputStream fos new FileOutputStream(target.toFile())) { byte[] buffer new byte[8192]; long remaining fileSize; int read; while (remaining 0) { read in.read(buffer, 0, (int) Math.min(buffer.length, remaining)); if (read -1) { throw new IOException(文件流提前关闭传输不完整); } fos.write(buffer, 0, read); remaining - read; } fos.flush(); } System.out.println(文件接收完成 target); } catch (Exception e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException ignored) { } } } }你可能会问为什么读取文件内容时不用readFully因为readFully会一次性把byte[]填满但文件大小可能不是8192的整数倍最后一部分会凑不满buffer。所以我用“剩余字节数remaining”来限制每次最多读多少读到最后只剩100字节时就new一个100字节大小的read长度避免多读下一个数据包的内容。这就是协议边界带来的好处。3.2 服务端并发控制与安全校验我刚才用的是FixedThreadPool(8)也就是固定8个线程处理客户端连接。这是故意为之原因很简单如果不限制线程数服务端每accept一个连接就new一个Thread一旦几十个客户端同时连进来机器会直接被线程开销拖垮。安全方面有两个点值得说。第一文件名做了Paths.get(fileName).getFileName()过滤把路径分隔符去掉防止有人搞../../etc/passwd这种目录穿越。第二实际生产里一定要在协议里加一个文件大小上限校验比如如果fileSize超过5GB就直接拒绝否则恶意客户端可以发一个假的超长fileSize让服务端一直傻等。3.3 关于Socket连接不关闭导致的问题服务端处理完一个文件后最好主动关闭Socket。如果不关闭客户端可能一直在等服务端响应连接也不会释放时间长了会把端口和句柄耗尽。上面的代码在finally里做了close这是必须的。4. 客户端设计与编码细节4.1 客户端连接与发送文件元数据客户端的重点在于发协议头部、发文件内容、实时打印进度。下面是我整理后的完整客户端代码import java.io.*; import java.net.*; import java.nio.charset.StandardCharsets; public class FileClient { public static void main(String[] args) throws Exception { String host 127.0.0.1; int port 9000; File file new File(/tmp/input/config.yaml); try (Socket socket new Socket()) { socket.connect(new InetSocketAddress(host, port), 5000); socket.setSoTimeout(10000); DataOutputStream out new DataOutputStream(socket.getOutputStream()); FileInputStream fis new FileInputStream(file); // 1. 发送文件名长度 文件名 byte[] nameBytes file.getName().getBytes(StandardCharsets.UTF_8); out.writeInt(nameBytes.length); out.write(nameBytes); // 2. 发送文件大小 long fileSize file.length(); out.writeLong(fileSize); // 3. 发送文件内容 byte[] buffer new byte[8192]; int read; long total 0; while ((read fis.read(buffer)) ! -1) { out.write(buffer, 0, read); total read; long percent total * 100 / fileSize; System.out.printf(\r上传进度%d%% (%d/%d), percent, total, fileSize); } out.flush(); System.out.println(\n文件上传完成); } } }4.2 超时设置与传输中断的兜底这里的connect超时设置5秒setSoTimeout设置10秒意思是客户端向服务端写数据时如果10秒内没有任何数据成功发送出去就会抛SocketTimeoutException避免程序挂在那边不动。这对网络抖动频繁的跨机房传输很有用。注意一个细节setSoTimeout对BIO的write也有影响但更关键的是读超时。我们这个场景只发不收所以平时一般不会触发但如果以后要加“服务端回执确认”机制一定要把读超时时间调合理否则服务端稍微慢一点客户端就会误判失败。4.3 进度条打印的体验优化打印进度这里我不是每一轮都打印而是通过\r回车符在同一行刷新百分比。因为文件循环可能很快如果每读一次就println一行十几万行日志就能把控制台刷瘫痪。你可以根据文件大小调整打印策略比如每传输2%打印一次或者每传输1MB打印一次。5. 常见问题排查与调优经验5.1 常见问题速查表写网络程序排查问题的时间往往比写代码的时间长。我把实际遇到的典型问题整理成了表格你可以直接对照着看问题现象可能原因解决办法服务端启动报Address already in use端口被占用换端口或用netstat -anp客户端连接超时或拒绝服务端没启动、防火墙拦截、IP端口错误先telnet IP 端口测连通性再查防火墙接收到的文件名乱码客户端和服务端编码不一致统一用UTF-8不要依赖系统默认编码文件接收不完整或比原文件大粘包/半包没处理干净严格按协议读固定长度的头部 已知大小的内容传大文件时内存溢出用了Files.readAllBytes()一次性读入内存改成BufferedInputStream循环读写缓冲区8KB到64KB传输速度很慢缓冲区太小、多次小包传输调整buffer大小服务端和客户端都用32KB以上缓冲区连接数一多服务端就卡死每连接一个Thread导致线程爆炸用线程池限制并发连接数5.2 性能优化缓冲区到底设多大合适缓冲区的大小不是越大越好。8KB是一次文件系统IO的常见页大小很多基础教程都这么写但在万兆网卡下就能明显感觉到还有优化空间。我自己测试下来32KB到64KB之间通常是个甜点区网络吞吐和内存占用比较平衡再往上比如1MB对单文件传输提升有限反而让内存峰值变高。如果你追求极致性能可以改用FileChannel的transferTo()做零拷贝传输。零拷贝意味着数据在内核态直接搬运减少一次用户态到内核态的拷贝。下面是发送端用零拷贝的简单思路try (SocketChannel channel SocketChannel.open(new InetSocketAddress(host, port)); FileChannel fileChannel FileChannel.open(Paths.get(/tmp/input/config.yaml))) { // 这里仍然要先写协议头只是文件内容改用transferTo fileChannel.transferTo(0, fileChannel.size(), channel); }实际上transferTo的目标是WritableByteChannelSocketChannel正好满足。但要注意transferTo一次可能传不完所有字节需要循环调用直到返回0。5.3 断点续传的思路别一上来就做很多人一开口就要断点续传。想法很好但断点续传并不适合在第一步就做因为它要求双方维护一个“传输状态”已经传输了多少字节文件MD5是什么是否校验过。这些一旦做起来代码量立马翻倍。如果真要做协议可以这样扩展客户端先发送一个“传输请求”内容包括文件名、文件总大小、文件已存在的最后偏移量offset。服务端收到后打开文件并seek到offset位置然后客户端从offset继续读文件发送。整个流程有点像HTTP的Range请求逻辑上并不复杂但要对文件读写异常做更多处理。我的建议是先把最简单的“整文件传输”跑通再考虑断点续传。因为它的核心难点不在续传而在程序怎么感知“上次传到哪里了”这本身就需要持久化状态通常直接存到一个临时文件记录偏移量即可。5.4 关于测试环境的一点提醒测试时最好在本地用127.0.0.1模拟一遍再换到真实机器之间测。本地回环网络几乎不会丢包能帮你先排除网络因素换到真实机器后再观察传输速度、超时设置是否合理。我自己踩过的一次坑是在测试环境一切正常上了生产环境就频繁连接超时最后发现是防火墙对非标准端口做了限制换到服务端配置放行就解决了。6. 这次实践下来我最想强调的东西代码写到这儿其实核心逻辑也就两百来行。如果让我给新人一句话总结那就是先把协议边界定义清楚再写功能代码。文件名长度、文件大小、谁来读多少个字节这些在设计阶段就定死后面所有问题都会少很多。别把文件名和内容一股脑write出去然后对面read就乱了这是绝大多数Socket文件传输bug的根源。另外如果只是临时传个小文件原生Socket足够但如果这个传输工具要长期维护还要支持断点续传、并发多开、断线重试我就比较推荐换Netty了。Netty自带LengthFieldBasedFrameDecoder这类解码器能优雅地处理长度字段你就不用自己跟readFully和缓冲区死磕了。不过底层原理还是今天讲的这套把原生Socket跑通了再去看Netty你会觉得豁然开朗。最后再分享一个小经验写网络传输程序时日志一定要把“每次write了多少字节、每次read了多少字节”打出来哪怕只在调试时打印。很多时候问题不是逻辑错而是某个瞬间的字节数对不上有这些日志你一眼就能定位是客户端发少了还是服务端读多了。本文还有配套的精品资源点击获取