前面学的 IO 是程序和文件对话,本篇让程序和网络另一端的程序对话。网络编程是分布式世界的地基,今天的一切微服务、RPC 最终都落在 Socket 上,这一课值得慢慢啃。

本篇目标

读完本篇你应该能够:说清网络通信三要素各自解决什么寻址问题;用 InetAddress 操作 IP;用 DatagramSocket 完成 UDP 收发并解释它”快但不可靠”的原因;用 ServerSocket/Socket 写出 TCP 客户端与服务端;把单连接服务端改造成每连接一线程的并发模型,并说出它的瓶颈在哪。

网络通信三要素

两台机器上的程序要通信,需要回答三个层层递进的问题:

IP 地址——找到哪台机器。 网络中每台设备的唯一标识。IPv4 由 4 个字节组成(约 42 亿个,2011 年已分完),IPv6 用 8 组十六进制数解决地址枯竭。127.0.0.1 是本地回环地址,数据不出网卡,是学习阶段的最佳拍档;ping 命令用来测试两台机器是否连通。

端口——找到机器上哪个程序。 一台机器同时跑着数据库、Web 服务、聊天软件,IP 只能定位到机器,端口(0~65535)才能定位到进程。1024 以下基本被系统占用,自己写程序用 1024 以上的端口;记住几个常用端口有直觉价值:MySQL 3306、HTTP 80、Tomcat 8080。

协议——双方怎么说话。 数据传输的规则约定,本篇的主角是两个:UDP 与 TCP。

Java 中用 InetAddress 封装 IP:

InetAddress local = InetAddress.getLocalHost();
System.out.println(local.getHostAddress()); // 本机 IP

InetAddress addr = InetAddress.getByName("www.example.com");
System.out.println(addr.getHostAddress()); // 域名解析出的 IP

UDP:把数据装进信封扔进邮筒

UDP 面向无连接:发送前不建立连接,不管对方在不在、收没收到,发出去就完事。因此它不可靠(可能丢包、乱序、重复),但也快——没有建连开销,没有确认重传,协议头只有 8 个字节。视频通话、直播、DNS 查询这类”丢一帧无所谓、慢一点都不行”的场景都用它。可以把 UDP 想象成寄平信:信封一扔就不管了,邮资便宜、投递快,但寄没寄到只有天知道;TCP 则像打客服电话,先确认对方在线、通话中句句有回应,稳妥但费时间。协议没有优劣,只有场景匹配。

Java 的 API 比喻很形象:DatagramSocket 是码头,DatagramPacket 是集装箱。

发送端:

DatagramSocket socket = new DatagramSocket();                 // 随机端口
byte[] data = "你好,UDP".getBytes();
DatagramPacket packet = new DatagramPacket(
data, data.length,
InetAddress.getByName("127.0.0.1"), 6666); // 装箱并写明目的地
socket.send(packet); // 发出
socket.close();

接收端:

DatagramSocket socket = new DatagramSocket(6666);             // 必须监听约定端口
byte[] buf = new byte[1024];
DatagramPacket packet = new DatagramPacket(buf, buf.length); // 空集装箱等货
socket.receive(packet); // 阻塞,直到有包到达

String msg = new String(packet.getData(), 0, packet.getLength());
System.out.println(packet.getAddress().getHostAddress() + ": " + msg);
socket.close();

两个常见错误:一是接收端没绑定发送端约定的端口,包寄到了没人收;二是 new String(buf) 不按 packet.getLength() 截断,把缓冲区的旧数据也读了进去。想让收发同时进行,就把收和发各放一条线程——这正是上一批并发知识的用武之地。

TCP:先握手,再通话

TCP 面向连接,通信前要先三次握手:客户端发 SYN 请求连接,服务端回 SYN+ACK 表示同意,客户端再回 ACK 确认,通道建立。之后数据有序、不丢、不重,代价是建连和维护连接的开销,速度略低于 UDP。文件传输、网页浏览这种”一个字节都不能错”的场景必须用它。

Java 中服务端用 ServerSocket 监听,客户端用 Socket 发起连接,连接建立后双方各自拿到一个 Socket,数据就通过 Socket 上的 IO 流双向传输——上一阶段的流操作在这里原样复用。

Socket(套接字)这个概念值得多停留一秒:IP 加端口构成网络中唯一可寻址的端点,通信两端各持一个,网络通信本质上就是两个 Socket 之间的数据流动。应用程序不需要关心网卡、路由和报文分片,操作系统把这些都封装在 Socket 背后,对上只暴露熟悉的”读流写流”接口——这是分层设计的胜利,传输层的复杂性对应用层完全透明。

服务端:

ServerSocket server = new ServerSocket(9999);
Socket socket = server.accept(); // 阻塞,直到有客户端连入

BufferedReader br = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
PrintStream ps = new PrintStream(socket.getOutputStream());

ps.println("已连接,请发言");
System.out.println("客户端说: " + br.readLine());

socket.close();
server.close();

客户端:

Socket socket = new Socket("127.0.0.1", 9999);   // 指定服务端的 IP 和端口

BufferedReader br = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
PrintStream ps = new PrintStream(socket.getOutputStream());

System.out.println("服务端说: " + br.readLine());
ps.println("你好,服务端");

socket.close();

注意两端读写顺序要配对:一端读的时候另一端必须在写,否则两边都阻塞在 readLine() 上干等——这是新手联调时最常踩的坑。另一个高频坑是忘记关流:Socket 及其流都是操作系统层面的稀缺资源,用 try-with-resources 或 finally 显式关闭,否则长时间运行的服务端会耗尽文件描述符。此外 accept() 和 read() 都是阻塞调用,程序”卡住不动”往往不是死机,而是在等网络对端。

多线程服务端:每连接一线程

上面的服务端只能服务一个客户端,accept() 返回一次就结束,现实中不可用。改造思路很直接:accept 循环 + 每来一个连接就交给一条新线程处理:

ServerSocket server = new ServerSocket(9999);
while (true) {
final Socket socket = server.accept(); // 主线程只负责接客
new Thread(new Runnable() {
@Override
public void run() {
try {
BufferedReader br = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
PrintStream ps = new PrintStream(socket.getOutputStream());
ps.println("欢迎");
System.out.println(br.readLine());
ps.println("收到");
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}

主线程不被某个客户端的长会话拖住,服务端因此能同时接待多个连接。配合一个练手题目就能跑通全流程:客户端键盘录入字符串发给服务端,服务端反转后写回,客户端打印结果。

但这个模型有个明显的天花板:每连接一线程,一万个连接就是一万条线程。回看上一篇的内容——线程创建贵、栈内存默认 1MB、上下文切换随线程数暴涨——连接数一高,服务端资源先耗尽,而不是业务先跑不动。这也正是线程池登场的理由:把”一连接一线程”改成”有限线程处理排队连接”,至少资源消耗有了上限;以及后来 NIO(用一个线程轮询管理成千上万个连接)出现的背景。从”一连接一线程”到线程池再到 NIO,是服务端并发模型演进的三级台阶,本篇先站在第一级上看清楚问题。学网络编程最有价值的收获往往不是 API 本身,而是这条”需求逼出演进”的线索——日后面对 Netty、Tomcat 连接器时,你会知道它们在解决什么。

小结

  • 三要素各管一层寻址:IP 找机器,端口找程序,协议定规则。
  • UDP 无连接、不可靠但快,DatagramSocket 收发要记住”按约定端口、按实际长度读数据”。
  • TCP 三次握手建连后可靠传输,通信本质是两个 Socket 之间的 IO 流读写,两端读写顺序必须配对。
  • 多线程服务端 = accept 循环 + 每连接一线程,能并发但受线程成本制约——这个瓶颈正是线程池与 NIO 的存在理由。

下一篇是本系列收官:反射与类加载,看看 Java 程序在运行时如何”认识自己”。