
说实话把代码丢给AI之前我对“AI代码审查”一直抱着半信半疑的态度。直到有天我接手一个2022年的Java老项目——Spring Boot 2.6、Java 8、MyBatis、Maven多模块代码量不大不小二十来个模块却没人敢随便动。我用AI做了一次完整体检它一口气挑出20个坑我逐条核验后真正值得动手修的只有15个。这篇文章就聊聊这20个坑是怎么被发现的又是怎么被我砍掉5个的。如果你手头也有一个没人愿意碰的遗留Java项目里面的方法和取舍思路应该能直接用上。1. 一个偶然的体检为什么我会把2022年的老项目交给AI过一遍1.1 技术债、人员离职和一句“先别动”的魔咒2022年听起来不算老但放在Java生态里Spring Boot 2.6、Java 8、MyBatis这套组合在2025年已经能感受到“历史气息”。这项目是核心交易链路的一部分代码里有不少“前任”留下的痕迹有的类上千行有的字段名缩写到必须靠猜注释基本靠commit message。更麻烦的是最近两年里负责过它的人走马灯一样换了三四个核心的那位已经离职走之前只留下一句这里逻辑很绕先别动。“先别动”三个字让后面接手的人连改bug都战战兢兢。但代码是必须得看的于是我想试试AI。老实说之前团队里也有人用AI写代码、写测试但把整个老项目交给AI做代码审查大家第一反应都是它连业务上下文都不懂能看出什么事实证明我低估了AI在“找雷”这件事上的耐心和视野。AI没有历史包袱。它不会因为你上个月刚改过某个模块就手下留情也不会因为某个类被老板夸过就绕着走。我一个一个模块喂进去它能做到二十几个模块一视同仁地过一遍它记性也比我好不会看到第15个文件就忘了第3个文件里的调用方式。更重要的是它能回答“为什么”和“改哪里”这比我翻遍git log然后靠猜要快得多。1.2 给AI设定边界不是让它重写而是让它找雷拿到AI结果之前我先把边界想清楚了这次审查的目标不是“让AI把项目重构成Spring Boot 3”也不是“让AI出一份架构评审报告”而是聚焦一个动作——找雷。为什么这么定位老项目最怕的不是修不完的坑而是有人借着一堆整改建议把整个技术栈推倒重来。AI如果放开了提建议大概率会告诉你升级到Java 17、换Spring Boot 3、引入新的ORM、把Controller层拆成六层架构。这些建议对不对对但对一个还在稳定运行的核心业务模块来说属于“正确但危险”的建议。所以我从一开始就告诉AI你是资深Java代码审查员只报告问题、影响和修复建议不改代码不做重构规划。这个“限定语”非常重要。AI自由发挥的空间缩小了输出的可执行性反而提高了。后面我会把提示词的具体写法展开讲这里先记住一个结论AI审查老项目定位越窄产出越准。后续所有工作都建立在“体检报告不是判决书”这个心态上。AI列出的问题它负责“怀疑”我负责“定罪”。2. 喂给AI的料决定它的准头审查前的准备工作2.1 工具选型我用的审查链路和为什么这样组合现在聊聊准备阶段。很多人以为AI代码审查就是把代码拖进对话框说一句“帮我看看有没有bug”然后坐等结果——我第一次也这么干过效果惨不忍睹。AI会给出一些“正确的废话”比如“建议提高代码可读性”或者反过来把正常写法当成问题列出来。所以我采用的不是单一AI而是一条三层链路第一层静态扫描工具。SonarQube、SpotBugs这类工具跑了整整一遍它们负责抓显式规则问题未关闭的资源、明显的NPE路径、可疑的空catch、重复代码。这些工具在明确规则上比AI更稳定因为它们就是靠规则引擎吃饭的。第二层IDE自带的Inspections。IntelliJ IDEA的代码检查会给出很多工程建议比如方法过长、参数过多、可以提取变量。这一层噪音很多但胜在零成本跑完后我只看高优先级。第三层AI语义级审查。这才是主角。静态工具擅长“按图索骥”但对跨方法、跨类的“设计气味”无能为力——比如事务自调用、缓存没有兜底、并发场景下的集合使用这些都需要理解业务意图AI能从一个更高的视角把代码串起来看。这三层不是替代关系而是“漏斗”关系。静态工具负责兜住最基础的雷IDE检查负责提醒工程规范AI负责判断那些藏在语义和调用关系里的深水区。跑完三层我再把结果合并去重得到一份带“疑似问题”标签的清单。2.2 上下文投喂让AI听懂这个项目的“方言”接下来是最关键的一步怎么把项目背景喂给AI。这一步决定了AI是“看代码”还是“看懂代码”。我第一次只发了几个类文件AI的反馈基本是在猜。后来我总结了四个必给的上下文项目技术栈Java版本、Spring Boot版本、MyBatis还是JPA、Maven还是Gradle、有没有用Spring Cloud。这些决定了AI对“老项目”的判断基准。目录结构不需要每个文件都列给顶层模块和核心包结构就够了。AI需要知道这是一个多模块项目哪些模块是入口哪些是基础设施。本次扫描范围这次只审某个交易模块还是整个仓库我只审了核心的二十几个模块与订单、支付无关的代码一概不看。已有约束比如“项目目前运行在JDK 8不能引入Java 11以上API”“数据库是MySQL不能用Oracle方言”“团队约定用Lombok”。约束给得越具体AI的误报越少。我用的提示词模板大致长这样你是一名有15年经验的Java代码审查员。以下是一个项目的技术栈和目录结构 技术栈Java 8 Spring Boot 2.6 MyBatis 3.5 Maven数据库MySQL。 扫描范围order、payment、user三个模块其余模块不需要分析。 请重点检查空指针风险、并发安全问题、资源泄漏、SQL注入、异常处理、过时API使用。 对每个问题按以下格式输出 - 文件路径与行号 - 问题类型 - 触发场景什么情况下会触发 - 修复建议 只报告真实风险不要给出重构方案不要修改我的代码。这段话的作用很直白角色设定资深审查员压缩了AI说废话的空间范围限定三个模块防止它天马行空输出格式固定字段让结果可以直接进入任务管理。实测下来同样的代码给了这个上下文和没给结果质量差了一个量级。2.3 范围裁剪不把时间浪费在格式化噪音上还有一个很容易被忽略的点范围裁剪。老项目里通常躺着大量“你不想要AI分析”的代码。比如target目录、front-end生成的静态文件、测试用例、第三方SDK的封装层、历次升级留下的临时工具类。我第一次全量投喂时AI花了大量篇幅分析一些无关紧要的工具方法还差点把一个自动生成的DTO文件当成业务代码来报。后来我把排除项写死忽略所有generated、target、test目录忽略所有以Mapper.xml结尾的MyBatis映射文件这些文件AI经常误判SQL实际人工写的时候都经过严格校验只保留src/main/java下的业务代码。另一个经验是分批审查。AI上下文窗口有限一次塞太多文件它就会开始“平均用力”前面分析得很细后面越来越泛。我按模块拆成五批每批大概5个类文件跑完再汇总。虽然次数多了但每一批的精度都高得多。这背后的道理和人工审查一样一次看50个文件和一次只看5个文件后者的注意力和判断力完全不在一个水平。这一章里所有的准备目标只有一个让AI把精力花在真正的业务代码上而不是被老项目里的历史垃圾干扰。3. 20个坑的完整清单从“AI觉得有问题”到“我确认有问题”3.1 第一类空指针和NPE隐患AI最擅长的项目准备做完进入正题。AI挑出的20个坑我按严重程度和类型分成了五类。第一类是空指针数量最多一共3个。老项目里的NPE往往不是那种一眼能看出来的“直接调用null对象的方法”而是藏在接口入口、Map取值和批量参数里。举个典型例子User user userMapper.selectById(userId); if (user.getStatus() 1) { // AIuser可能为null这里会NPE return active; }这种代码在老项目里太常见了。调用方觉得“传进来的ID一定有对应记录”但实际业务里ID可能来自前端、可能来自历史脏数据、也可能来自另一个已经失效的服务。AI对这类问题的判断非常准因为它只需要沿着调用关系查一遍就能发现“没有判空就取属性”这个路径。另一个例子是Map取值MapString, Object row jdbcTemplate.queryForMap(sql, id); String name row.get(name).toString(); // row.get(name)可能为nullNPEMyBatis时代很多老开发习惯把结果塞进Map省得定义VO取的时候又图省事直接链式调用。AI第一遍就精准锁定了这三处NPE我逐一确认后全部采纳。修复方式也没什么技术含量入口处加判空Map取值时先判断containsKey或直接用Optional包装。难的不是修是AI能准确告诉你坑在哪个文件的哪一行。3.2 第二类集合与并发老项目最容易藏雷的地方第二类是集合和并发问题一共5个数量不多但每个都值得重视。这里挑三个典型的展开说。第一个是在for-each循环里删除元素for (User user : userList) { if (user.getAge() 30) { userList.remove(user); // 触发ConcurrentModificationException } }这段代码在单线程、数据量小的情况下可能一辈子都不出错但只要有一次遍历中被修改就抛异常。AI给出的修复建议是使用Iterator的remove方法或者直接用removeIf。这个坑几乎每个Java老项目都有静态工具也能抓到但AI能把“为什么不能这么删”讲得让新人也能看懂。第二个是共享SimpleDateFormat。这个更隐蔽private static final SimpleDateFormat SDF new SimpleDateFormat(yyyy-MM-dd); public String formatDate(Date date) { return SDF.format(date); // 多线程共享线程不安全 }SimpleDateFormat不是线程安全的老项目里很多人图方便把它定义成static常量一旦并发调用就会在parse时得到错乱的结果甚至抛NumberFormatException。这个坑在测试阶段很难暴露因为单测很少会起多个线程去格式化同一个格式化器。AI能够识别出来是因为它会沿着“static字段多线程调用”这条线做语义推理。第三个是HashMap的并发自增逻辑private final MapString, Integer countMap new HashMap(); public void incr(String key) { Integer val countMap.get(key); countMap.put(key, val null ? 1 : val 1); // 并发下会丢更新 }如果只有一个线程调用incr这段代码没问题。但在我的项目里countMap可能被多个请求同时更新丢更新虽然不致命但数据不对。修复方式也很简单用ConcurrentHashMap或者用merge方法。AI把这一类问题归入“并发安全”并特别标注了“触发概率中等影响程度高”我一看就知道值得改。剩下的两个并发坑一个是双重检查锁单例缺volatile我直接采纳另一个是List转数组的低效写法我放在后面讲为什么不修。3.3 第三类资源泄漏与异常吞噬老项目里的慢性病第三类是资源泄漏和异常吞噬一共4个属于那种“平时不出事出事就是大新闻”的慢性病。资源泄漏的典型代表是文件流没关InputStream in null; try { in new FileInputStream(file); // 处理文件... } finally { // 没有关闭in }这段代码在低并发下可能感觉不到问题但只要调用量上来文件句柄就会被耗尽Windows上表现为“文件被占用”Linux上可能直接触发“Too many open files”。AI和静态工具都能抓到这一类但AI给出的修复建议更完整try-with-resources。它甚至自动帮你算好了try块的作用域避免人工改的时候把关闭语句放错位置。另一个同类问题是JDBC连接没有释放。老项目里手写JDBC的场景不少尤其在报表导出这类“一次性”任务里Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery(); // 没有finally连接一直不还这种代码在连接池不够大的时候几个线程就能把连接池打爆。AI的修复建议是标准的try-with-resources嵌套把rs、ps、conn按从内到外的顺序关闭。异常吞噬更常见也更隐蔽。空catchtry { paymentService.pay(order); } catch (Exception e) { // 什么都不做连日志都没有 }AI管这个叫“exception swallowing”翻译过来就是异常吞噬。它在元数据里标注的触发场景是“支付失败后静默返回成功”我一看就后背发凉。异常被吞掉意味着上层完全不知道发生了什么只能靠线上对账发现问题。修复方式很简单要么打日志要么抛出或返回错误码。这种“看起来不影响功能”的问题恰恰是老项目最危险的雷。还有一个异常吞噬的变种catch之后只打了一行堆栈然后继续往下执行。AI的评论是“可能让后续代码在错误状态下运行导致更隐蔽的故障”。我同样采纳了把它改成抛出一个带业务语义的异常。3.4 第四类SQL拼接、过时API与悄然失效的逻辑第四类包含SQL注入、过时API和几处悄然失效的逻辑一共8个占了大头。SQL注入是最不能忍的String sql SELECT * FROM user WHERE name name ;我的项目里确实有一处这样的旧代码可能是某次为了快速加功能而留下的。MyBatis时代这种写法并不少见有些外包项目甚至整张Mapper文件都用拼接。AI在报告里写得非常直白name如果来自用户输入可以直接拼出“OR 11”之类的注入语句。修复方式就是改成参数绑定#{name}或者PreparedStatement的?占位符。这个坑没有任何犹豫必改。过时API的问题AI列了两个。一个是new BigDecimal(double)BigDecimal price new BigDecimal(0.1d); // 实际值0.1000000000000000055511151231257827021181583404541015625单价计算如果用这个构造器会在金额上埋雷。AI建议改成new BigDecimal(0.1)或BigDecimal.valueOf(0.1)。另一个是String.getBytes()不指定字符集byte[] bytes str.getBytes(); // 依赖平台默认字符集如果把字符串转字节再转回来开发机是UTF-8没事生产环境可能是GBK就会出现神秘乱码。这种问题属于“环境切换才会爆发”的定时炸弹我同样采纳了全部改成显式的StandardCharsets.UTF_8。悄然失效的逻辑典型的是这一处——事务自调用public void batchCreate(ListOrder orders) { for (Order order : orders) { createOrder(order); // 这里的this.createOrder是自调用 } } Transactional public void createOrder(Order order) { orderMapper.insert(order); }batchCreate调用createOrder时事务注解并没有生效因为它走的是this引用绕过了Spring的代理。这是老项目里经常出现的“看起来有事务实际没有”的问题。AI重点标注了这一点但我在最终复核时没有把它列入必修原因后面会说。还有一处是缓存兜底逻辑User user userMapper.selectById(id); if (user ! null) { cache.put(id, user); } return user null ? fallback() : user;AI建议对null也做缓存避免缓存穿透。但这个判断涉及业务对“查不到用户时应该返回什么”的约定不能只凭代码逻辑定论。我把它保留在“待确认”清单没有直接开工。现在把20个坑完整列一下顺便标出我的最终决定编号问题类型典型场景AI建议我的决定1NPE查询用户未判空入口判空修2NPEMap取值链式调用判空或Optional修3NPE批量参数含null参数校验修4集合误用for-each中removeIterator/removeIf修5并发安全HashMap并发自增ConcurrentHashMap修6低效写法toArray(new String[size])toArray(new String[0])不修7并发安全SimpleDateFormat共享ThreadLocal或DateTimeFormatter修8并发安全双重检查锁缺volatile加volatile修9资源泄漏InputStream未关闭try-with-resources修10资源泄漏JDBC连接未释放try-with-resources修11异常吞噬空catch打日志或抛出修12异常吞噬catch后继续执行返回错误码修13SQL注入字符串拼接参数绑定修14精度问题BigDecimal(double)BigDecimal.valueOf修15字符集问题getBytes()无参数UTF-8显式指定修16日志规范占位符数量不匹配修正占位符不修17缓存穿透查无结果不缓存null缓存null不修18事务失效自调用Transactional拆分代理调用不修19循环依赖构造器注入互相依赖重构依赖不修20安全配置数据库密码硬编码配置中心或环境变量修15个“修”5个“不修”。接下来聊聊被我枪毙的5个这才是“老炮老在哪”的部分。4. 老炮只认15个那5个坑为什么被我“枪毙”了4.1 误报型AI没看到的运行时配置第一个被枪毙的是19号循环依赖。AI看到AService和BService的构造器互相注入立刻报了“循环依赖启动可能失败”。但项目在Spring Boot 2.6上跑得好好的原因是配置里开了spring.main.allow-circular-referencestrue而且这个开关是核心负责人离开前特意开的。AI不知道这个配置它只看代码所以它的结论是“可能”不是“必然”。我核对了启动日志和配置中心确认没有实际风险就把它挂起。这不是AI不聪明是它的上下文里缺少运行时信息。6号toArray也类似。AI建议从new String[size]改成new String[0]理由是在并发环境下更稳妥。我承认它说得有道理但这个类被调用频率很低而且传参构造在JDK 8下运行多年都没有出过问题。修复成本确实很低可收益也趋近于零。对老项目来说“没坏就不修”也是一种判断力。4.2 低收益型改了可能引入回归收益却趋近于零16号日志占位符数量不匹配AI是对的log.error(user{}, order{}, user)确实少了一个参数。但SLF4J对这个情况有一套兜底逻辑最多是日志里出现个{},不影响功能。我把所有日志点翻了一遍确认没有日志顺序错乱导致的误判就决定不单独排期改。这种问题属于“顺手改可以专门修不值”。17号缓存穿透不修的原因更实际AI建议把null也缓存起来但业务上如果用户不存在后续模块会走一套兜底逻辑。缓存null会让这套兜底逻辑不再执行改完可能引发新的业务问题。所以这不是技术问题是业务决策问题必须业务方拍板。在没人拍板的情况下“先不动”最安全。18号事务自调用不修是因为它确实存在但触发场景只在batchCreate里调用createOrder这一条路径。我查了代码项目里其他地方调用createOrder都是走外部入口事务是生效的。真正要修得把createOrder拆到另一个Bean里或者用AopContext.currentProxy()动的是核心支付链路风险不小。我把它标成“观察项”等下次有需求顺路改而不是现在为了一个低频场景去动主动脉。4.3 判断该不该修一个简单的风险值模型把5个坑枪毙之后我反思了一下自己的判断标准。老项目里的代码审查本质上是风险管理不是代码洁癖。我最后用的模型其实很朴素风险值 触发概率 × 影响程度 × 修复成本成本取倒数触发概率高、影响大、修复成本低的问题必须修比如SQL注入和资源泄漏。触发概率低、影响有限或修复成本高的问题标记观察比如事务自调用。AI擅长帮我们穷举“可能的坑”但“哪个坑值得填”这件事仍然依赖人对业务和运行环境的理解。这也是为什么标题里我说“老炮只认15个”——不是否认另外5个的合理性而是知道精力应该花在哪里。5. 从“挑出坑”到“填平坑”审查结论如何落地不翻车5.1 分级、排期、分派而不是“明天全改”15个坑确认后我做的第一件事不是改代码而是把任务分级。我的分级很简单P0上线阻塞SQL注入、数据库密码硬编码、资源泄漏、共享SimpleDateFormat。这四个如果线上出了问题要么是安全事件要么是性能故障必须在一个迭代内解决。P1业务正确性NPE、HashMap并发自增、双重检查锁、catch后继续执行。这些会导致偶发故障或数据不对优先安排。P2长期健康BigDecimal精度、getBytes字符集、for-each删除、空catch、Map取值判空。这些多数是隐性成本不急着一次清完但排期上要有名分。列完优先级再把任务分给对应模块的owner。老项目修坑最忌讳一个人闷头改因为你不知道哪个模块最近有发布计划、哪个模块周末要跑批。让owner参与排期能避开很多冲突。5.2 每个修复都要有回归验证AI的建议不能盲信修坑阶段我给团队定了一条规矩每个修复必须绑定一个验证方式。资源泄漏的修复就在修复后跑一遍压力测试观察文件句柄数和连接池曲线。SQL注入的修复就用安全扫描工具扫一次确认没有新的注入点。NPE的修复就是补一个针对空参数的单元测试。理由很简单AI给的修复建议大多数是对的但它是基于代码静态推理不是基于运行结果。我见过一次AI建议把new Date()改成LocalDateTime.now()改完发现接口返回类型从Date变成了LocalDateTime导致前端解析全部出错。不是AI错了是它在修复建议里没有考虑上下游契约。所以我的做法是AI建议当参考真正的验收标准是回归测试和现有测试套件跑绿。改完一个跑一遍相关模块的测试再提交——这个顺序不能省。老项目的坑改错了比不改更伤。5.3 把这套流程沉淀成团队约定后续新代码怎么防15个坑修完项目确实清爽了不少但我最在意的不是这一次修了多少而是下个月会不会又长出一样的坑。所以我把这次AI挑出来的问题归类成了一页纸的代码审查清单NPE判空、集合遍历修改、并发容器选择、资源关闭、异常日志、SQL参数绑定、字符集显式指定、配置不硬编码。这份清单后来直接变成了团队合并请求的“AI审查提示词”。新代码提交时我们会跑一个轻量的AI增量审查只盯这次改动涉及的文件不去扫整个仓库。增量审查比全量审查便宜得多反馈速度也快基本上提交后五分钟内能拿到结果。这轮做完最大的体会是AI给老项目做体检不是一次性的“治病”而是一次把体检标准建立起来的机会。老项目的坑不会一天填完但只要审查链路在下次再有人接手至少不会两眼一抹黑。最后再分享一个小技巧如果你也是第一次给老项目做AI审查别让AI“全面分析”而是给它一个最小范围和一个明确输出格式。宁可多跑几批也别一次把所有代码塞进去。20个坑里有一半是AI在“小步快跑”模式下才找出来的。反正我现在接手任何老项目第一件事就是先把上下文喂明白然后让它找雷剩下的我来拍板。