本节目标:弄清地址、端口和网卡三者的关系,能用
netstat/lsof解释一次绑定失败。
适用版本:JDK 25(Temurin 25.0.5+8)。
承接的问题
linkd 在开发机上监听 9090。某天启动日志写着 listening on localhost/127.0.0.1:9090,浏览器打开 http://localhost:9090/healthz 却得到 Connection refused;换另一台机器,启动直接抛 BindException: Address already in use,但 lsof 又看不出是谁占着。
这两个现象分别对应地址族和端口占用:localhost 可能解析到 ::1,而服务端只绑了 127.0.0.1;端口冲突要看内核的监听表,不能只靠应用日志。本节把 InetAddress、InetSocketAddress、NetworkInterface 和两条 macOS 诊断命令串起来。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
InetAddress 解析的不是「一个地址」
InetAddress 表示一个 IP 地址,不带端口;InetSocketAddress 才是地址加端口。一个主机名可以对应多个地址,getByName 只返回其中一个,getAllByName 才返回全部。
import java.net.InetAddress;
import java.net.UnknownHostException;
public class AddressDemo {
public static void main(String[] args) throws Exception {
System.out.println("loopback = " + InetAddress.getLoopbackAddress());
System.out.println("byName(localhost) = " + InetAddress.getByName("localhost"));
for (InetAddress a : InetAddress.getAllByName("localhost")) {
System.out.printf(" %s loopback=%s anyLocal=%s%n",
a, a.isLoopbackAddress(), a.isAnyLocalAddress());
}
try {
InetAddress.getByName("no-such-host.invalid");
} catch (UnknownHostException e) {
System.out.println(e.getClass().getName() + ": " + e.getMessage());
}
System.out.println("127.0.0.1 bytes=" + InetAddress.getByName("127.0.0.1").getAddress().length
+ ", ::1 bytes=" + InetAddress.getByName("::1").getAddress().length);
}
}
本机输出:
loopback = localhost/127.0.0.1
byName(localhost) = localhost/127.0.0.1
localhost/127.0.0.1 loopback=true anyLocal=false
localhost/0:0:0:0:0:0:0:1 loopback=true anyLocal=false
java.net.UnknownHostException: no-such-host.invalid: nodename nor servname provided, or not known
127.0.0.1 bytes=4, ::1 bytes=16
这台机器上 getByName("localhost") 返回 IPv4,但 getAllByName 同时给出 127.0.0.1 和 ::1。顺序由解析器和 /etc/hosts 决定,Java 不承诺 IPv4 优先。服务端只绑 127.0.0.1、客户端却选中 ::1 时,就会得到 Connection refused。解决办法不是猜顺序,而是让服务端监听通配地址,或者客户端把 getAllByName 的地址逐个尝试。
几个容易混淆的点:
InetAddress.toString()的localhost/127.0.0.1是「主机名/字面地址」,主机名可能来自反向解析;日志里不要依赖它做判断。getHostName()和getCanonicalHostName()可能触发 DNS 查询,放在请求路径上会引入不可控延迟。需要展示地址时优先getHostAddress()。isAnyLocalAddress()对0.0.0.0和::为真,isLoopbackAddress()对127.0.0.0/8和::1为真。两者都不是「本机所有地址」的等价判断。InetAddress不可变、线程安全;InetSocketAddress在地址未解析时isUnresolved()为真,getAddress()返回 null。
JDK 会缓存正向解析结果,默认 TTL 是 30 秒,可用 -Dsun.net.inetaddr.ttl 或安全属性 networkaddress.cache.ttl 调整。改 DNS 后短时间内仍拿到旧地址,先想到缓存,而不是「Java 有 bug」。
端口、绑定与 Address already in use
端口是 16 位无符号数,0 到 65535。传 0 给 ServerSocket 表示「让内核挑一个空闲端口」,绑定后用 getLocalPort() 读回来。下面程序先占住 19091,再尝试绑定同一个地址和端口:
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
public class BindDemo {
public static void main(String[] args) throws Exception {
InetAddress loop = InetAddress.getLoopbackAddress();
try (ServerSocket first = new ServerSocket()) {
first.bind(new InetSocketAddress(loop, 19091));
System.out.println("first bound to " + first.getLocalSocketAddress());
try (ServerSocket second = new ServerSocket()) {
second.bind(new InetSocketAddress(loop, 19091));
System.out.println("second bound to " + second.getLocalSocketAddress());
} catch (Exception e) {
System.out.println(e.getClass().getName() + ": " + e.getMessage());
}
}
try (ServerSocket ephemeral = new ServerSocket(0, 50, loop)) {
System.out.println("ephemeral port = " + ephemeral.getLocalPort());
}
}
}
本机输出:
first bound to localhost/127.0.0.1:19091
java.net.BindException: Address already in use
ephemeral port = 60119
第二次绑定抛 BindException: Address already in use,异常信息里没有「谁占了端口」,因为内核只告诉 Java 失败。临时端口 60119 每次运行都可能不同,不要把它写进配置。
ServerSocket.setReuseAddress(true) 在 macOS 上默认就是 true,但它不能让两个活跃监听绑定同一端口。它影响的是 TIME_WAIT 状态下能否复用本地地址,不是「端口冲突开关」。把 setReuseAddress(true) 当成万能解药,是常见的误判。
要查谁占着端口,用两条命令互补:
netstat -an -p tcp | grep 19091
lsof -nP -iTCP:19091 -sTCP:LISTEN -F pcn
本机输出:
tcp4 0 0 127.0.0.1.19091 *.* LISTEN
p10390
cjava
f5
n127.0.0.1:19091
netstat 给出内核视角的状态,LISTEN 表示有 socket 在监听,但不告诉你进程。lsof 的 -F pcn 是机器可读格式:p 是 PID,c 是命令名,f 是文件描述符,n 是地址和端口。两者对不上时,优先相信 netstat 的状态,再用 lsof 找进程;如果 lsof 没有输出,可能是权限不足、进程已退出,或者 socket 处于 TIME_WAIT 这种不再关联进程的状态。
绑定地址还决定暴露面。127.0.0.1:9090 只接受本机连接,0.0.0.0:9090 监听所有 IPv4 网卡,[::]:9090 监听 IPv6(在双栈系统上是否同时接受 IPv4 取决于系统配置)。开发环境默认绑 loopback 更安全;容器里需要外部访问时,再显式改成通配地址。
网络接口:回环、物理网卡与 MTU
NetworkInterface 描述一块网卡,能查到是否启用、是否回环、MTU 和绑定的地址。下面程序只列 lo0 和 en0:
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.Collections;
public class IfaceDemo {
public static void main(String[] args) throws Exception {
for (String name : new String[]{"lo0", "en0"}) {
NetworkInterface ni = NetworkInterface.getByName(name);
System.out.printf("%s up=%s loop=%s mtu=%d%n",
ni.getName(), ni.isUp(), ni.isLoopback(), ni.getMTU());
for (InetAddress a : Collections.list(ni.getInetAddresses())) {
System.out.printf(" %s %s%n", a.getClass().getSimpleName(), a.getHostAddress());
}
}
System.out.println("byInetAddress(127.0.0.1) = "
+ NetworkInterface.getByInetAddress(InetAddress.getByName("127.0.0.1")).getName());
}
}
本机输出(en0 地址末段已隐去):
lo0 up=true loop=true mtu=16384
Inet6Address fe80:0:0:0:0:0:0:1%lo0
Inet6Address 0:0:0:0:0:0:0:1%lo0
Inet4Address 127.0.0.1
en0 up=true loop=false mtu=1500
Inet6Address fe80:0:0:0:1015:69bd:181f:xxxx%en0
Inet4Address 10.115.24.xxx
byInetAddress(127.0.0.1) = lo0
lo0 的 MTU 是 16384,en0 是 1500。这个差异解释了第 2.2 节的现象:2000 字节的 UDP 数据报在 loopback 上不分片,在 en0 上会分片。MTU 不是 Java 设置的,是网卡和链路决定的;NetworkInterface.getMTU() 只读。
lo0 同时有 127.0.0.1 和 ::1,还有链路本地地址 fe80::1%lo0。链路本地地址带 %lo0 作用域,离开这块接口就没有意义;把它当成可对外访问的地址会失败。en0 的 fe80::...%en0 同理。
NetworkInterface.getByInetAddress() 只对「确实绑定在本机某块网卡上的地址」返回非 null。给网卡配了 VIP 但还没生效、或者地址属于已删除的接口时,它会返回 null。调用方必须判空,不能直接 .getName()。
枚举接口时还要注意:
getNetworkInterfaces()的顺序不保证稳定,日志里不要依赖「第一个就是 eth0」。isUp()为 false 的接口不要参与候选地址;isLoopback()为 true 的接口通常只用于本机调试。isVirtual()可以过滤掉部分虚拟接口,但 VPN、容器网桥不一定会被标成 virtual,不能把它当安全边界。- 生产日志不要打印全部地址。VPN、容器和临时接口会泄露拓扑,也会让日志难以阅读;只打印实际绑定的地址和端口。
在 linkd 里落地
linkd-app 把「解析绑定地址」和「启动时打印候选接口」收进一个小类,LinkdMain 只负责调用。这样测试可以注入 127.0.0.1,部署可以用环境变量切到通配地址。
package com.plume.linkd.app;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.util.Collections;
public final class BindAddress {
private BindAddress() { }
public static InetSocketAddress parse(String host, int port) throws Exception {
InetAddress address = (host == null || host.isBlank())
? InetAddress.getLoopbackAddress()
: InetAddress.getByName(host);
return new InetSocketAddress(address, port);
}
public static void printCandidates() throws SocketException {
for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) {
if (!ni.isUp() || ni.isLoopback()) {
continue;
}
System.out.printf("%s mtu=%d%n", ni.getName(), ni.getMTU());
for (InetAddress a : Collections.list(ni.getInetAddresses())) {
System.out.println(" " + a.getHostAddress());
}
}
}
}
LinkdMain 的启动片段:
InetSocketAddress bind = BindAddress.parse(System.getenv("LINKD_BIND"), 9090);
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(bind, 128);
System.out.println("linkd listening on " + server.getLocalAddress());
BindAddress.printCandidates();
这段落地代码对应四个决定:
LINKD_BIND未设置或为空时绑 loopback,开发机不会意外暴露到局域网;容器部署时显式设成0.0.0.0。bind的参数是InetSocketAddress,不是字符串。先解析再绑定,失败点清晰,日志里能打印实际地址。server.bind(bind, 128)的 128 是 backlog,限制已完成握手、等待 accept 的队列长度,不是最大连接数。printCandidates()只列启用且非回环的接口,帮助确认容器里的网卡和 MTU;它不参与绑定,失败也不应该阻止启动。
绑定失败时,LinkdMain 应该把地址、端口和异常一起打出并退出非零,而不是换一个端口重试。换端口会让健康检查和调用方找不到服务,把「端口被占」变成更难查的「服务时好时坏」。
连接建立后,Connection.channel().getLocalAddress() 能读到这条连接实际使用的本地地址和端口;EventLoop.register(SocketChannel, Handler) 注册的是 accept 出来的 SocketChannel,不是监听 channel。排查「到底绑在哪个地址」时,看启动日志和 netstat,不要猜。
常见错误与边界
错误一:服务端绑 127.0.0.1,客户端连 localhost 却走到 ::1。 本机 getAllByName("localhost") 同时返回两个地址。反例片段:
server.bind(new InetSocketAddress(InetAddress.getByName("::1"), 19096));
// 客户端 connect 到 127.0.0.1:19096
本机输出:
ipv4 client: ConnectException: Connection refused (connect failed)
ipv6 client connected
修法是绑定通配地址,或者客户端遍历 getAllByName 的结果逐个尝试;不要假设解析顺序。
错误二:用 setReuseAddress(true) 解决端口冲突。 它不能让两个活跃监听共享同一端口,本机第二次绑定仍然抛 BindException: Address already in use。它影响的是 TIME_WAIT 复用,不是冲突检测。
错误三:对 NetworkInterface.getByInetAddress() 的返回值不判空。 地址未绑定到本机、接口已删除或 VIP 未生效时返回 null,直接调用会 NullPointerException。同样,getByName("eth0") 在 macOS 上返回 null,接口名要按平台查。
错误四:把 0.0.0.0 当成本机地址去连接。 0.0.0.0 是通配地址,用于绑定;拿它做 connect 目标在不同系统上行为不一致。连本机用 127.0.0.1 或 ::1。
边界:netstat 的列在不同 macOS 版本上可能增减。 本机输出里的 tcp4、地址用点分端口(127.0.0.1.19091)是 BSD 风格;解析脚本不要按固定列号截取,按状态关键字和端口匹配更稳。
小结
InetAddress是一个 IP,不带端口;InetSocketAddress才是地址加端口,未解析时getAddress()可能为 null。- 一个主机名可能对应多个地址,
getByName只取一个,顺序不保证;本机localhost同时解析到127.0.0.1和::1。 - 端口 0 让内核分配临时端口;重复绑定同一地址和端口抛
BindException: Address already in use,setReuseAddress(true)不能解决。 netstat -an -p tcp看内核状态,lsof -nP -iTCP:<port> -sTCP:LISTEN -F pcn找进程;TIME_WAIT不关联进程。NetworkInterface给出接口的 up/loopback/MTU 和地址;本机lo0MTU 16384、en0MTU 1500,枚举顺序和接口名都不可跨平台假设。LinkdMain用BindAddress.parse显式解析绑定地址,默认 loopback,绑定失败直接退出而不是换端口。
地址和端口确定之后,下一节开始用 Socket 和 ServerSocket 建立连接、读写数据,并把超时和关闭策略落到代码里。
阅读导航:上一节:2.2 UDP 数据报、MTU 与丢包 · 下一节:3.1 Socket 与 ServerSocket 基本用法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。