
网络编程这块内容很多人学Java学到这个地方就开始犯迷糊。我当初也是前面集合、多线程、IO流都还跟得上一到Socket、TCP、UDP这些概念堆在一起加上BIO、NIO又冒出来脑子里直接成一锅粥。但说实话网络编程恰恰是Java进阶路上绕不开的一道坎——你后面不管做Web服务、中间件、还是分布式系统底层都是这些东西在撑着。这篇内容我打算把网络编程的核心脉络捋一遍从TCP和UDP的本质区别讲起到手写一个模拟B/S服务器再到BIO和NIO的模型对比尽量用大白话把每个环节讲透让正在学这块的朋友少走点弯路。1. 先把网络编程的底层逻辑搞清楚1.1 为什么需要网络编程单机程序的数据都在自己机器上读写文件、操作内存就行了。但一旦两台机器要通信问题就来了数据怎么传传给谁传的过程中会不会丢顺序会不会乱这些就是网络编程要解决的核心问题。Java把网络通信的底层细节封装成了Socket套接字这套API。你可以把Socket理解成两台机器之间的一条管道——一端往外写数据另一端就能读到。这条管道的建立、维护、关闭Java都帮你处理了底层协议栈的交互你只需要关注往管道里写什么和从管道里读什么。但这里有个关键点很多人一开始不理解Socket本身不负责数据的传输方式它只是一个端点抽象。真正决定怎么传的是你选择的传输层协议——TCP还是UDP。这就好比你要寄快递Socket是你家门口那个收发点但快递走航空还是走陆运那是另一回事。1.2 TCP和UDP的本质差异TCP和UDP的区别面试被问烂了但真正理解的人不多。我用一个生活场景来解释TCP就像打电话——你先拨号对方接了你们建立连接然后你说一句对方听一句顺序不会乱对方没听清你可以重说。通话结束要挂断双方都知道通话结束了。UDP就像发短信——你编辑好直接发出去对方收没收到你不知道短信可能延迟、可能乱序到达但胜在快、不用先建立连接。落到技术层面核心差异在这几个维度对比维度TCPUDP连接方式面向连接三次握手建立无连接直接发可靠性可靠有确认重传机制不可靠发了不管数据顺序保证有序不保证有序传输效率相对较低有握手、确认开销高无额外开销适用场景文件传输、网页请求、邮件视频直播、游戏、DNS查询数据边界字节流无边界数据报有边界这里重点说一个容易踩的坑TCP是字节流协议它不保留你发送时的数据边界。你客户端分三次write写了Hello、World、!服务端read的时候可能一次读到HelloWorld!也可能分五次读到碎片。所以用TCP传输自定义协议时必须自己定义消息边界——常见做法是固定长度、分隔符、或者长度头内容的方式。UDP就不存在这个问题你发一个数据报对方就收一个数据报边界天然保留。1.3 Java中网络编程的核心类族Java的java.net包提供了网络编程的基础设施核心类其实不多但组合起来很灵活InetAddress封装IP地址比如InetAddress.getByName(www.example.com)能拿到域名对应的IP。注意它不包含端口号。InetSocketAddressIP端口的组合Socket连接时用得多。ServerSocket服务端专用监听某个端口等待客户端连接。Socket客户端和服务端建立连接后双方都用这个类来读写数据。DatagramSocket / DatagramPacketUDP专用的发送和接收类。这些类的方法签名看起来简单但实际用的时候阻塞行为、超时设置、资源关闭这些细节才是真正决定程序稳不稳的地方。后面我会结合具体代码逐个拆解。2. TCP编程从三次握手到代码落地2.1 TCP连接建立的过程在代码里怎么体现TCP的三次握手是协议栈自动完成的Java代码层面你感知不到。但你要理解的是new Socket(host, port)这一行代码执行的时候底层发生了三次握手。如果目标端口没开、防火墙拦了、或者网络不通这行代码会阻塞一段时间然后抛ConnectException。服务端这边serverSocket.accept()方法会阻塞直到有客户端连接进来。每accept一次就返回一个新的Socket对象这个Socket专门负责和那个客户端通信。原来的ServerSocket继续监听等待下一个客户端。这里有个新手常犯的错误以为一个ServerSocket只能服务一个客户端。实际上ServerSocket可以反复accept每次返回不同的Socket。但问题是如果你在main线程里处理完一个客户端才accept下一个那同一时间就只能服务一个客户端。要支持并发就得把每个客户端交给独立线程处理——这就是后面BIO模型要讲的内容。2.2 一个能跑通的TCP通信示例先看服务端代码public class TcpServer { 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().getHostAddress()); InputStream in socket.getInputStream(); byte[] buffer new byte[1024]; int len; while ((len in.read(buffer)) ! -1) { System.out.println(收到消息 new String(buffer, 0, len)); } socket.close(); serverSocket.close(); } }客户端代码public class TcpClient { 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(); socket.close(); } }这段代码能跑但有几个问题需要点出来第一服务端的in.read(buffer)在客户端没有关闭连接之前会一直阻塞等待。客户端调了socket.close()之后服务端才会读到-1循环结束。如果你不关连接服务端就卡在那里。第二out.flush()这行很多人会忘。虽然Socket的OutputStream在缓冲区满或者关闭时会自动flush但手动flush能确保数据立即发出去避免我明明写了为什么对方没收到的困惑。第三资源关闭没有用try-with-resources实际项目中应该用避免异常时资源泄漏。2.3 TCP粘包和拆包为什么你的消息会粘在一起前面提过TCP是字节流不保留边界。实际表现就是客户端连续发两条消息服务端可能一次全收到。这就是所谓的粘包。反过来一条大消息可能被拆成多次收到这是拆包。解决思路有三种固定长度每条消息固定N个字节不够补空格。简单但浪费带宽且不灵活。分隔符每条消息以特定字符结尾比如换行符。服务端按分隔符切分。HTTP协议头就是用\r\n做分隔。缺点是如果消息内容本身包含分隔符需要转义。长度头内容先发4个字节表示内容长度再发内容。接收方先读4字节拿到长度再按长度读内容。这是最通用的方案Netty等框架内部就是这么做的。我推荐初学者先用分隔符方案练手理解原理后再上长度头方案。下面是一个基于换行符的简单实现// 发送方 BufferedWriter writer new BufferedWriter( new OutputStreamWriter(socket.getOutputStream())); writer.write(消息内容); writer.newLine(); // 写入换行符作为分隔 writer.flush(); // 接收方 BufferedReader reader new BufferedReader( new InputStreamReader(socket.getInputStream())); String line; while ((line reader.readLine()) ! null) { System.out.println(收到 line); }用BufferedReader的readLine方法它会自动按换行符切分省去了手动处理边界的麻烦。但注意readLine遇到流结束才返回null所以客户端关闭连接后服务端才能退出循环。3. UDP编程轻量但不简单的数据报通信3.1 UDP的发送和接收模型UDP的API和TCP差别很大。TCP是Socket对SocketUDP是DatagramSocket收发DatagramPacket。没有连接的概念每个数据包都是独立的。发送端DatagramSocket socket new DatagramSocket(); String msg UDP消息; byte[] data msg.getBytes(); DatagramPacket packet new DatagramPacket( data, data.length, InetAddress.getByName(127.0.0.1), 9999); socket.send(packet); socket.close();接收端DatagramSocket socket new DatagramSocket(9999); byte[] buffer new byte[1024]; DatagramPacket packet new DatagramPacket(buffer, buffer.length); socket.receive(packet); // 阻塞等待 String msg new String(packet.getData(), 0, packet.getLength()); System.out.println(收到 msg); socket.close();注意packet.getLength()返回的是实际收到的字节数不是buffer的长度。如果你用buffer.length去构造字符串会带上一堆空白字符。3.2 UDP的适用场景和注意事项UDP最大的优势是快、开销小。但它不可靠丢包、乱序、重复都可能发生。所以UDP适合那些丢一两个包无所谓的场景视频/音频直播丢一帧画面比卡顿半天好在线游戏位置信息过期了就没意义重传反而添乱DNS查询一次请求一次响应简单高效用UDP的时候有几个坑要注意数据包大小限制UDP单个数据报理论上最大64KB但实际网络中MTU通常是1500字节左右超过会被分片增加丢包概率。建议单包控制在1472字节以内1500减去IP头和UDP头。接收缓冲区如果发送速度超过接收处理速度缓冲区满了之后新来的包直接丢弃不会阻塞发送方。所以UDP接收端要尽快处理或者用多线程消费。没有连接状态服务端无法感知客户端上线或下线需要应用层自己用心跳机制维护。4. 手写模拟B/S服务器把HTTP协议拆开看4.1 B/S架构的本质B/SBrowser/Server架构听起来高大上本质上就是浏览器作为客户端通过HTTP协议向服务器发请求服务器返回HTML、CSS、JS等资源浏览器渲染成页面。HTTP协议底层就是TCP。浏览器和服务器先建立TCP连接然后在连接上发送符合HTTP格式的文本。所以我们完全可以用ServerSocket手写一个简易的Web服务器理解HTTP请求和响应的格式。4.2 HTTP请求报文的结构一个典型的HTTP请求长这样GET /index.html HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: Mozilla/5.0 Accept: text/html 空行 请求体GET请求通常没有第一行是请求行方法 路径 协议版本。后面是请求头每行一个键值对。空行之后是请求体。HTTP响应格式类似HTTP/1.1 200 OK Content-Type: text/html;charsetUTF-8 Content-Length: 1234 空行 html.../html4.3 用ServerSocket实现一个能返回网页的服务器核心思路accept到浏览器连接后读取请求行解析出浏览器要访问的路径然后从本地文件系统读取对应文件按HTTP响应格式写回去。public class SimpleHttpServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(8080); System.out.println(服务器启动访问 http://127.0.0.1:8080); while (true) { Socket socket serverSocket.accept(); new Thread(() - handleRequest(socket)).start(); } } private static void handleRequest(Socket socket) { try (socket; BufferedReader reader new BufferedReader( new InputStreamReader(socket.getInputStream())); OutputStream out socket.getOutputStream()) { // 读取请求行 String requestLine reader.readLine(); if (requestLine null) return; System.out.println(请求 requestLine); // 解析路径 String[] parts requestLine.split( ); String path parts[1]; if (/.equals(path)) path /index.html; // 读取文件 File file new File(webroot path); if (file.exists() file.isFile()) { byte[] content Files.readAllBytes(file.toPath()); String header HTTP/1.1 200 OK\r\n Content-Type: getContentType(path) \r\n Content-Length: content.length \r\n \r\n; out.write(header.getBytes()); out.write(content); } else { String body h1404 Not Found/h1; String response HTTP/1.1 404 Not Found\r\n Content-Type: text/html;charsetUTF-8\r\n Content-Length: body.getBytes().length \r\n \r\n body; out.write(response.getBytes()); } out.flush(); } catch (IOException e) { e.printStackTrace(); } } private static String getContentType(String path) { if (path.endsWith(.html)) return text/html;charsetUTF-8; if (path.endsWith(.css)) return text/css; if (path.endsWith(.js)) return application/javascript; if (path.endsWith(.png)) return image/png; return application/octet-stream; } }这段代码跑起来之后浏览器访问http://127.0.0.1:8080就能看到你放在webroot目录下的网页。虽然简陋但HTTP协议的核心流程全在里面了。4.4 手写B/S服务器时最容易翻车的几个点换行符必须是\r\nHTTP协议规定用CRLF回车换行作为行分隔符。如果你只写\n某些浏览器可能解析异常。响应头和响应体之间的空行也必须是\r\n\r\n。Content-Length必须准确浏览器根据这个值判断响应体读完了没有。如果写少了浏览器会截断内容写多了浏览器会一直等直到超时。对于文本内容用getBytes()后的长度不要用String.length()因为中文一个字符占多个字节。中文乱码问题响应头里要指定charsetUTF-8同时写body的时候也要用UTF-8编码。两边不一致就会出现乱码。线程模型上面代码用了new Thread每个请求一个线程请求量大了线程数会爆炸。生产环境肯定不能用这种方案但作为学习理解BIO模型足够了。5. BIO和NIO两种IO模型的本质区别5.1 BIO为什么阻塞BIOBlocking IO就是传统的IO模型。它的核心特点是每个连接需要一个线程线程在读写数据时会阻塞。具体表现accept()阻塞等待连接read()阻塞等待数据。如果一个连接建立了但迟迟不发数据处理它的线程就一直卡在read上什么也干不了。为了支持更多连接只能开更多线程。但线程是有限资源一台机器开几千个线程基本就到头了而且线程上下文切换的开销也很大。BIO的编程模型简单直观适合连接数不多、每个连接交互频繁的场景。但面对高并发比如上万连接BIO就力不从心了。5.2 NIO的三个核心组件NIONew IO / Non-blocking IO是Java 1.4引入的。它的核心思想是一个线程可以管理多个连接。NIO有三个关键组件Channel通道类似流但可以双向读写且支持非阻塞模式。常见的SocketChannel、ServerSocketChannel。Buffer缓冲区数据的容器。NIO读写数据都要经过Buffer不能直接操作Channel。Buffer有position、limit、capacity三个关键属性flip()、clear()、compact()这几个方法容易搞混。Selector选择器NIO的灵魂。一个Selector可以注册多个Channel然后通过select()方法阻塞等待直到有Channel就绪可读、可写、可连接。这样单线程就能处理成百上千个连接。5.3 一个NIO服务端的骨架public class NioServer { public static void main(String[] args) throws IOException { ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8888)); serverChannel.configureBlocking(false); // 关键非阻塞 Selector selector Selector.open(); serverChannel.register(selector, SelectionKey.OP_ACCEPT); ByteBuffer buffer ByteBuffer.allocate(1024); while (true) { selector.select(); // 阻塞直到有事件 SetSelectionKey keys selector.selectedKeys(); IteratorSelectionKey it keys.iterator(); while (it.hasNext()) { SelectionKey key it.next(); it.remove(); if (key.isAcceptable()) { SocketChannel client serverChannel.accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); System.out.println(新连接 client.getRemoteAddress()); } else if (key.isReadable()) { SocketChannel client (SocketChannel) key.channel(); buffer.clear(); int len client.read(buffer); if (len 0) { buffer.flip(); byte[] data new byte[buffer.remaining()]; buffer.get(data); System.out.println(收到 new String(data)); } else if (len -1) { client.close(); } } } } } }这段代码用单线程处理所有连接。selector.select()会阻塞直到至少有一个Channel有事件就绪。然后遍历就绪的key分别处理accept和read事件。5.4 BIO和NIO的选型对比对比维度BIONIO编程复杂度低直观高需要理解Selector和Buffer线程模型一连接一线程一线程多连接适用连接数少几百以内多几千到几万吞吐量连接少时表现好高并发下优势明显典型应用传统Web应用、内部服务聊天服务器、推送服务、网关实际选型不是非此即彼。很多框架是混合的Tomcat早期用BIO后来支持NIONetty基于NIO但封装了更友好的API。对于初学者先把BIO写熟理解阻塞的本质再学NIO会顺畅很多。5.5 NIO的坑空轮询和Buffer的翻转NIO有个著名的epoll空轮询bug在某些Linux内核版本下Selector.select()可能在没有事件时也返回导致CPU飙到100%。Netty通过计数重建Selector的方式绕过了这个问题。自己写NIO服务端时如果发现CPU异常高要检查是不是空轮询。另一个高频错误是Buffer的flip()。写数据到Buffer后position在末尾要读数据必须先flip()把position重置到0、limit设到原来position的位置。读完数据后要clear()或compact()才能继续写。很多人忘了flip结果读出来是空的。6. 学习网络编程时我踩过的那些坑6.1 端口被占用Address already in use这个错误几乎每个人都会遇到。原因通常是上一次程序没正常关闭端口还处于TIME_WAIT状态。解决办法有两个换一个端口或者在ServerSocket上设置setReuseAddress(true)允许端口复用。ServerSocket serverSocket new ServerSocket(); serverSocket.setReuseAddress(true); serverSocket.bind(new InetSocketAddress(8888));注意setReuseAddress必须在bind之前调用才生效。6.2 客户端连不上Connection refused这个错误说明目标IP和端口上没有服务在监听。排查顺序先确认服务端程序启动了没有再确认端口号对不对然后检查防火墙有没有拦截。如果是跨机器通信还要确认两台机器网络是否互通。6.3 中文乱码编码不一致网络传输的是字节字符串转字节和字节转字符串时如果用了不同的字符集就会乱码。统一用UTF-8是最省心的做法。发送端getBytes(UTF-8)接收端new String(bytes, UTF-8)两边保持一致。6.4 连接不关闭导致资源泄漏Socket、ServerSocket、流对象都是需要关闭的资源。忘记关闭会导致文件描述符耗尽最终程序无法建立新连接。用try-with-resources是最稳妥的方式try (Socket socket new Socket(host, port); InputStream in socket.getInputStream()) { // 使用资源 } catch (IOException e) { // 异常处理 }6.5 阻塞导致程序假死BIO的read()在没有数据时会一直阻塞。如果客户端建立了连接但不发数据服务端线程就卡住了。解决办法是设置超时socket.setSoTimeout(5000); // 5秒没数据就抛SocketTimeoutException这样至少不会无限期等待可以在超时后做清理或重试。网络编程这块内容光看是看不会的必须自己动手敲代码、改参数、制造异常才能真正理解每个API的行为。TCP和UDP的代码各写一遍模拟B/S服务器跑起来用浏览器访问一下再把BIO和NIO的示例对比着调试这一套下来网络编程的底子就算打牢了。后面学Netty、学HTTP协议细节、学分布式通信都会顺很多。