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层抛异常。

面试官:递归和迭代各适合什么场景?

小张:递归适合树形结构遍历、归并排序、分治算法。

迭代适合简单的线性遍历、计数类操作。

递归写法简单但有栈溢出风险,迭代更安全但代码复杂。

【面试官手记】

小张这场面试的亮点:

  1. 排查思路清晰:日志→jstack→分析调用链

  2. 知道多种解决方案

  3. 能说清楚递归和迭代的适用场景

栈溢出是P6工程师必备知识点,能完整回答的候选人,说明有扎实的基础。

栈溢出的核心是递归必须有出口。记住三个要点:

  1. 递归必须有终止条件:且终止条件必须能被满足
  2. 避免循环调用:Service之间不要相互调用,用事件解耦
  3. 合理配置栈大小:根据业务选择512K/1M/2M

递归是简洁优雅的写法,但也是栈溢出的重灾区。用递归前先问自己:递归深度最大是多少?终止条件是什么?