《Java 网络编程基础》2.3 InetAddress、端口与网络接口

InetAddress 解析可能返回多个地址,localhost 同时是 127.0.0.1 和 ::1。本节观察 getAllByName、IPv4/IPv6 回环、端口 0 的临时端口分配、重复绑定的 BindException 原文,以及 lo0 MTU 16384 与 en0 MTU 1500,并用 netstat/lsof 验证,最后落到 LinkdMain 的绑定日志。

本节目标:弄清地址、端口和网卡三者的关系,能用 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 和地址;本机 lo0 MTU 16384、en0 MTU 1500,枚举顺序和接口名都不可跨平台假设。
  • LinkdMain 用 BindAddress.parse 显式解析绑定地址,默认 loopback,绑定失败直接退出而不是换端口。

地址和端口确定之后,下一节开始用 Socket 和 ServerSocket 建立连接、读写数据,并把超时和关闭策略落到代码里。

阅读导航:上一节:2.2 UDP 数据报、MTU 与丢包 · 下一节:3.1 Socket 与 ServerSocket 基本用法 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发