有一次线上告警没响,CPU 也正常,用户却在群里说页面转圈。我 curl localhost:8080/api/order,30 秒没反应。进程在、端口也在听——这就是服务假死:JVM 没挂,但已经接不住新请求了。
一、假死和真死,别搞混
| 状态 | 进程 | 端口 | 业务请求 |
|---|---|---|---|
| 真死 | 退出 | 连不上 | 直接失败 |
| 假死 | 还在跑 | 能连上 | 超时、极慢、时好时坏 |
假死比真死难查:监控往往还是绿的,重启有时能好、有时不行,最后常被甩锅给「网络问题」。说白了,就是工作线程或连接池这类关键资源被占满了,新请求进来只能干等。
二、四种典型场景,本地能复现
1. Tomcat 线程池被打满
Spring Boot 默认 Tomcat 工作线程 max=200。接口里要是没设超时、傻等下游,200 个线程很快占满,后面的请求全排队。
@RestController
public class FakeDeathController {
@GetMapping("/slow")
public String slow() throws InterruptedException {
// 模拟慢请求:占住一个 Tomcat 线程 30s
Thread.sleep(30_000);
return "ok";
}
}
拿 ab 或 wrk 打 250 并发,很快就能看到:ss -ant | grep 8080 连接堆起来,jstack 里一堆 http-nio-8080-exec-* 卡在 TIMED_WAITING(Demo 里是 Thread.sleep)。真实场景里等下游 HTTP 时,常见是 RUNNABLE(堵在 socketRead0)或 WAITING——别只盯一种状态,看栈顶在哪才是关键。
2. 数据库连接池泄漏
HikariCP 默认就 10 个连接。借出来不还,池子干了,后面所有要查库的线程都堵在 getConnection()。
@Service
public class LeakService {
@Autowired DataSource ds;
public void leak() throws SQLException {
Connection conn = ds.getConnection();
// 没 close,也没 try-with-resources
conn.prepareStatement("SELECT 1").execute();
}
}
jstack 里会看到大量线程停在 HikariPool.getConnection,下面跟着 AbstractQueuedSynchronizer.park。
3. 死锁
public class DeadlockDemo {
static final Object A = new Object(), B = new Object();
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
public static void main(String[] args) {
new Thread(() -> { synchronized (A) { sleep(100); synchronized (B) {} } }).start();
new Thread(() -> { synchronized (B) { sleep(100); synchronized (A) {} } }).start();
}
}
跑完 jstack,会直接看到 Found one Java-level deadlock。注意:两个线程死锁,通常只卡部分请求;全站假死一般是线程池或连接池整体耗尽。线上更绕,业务锁、Redis 锁、DB 行锁搅在一起,我一般用 Arthas 的 thread -b 查。
4. Full GC 间歇性卡死
堆里长期存活对象太多、回收跟不上,Full GC 一 STW 就是好几秒,表现就是「偶尔能访问,偶尔全超时」。下面这段代码持续分配大对象且不释放,能把 Old 区慢慢顶满:
public class GcPressureDemo {
static List<byte[]> cache = new ArrayList<>();
public static void main(String[] args) {
while (true) {
cache.add(new byte[1024 * 1024]); // 1MB,只增不减
}
}
}
配合 jstat -gcutil <pid> 1000 看,OU(Old 使用率)持续走高,FGC 次数往上飙、GCT 占比高,基本就对上了。
三、为什么进程看着还正常?
客户端 → TCP 握手成功 → 进 Tomcat Connector 队列
→ 分配 worker 线程 → 跑业务
→ 线程卡在 I/O / 锁 / getConnection()
→ 线程池或队列满了 → 连接能建,没人处理 → 假死
几个容易误判的点:
- 端口能连,不代表服务好使。listen 成功只说明 TCP 层 OK,Tomcat 有没有空闲线程是另一回事。
- 进程在,不代表线程池能用。GC 线程、定时任务还在跑,CPU 可能很低——业务线程全在等。
- health 返回 200 也可能骗人。没配 DB/Redis 探活,或者没把线程池队列、连接池 active 数放进指标,探活照样绿。
四、开发时我会守的几条线
超时别省。 RestTemplate、Feign、WebClient 都要设 connect + read timeout,别裸调。下面 RestTemplate 示例适用于 Spring Boot 2.x;3.x 需改用 ClientHttpRequestFactory 配超时:
@Bean
RestTemplate restTemplate(RestTemplateBuilder b) {
return b.setConnectTimeout(Duration.ofSeconds(2))
.setReadTimeout(Duration.ofSeconds(3))
.build();
}
线程池分开用。 HTTP 入口、异步任务、MQ 消费各走各的池,别共用一个,一个慢接口拖死全部。
连接一定还。 查库走 try-with-resources,别裸借连接:
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
// ...
}
下游慢就熔断。 Sentinel 或 Resilience4j 配一下,快速失败比干等强,至少保住 Tomcat 线程。
探活做深一点。 DB、Redis、核心下游都测;线程池排队数、连接池使用率也打到监控上。
出事了先留现场再重启:
jstack <pid> > thread.dump
jstat -gcutil <pid> 1000
# 有 Arthas 的话
thread -n 10
dashboard
dump 完再重启。直接重启,现场没了,下次还得猜。
五、我自己踩过的坑
同步写日志。 某接口压测 P99 飙到秒级,jstack 里大量 http-nio-* 线程栈顶卡在 FileAppender.doAppend(同步写盘),业务代码反而在下面几层。换成异步日志、调高级别后,同一套压测 P99 从 8s 掉到 200ms 左右。
事务里调 HTTP。 接口超时,jstack 里业务线程在等 Feign 返回,同时 HikariCP active=10、多线程堵在 getConnection()——根因是 @Transactional 包着远程调用,事务占着 DB 连接等下游。后来改成:事务里只动 DB,HTTP 放事务外面。