一个端口能被两个程序同时监听吗:SO_REUSEADDR、SO_REUSEPORT Java 实测和 Spring Boot 端口被占照样启动的坑

导读 :这是个常被拿来面试的问题,但它在日常开发里也会咬人。我在 macOS 上用 JDK 17 把 8 种绑定组合、SO_REUSEPORT、TCP 和 UDP 同端口、TIME_WAIT 重启都跑了一遍,还复现了一个坑:别的程序占着 127.0.0.1:8080,Spring Boot 照样启动成功,可你在浏览器里访问 127.0.0.1:8080 打到的是别人。文末附 Port 8080 was already in use 的排查步骤。


一、先说结论

端口可以理解成一栋楼里的门牌号,"监听"就是某个程序站在这个门口等人敲门。操作系统默认的规矩是:一个门口只站一个程序 ,第二个程序再来,bind 就失败,Java 里抛 java.net.BindException: Address already in use,Spring Boot 里就是那句 Port 8080 was already in use。

但这条规矩有三个例外,实测下来都成立:

例外 能不能同时监听 连接交给谁
地址不同:一个绑 0.0.0.0,一个绑 127.0.0.1 macOS 上能 访问 127.0.0.1 的连接交给绑得更具体的那个
两边都在 bind 前设置了 SO_REUSEPORT 能 macOS 上不分摊;Linux 3.9+ 据文档会分摊
一个 TCP、一个 UDP 能 各收各的,互不相干

不管它会怎样?大多数时候没事,端口冲突会直接报错,你换个端口就好。真正麻烦的是第一个例外:冲突了却不报错。本地联调时你会碰到"代码改了没生效""接口返回的根本不是我的服务"这类怪事,查半天才发现请求被另一个进程截走了。

什么时候值得花时间弄清楚:本地同时跑好几个服务、用代理或隧道工具占了本地端口、想用多进程监听同一端口做负载分摊,或者面试被问到。


二、测试环境

项目 版本
系统 macOS 15.0,Apple Silicon(arm64)
JDK 17.0.12
Spring Boot 4.1.2(内嵌 Tomcat)
Linux 未实测,相关结论据 man 7 socket

⚠️ 这篇的结论都是 macOS(BSD 系)上跑出来的。生产服务器多是 Linux,两边在"地址重叠"这一点上规则不一样,第六节单独说。


三、测试程序

用一个很小的监听程序,参数控制绑定地址、端口、SO_REUSEADDR、SO_REUSEPORT,收到连接就回一个 HTTP 响应,内容是自己的名字,这样用 curl 一看就知道连接落到了谁身上:

java 复制代码
import java.net.*;
import java.nio.channels.*;
import java.io.*;

// 用法: java Listen <名字> <地址> <端口> <reuseaddr: true|false|default> <reuseport: true|false> <秒数>
public class Listen {
  public static void main(String[] a) throws Exception {
    String name = a[0], host = a[1];
    int port = Integer.parseInt(a[2]);
    ServerSocketChannel ch = ServerSocketChannel.open();
    // default 表示不动 JDK 的默认值
    if (!a[3].equals("default")) ch.setOption(StandardSocketOptions.SO_REUSEADDR, Boolean.parseBoolean(a[3]));
    // SO_REUSEPORT 是 JDK 9 加进 StandardSocketOptions 的,必须在 bind 之前设置
    if (Boolean.parseBoolean(a[4])) ch.setOption(StandardSocketOptions.SO_REUSEPORT, true);
    System.out.println(name + " options reuseaddr=" + ch.getOption(StandardSocketOptions.SO_REUSEADDR)
        + " reuseport=" + ch.getOption(StandardSocketOptions.SO_REUSEPORT));
    try {
      ch.bind(new InetSocketAddress(host, port));
    } catch (IOException e) {
      System.out.println(name + " BIND FAIL: " + e);
      return;
    }
    System.out.println(name + " BIND OK " + ch.getLocalAddress());
    ch.configureBlocking(false);
    long end = System.currentTimeMillis() + Integer.parseInt(a[5]) * 1000L;
    int n = 0;
    while (System.currentTimeMillis() < end) {
      SocketChannel c = ch.accept();
      if (c != null) {
        n++;
        String body = name + "\n";
        c.write(java.nio.ByteBuffer.wrap(("HTTP/1.0 200 OK\r\nContent-Length: " + body.length() + "\r\n\r\n" + body).getBytes()));
        c.close();
      } else {
        Thread.sleep(5);
      }
    }
    System.out.println(name + " accepted=" + n);
  }
}

每组测试先启动 A,0.6 秒后启动 B,再连 127.0.0.1 发 20 个连接,看两边各收到多少:

bash 复制代码
# 在 Listen.java 所在目录执行,JDK 17
javac Listen.java
java Listen A 0.0.0.0 18080 default false 4 &
sleep 0.6
java Listen B 0.0.0.0 18080 default false 4 &

四、实测结果:8 种组合

先说一个容易忽略的前提:JDK 在 macOS 上默认就打开了 SO_REUSEADDR 。ServerSocketChannel 打印出来是 reuseaddr=true,老的 java.net.ServerSocket 我也单独测了,getReuseAddress() 返回 true。所以下面"默认"那几行,其实是两边都开着 SO_REUSEADDR。

# A 绑定 B 绑定 B 能否 bind 20 个连接 127.0.0.1 落到
1 0.0.0.0,默认 0.0.0.0,默认 ❌ Address already in use A=20
2 0.0.0.0,REUSEADDR 0.0.0.0,REUSEADDR ❌ A=20
3 0.0.0.0,REUSEPORT 0.0.0.0,REUSEPORT ✅ A=20,B=0
4 0.0.0.0,无 REUSEPORT 0.0.0.0,REUSEPORT ❌ A=20
5 0.0.0.0,默认 127.0.0.1,默认 ✅ B=20
6 0.0.0.0,REUSEADDR=false 127.0.0.1,REUSEADDR=false ❌ A=20
7 127.0.0.1,默认 0.0.0.0,默认 ✅ A=20
8 127.0.0.1,REUSEPORT 127.0.0.1,REUSEPORT ✅ A=0,B=20

从表里能读出几件事:

  1. 完全相同的地址和端口,光开 SO_REUSEADDR 没用(第 2 行)。很多文章说"开了 REUSEADDR 就能复用端口",说的是 TIME_WAIT 场景(第七节),不是两个进程同时监听。
  2. SO_REUSEPORT 要两边都开(第 3、4 行)。先启动的那个没开,后来的开了也没用。
  3. 地址一个通配、一个具体,macOS 上能共存,谁先谁后都行 (第 5、7 行),连接交给绑得更具体的 127.0.0.1 那个。
  4. 第 6 行两边都关了 REUSEADDR 才失败。我又补测了两种:A 关、B 默认(开)时 B 照样成功;A 默认、B 关时 B 失败。能不能抢进来,看的是后来者自己有没有开 REUSEADDR,先启动的一方关掉它也拦不住别人。
  5. macOS 上的 SO_REUSEPORT 不分摊连接 (第 3、8 行)。两个都绑通配地址时,连接全给先启动的;都绑 127.0.0.1 时,全给后启动的。每种我都重复跑了 2~3 次,结果一样。所以别指望在 Mac 上用它做多进程负载均衡的实验。

五、Spring Boot 端口被占,照样启动

第 5、7 行放到 Spring Boot 身上,就是标题里那个坑。我用一个只有一个接口的 Spring Boot 4.1.2 应用测了三种情况:

java 复制代码
package demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;

// 访问根路径返回 boot,用来区分请求落到了谁身上
@SpringBootApplication
@RestController
public class App {
  @GetMapping("/") public String hi() { return "boot\n"; }
  public static void main(String[] args) { SpringApplication.run(App.class, args); }
}

5.1 别人占的是 0.0.0.0:正常报错

先用 Listen 占住 0.0.0.0:18090,再启动 Boot:

bash 复制代码
# 先占端口,再用同一个端口启动 Boot
java Listen A 0.0.0.0 18090 default false 25 &
java -jar target/bootport-1.0.jar --server.port=18090

Boot 退出码 1,日志就是大家熟悉的那段:

text 复制代码
***************************
APPLICATION FAILED TO START
***************************

Description:

Web server failed to start. Port 18090 was already in use.

Action:

Identify and stop the process that's listening on port 18090 or configure this application to listen on another port.

5.2 别人只占了 127.0.0.1:Boot 启动成功,请求被截走

把 Listen 换成只绑 127.0.0.1:18092,Boot 照样用 18092 启动:

bash 复制代码
# A 只绑回环地址,Boot 默认绑所有地址
java Listen A 127.0.0.1 18092 default false 20 &
java -jar target/bootport-1.0.jar --server.port=18092 &

这次日志里是 Tomcat started on port 18092 (http),没有任何警告。然后用四种地址各访问一次:

访问地址 返回 落到谁
http://127.0.0.1:18092/ A 别的进程
http://localhost:18092/ boot Spring Boot
http://[::1]:18092/ boot Spring Boot
http://<本机局域网 IP>:18092/ boot Spring Boot

localhost 落到 Boot,是因为 macOS 上 curl 解析 localhost 先走了 IPv6 的 ::1,A 只绑了 IPv4 的 127.0.0.1。也就是说,同一台机器,浏览器地址栏写 localhost 和写 127.0.0.1,可能打到两个不同的程序 。前端代理、Postman 环境变量、Nacos 里注册的地址,只要有一处写的是 127.0.0.1,就会出现"有的请求正常、有的请求返回奇怪内容"。

用 lsof 看,两个进程都在 LISTEN(这段输出是前一轮用 18091 端口测同样情况时抓的):

text 复制代码
COMMAND   PID     USER   FD   TYPE  DEVICE              SIZE/OFF NODE NAME
java    87047 aiksyuan    4u  IPv6  0xb4c9047e26ff136f      0t0  TCP 127.0.0.1:18091 (LISTEN)
java    87049 aiksyuan    9u  IPv6  0x643ef49dda71ba0f      0t0  TCP *:18091 (LISTEN)

*:18091 是 Boot,127.0.0.1:18091 是那个"截胡"的进程。加 -Djava.net.preferIPv4Stack=true 让 Boot 只走 IPv4,结果不变:Boot 照样启动成功,127.0.0.1 依旧落到 A。

5.3 Boot 也绑 127.0.0.1:又会正常报错

bash 复制代码
# 两边地址完全相同,回到第 1 行的情况
java -jar target/bootport-1.0.jar --server.port=18094 --server.address=127.0.0.1

这次 Boot 报 Port 18094 was already in use,退出码 1。所以只在本机用的服务,配上 server.address=127.0.0.1,冲突时至少会报错,不会悄悄共存。不过要被局域网里别的机器访问的服务不能这么配。

💡 本地联调出现"改了代码没生效""返回的内容不像自己的接口",先跑一下 lsof -nP -iTCP:<端口> -sTCP:LISTEN,看看是不是有两行。


六、Linux 上不一样(据文档,未实测)

这篇没有在 Linux 上跑。man 7 socket 对 SO_REUSEADDR 的说明是:对 AF_INET 套接字,有处于监听状态的套接字绑在这个地址上时不能再 bind;监听套接字绑的是 INADDR_ANY(也就是 0.0.0.0)时,这个端口的任何本地地址都不能再 bind。

按这段描述,第四节第 5、7 行那种"一个通配、一个具体"在 Linux 上应该会报 Address already in use,第五节的截胡问题主要出在 macOS 开发机上。Windows 有自己的一套规则(SO_EXCLUSIVEADDRUSE),这里也没测。

SO_REUSEPORT 在 Linux 3.9 加入,手册写的是:每个套接字(包括第一个)都要在 bind 前设置;为了防止端口被劫持,所有绑同一地址的进程必须是同一个有效 UID;对 TCP 来说,可以让多个监听套接字分摊 accept。这和 macOS 上"全给一个"的实测结果不同,想用它做多进程分摊,要在 Linux 上验证。


七、重启时报 Address already in use:TIME_WAIT

还有一种"端口明明没人用却 bind 失败":服务刚停,马上重启。原因是 TCP 连接主动关闭的一方会进入 TIME_WAIT 状态,停留一段时间,这段时间里端口还被这些连接占着。

我让 A 接 5 个连接、由服务端先关闭,A 退出后立刻看:

bash 复制代码
# 查看 18130 端口上残留的连接状态(macOS)
netstat -an -p tcp | grep "\.18130 "

能看到服务端这一侧留下了 TIME_WAIT 连接(只截了前 3 行):

text 复制代码
tcp4       0      0  127.0.0.1.18130        127.0.0.1.62838        TIME_WAIT
tcp4       0      0  127.0.0.1.18130        127.0.0.1.62839        TIME_WAIT
tcp4       0      0  127.0.0.1.18130        127.0.0.1.62840        TIME_WAIT

这时立刻用三种设置重新 bind 同一个端口:

重启时的设置 结果
SO_REUSEADDR=false ❌ Address already in use
SO_REUSEADDR=true ✅
不设置(JDK 默认) ✅

这才是 SO_REUSEADDR 的本职工作:允许在有 TIME_WAIT 残留时重新绑定。JDK 默认已经打开,所以用 Spring Boot、Tomcat 的项目一般碰不到这个问题;自己写 Socket 服务又显式设成了 false,或者用的是别的语言写的工具,才会遇到"停了马上起起不来,等一两分钟又好了"。


八、TCP 和 UDP 能用同一个端口吗

能。TCP 和 UDP 是两套独立的端口空间:

java 复制代码
import java.net.*;
import java.nio.channels.*;

// 同一端口先绑 TCP 再绑 UDP,最后再试一个 UDP
public class Udp {
  public static void main(String[] a) throws Exception {
    ServerSocketChannel t = ServerSocketChannel.open();
    t.bind(new InetSocketAddress(18120));
    DatagramChannel u = DatagramChannel.open();
    u.bind(new InetSocketAddress(18120));
    System.out.println("TCP " + t.getLocalAddress() + " + UDP " + u.getLocalAddress() + " 同时绑定成功");
    DatagramChannel u2 = DatagramChannel.open();
    try {
      u2.bind(new InetSocketAddress(18120));
      System.out.println("第二个 UDP 也成功");
    } catch (Exception e) {
      System.out.println("第二个 UDP: " + e);
    }
  }
}

输出:

text 复制代码
TCP /[0:0:0:0:0:0:0:0]:18120 + UDP /[0:0:0:0:0:0:0:0]:18120 同时绑定成功
第二个 UDP: java.net.BindException: Address already in use

TCP 和 UDP 共存没问题,两个 UDP 抢同一个端口一样会冲突。DNS 的 53 端口就是 TCP、UDP 都用。


九、Port 8080 was already in use 怎么排查

碰到 Spring Boot 报端口被占,按这个顺序处理:

1. 找出是谁占的

bash 复制代码
# macOS(本文实测):只看 LISTEN 状态,-n -P 不解析主机名和端口名,输出更快
lsof -nP -iTCP:8080 -sTCP:LISTEN

Linux 和 Windows 上对应的命令如下(这两条本文没有实测):

bash 复制代码
# Linux:-l 只看监听,-t TCP,-n 不解析,-p 显示进程(看别人的进程要 sudo)
ss -ltnp 'sport = :8080'
# Windows(cmd):最后一列是 PID,再去任务管理器里按 PID 找进程
netstat -ano | findstr :8080

2. 看清绑的是哪个地址 。lsof 里 *:8080 是所有地址,127.0.0.1:8080 只是回环地址。如果占用方只绑了回环地址,而你的服务没报错,就是第五节那种情况。

3. 决定怎么处理

情况 处理
是自己上次没关掉的进程(IDEA 里重复点了运行很常见) 停掉它,kill <PID>,不要一上来就 kill -9
是别的必须保留的程序 换端口:--server.port=8081 或在 application.yml 里改 server.port
写集成测试、不在乎具体端口 server.port=0,Boot 会随机挑一个空闲端口,实测日志里是 Tomcat started on port 63561
服务只给本机用 加 server.address=127.0.0.1,冲突时能报错而不是悄悄共存

十、速查表

问题 答案(macOS + JDK 17 实测,Linux 据文档)
两个进程绑同一地址同一端口 不行,Address already in use
开了 SO_REUSEADDR 就行吗 不行,它管的是 TIME_WAIT 时重新绑定
JDK 默认开 SO_REUSEADDR 吗 macOS 上实测默认开
SO_REUSEPORT 呢 两边都在 bind 前设置才行;macOS 不分摊,Linux 据文档分摊
一个 0.0.0.0,一个 127.0.0.1 macOS 能共存,127.0.0.1 的连接给具体的那个;Linux 据文档会报错
TCP 和 UDP 同端口 可以
端口冲突 Boot 没报错 用 lsof -nP -iTCP:<端口> -sTCP:LISTEN 看是否有两行
只想让冲突一定报错 本机服务配 server.address=127.0.0.1

十一、常见问题

Q:Nginx、Gunicorn 多个 worker 监听同一个端口,用的是 SO_REUSEPORT 吗?

传统做法不是。它们是 master 进程先 bind 并 listen,再 fork 出 worker,worker 继承的是同一个监听套接字,内核里其实只有一个监听队列。Nginx 1.9.1 开始提供 reuseport 参数,打开后每个 worker 有自己的监听套接字,这才用到 SO_REUSEPORT。Java 进程没法 fork,所以 Java 里多进程监听同一端口只能走 SO_REUSEPORT。这一条是按 Nginx 和 Linux 的公开文档整理的,本文没有实测。

Q:IDEA 里停了服务,再启动还是报端口被占?

多半是上一次的进程没真正退出,比如 Run 和 Debug 各开了一个,或者开了 DevTools 重启。用第九节的 lsof 看一下 PID,和 IDEA Run 窗口里的对一对。

Q:Docker 里映射了 8080,本机 Boot 也用 8080,会怎样?

这要看 Docker 端口映射绑的是哪个地址、用的是哪种 Docker 实现。本文没有测,碰到时同样先用 lsof 看是不是两行 LISTEN。


参考链接


你本地开发碰到过"端口被占却没报错、请求跑到别的程序"的情况吗?当时占端口的是什么程序?评论区说说你的系统和情况,Linux 上实测过的也欢迎贴结果,我补进第六节。

觉得有用的话点个赞、收个藏,下次碰到端口冲突翻得到。

相关推荐
雷✘5 小时前
C++ 类与对象的初始化机制、成员访问边界与拷贝优化
java·网络·c++
JOEH605 小时前
为什么你的消息队列总是丢消息?Java 后端 5 个经典坑
java·架构
程序员敲笔记5 小时前
Spring IoC 容器底层实现原理与 Bean 生命周期实战
java
niuyueyang5 小时前
MCP `/mcp/status` Lifecycle 技术方案
java·服务器·数据库
杨运交6 小时前
086-对象存储模块-基于Spring Boot的文件存储模块监控设计与实现
java·spring boot·后端
Wang's Blog6 小时前
Java 项目实战:交友项目 - 我的访客:需求说明
java·开发语言·交友
云析赢指标公式网47 小时前
文华财经指标源码笔记:WINNER筹码函数、控盘变量与MACD变体的调试记录
java·笔记·算法
从琳开始9897 小时前
从Socket到TCP回显服务器:Java网络编程完整指南
java·服务器·网络·tcp/ip·java-ee
养军博客7 小时前
Spring Security 4.x / 5.x / 6.x 配置全对比:前后端不分离与前后端分离
java·后端·spring·springsecurity