☰
Java网络编程核心脉络:TCP/UDP、BIO/NIO与手写B/S服务器
2026/10/18 5:41:04 网站建设 项目流程

网络编程这块内容,很多人学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。注意它不包含端口号。
  • InetSocketAddress:IP+端口的组合,Socket连接时用得多。
  • ServerSocket:服务端专用,监听某个端口,等待客户端连接。
  • Socket:客户端和服务端建立连接后,双方都用这个类来读写数据。
  • DatagramSocket / DatagramPacket:UDP专用的发送和接收类。

这些类的方法签名看起来简单,但实际用的时候,阻塞行为、超时设置、资源关闭这些细节才是真正决定程序稳不稳的地方。后面我会结合具体代码逐个拆解。

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对Socket,UDP是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/S(Browser/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;charset=UTF-8 Content-Length: 1234 (空行) <html>...</html>

4.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 = "<h1>404 Not Found</h1>"; String response = "HTTP/1.1 404 Not Found\r\n" + "Content-Type: text/html;charset=UTF-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;charset=UTF-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\n:HTTP协议规定用CRLF(回车+换行)作为行分隔符。如果你只写\n,某些浏览器可能解析异常。响应头和响应体之间的空行也必须是\r\n\r\n。

Content-Length必须准确:浏览器根据这个值判断响应体读完了没有。如果写少了,浏览器会截断内容;写多了,浏览器会一直等,直到超时。对于文本内容,用getBytes()后的长度,不要用String.length(),因为中文一个字符占多个字节。

中文乱码问题:响应头里要指定charset=UTF-8,同时写body的时候也要用UTF-8编码。两边不一致就会出现乱码。

线程模型:上面代码用了new Thread每个请求一个线程,请求量大了线程数会爆炸。生产环境肯定不能用这种方案,但作为学习理解BIO模型足够了。

5. BIO和NIO:两种IO模型的本质区别

5.1 BIO为什么"阻塞"

BIO(Blocking IO)就是传统的IO模型。它的核心特点是:每个连接需要一个线程,线程在读写数据时会阻塞。

具体表现:accept()阻塞等待连接,read()阻塞等待数据。如果一个连接建立了但迟迟不发数据,处理它的线程就一直卡在read上,什么也干不了。为了支持更多连接,只能开更多线程。但线程是有限资源,一台机器开几千个线程基本就到头了,而且线程上下文切换的开销也很大。

BIO的编程模型简单直观,适合连接数不多、每个连接交互频繁的场景。但面对高并发(比如上万连接),BIO就力不从心了。

5.2 NIO的三个核心组件

NIO(New 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(); // 阻塞直到有事件 Set<SelectionKey> keys = selector.selectedKeys(); Iterator<SelectionKey> 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,后来支持NIO;Netty基于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协议细节、学分布式通信,都会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询