#StackOverflowError实战
2021年某金融平台的交易系统在运行过程中突然崩溃,日志中充斥着StackOverflowError。
技术团队排查后发现:开发同学写了一个递归计算用户返佣的方法,但忘记写递归终止条件,导致返佣链路形成了一个死循环。
更可怕的是:这个方法在用户下单时被调用,用户量上来后,每秒触发数千次递归调用。
这次故障导致交易中断30分钟,影响了约1万笔交易。
【面试官手记】
StackOverflowError是生产环境常见的崩溃原因。我面试过的候选人里,能说清楚"递归调用"的有60%,能说清楚"线程栈配置"的有30%,能说清楚"循环调用"的有20%。栈溢出的关键词是递归必须有出口。
#一、栈溢出的原理 🔴
#1.1 线程栈结构
线程栈结构:
线程栈(Thread Stack):
┌─────────────────────────────────┐
│ 主方法栈帧 │
│ ┌───────────────────────────┐ │
│ │ method1() 局部变量 │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ method2() 栈帧 │ │ │
│ │ │ ┌───────────────┐ │ │ │
│ │ │ │ method3() │ │ │ │
│ │ │ │ ┌─────────┐ │ │ │ │
│ │ │ │ │ ... │ │ │ │ │
│ │ │ │ └─────────┘ │ │ │ │
│ │ │ └───────────────┘ │ │ │
│ │ └─────────────────────┘ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
默认栈大小:
- 32位JVM:320KB
- 64位JVM:1MB
- 可以通过 -Xss 参数调整#1.2 栈溢出原因
栈溢出原因:
1. 递归调用过深
- 递归没有终止条件
- 递归终止条件不满足
- 递归深度超过栈容量
2. 线程创建过多
- 线程栈总大小 = 线程数 × 栈大小
- 物理内存不足
- 报错:unable to create native thread
3. 大对象作为局部变量
- 在栈上分配大数组
- 在栈上存储大量数据
4. 循环调用
- AOP切面循环
- Service互相调用#二、递归调用问题 🔴
#2.1 经典递归问题
// 问题代码1:忘记终止条件
public class CommissionService {
/**
* 计算用户返佣
* 问题:没有终止条件,导致无限递归
*/
public BigDecimal calculateCommission(Long userId) {
User user = userDao.selectById(userId);
BigDecimal selfCommission = calculateSelfCommission(userId);
// 忘记判断user.getParentId()是否为null
BigDecimal parentCommission = calculateCommission(user.getParentId());
return selfCommission.add(parentCommission);
}
}
// 问题代码2:终止条件错误
public class TreeService {
/**
* 遍历树结构
* 问题:终止条件永远不满足
*/
public void traverse(TreeNode node) {
if (node == null) {
return;
}
// 永远返回true,递归无法终止
if (node.getChildren() != null && node.hasNext()) {
traverse(node);
}
for (TreeNode child : node.getChildren()) {
traverse(child);
}
}
}
// 问题代码3:递归深度过大
public class FibonacciService {
/**
* 斐波那契数列递归实现
* 问题:指数级时间复杂度,深度过大
* fib(100) 会栈溢出
*/
public long fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
}#2.2 正确递归写法
// 正确写法1:必须判断终止条件
public BigDecimal calculateCommission(Long userId) {
if (userId == null) {
return BigDecimal.ZERO;
}
User user = userDao.selectById(userId);
if (user == null) {
return BigDecimal.ZERO;
}
BigDecimal selfCommission = calculateSelfCommission(userId);
// 必须判断:父节点是否存在,是否是自己的下级
if (user.getParentId() == null || !user.getParentId().equals(userId)) {
return selfCommission;
}
BigDecimal parentCommission = calculateCommission(user.getParentId());
return selfCommission.add(parentCommission);
}
// 正确写法2:尾递归优化(需要JVM支持)
public long fibonacciTail(int n, long a, long b) {
if (n == 0) return a;
if (n == 1) return b;
return fibonacciTail(n - 1, b, a + b);
}
// 正确写法3:迭代替代递归
public long fibonacciIterative(int n) {
if (n <= 1) return n;
long a = 0, b = 1;
for (int i = 2; i <= n; i++) {
long temp = a + b;
a = b;
b = temp;
}
return b;
}#2.3 递归优化策略
递归优化策略:
1. 尾递归优化
- 递归调用是函数的最后一个操作
- 某些编译器可以优化为循环
- JVM不原生支持,需要手动改写
2. 记忆化搜索
- 用Map缓存已计算的结果
- 避免重复计算
- 时间复杂度从指数级降到线性
3. 改写为迭代
- 手动维护栈
- 空间复杂度从O(n)降到O(1)
- 代码更复杂,但更安全
4. 限制递归深度
- 设置最大递归深度
- 超过深度抛异常
- 防止完全崩溃// 记忆化搜索
public class FibonacciWithMemo {
private Map<Integer, Long> cache = new HashMap<>();
public long fibonacci(int n) {
if (n <= 1) return n;
if (cache.containsKey(n)) {
return cache.get(n);
}
long result = fibonacci(n - 1) + fibonacci(n - 2);
cache.put(n, result);
return result;
}
}
// 限制递归深度
public class SafeRecursiveService {
private static final int MAX_DEPTH = 100;
private int currentDepth = 0;
public void safeRecursive() {
if (currentDepth >= MAX_DEPTH) {
throw new BizException("递归深度超限,最大深度:" + MAX_DEPTH);
}
try {
currentDepth++;
doRecursive();
} finally {
currentDepth--;
}
}
private void doRecursive() {
// 业务逻辑
}
}#三、循环调用问题 🟡
#3.1 AOP循环调用
// 问题代码:AOP切面导致循环调用
@Aspect
@Component
@Slf4j
public class PerformanceAspect {
@Around("execution(* com.example..*Service.*(..))")
public Object measureTime(ProceedingJoinPoint point) throws Throwable {
long start = System.currentTimeMillis();
// 这里是关键:切面本身调用了Service方法
// 如果被切入的方法内部调用了另一个Service
// 而这个Service又触发了AOP
// 可能导致循环
// 正确的做法:不要在AOP切面中调用其他Service
Object result = point.proceed();
long cost = System.currentTimeMillis() - start;
if (cost > 1000) {
log.warn("方法执行慢: {} 耗时 {}ms", point.getSignature(), cost);
// 错误:不要在这里调用alertService,可能循环
// alertService.sendAlert(point.getSignature().toString());
}
return result;
}
}
// 正确做法:使用异步或消息队列通知
@Aspect
@Component
@Slf4j
public class SafePerformanceAspect {
@Autowired
private AlertProducer alertProducer; // 消息队列生产者
@Around("execution(* com.example..*Service.*(..))")
public Object measureTime(ProceedingJoinPoint point) throws Throwable {
long start = System.currentTimeMillis();
Object result = point.proceed();
long cost = System.currentTimeMillis() - start;
if (cost > 1000) {
// 正确:异步发送告警,不阻塞主流程
alertProducer.sendAsync(
"slow-method",
point.getSignature().toString(),
cost
);
}
return result;
}
}#3.2 Service互相调用
// 问题代码:Service互相调用
@Service
public class UserService {
@Autowired private OrderService orderService;
public void deactivateUser(Long userId) {
// 停用用户
userDao.updateStatus(userId, 0);
// 调用OrderService
orderService.cancelUserOrders(userId); // 这里会触发OrderService的逻辑
}
}
@Service
public class OrderService {
@Autowired private UserService userService;
public void cancelUserOrders(Long userId) {
// 取消用户所有订单
orderDao.cancelByUserId(userId);
// 错误:又调用回UserService
userService.deactivateUser(userId); // 死循环!
}
}
// 解决方案1:使用ApplicationContext获取代理对象
@Service
public class OrderService {
@Autowired private ApplicationContext applicationContext;
public void cancelUserOrders(Long userId) {
orderDao.cancelByUserId(userId);
// 不要调用userService,避免循环
}
}
// 解决方案2:提取公共方法到新Service
@Service
public class UserStatusService { // 新Service
@Autowired private UserDao userDao;
@Autowired private OrderDao orderDao;
// 统一的用户停用逻辑
public void deactivateUser(Long userId) {
userDao.updateStatus(userId, 0);
orderDao.cancelByUserId(userId);
}
}
// 解决方案3:使用@Lazy注解
@Service
public class OrderService {
@Autowired @Lazy private UserService userService;
}#3.3 事件驱动解耦
// 使用事件机制解耦,避免循环调用
@Service
public class UserService {
@Autowired private ApplicationEventPublisher publisher;
public void deactivateUser(Long userId) {
userDao.updateStatus(userId, 0);
// 发布事件,不直接调用
publisher.publishEvent(new UserDeactivatedEvent(userId));
}
}
@Service
public class OrderService {
@EventListener
public void handleUserDeactivated(UserDeactivatedEvent event) {
// 监听事件,异步处理
orderDao.cancelByUserId(event.getUserId());
}
}
// 或者使用消息队列
@Service
public class UserService {
@Autowired private MessageProducer producer;
public void deactivateUser(Long userId) {
userDao.updateStatus(userId, 0);
producer.send("user.deactivated", userId);
}
}
@Service
public class OrderService {
@RabbitListener(queues = "user.deactivated.queue")
public void handleUserDeactivated(Long userId) {
orderDao.cancelByUserId(userId);
}
}#四、线程栈配置 🟡
#4.1 线程栈大小配置
# JVM线程栈大小配置
# 设置线程栈为512KB
java -Xss512k -jar app.jar
# 设置线程栈为1MB(默认值)
java -Xss1m -jar app.jar
# 设置线程栈为2MB(适合深度递归)
java -Xss2m -jar app.jar
# 查看默认栈大小
java -XX:+PrintFlagsFinal -version | grep ThreadStackSize#4.2 线程数与栈大小关系
线程数与内存关系:
公式:线程总内存 = 线程数 × 线程栈大小
场景1:1000线程 × 1MB栈 = 1GB堆外内存
场景2:2000线程 × 1MB栈 = 2GB堆外内存
场景3:500线程 × 512KB栈 = 256MB堆外内存
建议:
- 正常业务:1线程 = 1MB栈足够
- 深度递归:1线程 = 2MB栈
- 高并发场景:减少栈大小以支持更多线程#4.3 线程泄漏排查
# 1. 查看线程数
jstack 12345 | grep "Thread" | wc -l
# 2. 查看线程堆栈
jstack 12345 > thread.log
# 3. 搜索WAITING/Blocked线程
grep -E "(WAITING|Blocked)" thread.log | head -50
# 4. 使用Arthas监控线程
# 线程概览
thread
# 查看最忙的线程
thread -n 5
# 查看指定线程
thread 123
# 查看死锁
thread -b#五、生产避坑 🟡
#5.1 栈溢出的五大坑
坑1:递归没有终止条件
问题:递归调用永不停止
场景:树遍历、佣金计算等
解决方案:
- 必须有明确的终止条件
- 终止条件必须能被满足
- 单元测试验证递归深度坑2:相互调用形成死循环
问题:Service A调用B,B调用A
场景:用户服务和订单服务互相调用
解决方案:
- 提取公共逻辑到独立Service
- 使用事件机制解耦
- 使用@Lazy打破循环坑3:线程栈配置过小
问题:正常递归也栈溢出
场景:-Xss256k + 1000层递归
解决方案:
- 根据业务配置合理的栈大小
- 递归场景用-Xss2m
- 或者改写为迭代坑4:AOP切面调用Service
问题:AOP切面中调用其他Service
场景:日志记录、监控埋点
解决方案:
- AOP切面不要调用业务Service
- 使用异步消息通知
- 使用ApplicationEventPublisher坑5:没有限制递归深度
问题:异常数据导致无限递归
场景:树结构有环、父子关系成环
解决方案:
- 设置最大递归深度
- 检测数据是否有环
- 使用迭代替代递归#5.2 栈溢出检查清单
代码规范:
- [ ] 递归必须有终止条件
- [ ] 终止条件必须能被满足
- [ ] 避免Service相互调用
- [ ] AOP切面不调用业务Service
- [ ] 深度递归改写为迭代
配置规范:
- [ ] 根据业务配置合理的栈大小
- [ ] 监控线程数
- [ ] 设置最大递归深度限制
测试规范:
- [ ] 单元测试验证递归正确性
- [ ] 压力测试验证栈深度
- [ ] 边界测试验证终止条件#六、真实面试回放 🟡
面试官:遇到过StackOverflowError吗?怎么排查的?
候选人(小张):遇到过。
当时是递归计算用户佣金,递归调用了几千层后栈溢出。
排查过程:先看日志,发现是StackOverflowError,然后jstack导出线程栈,一看调用链路全是同一个方法。
面试官:怎么解决?
小张:三个方向:
一是加终止条件。递归调用前判断父节点ID是否存在。
二是改写为迭代。用while循环代替递归。
三是限制递归深度。超过100层抛异常。
面试官:递归和迭代各适合什么场景?
小张:递归适合树形结构遍历、归并排序、分治算法。
迭代适合简单的线性遍历、计数类操作。
递归写法简单但有栈溢出风险,迭代更安全但代码复杂。
【面试官手记】
小张这场面试的亮点:
排查思路清晰:日志→jstack→分析调用链
知道多种解决方案
能说清楚递归和迭代的适用场景
栈溢出是P6工程师必备知识点,能完整回答的候选人,说明有扎实的基础。
栈溢出的核心是递归必须有出口。记住三个要点:
- 递归必须有终止条件:且终止条件必须能被满足
- 避免循环调用:Service之间不要相互调用,用事件解耦
- 合理配置栈大小:根据业务选择512K/1M/2M
递归是简洁优雅的写法,但也是栈溢出的重灾区。用递归前先问自己:递归深度最大是多少?终止条件是什么?