本仓库是对 Beej's Guide to Network Programming 的学习实践。
Beej 原文用 C 讲解 socket 编程,这里把每个核心概念都用 Java(java.net / java.nio)重新实现,
方便你用更简洁的代码理解同一套底层概念。
原文是 Unix/C 视角。Java 把很多琐碎的
struct/getaddrinfo()封装掉了, 所以 Java 代码更短,但概念完全对应——每个 Java 调用背后是哪个 C 系统调用, 都在源码注释里标了出来。
- Socket 是什么
- 两种 Internet Socket:TCP vs UDP
- IP 地址、字节序、struct
- 系统调用流程总览
- 示例 1:TCP 流式服务器 / 客户端
- 示例 2:UDP 数据报 listener / talker
- 示例 3:广播 broadcaster
- 示例 4:Selector 多路复用(poll/select 的 Java 版)
- 示例 5:数据封装与分帧
- 编译运行
- C ↔ Java 速查表
Unix 里有句话:「一切皆文件」。程序做 I/O 靠的是文件描述符(file descriptor,一个整数), 而网络连接也是一种"文件"。socket 就是一个用于网络通信的文件描述符。
- C 里:调用
socket()系统调用拿到一个int类型的 socket 描述符,再用send()/recv()收发。 - Java 里:
java.net.Socket/ServerSocket/DatagramSocket对象内部就持有一个 socket 描述符, 你调用它的方法相当于在底层调用send()/recv()。
为什么不直接用 read()/write()?因为 send()/recv() 提供更多控制(带外数据、对端关闭感知等)。
不过 Beej 指出:在 socket 上用 read()/write() 其实也行——Java 的 Socket.getInputStream()
返回的 InputStream,底层就是在这条 socket 上做 read。
SOCK_STREAM(流式) |
SOCK_DGRAM(数据报) |
|
|---|---|---|
| 协议 | TCP | UDP |
| 连接 | 面向连接(要先 connect/accept) |
无连接 |
| 可靠性 | 可靠、有序、不丢 | 不保证送达、可能乱序 |
| 边界 | 字节流,没有消息边界 | 每个包独立,保留消息边界 |
| Java 类 | Socket / ServerSocket |
DatagramSocket / DatagramPacket |
| 本仓库示例 | StreamServer / StreamClient |
Listener / Talker |
Beej 的比喻:TCP 像打电话(先拨号建立连接,然后双向通话,挂断要挥手); UDP 像寄明信片(写好地址扔邮筒,到不到不管)。
关键推论:因为 TCP 是字节流没有边界,发 3 条消息到对端可能粘成 1 块,也可能被拆成 5 块—— 这就引出了第 5 节"数据封装"的必要性。
这是 Beej 用大量篇幅强调的点:不同的 CPU 把多字节数存进内存的顺序不一样。
- 大端(Big-Endian):高位字节在前。
0xb34f存成b3 4f。 - 小端(Little-Endian):低位字节在前。Intel x86 就是小端,存成
4f b3。
网络上传输规定用大端,称为 Network Byte Order;机器本地的称为 Host Byte Order。 所以发数据前要转成网络序,收到后转回主机序。C 提供四个函数:
| C 函数 | 含义 |
|---|---|
htons() |
host to network short(2 字节) |
htonl() |
host to network long(4 字节) |
ntohs() |
network to host short |
ntohl() |
network to host long |
Java 里你基本不用操心这个:
java.io.DataInput/DataOutput、ByteBuffer默认就是大端序 (= Network Byte Order)。只有当你用ByteBuffer.order(ByteOrder.LITTLE_ENDIAN)显式改过时才需要注意。 这是因为 Java 从一开始就规定int在内存里是大端,跨平台一致。
C 的 socket API 需要你手工填充一堆地址结构体:
struct sockaddr // 通用地址,sa_family + 14 字节裸数据
struct sockaddr_in // IPv4 地址:sin_family + sin_port + sin_addr
struct sockaddr_in6 // IPv6 地址:sin6_family + sin6_port + sin6_addr + ...
struct sockaddr_storage // 足够大,能装下 IPv4 或 IPv6,事后按 ss_family 转型
struct addrinfo // getaddrinfo() 返回的链表节点,封装了上面的信息
历史包袱:sockaddr 太难填,于是发明了 sockaddr_in,两者可以互相转型。
getaddrinfo() 是现代做法,能自动处理 DNS 解析和 IPv4/IPv6,返回一个填好地址的链表。
Java 把这些全封装了:
InetAddress/Inet4Address/Inet6Address对应struct in_addr/in6_addr。InetSocketAddress(host, port)= 地址 + 端口,对应sockaddr_in。InetAddress.getByName("www.example.com")做的就是getaddrinfo()的 DNS 解析工作。- 你永远不需要手工填
struct,也不需要memset、htons(sin_port)这些。
C 的 inet_pton()(presentation to network,字符串→二进制)和 inet_ntop()(反向)。
Java 里 InetAddress.getByName("10.12.110.57") 和 inetAddress.getHostAddress() 完全对应。
Beej 最有价值的地方之一:明确告诉你这些调用该按什么顺序来。man page 不会告诉你这个。
getaddrinfo() → socket() → bind() → listen() → accept() → recv()/send() → close()
对应 Java:
new ServerSocket(port) // 内含 bind + listen
↓
.accept() // 阻塞等连接,返回 Socket
↓
socket.getInputStream() // recv
socket.getOutputStream() // send
↓
socket.close() // close
getaddrinfo() → socket() → connect() → send()/recv() → close()
对应 Java:
new Socket(); socket.connect(new InetSocketAddress(host, port)) // 内含 getaddrinfo + connect
↓
getInputStream / getOutputStream
↓
close
服务器:getaddrinfo() → socket() → bind() → recvfrom() → close()
客户端:getaddrinfo() → socket() → sendto() → close()
对应 Java:new DatagramSocket(port) 就完成 socket+bind;receive(packet) / send(packet) 收发。
对应 Beej 6.1 / 6.2 节。源码:
beej/tcp/StreamServer.java、beej/tcp/StreamClient.java
服务器:监听 3490 端口,每来一个连接就 fork 一个线程回送 "Hello, world!"。
核心对照:
| Beej 的 C 代码 | Java 等价 |
|---|---|
getaddrinfo(NULL, PORT, {AI_PASSIVE}) + bind() |
new ServerSocket(PORT, BACKLOG) |
listen(sockfd, BACKLOG) |
ServerSocket 构造时已隐含 |
accept(sockfd, &their_addr, &sin_size) |
listener.accept() |
if (!fork()) { send(new_fd, ...); } |
new Thread(() -> handleClient(...)).start() |
sigaction(SIGCHLD, reap_handler) 回收僵尸进程 |
Java 线程结束自动回收,无需 |
close() |
try-with-resources 自动 close() |
注意 Beej 在服务器里处理了 SIGCHLD(回收 fork() 产生的僵尸子进程)。Java 用线程而非进程,
线程结束由 GC 处理,所以这整段信号处理代码在 Java 里不存在——这是用 Java 学的好处之一:
概念更少,但能聚焦在网络逻辑本身。
客户端:连接指定主机:3490,读一行打印。先不启动服务器直接连,会得到
Connection refused——和 C 版完全一样的体验。
对应 Beej 6.3 节。源码:
beej/udp/Listener.java、beej/udp/Talker.java
UDP 的最大特点:不需要 listen() / accept()。绑定端口后直接 recvfrom() 等包。
talker 连 connect() 都不用,sendto() 把目的地址写在参数里直接扔出去。
DatagramPacket 这个类同时承载"数据 + 来源/目的地址 + 长度",
正好对应 C 里 recvfrom 的 buf + struct sockaddr *src_addr + socklen_t *addrlen 三件套。
测试时你会发现:先启动 Listener,再用 Talker 发包,Listener 收到;但如果先发包再启动 Listener, 包就丢了——这就是 UDP 的"不保证送达"。
对应 Beej 7.7 节。源码:
beej/udp/Broadcaster.java
广播(broadcast)是 UDP 独有的能力(TCP 没有),可以让同一局域网内所有机器都收到一个包。
C 里的关键步骤是必须先 setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &yes, sizeof yes),
否则 sendto 到广播地址会返回 Permission denied。
Java 里这一行变成 sock.setBroadcast(true)——其余和普通 UDP 发送一模一样。
两种广播地址:
255.255.255.255:受限广播,只在本地网段,路由器不转发。192.168.1.255:子网定向广播(网络号 + 主机位全 1)。
Beej 警告:广播会让局域网内每台机器都被迫处理这个包(哪怕它不监听该端口), 所以要慎用,别搞成 "smurf 攻击"。IPv6 已废弃广播,改用更优雅的 multicast。
对应 Beej 7.2 / 7.3 节。源码:
beej/advanced/SelectorServer.java
朴素的服务器是"一个连接一个线程/进程"(像 StreamServer 那样 fork)。
但连接数一多(几千上万),线程开销就扛不住。而且很多连接大部分时间在空闲等待数据,
占着线程却不干活。
select() / poll() 的思路:一个线程同时盯着很多 socket,谁就绪了就处理谁。
你把所有关心的 fd 塞进一个集合,调用 poll(),内核会阻塞直到至少一个 fd 有事件,
然后告诉你"这几个好了",你只处理这几个。
Java 把 select/poll 统一抽象成 java.nio.channels.Selector,概念完全对应:
| Beej 的 poll() | Java NIO |
|---|---|
struct pollfd fds[N]; poll(fds, N, timeout) |
selector.select(timeout) |
POLLIN / POLLOUT |
SelectionKey.OP_READ / OP_WRITE |
POLLERR / POLLHUP |
就绪后 read 返回 -1 表示对端关闭 |
遍历 revents 找就绪 fd |
遍历 selector.selectedKeys() |
监听 socket 用 OP_ACCEPT |
同 SelectionKey.OP_ACCEPT |
fcntl(O_NONBLOCK) 设非阻塞 |
channel.configureBlocking(false) |
本示例 SelectorServer 是个 echo 服务器:单线程,却能同时服务多个客户端。
用 telnet localhost 3490 连多个终端,输入字符会被原样回显,互不阻塞。
为什么示例里要
it.remove()?selectedKeys()返回的是就绪键的集合, 处理完一个必须手动移除,否则下一轮循环还会重复处理——这和 C 里poll返回后 你要自己检查revents并清空是同一个道理。
对应 Beej 7.6 节 "Son of Data Encapsulation"。源码:
beej/serialization/包
这是 Beej 全书最实用也最容易踩坑的一节,专门解决 TCP 的"粘包/半包"问题。
TCP 是字节流,没有消息边界。客户端连发两条消息:
"tom: Hi" (很短)
"Benjamin: Hey guys what is up?" (很长)
接收方 recv() 时可能遇到:
- 两条粘在一起一次返回:
tom: HiBenjamin: Hey guys... - 第二条被拆成两次:先收到
Benjamin: Hey gu,再收到ys what is up?
光看字节流,你根本不知道一条消息从哪开始、到哪结束。
Beej 的方案:给每条消息前面加一个小头部,定义包结构:
┌─────────┬──────────────────┬───────────────────┐
│ len (1) │ name (8, NUL 填充)│ chatdata (≤128) │
└─────────┴──────────────────┴───────────────────┘
len = 整个包的字节数 = 1 + 8 + chatdata.length
有了 len,接收方就知道"还要再读多少字节才算一个完整包"。
Beej 强调:包里的多字节整数要用 Network Byte Order(大端)。
ChatPacket.toBytes()用ByteBuffer,Java 默认就是大端,天然满足。
维护一个工作缓冲区 work[],记录已用字节 used
循环:
1. recv() 读更多字节追加到 work 末尾,used += n
2. 只要 work 里够一个完整包:
a. 读第一个字节 = packetLen
b. 若 used >= packetLen,说明包齐了,解析它
c. 把 work 里剩余字节(可能是下个包的开头)前移到首部
d. used -= packetLen,回到步骤 2 继续看下一个包
3. 包没齐就回到步骤 1 继续读
Beej 特意提醒:工作缓冲区要能装下至少两个包,因为一次 recv() 可能读进来
"一个完整包 + 下一个包的开头"。FramedServer 里 work = new byte[MAX_PACKET * 2] 就是这个原因。
Beej 7.4 节指出 send() 可能只发出请求字节的一部分(比如你要发 512 字节,它只发了 412),
剩下的要你自己循环补发。这就是 sendall() 函数。FramedClient.sendAll() 是它的 Java 版。
需要 JDK 8+(已用 JDK 25 测试通过)。
cd /Users/admin/work/learning-bgnet
# 一次性编译所有源码到 out/
javac -d out $(find src -name "*.java")# 终端 1
java -cp out beej.tcp.StreamServer
# 终端 2
java -cp out beej.tcp.StreamClient localhost
# 或直接用 telnet
telnet localhost 3490# 终端 1
java -cp out beej.udp.Listener
# 终端 2
java -cp out beej.udp.Talker localhost "hello-udp"# 终端 1
java -cp out beej.advanced.SelectorServer
# 终端 2(可同时开多个,验证单线程服务多客户端)
telnet localhost 3490# 终端 1
java -cp out beej.serialization.FramedServer
# 终端 2,交互式输入 "名字 消息"
java -cp out beej.serialization.FramedClient localhost
# 输入:
# tom Hi
# Benjamin Hey guys what is up?
# tom bye# 终端 1
java -cp out beej.udp.Listener
# 终端 2(注意:要把 Listener 也设成能收广播才有效,本示例演示发送权限即可)
java -cp out beej.udp.Broadcaster 255.255.255.255 "hello all"| 概念 / C 调用 | Java 等价 |
|---|---|
socket() |
new Socket() / new ServerSocket() / new DatagramSocket() |
bind() |
构造函数传端口即绑定 |
listen() |
ServerSocket 构造时隐含 |
accept() |
ServerSocket.accept() |
connect() |
Socket.connect(InetSocketAddress) 或构造时传 host/port |
send() / write() |
OutputStream.write() |
recv() / read() |
InputStream.read() |
sendto() |
DatagramSocket.send(DatagramPacket) |
recvfrom() |
DatagramSocket.receive(DatagramPacket) |
close() |
Socket.close()(推荐 try-with-resources) |
shutdown() 半关闭 |
socket.shutdownOutput() / shutdownInput() |
getaddrinfo() DNS 解析 |
InetAddress.getByName() |
inet_pton() 字符串→地址 |
InetAddress.getByName("10.0.0.1") |
inet_ntop() 地址→字符串 |
InetAddress.getHostAddress() |
htons/htonl 字节序转换 |
不需要,Java 默认大端 |
struct sockaddr_in |
InetSocketAddress |
struct sockaddr_storage |
InetSocketAddress(自动适配 v4/v6) |
fcntl(O_NONBLOCK) 非阻塞 |
channel.configureBlocking(false) |
poll() / select() |
Selector.select() + SelectionKey |
setsockopt(SO_REUSEADDR) |
socket.setReuseAddress(true) |
setsockopt(SO_BROADCAST) |
socket.setBroadcast(true) |
fork() 多进程 |
new Thread() 多线程,或用线程池 |
SIGCHLD 回收僵尸 |
无需,线程由 GC 回收 |
perror() |
e.getMessage() / printStackTrace() |
errno |
异常对象 |
- 先读 Beej 原文第 2、3、5 章建立概念(socket 是什么、字节序、系统调用顺序), 原文的幽默风格能帮你记住这些枯燥的 API。
- 对照本仓库的 Java 代码看每个 C 调用如何映射,注释里都标了对应关系。
- 动手跑:编译后按上面的命令实际运行,改改端口/消息观察行为。
- 重点啃第 5 节(数据封装):这是从"会调 API"到"能写真实网络协议"的关键一步, 理解了粘包/半包和分帧,你就真正入门网络编程了。
- 想深入非阻塞 I/O,再回到 Beej 7.2/7.3 节读
poll/select,配合本仓库的SelectorServer。