
最近在开发一个基于 Spring Boot 的在线学习平台时遇到了一个非常棘手的问题系统在特定场景下会间歇性地抛出NullPointerException导致部分用户的学习进度无法保存。更令人困惑的是这个异常并非每次操作都出现而是在高并发或特定数据组合下才“显灵”排查过程如同大海捞针团队内部戏称遇到了“玄学BUG”。经过一轮紧张的代码审查和日志分析我们最终定位到问题根源——集合操作中的并发修改异常与空指针的叠加效应。这个案例非常典型它涉及 Java 基础、Spring 事务管理以及高并发下的常见陷阱。本文将完整复盘此次排查与修复的全过程从问题现象、根因分析、最小复现案例到最终的解决方案与最佳实践。无论你是正在学习 Java 集合框架的初学者还是需要处理生产环境并发问题的资深开发者都能从中获得直接的代码参考和排查思路。1. 问题背景与核心概念当“显灵”的异常遇上“笨蛋”集合在深入代码之前我们需要厘清两个核心概念它们正是本次问题的“主角”。1.1 空指针异常NullPointerException, NPE这是 Java 开发中最常见的运行时异常。简单来说当你试图调用一个null引用对象的方法或访问其字段时JVM 就会抛出 NPE。它本身并不“玄学”其显灵往往是因为某些隐蔽的代码路径导致对象未被正确初始化。1.2 并发修改异常ConcurrentModificationException这个异常是理解本次问题的关键。它常在使用Iterator遍历集合如ArrayList,HashMap时如果集合的结构被非迭代器自身的修改操作例如直接调用add,remove改变迭代器就会抛出此异常。它在单线程中容易避免但在多线程环境下极易成为幽灵问题。在我们的场景中一个“笨蛋”操作即未考虑线程安全的集合操作在高并发下引发了ConcurrentModificationException而异常处理逻辑中的缺陷又间接导致了一个核心业务对象为null最终触发了 NPE。两个异常叠加使得问题现象变得飘忽不定。2. 环境准备与版本说明为了清晰复现和演示问题我们搭建以下最小化环境。你的实际项目版本可能不同但核心原理和解决方案是通用的。操作系统: macOS/Linux/Windows (不限)Java 版本: JDK 8 或 JDK 11 (本文示例基于 JDK 8)构建工具: Maven 3.6IDE: IntelliJ IDEA 或 Eclipse关键依赖: Spring Boot 2.3.x, Spring Framework 5.2.x示例项目结构:concurrent-bug-demo ├── src/main/java/com/example/demo │ ├── DemoApplication.java │ ├── service │ │ ├── LearningService.java // 存在问题的业务服务 │ │ └── FixedLearningService.java // 修复后的服务 │ └── controller │ └── TestController.java // 测试接口 ├── src/main/resources │ └── application.properties └── pom.xmlpom.xml 关键依赖:parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.3.12.RELEASE/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies3. 核心原理与问题代码拆解我们先来看导致问题的原始代码。这是一个模拟用户更新学习进度的方法。文件路径:src/main/java/com/example/demo/service/LearningService.javapackage com.example.demo.service; import org.springframework.stereotype.Service; import java.util.*; Service public class LearningService { // 问题1使用非线程安全的集合作为缓存且没有同步控制 private ListString userProgressCache new ArrayList(); /** * 模拟更新用户学习进度存在并发BUG的方法 * param userId 用户ID * param progress 进度数据 * return 更新后的进度列表 */ public ListString updateUserProgress(String userId, String progress) { // 问题2先检查再操作的典型非原子性操作 if (!userProgressCache.contains(userId)) { userProgressCache.add(userId : progress); } else { // 问题3遍历过程中直接修改原集合 for (int i 0; i userProgressCache.size(); i) { String item userProgressCache.get(i); if (item.startsWith(userId :)) { userProgressCache.set(i, userId : progress); // 直接修改 break; } } } // 问题4返回一个可能正在被其他线程修改的集合的引用 return new ArrayList(userProgressCache); // 这里看似安全但源头已污染 } /** * 一个“清理”过期进度的方法会被定时任务或另一个线程调用 */ public void cleanupOldProgress() { IteratorString iterator userProgressCache.iterator(); while (iterator.hasNext()) { String item iterator.next(); // 模拟一个清理条件例如进度完成时间超过1小时 if (item ! null item.endsWith(:finished)) { // 问题5在迭代器遍历过程中通过原集合删除元素 // 这会导致 iterator.next() 可能抛出 ConcurrentModificationException userProgressCache.remove(item); } } } // 获取当前缓存仅用于演示 public ListString getCache() { return userProgressCache; } }代码问题逐行分析:private ListString userProgressCache new ArrayList();ArrayList是非线程安全的。多个线程同时调用updateUserProgress和cleanupOldProgress时其内部结构如elementData,size可能处于不一致状态导致数据错乱、元素丢失或ArrayIndexOutOfBoundsException。if (!userProgressCache.contains(userId)) { ... }contains和随后的add操作不是原子的。线程A检查contains返回false后在执行add前线程B可能已经添加了相同的userId导致重复添加或覆盖。for循环内直接userProgressCache.set(...)在单线程下可行。但在多线程下如果其他线程在此时修改了userProgressCache例如cleanupOldProgress正在执行删除可能导致循环索引失效或元素错位。cleanupOldProgress方法中的Iterator与remove:这是引发ConcurrentModificationException的经典场景。iterator()返回的迭代器会记录集合的modCount修改次数。在迭代过程中如果通过原集合的remove(Object)方法而不是迭代器的iterator.remove()删除了元素modCount会增加但迭代器内部的expectedModCount未更新。下一次调用iterator.next()时两者不一致立即抛出ConcurrentModificationException。异常处理的缺失与NPE的诞生:当ConcurrentModificationException抛出时如果外层没有妥善处理例如被 Spring 的全局异常处理器吞掉或只打印日志updateUserProgress方法可能执行中断。这可能导致某个关键的业务状态未更新进而使得后续依赖该状态的方法收到了一个null值最终引发 NPE。这就是“显灵”的根源——NPE 是结果并发修改异常才是起因。4. 完整实战复现、诊断与修复4.1 创建测试接口复现问题我们创建一个简单的 HTTP 接口来模拟高并发请求。文件路径:src/main/java/com/example/demo/controller/TestController.javapackage com.example.demo.controller; import com.example.demo.service.LearningService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; RestController public class TestController { Autowired private LearningService learningService; GetMapping(/testConcurrentBug) public String testConcurrentBug() throws InterruptedException { ExecutorService executorService Executors.newFixedThreadPool(10); int taskCount 100; for (int i 0; i taskCount; i) { final int userId i % 5; // 5个用户重复请求 final String progress progress_ System.currentTimeMillis(); executorService.submit(() - { try { ListString result learningService.updateUserProgress(user_ userId, progress); // 模拟另一个线程同时清理 if (userId 0) { learningService.cleanupOldProgress(); } } catch (Exception e) { // 捕获并打印所有异常观察“显灵”现象 System.err.println(线程异常: e.getClass().getName() - e.getMessage()); } }); } executorService.shutdown(); executorService.awaitTermination(5, TimeUnit.SECONDS); return 测试完成。当前缓存: learningService.getCache(); } }启动 Spring Boot 应用访问http://localhost:8080/testConcurrentBug。观察控制台输出你很可能会看到ConcurrentModificationException和NullPointerException交替或同时出现完美复现了线上“玄学”BUG。4.2 诊断与排查思路当遇到此类间歇性异常时可按以下步骤排查查看完整异常栈不要只看最顶层的 NPE要找到最底层的Caused by。很可能第一个异常是ConcurrentModificationException。审查共享资源定位所有被多个线程访问的变量尤其是集合类List,Map,Set。检查迭代与修改审查所有遍历这些集合的代码for-each,Iterator,Stream看是否有在循环体内直接通过集合自身方法进行增删改的操作。分析线程模型确认是 Spring 的异步任务、Async、定时任务、还是 Web 请求的天然多线程导致的并发访问。4.3 解决方案修复问题代码针对上述问题我们提供一套完整的修复方案。方案一使用线程安全的集合类适用于读多写少且性能要求不极致的场景import java.util.*; import java.util.concurrent.CopyOnWriteArrayList; Service public class FixedLearningService1 { // 使用 CopyOnWriteArrayList写时复制避免并发修改异常 private ListString userProgressCache new CopyOnWriteArrayList(); public ListString updateUserProgress(String userId, String progress) { String newItem userId : progress; // 由于遍历 CopyOnWriteArrayList 是快照contains 和 add 之间的竞态条件仍需处理 // 更好的方式是使用 ConcurrentHashMap见方案二 boolean found false; for (String item : userProgressCache) { // 使用 for-each 安全 if (item.startsWith(userId :)) { found true; // CopyOnWriteArrayList.set 操作成本高且需要索引 // 这体现了方案一的局限性 break; } } if (!found) { userProgressCache.add(newItem); } // 返回副本避免外部修改 return new ArrayList(userProgressCache); } public void cleanupOldProgress() { // 使用迭代器安全删除 IteratorString iterator userProgressCache.iterator(); while (iterator.hasNext()) { String item iterator.next(); if (item ! null item.endsWith(:finished)) { iterator.remove(); // 关键使用迭代器的 remove 方法 } } } }优点CopyOnWriteArrayList的迭代器不会抛出ConcurrentModificationException。缺点set、add非尾部操作性能差因为涉及数组复制。且containsadd的竞态条件未根本解决。方案二使用 ConcurrentHashMap 重构推荐更符合本例业务场景我们的业务本质是用户ID到进度的映射Map结构比List更合适。package com.example.demo.service; import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; Service public class FixedLearningService2 { // 使用 ConcurrentHashMap键为用户ID值为进度字符串 private MapString, String userProgressMap new ConcurrentHashMap(); /** * 修复后的更新方法 - 线程安全且高效 */ public MapString, String updateUserProgressSafe(String userId, String progress) { // ConcurrentHashMap 的 put 操作是原子的直接覆盖或新增 userProgressMap.put(userId, progress); // 返回一个不可修改的副本防止调用方意外修改 return Collections.unmodifiableMap(new HashMap(userProgressMap)); } /** * 更复杂的更新逻辑仅当旧进度不是“finished”时才更新 */ public boolean updateIfNotFinished(String userId, String newProgress) { // 使用原子引用和循环实现 CAS 风格更新 AtomicReferenceBoolean result new AtomicReference(false); userProgressMap.compute(userId, (key, oldProgress) - { if (oldProgress null || !oldProgress.equals(finished)) { result.set(true); return newProgress; } result.set(false); return oldProgress; // 保持不变 }); return result.get(); } /** * 安全的清理方法 */ public void cleanupOldProgressSafe() { // 使用 ConcurrentHashMap 的 entrySet遍历是安全的 // 可以直接在遍历中调用 map.remove(key)但更推荐用迭代器 IteratorMap.EntryString, String iterator userProgressMap.entrySet().iterator(); while (iterator.hasNext()) { Map.EntryString, String entry iterator.next(); if (finished.equals(entry.getValue())) { iterator.remove(); // 使用迭代器安全删除 } } // 或者使用 Java 8 的 removeIf 方法更简洁 // userProgressMap.entrySet().removeIf(entry - finished.equals(entry.getValue())); } /** * 获取所有进度列表转换格式 */ public ListString getAllProgressAsList() { return userProgressMap.entrySet() .stream() .map(entry - entry.getKey() : entry.getValue()) .collect(Collectors.toList()); } // 初始化一些测试数据 PostConstruct public void init() { userProgressMap.put(user_1, progress_50); userProgressMap.put(user_2, finished); userProgressMap.put(user_3, progress_80); } }4.4 运行与验证修复效果修改TestController注入并使用FixedLearningService2再次运行并发测试。你会发现控制台异常消失数据一致性得到保证。5. 常见问题与排查清单问题现象可能原因排查步骤与解决方案间歇性NullPointerException1. 上游方法因并发异常未执行完。2. 依赖的 Bean 未正确注入如Autowired在非 Spring 管理类中使用。3. 从并发集合中获取的值被其他线程移除。1. 查看异常栈找到最初的异常通常是ConcurrentModificationException,TransactionException。2. 检查类是否被Component,Service等注解管理。3. 使用ConcurrentHashMap的get方法并处理null返回值。ConcurrentModificationException1. 单线程在for-each或Iterator循环中直接调用集合的add/remove。2. 多线程一个线程遍历另一个线程修改集合。1. 单线程改用Iterator.remove()或在循环外收集要删除的元素循环后统一删除。2. 多线程改用线程安全集合CopyOnWriteArrayList,ConcurrentHashMap或使用显式锁synchronized,ReentrantLock。集合数据丢失或重复非原子性的“检查-执行”操作Check-Then-Act。使用ConcurrentHashMap的putIfAbsent,compute,merge等原子方法。性能下降错误使用synchronized锁住大段代码或在高频写场景使用CopyOnWriteArrayList。1. 缩小同步代码块范围。2. 写多读少用ConcurrentHashMap代替CopyOnWriteArrayList。3. 考虑使用ReadWriteLock。6. 最佳实践与工程建议选择合适的并发容器ConcurrentHashMap默认首选适用于大部分 K-V 存储的并发场景。CopyOnWriteArrayList适用于读操作极其频繁写操作非常少的监听器列表、配置列表等。ConcurrentLinkedQueue适用于高效的并发队列。避免在任何多线程场景下直接使用ArrayList,HashMap,HashSet。使用原子操作与函数式API利用ConcurrentHashMap的compute,merge,putIfAbsent方法可以优雅地实现复杂的线程安全更新逻辑避免手动加锁。迭代器安全牢记永远不要在使用迭代器遍历时通过原集合的方法修改结构。要删除元素必须使用Iterator.remove()。返回防御性副本即使内部使用了线程安全集合对外返回时也应返回其副本如new ArrayList(internalList)或不可修改视图如Collections.unmodifiableList(...)。这可以防止调用方代码破坏你的内部不变性条件。明确线程边界在 Spring 项目中清楚每个 Bean 的作用域singleton,prototype,request,session。singletonBean 的属性是共享的必须考虑线程安全。对于Async方法、定时任务Scheduled、消息监听器要默认认为它们运行在并发环境下。日志与监控在多线程代码的关键路径上添加详细的 TRACE 或 DEBUG 级别日志有助于事后复盘。考虑使用ThreadLocal来传递请求上下文但要注意清理避免内存泄漏。通过本次对“玄学”BUG的深度剖析我们可以看到绝大部分看似“显灵”的线上问题其根源往往在于对基础原理的忽视尤其是在并发环境下。写出“笨蛋”代码不可怕可怕的是没有意识到它是“笨蛋”。建立牢固的并发编程意识善用 Java 并发工具包严格遵守最佳实践才能让你的系统在复杂环境下稳如磐石。下次当你遇到飘忽不定的 Bug 时不妨先从共享数据的线程安全角度入手排查或许就能快速擒获元凶。