导读 :这是个常被拿来面试的问题,但它在日常开发里也会咬人。我在 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 |
从表里能读出几件事:
- 完全相同的地址和端口,光开
SO_REUSEADDR没用(第 2 行)。很多文章说"开了 REUSEADDR 就能复用端口",说的是 TIME_WAIT 场景(第七节),不是两个进程同时监听。 SO_REUSEPORT要两边都开(第 3、4 行)。先启动的那个没开,后来的开了也没用。- 地址一个通配、一个具体,macOS 上能共存,谁先谁后都行 (第 5、7 行),连接交给绑得更具体的
127.0.0.1那个。 - 第 6 行两边都关了 REUSEADDR 才失败。我又补测了两种:A 关、B 默认(开)时 B 照样成功;A 默认、B 关时 B 失败。能不能抢进来,看的是后来者自己有没有开 REUSEADDR,先启动的一方关掉它也拦不住别人。
- 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
socket(7)手册(SO_REUSEADDR、SO_REUSEPORT):man7.org/linux/man-p... - JDK 17
StandardSocketOptions:docs.oracle.com/en/java/jav... - Spring Boot 文档 Embedded Web Servers(改端口、随机端口):docs.spring.io/spring-boot...
- LWN:The SO_REUSEPORT socket option:lwn.net/Articles/54...
你本地开发碰到过"端口被占却没报错、请求跑到别的程序"的情况吗?当时占端口的是什么程序?评论区说说你的系统和情况,Linux 上实测过的也欢迎贴结果,我补进第六节。
觉得有用的话点个赞、收个藏,下次碰到端口冲突翻得到。