news 2026/8/8 8:25:36

Spring Boot高并发下集合操作引发的NullPointerException排查与修复

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot高并发下集合操作引发的NullPointerException排查与修复

最近在开发一个基于 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.6+
  • IDE: 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.xml

pom.xml 关键依赖:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.12.RELEASE</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>

3. 核心原理与问题代码拆解

我们先来看导致问题的原始代码。这是一个模拟用户更新学习进度的方法。

文件路径:src/main/java/com/example/demo/service/LearningService.java

package com.example.demo.service; import org.springframework.stereotype.Service; import java.util.*; @Service public class LearningService { // 问题1:使用非线程安全的集合作为缓存,且没有同步控制 private List<String> userProgressCache = new ArrayList<>(); /** * 模拟更新用户学习进度(存在并发BUG的方法) * @param userId 用户ID * @param progress 进度数据 * @return 更新后的进度列表 */ public List<String> 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() { Iterator<String> 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 List<String> getCache() { return userProgressCache; } }

代码问题逐行分析:

  1. private List<String> userProgressCache = new ArrayList<>();

    • ArrayList是非线程安全的。多个线程同时调用updateUserProgresscleanupOldProgress时,其内部结构(如elementData,size)可能处于不一致状态,导致数据错乱、元素丢失或ArrayIndexOutOfBoundsException
  2. if (!userProgressCache.contains(userId)) { ... }

    • contains和随后的add操作不是原子的。线程A检查contains返回false后,在执行add前,线程B可能已经添加了相同的userId,导致重复添加或覆盖。
  3. for循环内直接userProgressCache.set(...)

    • 在单线程下可行。但在多线程下,如果其他线程在此时修改了userProgressCache(例如cleanupOldProgress正在执行删除),可能导致循环索引失效或元素错位。
  4. cleanupOldProgress方法中的Iteratorremove:

    • 这是引发ConcurrentModificationException的经典场景。iterator()返回的迭代器会记录集合的modCount(修改次数)。在迭代过程中,如果通过原集合的remove(Object)方法(而不是迭代器的iterator.remove())删除了元素,modCount会增加,但迭代器内部的expectedModCount未更新。下一次调用iterator.next()时,两者不一致,立即抛出ConcurrentModificationException
  5. 异常处理的缺失与NPE的诞生:

    • ConcurrentModificationException抛出时,如果外层没有妥善处理(例如,被 Spring 的全局异常处理器吞掉或只打印日志),updateUserProgress方法可能执行中断。这可能导致某个关键的业务状态未更新,进而使得后续依赖该状态的方法收到了一个null值,最终引发 NPE。这就是“显灵”的根源——NPE 是结果,并发修改异常才是起因。

4. 完整实战:复现、诊断与修复

4.1 创建测试接口复现问题

我们创建一个简单的 HTTP 接口来模拟高并发请求。

文件路径:src/main/java/com/example/demo/controller/TestController.java

package 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 { List<String> 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。观察控制台输出,你很可能会看到ConcurrentModificationExceptionNullPointerException交替或同时出现,完美复现了线上“玄学”BUG。

4.2 诊断与排查思路

当遇到此类间歇性异常时,可按以下步骤排查:

  1. 查看完整异常栈:不要只看最顶层的 NPE,要找到最底层的Caused by。很可能第一个异常是ConcurrentModificationException
  2. 审查共享资源:定位所有被多个线程访问的变量,尤其是集合类(List,Map,Set)。
  3. 检查迭代与修改:审查所有遍历这些集合的代码(for-each,Iterator,Stream),看是否有在循环体内直接通过集合自身方法进行增删改的操作。
  4. 分析线程模型:确认是 Spring 的异步任务、@Async、定时任务、还是 Web 请求的天然多线程导致的并发访问。
4.3 解决方案:修复问题代码

针对上述问题,我们提供一套完整的修复方案。

方案一:使用线程安全的集合类(适用于读多写少,且性能要求不极致的场景)

import java.util.*; import java.util.concurrent.CopyOnWriteArrayList; @Service public class FixedLearningService1 { // 使用 CopyOnWriteArrayList,写时复制,避免并发修改异常 private List<String> userProgressCache = new CopyOnWriteArrayList<>(); public List<String> 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() { // 使用迭代器安全删除 Iterator<String> iterator = userProgressCache.iterator(); while (iterator.hasNext()) { String item = iterator.next(); if (item != null && item.endsWith(":finished")) { iterator.remove(); // 关键!使用迭代器的 remove 方法 } } } }
  • 优点CopyOnWriteArrayList的迭代器不会抛出ConcurrentModificationException
  • 缺点setadd(非尾部)操作性能差,因为涉及数组复制。且contains+add的竞态条件未根本解决。

方案二:使用 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 Map<String, String> userProgressMap = new ConcurrentHashMap<>(); /** * 修复后的更新方法 - 线程安全且高效 */ public Map<String, 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 风格更新 AtomicReference<Boolean> 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),但更推荐用迭代器 Iterator<Map.Entry<String, String>> iterator = userProgressMap.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<String, String> entry = iterator.next(); if ("finished".equals(entry.getValue())) { iterator.remove(); // 使用迭代器安全删除 } } // 或者使用 Java 8+ 的 removeIf 方法,更简洁 // userProgressMap.entrySet().removeIf(entry -> "finished".equals(entry.getValue())); } /** * 获取所有进度列表(转换格式) */ public List<String> 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. 使用ConcurrentHashMapget方法,并处理null返回值。
ConcurrentModificationException1. 单线程:在for-eachIterator循环中直接调用集合的add/remove
2. 多线程:一个线程遍历,另一个线程修改集合。
1. 单线程:改用Iterator.remove()或在循环外收集要删除的元素,循环后统一删除。
2. 多线程:改用线程安全集合(CopyOnWriteArrayList,ConcurrentHashMap)或使用显式锁(synchronized,ReentrantLock)。
集合数据丢失或重复非原子性的“检查-执行”操作(Check-Then-Act)。使用ConcurrentHashMapputIfAbsent,compute,merge等原子方法。
性能下降错误使用synchronized锁住大段代码,或在高频写场景使用CopyOnWriteArrayList1. 缩小同步代码块范围。
2. 写多读少用ConcurrentHashMap代替CopyOnWriteArrayList
3. 考虑使用ReadWriteLock

6. 最佳实践与工程建议

  1. 选择合适的并发容器

    • ConcurrentHashMap:默认首选,适用于大部分 K-V 存储的并发场景。
    • CopyOnWriteArrayList:适用于读操作极其频繁,写操作非常少的监听器列表、配置列表等。
    • ConcurrentLinkedQueue:适用于高效的并发队列。
    • 避免在任何多线程场景下直接使用ArrayList,HashMap,HashSet
  2. 使用原子操作与函数式API

    • 利用ConcurrentHashMapcompute,merge,putIfAbsent方法,可以优雅地实现复杂的线程安全更新逻辑,避免手动加锁。
  3. 迭代器安全

    • 牢记:永远不要在使用迭代器遍历时,通过原集合的方法修改结构。要删除元素,必须使用Iterator.remove()
  4. 返回防御性副本

    • 即使内部使用了线程安全集合,对外返回时,也应返回其副本(如new ArrayList<>(internalList))或不可修改视图(如Collections.unmodifiableList(...))。这可以防止调用方代码破坏你的内部不变性条件。
  5. 明确线程边界

    • 在 Spring 项目中,清楚每个 Bean 的作用域(singleton,prototype,request,session)。singletonBean 的属性是共享的,必须考虑线程安全。
    • 对于@Async方法、定时任务(@Scheduled)、消息监听器,要默认认为它们运行在并发环境下。
  6. 日志与监控

    • 在多线程代码的关键路径上添加详细的 TRACE 或 DEBUG 级别日志,有助于事后复盘。
    • 考虑使用ThreadLocal来传递请求上下文,但要注意清理,避免内存泄漏。

通过本次对“玄学”BUG的深度剖析,我们可以看到,绝大部分看似“显灵”的线上问题,其根源往往在于对基础原理的忽视,尤其是在并发环境下。写出“笨蛋”代码不可怕,可怕的是没有意识到它是“笨蛋”。建立牢固的并发编程意识,善用 Java 并发工具包,严格遵守最佳实践,才能让你的系统在复杂环境下稳如磐石。下次当你遇到飘忽不定的 Bug 时,不妨先从共享数据的线程安全角度入手排查,或许就能快速擒获元凶。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/8 8:23:17

2026主流开源商城源码横向测评|6款可二开电商系统适配场景深度对比

导读&#xff1a;电商系统开发选型&#xff0c;核心痛点从来不是“缺源码”&#xff0c;而是选到适配自身业务、技术团队、长期迭代的开源框架。市面上大量开源商城存在架构老旧、停止维护、二开难度高、商用功能阉割等问题&#xff0c;极易导致项目烂尾。本文从技术架构、迭代…

作者头像 李华
网站建设 2026/8/8 8:23:14

一流的项目经理,决不触碰这五大管理禁忌

很多企业做项目时&#xff0c;都有这样的经历&#xff1a; 项目刚启动&#xff0c;大家信心满满&#xff0c;负责人安排分工&#xff1a; “目标明确&#xff0c;按计划推进。” 团队成员也纷纷回应&#xff1a; “没问题&#xff0c;可以完成。” 但真正执行一段时间后&#…

作者头像 李华
网站建设 2026/8/8 8:21:23

施工企业采购申请与预算软件测评:蓝燕云采购管理控制

在工程项目的采购管理中&#xff0c;采购申请是连接需求与执行的关键环节&#xff0c;也是预算控制的重要关口。施工班组或项目部门提出采购需求后&#xff0c;需要经过审批确认&#xff0c;方能进入询价和采购流程。这一环节若控制不严&#xff0c;可能出现随意采购、预算超支…

作者头像 李华
网站建设 2026/8/8 8:18:46

2006-2026年《新闻联播》日度文本数据集

数据概览 《新闻联播》自1978年开播以来&#xff0c;一直是国内最具权威性的时政新闻节目&#xff0c;其内容经过严格筛选与审核&#xff0c;涵盖国家政策发布、重大事件报道、社会动态跟踪等多个维度&#xff0c;是研究中国政治、经济与社会变迁的重要一手文本资料。 本数据…

作者头像 李华
网站建设 2026/8/8 8:17:18

鸿蒙系统ADB连接与调试全攻略:从环境配置到实战命令

1. 项目概述&#xff1a;为什么需要连接鸿蒙系统&#xff1f; 作为一名长期与各种移动操作系统打交道的开发者&#xff0c;我经常需要深入设备内部进行调试、抓取日志、安装测试应用或者提取数据。对于华为的HarmonyOS&#xff08;鸿蒙系统&#xff09;&#xff0c;这种需求同样…

作者头像 李华
网站建设 2026/8/8 8:16:26

React Ant Design 5.x 企业级实战:从配置到性能优化的完整指南

1. 项目概述&#xff1a;为什么是Ant Design&#xff1f; 在React生态里&#xff0c;UI组件库的选择多如牛毛&#xff0c;从Material-UI到Chakra UI&#xff0c;再到各种小而美的库。但当你接手一个需要快速搭建、风格统一且要求稳定的企业级中后台项目时&#xff0c;Ant Desig…

作者头像 李华