网络编程这块内容我前前后后翻过不少资料但真正让我把零散知识点串起来的还是跟着一套实战向的视频课程从头敲了一遍代码。今天这篇笔记就是把我学习Java网络编程过程中踩过的坑、想明白的原理、以及那些当时不理解、后来恍然大悟的设计完整地梳理出来。如果你也正在啃Socket、TCP/UDP、BIO/NIO这些概念或者写过Demo但说不清楚底层到底发生了什么那这篇内容应该能帮你省下不少自己摸索的时间。我默认你已经有Java基础知道什么是线程、什么是IO流但网络编程这块可能是第一次系统接触。全文会从最基础的网络分层讲起一路走到多线程聊天室、文件上传、NIO非阻塞通信这些实战场景每个环节我都会解释为什么要这样设计而不只是贴一段能跑的代码。1. 先搞清楚网络编程到底在编什么很多人学网络编程一上来就背ServerSocket和Socket的API结果写完一个客户端发一句、服务端回一句的Demo之后完全不知道这东西能干嘛。问题出在跳过了最该先理解的一层网络编程的本质是让两个运行在不同机器或同一机器不同进程上的程序通过操作系统提供的网络协议栈来交换数据。你写的Java代码其实是在跟操作系统的Socket接口打交道而操作系统再通过网卡把数据发出去。1.1 从OSI七层到TCP/IP四层你只需要关心哪几层教科书上那张OSI七层图说实话我看了好几遍都没记住。后来我换了个思路只记TCP/IP的四层模型并且明确自己写的代码落在哪一层。层级职责你写代码时接触到的应用层HTTP、FTP、SMTP等具体协议你自定义的通信格式、JSON、XML传输层TCP、UDP负责端到端传输Socket、DatagramSocket网络层IP负责寻址和路由基本不直接接触链路层网卡、物理介质完全不接触结论很清晰Java网络编程主要工作在传输层。你操作的是TCP或UDP至于数据怎么经过路由器、怎么变成电信号那是操作系统和硬件的事。理解这一点你就不会纠结为什么我不用管IP分片这类问题了。1.2 TCP和UDP不是谁更好的问题是场景不同新手最容易问的一句话是TCP和UDP哪个好。这问题本身就问错了。它们解决的是不同问题TCP面向连接、可靠、有序、有流量控制和拥塞控制。代价是建立连接有开销三次握手传输有确认机制速度相对慢。UDP无连接、不可靠、不保证顺序。好处是快、开销小适合能容忍丢包的场景。我打个生活化的比方。TCP像打电话先拨号接通三次握手通话中对方没听清会说你再说一遍重传确认双方都挂了才结束四次挥手。UDP像寄明信片直接扔进邮筒不确认对方收没收到也不保证顺序但胜在便宜快捷。实际选型时我的经验是文件传输、聊天消息、需要数据完整性的场景一律用TCP视频流、游戏位置同步、DNS查询这类丢一两帧无所谓、但延迟必须低的场景用UDP。初学阶段把TCP吃透UDP的API其实非常相似迁移成本很低。1.3 三次握手和四次挥手为什么必须是这个次数这块面试常问但很多人只是背了流程没理解为什么。三次握手的核心目的是双方都确认对方的收发能力正常。第一次客户端发SYN服务端知道了客户端能发第二次服务端回SYNACK客户端知道了服务端能收也能发第三次客户端再回ACK服务端知道了客户端能收。三次是理论最小值少一次就无法双向确认。四次挥手是因为TCP是全双工的两个方向要分别关闭。主动关闭方发FIN被动方回ACK此时被动方可能还有数据要发等被动方数据发完再发FIN主动方回ACK。中间那个被动方可能还有数据的状态就是为什么不能合并成三次的原因。提示理解这两个机制对后面理解Socket的close()行为、以及为什么会出现TIME_WAIT状态非常关键。很多人写服务端时遇到端口占用问题根源就在这里。2. 第一个能跑通的Socket通信从单句对话到循环收发理论讲完必须动手。我建议的第一个练习不是直接写聊天室而是先写一个客户端发一句、服务端收一句并回复的最小闭环。这个Demo虽然简单但它包含了网络编程的所有核心要素建立连接、获取流、读写数据、关闭资源。2.1 服务端ServerSocket的accept到底在等什么先看服务端代码public class SimpleServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(8888); System.out.println(服务端启动监听8888端口); Socket socket serverSocket.accept(); System.out.println(有客户端连接进来了 socket.getInetAddress()); InputStream in socket.getInputStream(); byte[] buffer new byte[1024]; int len in.read(buffer); System.out.println(收到消息 new String(buffer, 0, len)); OutputStream out socket.getOutputStream(); out.write(服务端已收到.getBytes()); socket.close(); serverSocket.close(); } }这里最关键的一行是serverSocket.accept()。它是一个阻塞方法程序执行到这里会停住直到有客户端连接进来才返回一个Socket对象。这个Socket代表的是服务端和这个特定客户端之间的那条连接而不是服务端本身。理解这一点很重要ServerSocket负责监听和接受连接真正通信用的是accept()返回的那个Socket。2.2 客户端为什么连上之后还要flushpublic class SimpleClient { public static void main(String[] args) throws IOException { Socket socket new Socket(127.0.0.1, 8888); OutputStream out socket.getOutputStream(); out.write(你好我是客户端.getBytes()); out.flush(); InputStream in socket.getInputStream(); byte[] buffer new byte[1024]; int len in.read(buffer); System.out.println(收到服务端回复 new String(buffer, 0, len)); socket.close(); } }flush()这行是新手最容易漏的。网络流的缓冲区机制和文件流类似你write的数据可能还在缓冲区里没真正发出去。flush()强制把缓冲区数据推出去。我踩过的坑就是客户端写了数据但服务端一直收不到排查半天发现是没flush。养成习惯写完数据就flush尤其是交互式通信。2.3 把单次改成循环一个容易忽略的read返回值问题上面的代码只能收发一次。要持续通信得加循环。但这里有个坑byte[] buffer new byte[1024]; int len; while ((len in.read(buffer)) ! -1) { System.out.println(收到 new String(buffer, 0, len)); }read()返回-1表示对方关闭了连接流结束。注意一定要用len来构造字符串而不是buffer.length。因为最后一次读取可能没填满缓冲区如果直接用整个buffer会读到上一次残留的数据或者一堆0。这个细节我在早期代码里犯过导致输出里总是多出一串乱码。注意read()也是阻塞的。如果对方没发数据也没关闭连接程序会一直卡在read()这里。这就是为什么后面要引入多线程——单线程下你没法同时处理读和写。3. 多线程聊天室从一问一答到多人同时在线单客户端Demo跑通后真正的挑战来了怎么让服务端同时服务多个客户端怎么让一个客户端发的消息广播给所有人这两个问题分别对应多线程处理连接和消息广播机制是聊天室的核心。3.1 为什么必须给每个客户端开一个线程假设服务端只有一个线程它accept()了一个客户端后就开始读这个客户端的数据。此时第二个客户端来连接服务端根本无暇accept()因为线程还卡在第一个客户端的read()上。这就是阻塞式IO的天然缺陷。解决方案是主线程只负责accept()每接受一个连接就启动一个新线程专门处理这个连接的读写。while (true) { Socket socket serverSocket.accept(); new Thread(new ClientHandler(socket)).start(); }这样主线程能立刻回去继续accept()每个客户端都有独立的线程服务。这就是经典的BIOBlocking IO模型一个连接一个线程。3.2 广播的实现为什么用CopyOnWriteArrayList聊天室需要维护一个当前所有在线客户端的集合当有人发消息时遍历这个集合把消息发给每个人。这里有个并发问题多个线程可能同时往集合里加人新连接或删人断开也可能同时遍历集合发消息。我一开始用ArrayList结果偶尔抛ConcurrentModificationException。后来换成CopyOnWriteArrayList就稳了。它的原理是读操作不加锁写操作时复制一份新数组再修改。聊天室场景读多写少发消息遍历是读上下线是写非常适合。private static final ListClientHandler onlineClients new CopyOnWriteArrayList(); public static void broadcast(String message, ClientHandler sender) { for (ClientHandler client : onlineClients) { if (client ! sender) { client.send(message); } } }3.3 客户端也要开两个线程读和写分离服务端多线程解决了客户端这边也有同样的问题。如果客户端主线程在等用户输入就没法同时接收服务端推来的消息。所以客户端通常开两个线程一个专门读服务端消息并打印一个专门读键盘输入并发送。// 接收线程 new Thread(() - { try { BufferedReader reader new BufferedReader( new InputStreamReader(socket.getInputStream())); String msg; while ((msg reader.readLine()) ! null) { System.out.println(msg); } } catch (IOException e) { /* 处理异常 */ } }).start(); // 主线程负责发送 BufferedReader consoleReader new BufferedReader(new InputStreamReader(System.in)); String input; while ((input consoleReader.readLine()) ! null) { out.println(input); out.flush(); }这里用BufferedReader的readLine()比直接操作字节流方便很多因为它按行读取天然解决了消息边界问题。但要注意readLine()要求对方发送时也以换行符结尾所以发送端要用println()而不是print()。提示消息边界是网络编程的一个核心问题。TCP是字节流协议它不保证你发一次、对方就收一次。你可能发了三条消息对方一次read全收到了。解决办法有两种一是用换行符等分隔符readLine二是先发长度再发内容自定义协议。聊天室用前者就够了。4. 文件上传把字节流玩明白聊天室练的是文本通信文件上传则是字节流的实战。这个场景能让你彻底理解网络传输的本质就是字节这句话。4.1 服务端接收文件为什么文件名要单独传最朴素的做法是客户端把文件内容直接发过来服务端存成一个固定名字。但这样多个客户端上传会互相覆盖。所以需要把文件名也传过去。常见做法是先发文件名一行文本再发文件内容字节流。// 服务端 BufferedReader nameReader new BufferedReader( new InputStreamReader(socket.getInputStream())); String fileName nameReader.readLine(); FileOutputStream fos new FileOutputStream(upload/ fileName); InputStream in socket.getInputStream(); byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { fos.write(buffer, 0, len); }这里有个细节坑BufferedReader会预读数据到自己的缓冲区。如果你先用BufferedReader读文件名再用原始InputStream读文件内容可能会发现文件内容开头少了一截——因为那部分已经被BufferedReader读进缓冲区了。解决办法是文件名和内容用不同的流或者干脆用DataInputStream的readUTF()来读文件名避免缓冲冲突。4.2 缓冲区大小8KB是个经验值byte[] buffer new byte[8192]这个8KB不是随便定的。太小比如1KB会导致频繁的系统调用效率低太大比如1MB会占用过多内存而且对传输速度提升有限。8KB是很多IO框架的默认值实测下来在大多数场景下表现均衡。当然如果你传的是大文件可以适当调到32KB或64KB。4.3 上传进度和断点进阶但值得了解基础版上传没有进度提示用户体验差。要显示进度可以在循环里累加已传输字节数除以文件总大小得到百分比。至于断点续传核心思路是记录已传输的偏移量重连后从该位置继续需要客户端和服务端约定一个从第几字节开始传的协议。这块内容偏进阶初学阶段先把基础上传跑通理解字节流的读写节奏更重要。5. NIO登场为什么BIO在高并发下会崩前面所有内容都是BIO模型。它在连接数不多时工作得很好但一旦并发上千问题就暴露了每个连接一个线程线程的创建和上下文切换开销巨大。一台普通服务器开几千个线程CPU光在切换线程上就耗尽了。这就是NIO要解决的问题。5.1 三大核心组件Channel、Buffer、SelectorNIO的三个关键词我用一个比喻来解释Channel通道像一条双向的管道数据从这里面流。和流不同的是通道可以同时读写。Buffer缓冲区数据必须先读进Buffer再从Buffer里取出来处理。它是数据的中转站。Selector选择器像一个前台接待同时盯着多个通道哪个通道有数据来了就通知你处理。核心变化在于BIO是我去问你有没有数据阻塞等待NIO是你有了数据告诉我事件驱动。一个Selector可以管理成千上万个Channel一个线程就能处理大量连接。5.2 一个最小的NIO服务端骨架Selector selector Selector.open(); ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8888)); serverChannel.configureBlocking(false); serverChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select(); SetSelectionKey keys selector.selectedKeys(); IteratorSelectionKey it keys.iterator(); while (it.hasNext()) { SelectionKey key it.next(); if (key.isAcceptable()) { // 处理新连接 } else if (key.isReadable()) { // 处理读事件 } it.remove(); } }这段代码看着简单但有几个新手必踩的坑configureBlocking(false)必须调用否则注册到Selector会直接抛异常。it.remove()必须调用否则处理过的key会一直留在集合里下次循环重复处理。selector.select()是阻塞的但它是阻塞在等待事件上而不是阻塞在某个具体连接上这是本质区别。5.3 NIO的坑半包和粘包NIO虽然高效但它把消息边界问题赤裸裸地暴露给了开发者。BIO里用readLine()能自动处理NIO里你得自己判断这条消息收完了没有。常见方案是自定义协议消息头固定长度存消息体长度先读头、再根据长度读体。这块内容足够单独写一篇初学阶段知道有这个问题、知道解决方向就够了。注意不要因为NIO高级就无脑用它。连接数少、逻辑简单的场景BIO的代码可读性和维护性反而更好。技术选型永远看场景不看谁更新。6. 学习路径复盘哪些坑可以提前避开最后聊聊我学完这一整套内容后的复盘。有些坑我踩过有些是看别人踩的列出来供你参考。6.1 端口占用改端口不如先查进程调试服务端时最常遇到BindException: Address already in use。原因通常是上一次的程序没正常关闭端口还处于TIME_WAIT状态。解决办法一是换个端口快速验证二是找到占用进程结束掉。在服务端代码里加serverSocket.setReuseAddress(true)也能缓解这个问题它允许端口在关闭后立即被重用。6.2 中文乱码统一用UTF-8网络传输的是字节字符串转字节时如果编码不一致就会乱码。我的习惯是所有getBytes()和new String()都显式指定StandardCharsets.UTF_8不依赖平台默认编码。这样跨平台、跨机器都不会出问题。6.3 资源关闭try-with-resources是救星Socket、流这些资源如果不关闭会泄漏文件描述符时间长了服务端直接崩。Java 7之后用try-with-resources最省心try (Socket socket serverSocket.accept(); InputStream in socket.getInputStream()) { // 使用资源 } catch (IOException e) { // 处理异常 }代码块结束时自动关闭不用手写一堆finally。这个习惯我从学网络编程开始就养成了后来写任何IO代码都受益。6.4 调试技巧先本机回环再跨机测试我建议所有网络程序先在127.0.0.1上跑通确认逻辑没问题再换到局域网IP测试。因为跨机测试会引入防火墙、网段、路由等额外变量一旦出问题很难判断是代码问题还是网络问题。先排除代码因素再排查环境因素这个排查顺序能帮你省下大量时间。网络编程这东西光看是学不会的必须自己敲、自己调、自己踩坑。我上面写的每一个坑背后都是一段真实的调试经历。你照着把聊天室和文件上传完整实现一遍再回头看看NIO的代码很多当时模糊的概念会突然清晰起来。