1. 从单机到联网网络编程到底在解决什么问题很多学 Java 的朋友把集合、IO、多线程啃完之后一到网络编程就卡壳。原因很简单——前面那些东西都是在一台机器、一个进程里自娱自乐而网络编程第一次把两个独立的程序放到了不同的机器上让它们隔着网线对话。这个跨度比从写方法到调方法要大得多。我当初学这块的时候最大的困惑不是 API 怎么调而是数据到底是怎么从我这台机器跑到对面那台机器上的。后来才慢慢想明白网络编程的本质就是用一套约定好的规则把字节流从一端送到另一端再在另一端还原成有意义的数据。Java 把这套底层的东西封装成了java.net包我们调Socket、ServerSocket、DatagramSocket这些类其实是在操作操作系统提供的网络能力。这一篇要聊的东西挺多TCP 和 UDP 两种传输方式的区别、怎么用 Java 写客户端和服务端、怎么模拟一个浏览器访问服务器的 B/S 结构、以及 BIO 和 NIO 这两种 IO 模型到底差在哪。这些内容看着散其实是一条线串起来的——先搞懂传输层怎么传数据再搞懂 Java 怎么封装传输层最后搞懂高并发场景下 IO 模型怎么选。适合谁看如果你已经会写 Java 基础代码懂一点多线程和 IO 流但一提到 Socket 就发怵那这篇就是给你准备的。我会尽量把每个概念都落到能跑的代码上不玩虚的。2. 网络编程的底层地基三个必须先搞懂的概念在动手写代码之前有几个概念必须先掰扯清楚不然代码能跑起来你也不知道为什么。2.1 IP、端口、协议网络通信的三要素网络通信说白了就是哪台机器上的哪个程序用什么规矩说话。IP 地址解决的是哪台机器的问题。它就像现实里的门牌号192.168.1.100这种就是 IPv4 地址由四段 0-255 的数字组成。本机回环地址127.0.0.1是个特殊存在它永远指向你自己这台机器用来做本地测试最方便不经过网卡速度快。端口解决的是哪个程序的问题。一台机器上跑着浏览器、聊天软件、数据库数据包到了机器上得知道交给谁。端口号范围是 0-65535其中 0-1023 是系统保留的知名端口比如 80 是 HTTP、443 是 HTTPS、3306 是 MySQL我们自己写程序一般用 1024 以上的。这里有个坑同一个端口在同一时刻只能被一个程序占用你启动服务端报Address already in use八成就是端口被占了。协议解决的是怎么说话的问题。TCP 和 UDP 就是传输层的两大协议后面会详细展开。再往上还有应用层的 HTTP、FTP 等那是另一层的事。提示写网络程序时服务端绑定的 IP 一般写0.0.0.0或者干脆不写表示监听本机所有网卡客户端连接时才需要指定具体的目标 IP。2.2 TCP 与 UDP一个靠谱一个快这两个协议的区别是网络编程面试和实战都绕不开的。我用一个生活化的类比来讲TCP 像打电话。你拨号对方接了双方确认喂能听到吗然后才开始聊。聊的过程中你说一句对方应一句确保每句话都听清了。如果信号不好某句没听清对方会说你再说一遍。通话结束要互相说拜拜再挂断。这一套下来可靠、有序、但慢。UDP 像寄明信片。你把内容写好扔进邮筒就完事了对方收没收到、什么时候收到、收到的顺序对不对你一概不管。快、省事、但不可靠。落到技术层面对比如下对比维度TCPUDP连接方式面向连接需三次握手无连接直接发可靠性可靠有确认重传机制不可靠发了不管数据顺序保证有序不保证顺序传输效率相对低相对高数据边界字节流无边界数据报有边界适用场景文件传输、网页、登录视频直播、游戏、DNS选哪个记住一句话对数据完整性要求高的用 TCP对实时性要求高的用 UDP。你传个文件丢一个字节就废了必须 TCP你打游戏卡一下无所谓但延迟高了没法玩那就 UDP。2.3 SocketJava 操作网络的抓手Socket 这个词直译是插座特别形象——它就像两台机器之间插上的一根管子数据从这头流进去从那头流出来。Java 里 Socket 是对 TCP 连接的抽象ServerSocket负责服务端监听Socket负责两端的具体通信。这里要建立一个关键认知Socket 内部封装的就是输入流和输出流。你往 Socket 的输出流写数据数据就通过网络发到对面你从 Socket 的输入流读数据读到的就是对面发过来的。所以前面学的 IO 流知识在这里全用得上只是数据源从文件变成了网络。理解了这一点后面写代码就是套模板的事了。3. TCP 编程实战手写一个能聊天的客户端和服务端理论说再多不如跑一遍代码。这一节我们从最简单的 TCP 通信开始一步步把服务端和客户端搭起来。3.1 服务端ServerSocket 的监听与阻塞服务端要做的事很明确绑定端口、等待连接、处理数据。核心类是ServerSocket。import java.io.*; import java.net.ServerSocket; import java.net.Socket; public class TcpServer { public static void main(String[] args) throws IOException { // 1. 创建服务端绑定 8888 端口 ServerSocket serverSocket new ServerSocket(8888); System.out.println(服务端已启动等待连接...); // 2. accept() 会阻塞直到有客户端连上来 Socket socket serverSocket.accept(); System.out.println(客户端已连接 socket.getInetAddress().getHostAddress()); // 3. 获取输入流读取客户端发来的数据 InputStream in socket.getInputStream(); byte[] buffer new byte[1024]; int len in.read(buffer); System.out.println(收到消息 new String(buffer, 0, len)); // 4. 回一条消息给客户端 OutputStream out socket.getOutputStream(); out.write(服务端已收到你的消息.getBytes()); // 5. 关闭资源 socket.close(); serverSocket.close(); } }这段代码有几个点必须讲透accept()是阻塞的。程序执行到这一行会停住一直等到有客户端连上来才继续往下走。这就是为什么服务端要单独跑一个进程或线程——它不能干等着。流的读写也是阻塞的。in.read(buffer)会一直等直到读到数据或者连接关闭。如果客户端连上了但一直不发数据服务端就卡在这行。这是 BIO 的典型特征后面讲 BIO 和 NIO 时会重点说这个问题。资源必须关。Socket 底层是操作系统的文件描述符不关会泄漏。生产代码里要用 try-with-resources 或者 finally 保证关闭。3.2 客户端Socket 发起连接客户端用Socket类指定目标 IP 和端口就能连。import java.io.*; import java.net.Socket; public class TcpClient { public static void main(String[] args) throws IOException { // 1. 连接服务端 Socket socket new Socket(127.0.0.1, 8888); // 2. 发消息 OutputStream out socket.getOutputStream(); out.write(你好我是客户端.getBytes()); // 3. 读服务端的回复 InputStream in socket.getInputStream(); byte[] buffer new byte[1024]; int len in.read(buffer); System.out.println(服务端回复 new String(buffer, 0, len)); // 4. 关闭 socket.close(); } }先启动服务端再启动客户端你就能在两边控制台看到消息了。这个例子虽然简单但把 TCP 通信的骨架完整跑通了。3.3 让服务端支持多客户端多线程改造上面的服务端只能处理一个客户端处理完就退出了。真实场景肯定不行得让它一直监听来一个客户端开一个线程。public class MultiTcpServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(8888); System.out.println(服务端启动等待连接...); while (true) { // 主线程只负责 accept Socket socket serverSocket.accept(); // 每个客户端交给一个独立线程处理 new Thread(() - handle(socket)).start(); } } private static void handle(Socket socket) { try (InputStream in socket.getInputStream(); OutputStream out socket.getOutputStream()) { byte[] buffer new byte[1024]; int len; while ((len in.read(buffer)) ! -1) { String msg new String(buffer, 0, len); System.out.println(收到 msg); out.write((已收到 msg).getBytes()); } } catch (IOException e) { e.printStackTrace(); } } }这个改造是理解 BIO 的关键。主线程负责接收连接工作线程负责处理数据这是最经典的一对多模型。注意while ((len in.read(buffer)) ! -1)这个循环会一直读到连接关闭返回 -1才结束。如果客户端不关连接也不发数据这个线程就一直阻塞着白白占着资源。客户端多了线程就爆炸了。这就是 BIO 的硬伤。3.4 实操心得几个容易踩的坑我在这块踩过的坑列出来给你避雷中文乱码new String(bytes)默认用平台编码跨平台会乱码。稳妥做法是两端统一用getBytes(UTF-8)和new String(bytes, UTF-8)。read 返回 -1 的判断很多人只写in.read(buffer)不判断 -1导致连接关闭后还在死循环读CPU 飙满。端口占用调试时反复启动服务端经常报端口被占。加一句serverSocket.setReuseAddress(true)能缓解或者干脆换个端口。先启服务端再启客户端顺序反了客户端会直接抛Connection refused这是新手最常见的报错。4. UDP 编程不建立连接的快枪手TCP 那套是先握手再说话UDP 完全不一样它把数据打包成一个个数据报直接扔出去不管对方收没收到。4.1 DatagramSocket 与 DatagramPacketUDP 编程就两个核心类DatagramSocket负责收发DatagramPacket负责装数据。注意 UDP 没有 ServerSocket 和 Socket 的区分收发两端都用DatagramSocket。先看接收端import java.net.*; public class UdpReceiver { public static void main(String[] args) throws Exception { // 绑定 9999 端口 DatagramSocket socket new DatagramSocket(9999); byte[] buffer new byte[1024]; // 准备一个空包用来接收 DatagramPacket packet new DatagramPacket(buffer, buffer.length); System.out.println(接收端启动...); // receive 会阻塞直到收到数据报 socket.receive(packet); String msg new String(packet.getData(), 0, packet.getLength()); System.out.println(收到 msg 来自 packet.getAddress().getHostAddress()); socket.close(); } }再看发送端import java.net.*; public class UdpSender { public static void main(String[] args) throws Exception { DatagramSocket socket new DatagramSocket(); String msg UDP 消息来了; byte[] data msg.getBytes(); // 指定目标 IP 和端口 DatagramPacket packet new DatagramPacket( data, data.length, InetAddress.getByName(127.0.0.1), 9999 ); socket.send(packet); System.out.println(已发送); socket.close(); } }对比 TCP 的代码你会发现 UDP 少了accept()这一步发送端也不需要先连上谁直接send就完事。这就是无连接的直观体现。4.2 UDP 的数据边界问题UDP 有个 TCP 没有的特性它保留数据边界。你发三次对面就收三次不会像 TCP 那样粘成一坨。这在某些场景下是优势比如你要传一批独立的消息不希望它们被合并。但反过来UDP 单个数据报有大小限制一般建议不超过 1472 字节以太网 MTU 1500 减去 IP 头和 UDP 头。超了会被分片分片后丢一片整个包就废了。所以UDP 不适合传大文件。4.3 什么时候该用 UDP我总结了几类典型场景实时音视频丢一两帧画面无所谓但延迟不能高。在线游戏位置同步要快偶尔丢包可以靠下一帧补上。DNS 查询一次请求一次响应数据量小用 UDP 省去握手开销。广播/组播一个包发给多台机器TCP 做不到。反过来凡是数据不能丢、不能乱序的场景老老实实用 TCP。5. 模拟 B/S 服务器让浏览器访问你的 Java 程序B/S 结构Browser/Server是现在最主流的架构浏览器当客户端服务器返回网页。这一节我们用最原始的 Socket 手写一个简易 HTTP 服务器让你彻底看清浏览器和服务器之间到底发生了什么。5.1 浏览器发过来的到底是什么先别急着写代码我们得知道浏览器请求长什么样。其实浏览器就是个 TCP 客户端它连上你的服务器后会发一段符合 HTTP 协议的文本过来大概是这样GET /index.html HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: Mozilla/5.0 ... Accept: text/html第一行是请求行包含请求方法GET、请求路径/index.html、协议版本HTTP/1.1。后面是请求头一行一个键值对。最后是一个空行表示请求头结束。服务器要做的就是解析这段文本然后按 HTTP 格式返回响应HTTP/1.1 200 OK Content-Type: text/html;charsetUTF-8 Content-Length: 25 html你好/html响应也分三部分状态行、响应头、响应体。中间同样用空行隔开。5.2 手写一个能返回网页的服务器理解了协议格式代码就好写了import java.io.*; import java.net.ServerSocket; import java.net.Socket; public class SimpleBsServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(8080); System.out.println(B/S 服务器启动请用浏览器访问 http://127.0.0.1:8080); while (true) { Socket socket serverSocket.accept(); new Thread(() - handle(socket)).start(); } } private static void handle(Socket socket) { try (InputStream in socket.getInputStream(); OutputStream out socket.getOutputStream()) { // 1. 读取请求简化处理只读一次 byte[] buffer new byte[4096]; int len in.read(buffer); String request new String(buffer, 0, len); // 打印第一行请求行方便观察 System.out.println(请求行 request.split(\r\n)[0]); // 2. 准备响应体 String body htmlbodyh1你好这是 Java 手写的服务器/h1/body/html; byte[] bodyBytes body.getBytes(UTF-8); // 3. 拼接响应头 String header HTTP/1.1 200 OK\r\n Content-Type: text/html;charsetUTF-8\r\n Content-Length: bodyBytes.length \r\n \r\n; // 4. 先写头再写体 out.write(header.getBytes(UTF-8)); out.write(bodyBytes); out.flush(); } catch (IOException e) { e.printStackTrace(); } } }启动后打开浏览器访问http://127.0.0.1:8080你就能看到自己写的网页了。控制台还会打印出浏览器发来的请求行。5.3 几个关键细节决定你能不能跑通这个例子看着简单但有几个地方不注意就跑不起来Content-Length必须准确。浏览器靠这个头判断响应体有多长写错了要么页面截断要么一直转圈等。注意它算的是字节数不是字符数中文一个字占 3 个字节UTF-8别用body.length()。响应头之间用\r\n分隔头和体之间用两个\r\n。这是 HTTP 协议的硬规定少一个\r浏览器就解析不了。我当初就是漏了\r折腾半天。Content-Type要带 charset。不写charsetUTF-8中文网页会乱码。记得 flush。out.write只是写到缓冲区不 flush 可能数据还在内存里没发出去。提示这个服务器只能返回固定内容真实场景要根据请求路径返回不同文件。你可以试着解析请求行里的路径然后去读对应的文件返回这就是一个迷你静态资源服务器了。5.4 从手写服务器到理解框架写完这个例子你再看 Tomcat、Netty 这些框架就不会觉得神秘了。它们本质上就是把这个过程做得更健壮、更高效解析请求更完整、支持更多 HTTP 方法、处理并发更优雅、支持各种协议。手写一遍的价值在于你知道框架帮你做了什么。以后遇到请求 404、响应乱码、连接超时这些问题你能从协议层面去定位而不是只会百度报错信息。6. BIO 与 NIO并发场景下的两种活法前面所有的例子用的都是 BIO。它在客户端少的时候没问题一旦并发上来就顶不住。这一节我们把 BIO 的问题讲透再看 NIO 怎么解决。6.1 BIO 的致命伤一个连接一个线程BIO 全称 Blocking IO阻塞式 IO。它的模型是每来一个连接就分配一个线程这个线程从头到尾陪着这个连接。问题在哪假设你有 1 万个客户端连着但大部分时间它们都不发数据比如聊天软件里大部分人是在看不是在发。BIO 就得开 1 万个线程每个线程都阻塞在read上等着。线程是稀缺资源1 万个线程光上下文切换就能把 CPU 拖垮内存也扛不住每个线程默认栈 1MB1 万个就是 10GB。这就是所谓的C10K 问题——如何让单机支撑一万个并发连接。BIO 的答案是撑不住。6.2 NIO 的破局思路一个线程管一堆连接NIO 全称 Non-blocking IO非阻塞式 IO。它的核心思想是不让线程傻等谁有数据就处理谁。NIO 有三大核心组件Channel通道双向的既能读又能写替代了 BIO 里的 InputStream/OutputStream。Buffer缓冲区数据都先读到 Buffer 里再从 Buffer 处理替代了 BIO 里直接读字节数组。Selector选择器这是 NIO 的灵魂一个 Selector 可以监听多个 Channel哪个 Channel 有事件可读、可写、可连接就处理哪个。打个比方BIO 像餐厅里一个服务员盯一桌客人客人不点菜服务员就干站着NIO 像一个服务员管十桌谁举手就去谁那效率天差地别。6.3 一个最小的 NIO 服务端NIO 的代码比 BIO 复杂不少先看个骨架import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.*; import java.util.Iterator; public class NioServer { public static void main(String[] args) throws IOException { // 1. 打开选择器 Selector selector Selector.open(); // 2. 打开服务端通道并绑定端口 ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8888)); // 设置为非阻塞这是 NIO 的前提 serverChannel.configureBlocking(false); // 3. 把服务端通道注册到选择器关注接受连接事件 serverChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println(NIO 服务端启动...); while (true) { // 4. 阻塞等待有事件发生 selector.select(); // 5. 拿到所有就绪的事件 IteratorSelectionKey it selector.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key it.next(); it.remove(); // 必须移除否则下次还会处理 if (key.isAcceptable()) { // 有客户端连上来了 ServerSocketChannel server (ServerSocketChannel) key.channel(); SocketChannel client server.accept(); client.configureBlocking(false); // 注册读事件 client.register(selector, SelectionKey.OP_READ); System.out.println(新客户端 client.getRemoteAddress()); } else if (key.isReadable()) { // 有数据可读 SocketChannel client (SocketChannel) key.channel(); ByteBuffer buffer ByteBuffer.allocate(1024); int len client.read(buffer); if (len 0) { buffer.flip(); // 切换为读模式 String msg new String(buffer.array(), 0, len); System.out.println(收到 msg); } else if (len -1) { // 客户端断开 client.close(); } } } } } }这段代码第一次看会有点懵我拆解一下关键点configureBlocking(false)是 NIO 的开关。设成 false 后accept()和read()不再阻塞没数据就立刻返回。selector.select()是唯一会阻塞的地方。它阻塞到至少有一个通道有事件就绪然后返回。所以一个线程就能管住成千上万个连接。buffer.flip()是 NIO 最容易搞错的地方。Buffer 有 position、limit、capacity 三个指针写数据时 position 往后走要读数据前必须 flip把 limit 设到 positionposition 归零。忘了 flip 就读不到数据。it.remove()必须调用否则处理过的事件下次还在集合里会重复处理甚至报错。6.4 BIO 与 NIO 的选型对照到底用哪个看场景维度BIONIO连接数少几百以内多成千上万编程难度简单直观复杂易出错线程模型一连接一线程一线程管多连接适用场景内部工具、低并发高并发服务器典型框架传统 SocketNetty、Mina我的建议是除非你明确要处理高并发否则别一上来就用 NIO。NIO 的代码复杂度和调试难度都高一个量级写不好反而出各种诡异 bug。真要做高并发直接用 Netty别自己手撸 NIO。7. 常见问题与排查技巧实录网络编程的 bug 有个特点报错信息往往很模糊但原因就那么几个。我把这些年遇到的高频问题整理成表方便你对照排查。现象可能原因排查方向Connection refused服务端没启动或端口不对确认服务端已启动、端口一致Address already in use端口被占用换端口或关掉占用进程中文乱码编码不一致两端统一 UTF-8读不到数据忘了 flush 或没判断 -1检查 flush、检查循环条件连接建立后卡住阻塞在 read 上确认对方是否发了数据浏览器一直转圈Content-Length 不对检查字节数计算NIO 读不到数据忘了 flip读之前调用 buffer.flip()客户端断开后服务端报错没处理 -1 情况read 返回 -1 时关闭连接再补充几个独家避坑技巧调试网络程序先保证单机能通。用127.0.0.1测别一上来就跨机器网络问题会掩盖代码问题。用 telnet 或 nc 当临时客户端。telnet 127.0.0.1 8888能连上就说明服务端在监听比写客户端快。抓包是终极手段。实在搞不清数据发没发出去用抓包工具看一眼比猜强一百倍。超时一定要设。socket.setSoTimeout(5000)能让 read 最多阻塞 5 秒避免程序永久卡死。生产环境不设超时是大忌。关闭顺序有讲究。先关输出流再关 Socket或者直接关 Socket 也行但别只关一半容易导致对方读到半截数据。8. 我个人的一点学习体会网络编程这块我最大的感受是光看是学不会的必须动手跑。TCP 三次握手、四次挥手这些概念书上讲得再清楚不如你自己写个客户端服务端用抓包工具看一眼真实的数据包来得深刻。另外别被 NIO 吓住。BIO 是基础把 BIO 的阻塞模型理解透了NIO 的非阻塞多路复用思路自然就懂了。很多人跳过 BIO 直接学 NIO结果 Selector 那套机制怎么都想不明白就是因为没体会过一个线程被一个连接绑死的痛苦。最后分享一个我常用的练习方法给自己定个小目标比如写一个支持多人聊天的程序。从 BIO 版本开始跑通了再改成 NIO 版本最后用 Netty 重写一遍。三遍下来这块知识就真正长在你身上了。这个过程我走过虽然折腾但收获比看十篇文章都大。