大家好,我是小悟。
现在用AI辅助编程已成为常态。但你是否遇到过这样的情况:AI信心满满地给出了一段代码,运行后却报错;或者代码能跑,但结果明显不对?这篇文章将分享我用AI生成Java代码时遭遇的一个典型案例——一个看似简单的多线程数据聚合服务,从功能异常到性能优化的完整排查过程。
需求:实现一个服务,从多个数据源(模拟为HTTP接口)并发获取用户信息,然后聚合、去重、排序后返回。数据量不大(单次最多100个用户ID),但对响应时间有要求(<500ms)。
我给AI(以某常见大模型为例)的提示词摘要:
“用Java 8实现一个UserDataService,提供getUsers(List<Integer> userIds)方法。要求:对每个userId并发调用外部API获取用户详情,如果某个调用失败则重试1次,最后返回按userId升序排列的List<User>,重复的userId只保留一个。”
AI很快给出了完整的代码。看似工整,我稍作调整后便集成到项目中。
测试时发现,当传入的userId列表包含重复ID(如[101, 102, 101]),返回结果中101偶尔会出现两次。按理说已经在最后做了distinct(),为什么还会重复?
压测时,100个userId的响应时间稳定在1.2秒左右,完全超出500ms的指标。代码中用了CompletableFuture并发,理论上应该很快,问题出在哪?
写了一个单元测试,传入包含重复ID的列表,并循环调用100次。发现约20%的情况返回重复数据。同步现象:打印每次请求的时间戳,发现所有调用似乎“串行”了。
AI生成的核心代码片段(简化后)如下:
public List<User> getUsers(List<Integer> userIds) {
// 去重保留顺序
List<Integer> distinctIds = userIds.stream().distinct().collect(Collectors.toList());
List<CompletableFuture<User>> futures = distinctIds.stream()
.map(id -> CompletableFuture.supplyAsync(() -> fetchWithRetry(id)))
.collect(Collectors.toList());
// 等待全部完成
List<User> users = futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
// 去重(?)并排序
return users.stream()
.filter(Objects::nonNull)
.distinct() // 这里看起来多余但实际上没起作用
.sorted(Comparator.comparing(User::getUserId))
.collect(Collectors.toList());
}重复数据不是来自输入,而是在users列表中出现了相同userId的两个User对象。这意味着:
userId被调用了两次?fetchWithRetry对于同一个id返回了不同对象?添加日志后发现:并发执行时,同一个userId的多次请求被同时发起了。但我们的distinctIds已经去重了,怎么可能?
进一步检查fetchWithRetry的实现:
private User fetchWithRetry(Integer userId) {
int attempt = 0;
while (attempt <= 1) {
try {
return externalApi.fetchUser(userId);
} catch (Exception e) {
attempt++;
if (attempt > 1) throw new RuntimeException(e);
// 重试前等待
try { Thread.sleep(100); } catch (InterruptedException ie) {}
}
}
return null;
}单独看没问题。但结合调用上下文:AI没有考虑到 CompletableFuture.supplyAsync 默认使用 ForkJoinPool.commonPool(),而这个公共线程池在任务阻塞(如等待外部API、sleep)时,可能触发工作窃取并导致某些任务被重复执行?这个猜测方向其实错了——后来发现真正的原因更隐蔽。
真正的原因出现在两个地方:
externalApi.fetchUser内部使用了基于userId的缓存,但缓存的computeIfAbsent实现是非线程安全的(比如用了HashMap而非ConcurrentHashMap)。并发调用同一个userId时,多个线程几乎同时发现缓存缺失,然后各自调用真实API,并在之后put到非线程安全的Map中,可能导致数据错乱或重复插入。fetchWithRetry中加了Thread.sleep(100)做退避等待。在并发场景下,这个sleep不仅没有帮助,反而让ForkJoinPool的任务等待时间变长,配合默认并行度(CPU核数-1)不够大,导致大量任务被排队,整体执行时间大幅增加。用JMC(Java Mission Control)采样发现,超过60%的时间线程处于WAITING/TIMED_WAITING状态,主要源于:
Thread.sleep(100)在重试逻辑中CompletableFuture.join()的阻塞等待加上ForkJoinPool的默认并行度在我的机器上是7,但任务数量是100,线程切换和队列等待开销显著。
// 方案A:确保缓存线程安全
private final ConcurrentHashMap<Integer, User> cache = new ConcurrentHashMap<>();
public User fetchUser(Integer userId) {
return cache.computeIfAbsent(userId, id -> callRemoteApi(id));
}computeIfAbsent在ConcurrentHashMap中是原子的,保证同一key的第一次计算只执行一次。
放弃ForkJoinPool.commonPool(),使用自定义线程池,并根据任务特点调整参数:
private final ExecutorService executor = new ThreadPoolExecutor(
20, // corePoolSize 大于默认并行度
50,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200),
new ThreadPoolExecutor.CallerRunsPolicy()
);
public List<User> getUsers(List<Integer> userIds) {
List<Integer> distinctIds = userIds.stream().distinct().collect(Collectors.toList());
List<CompletableFuture<User>> futures = distinctIds.stream()
.map(id -> CompletableFuture.supplyAsync(() -> fetchUserSafely(id), executor))
.collect(Collectors.toList());
// 不直接join,使用超时控制
List<User> users = futures.stream()
.map(f -> f.get(800, TimeUnit.MILLISECONDS))
.filter(Objects::nonNull)
.collect(Collectors.toList());
// 此时数据已天然无重复(从源头保证),只需排序
return users.stream()
.sorted(Comparator.comparing(User::getUserId))
.collect(Collectors.toList());
}去掉固定sleep,改用指数退避 + 快速失败:
private User fetchUserSafely(Integer userId) {
User cached = cache.get(userId);
if (cached != null) return cached;
int retries = 1;
long waitMs = 50;
Exception lastException = null;
for (int i = 0; i <= retries; i++) {
try {
User user = externalApi.fetchUser(userId);
if (user != null) {
cache.put(userId, user);
return user;
}
} catch (Exception e) {
lastException = e;
if (i < retries) {
try { Thread.sleep(waitMs); } catch (InterruptedException ie) {}
waitMs *= 2; // 指数退避
}
}
}
throw new RuntimeException("Failed to fetch user " + userId, lastException);
}AI没有考虑到部分失败场景。增加降级逻辑:超时或失败的userId,返回默认User对象或跳过,不让整个请求失败。
// 在get()时使用超时,失败时记录日志并返回null
.map(f -> {
try {
return f.get(600, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
log.warn("Fetch user timeout");
f.cancel(true);
return null;
} catch (Exception e) {
log.error("Fetch failed", e);
return null;
}
})指标 | AI原始代码 | 优化后 |
|---|---|---|
重复数据概率 | ~20% | 0% |
平均响应时间(100 userIds) | 1180ms | 210ms |
P99响应时间 | 1650ms | 380ms |
并发安全 | 否 | 是 |
失败降级 | 无 | 有 |
AI经常默认使用ForkJoinPool.commonPool(),但它不适合I/O密集型任务(阻塞、等待多)。同样,AI喜欢在重试里加sleep,却在并发环境下放大延迟。
技巧:明确要求AI“使用自定义线程池,I/O密集型任务设置核心线程数较高(如CPU核数*2+1)”。
AI很容易写出HashMap + 缓存的模式,但几乎不会自动提醒线程安全问题。必须自己审查所有跨线程的共享变量。
技巧:在提示词中加入“请确保线程安全,使用ConcurrentHashMap或同步机制”。
AI写的代码编译通过不等于逻辑正确。重复数据问题就是典型——它用了.distinct(),但源头上已经产生重复,后续去重也无法挽救。
技巧:单测覆盖边界条件(重复输入、并发调用、部分失败)。
最初我怀疑是sleep导致,但通过JMC才定位到线程池调度和缓存竞争的复合问题。
技巧:准备一个性能测试基线,用工具(JMC、Async Profiler)替代直觉。
这是个反向技巧:把你优化后的代码再给AI,问“这段代码在高并发下有哪些潜在问题?请指出三个”。AI往往能发现你忽视的边界情况。
AI生成代码像是一个“热情但经验不足的结对程序员”——它速度快、不疲劳,但会依赖默认模式、忽视并发细节、对性能影响想得简单。作为开发者,我们的价值不是拒绝AI,而是:
当你从AI那里复制Java代码时,不妨先问自己三个问题:它真的并发安全吗?失败时系统表现如何?有没有我本应该讲清楚的约束条件? 答案会让你少踩很多坑。

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。
您的一键三连,是我更新的最大动力,谢谢
山水有相逢,来日皆可期,谢谢阅读,我们再会
我手中的金箍棒,上能通天,下能探海
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。