从"一个请求到达 Spring Boot 后发生了什么" → 到"为什么 ArrayList 会丢数据" → 到"什么时候该用并发集合"
你的困惑核心是"线程是什么、谁创建的、什么变量被共享"。我们从一个 HTTP 请求到达开始,一步步拆开看。
核心结论:你写的 Spring Boot 程序运行时,背后有一组由 Tomcat 管理的线程。每个 HTTP 请求 = 一个线程。你写的 @Service / @Controller 代码,就是这些线程在执行。
你启动 Spring Boot,它内嵌了 Tomcat。Tomcat 一上来就创建一个线程池(默认 200 个线程),这些线程都在等待请求到来。
类比:就像一家银行,开门营业前就把 200 个柜台窗口准备好了,柜员(线程)坐在里面等客户。
👇 图里的每个对象,都对应上面代码里 new OrderService() 等 main线程启动时创建的。线程栈里的 createOrder() 是请求进来后才执行的方法——进每个线程自己的栈。
HTTP 请求到达 Tomcat 的 8080 端口
假设分配了 http-nio-8080-exec-1 这个线程来处理
这个线程的栈帧里会存储方法中的局部变量
exec-1 线程调用 OrderController.createOrder()
OrderController 是单例对象,堆内存里只有一个
但方法的参数(goodsId、quantity)存在线程的栈里,每个线程一份
exec-1 线程接着调用 orderService.createOrder(goodsId)
OrderService 也是单例,整个 JVM 只有一个实例
exec-1 线程操作的就是这同一个对象
现在 100 个用户同时提交订单。Tomcat 从线程池拿出 100 个线程,它们同时执行同样的代码路径:
100 个线程同时执行同一个 OrderService 对象的 createOrder 方法。如果这个方法里有修改实例变量(比如一个 List、一个 Map、一个 int 计数器),100 个线程就是在同时修改这同一个东西。
这就是"并发"——不是你主动创建的,而是 Spring 单例 + 多用户访问天然产生的。
这是理解一切并发问题的关键。一个变量到底会不会被多个线程同时碰,取决于它存在哪里、怎么声明的。
核心规则:方法内的局部变量 = 安全(每个线程自己一份);类的实例变量/静态变量 = 危险(所有线程共享同一个)。
| 变量类型 | 声明位置 | 存在哪里 | 100个线程是否共享 | 安全吗 |
|---|---|---|---|---|
| 局部变量 | 方法内声明 | 线程栈 | 否(各线程独立) | ✅ 安全 |
| 方法参数 | 方法签名 | 线程栈 | 否 | ✅ 安全 |
| 局部 ArrayList | 方法内 new | 对象在堆,引用在栈 | 否(其他线程拿不到引用) | ✅ 安全 |
| 实例变量 | 类内、方法外 | 堆内存 | 是(同一个单例对象) | ❌ 危险 |
| 实例 ArrayList | 类内 new | 堆内存 | 是 | ❌ 危险 |
| static 变量 | 类内 static | 方法区/堆 | 是(全局唯一) | ❌ 危险 |
局部变量 = 客户自己的手机:每个客户自己带一个手机进银行,你用你的、他用他的,互不干扰。对应方法内的局部变量,存在每个线程自己的栈里。
实例变量 = 银行大厅的叫号机:100 个客户共用同一台叫号机,大家都按同一个机器。对应 Spring 单例的实例变量,存在堆内存,所有线程操作同一个对象。
static 变量 = 银行门口的招牌:整个银行就一块招牌,所有客户看到的都是同一块。对应全局唯一。
你说"不理解实际发生了什么"——这里我用一个具体的生产场景,逐步拆解 ArrayList 在多线程下是怎么丢数据的。
学生在考试系统中答题,每答完一道题点击"提交",后端把答案记录到一个 List 里。考试结束后统计这个 List。
系统同时有 50 个学生在线答题,每个人答完都提交。
开发者写了一个 @Service,里面用 实例变量 ArrayList 存储所有提交记录。
ArrayList 内部有一个 Object[] elementData 数组和一个 int size 计数器。add() 的核心逻辑是:
elementData[size++] = e,但它不是原子操作!进入 add(),读到 size=5
执行 ensureCapacityInternal(6) → 容量足够,不需要扩容
也进入 add(),读到 size=5(线程A 还没执行 size++)
也执行 ensureCapacityInternal(6) → 容量足够
数组第 5 个位置被写入"张三"
也往第 5 个位置写入"李四"!覆盖了张三!
结果:张三的答案被覆盖了!
数组实际内容:[..., ..., ..., ..., ..., "李四的答案", null]
size=7 但第 6 个位置是 null → 遍历时 NullPointerException!
翻车1:数据覆盖 —— 两个线程读到同一个 size,往同一位置写,后者覆盖前者(上面的例子)
翻车2:数组越界 —— size=9(数组容量10),两个线程都 ensureCapacity(10) 通过,一个放 [9],另一个也放 [9],然后 size 变 11 → 下一个线程 elementData[10] 越界 → ArrayIndexOutOfBoundsException
翻车3:size 不对 —— size++ 不是原子的,两个线程同时从 5 变 6,结果实际是 6 而不是 7 → 丢数据
方案1:用 Collections.synchronizedList(new ArrayList()) —— 给所有操作加 synchronized 锁,简单粗暴但性能差
方案2:用 CopyOnWriteArrayList —— 写时复制,适合读多写少(推荐)
方案3:用 ConcurrentLinkedQueue —— 如果只是追加数据,用队列更合适
ArrayList 顶多丢数据,HashMap 在并发 put 时可能直接死循环(JDK 7)或数据丢失(JDK 8)。
开发者想在内存里做一个简单的用户信息缓存,key=用户ID,value=用户信息。为了简单,直接在 @Service 里放了一个 实例变量 HashMap。
100 个请求同时查不同的用户,缓存没有就查 DB 再放入 HashMap。
HashMap 内部是一个 Node[] table 数组。当元素个数超过阈值(容量 × 负载因子 0.75),会触发 扩容(resize):创建一个 2 倍大的新数组,把所有元素重新哈希搬到新数组。
问题就出在搬家的过程:
👇 下图对应 UserCacheService.java 中 cache.put(userId, user) 这一行——当 put 触发 resize 时,两个线程同时搬家,链表指针互相覆盖就成环了。
HashMap 容量=16,已有 12 个元素(达到阈值 16×0.75=12)
线程A 和线程B 同时 put 第 13、14 个元素
线程A 发现 size=12 超过阈值,开始扩容
创建新数组 newTable[32],准备把旧数据搬过去
线程B 也发现 size=12(因为线程A 还没搬完,size 还没更新)
两个线程同时在对同一个旧数组做搬家操作!
JDK 7 搬家时用"头插法",多线程下链表节点会形成环形链表
之后 get() 时遍历到环形链表 → 无限循环 → CPU 飙到 100%
JDK 8 改成"尾插法"解决了死循环,但仍然有问题:
两个线程同时搬家,可能互相覆盖对方写入的数据 → 数据丢失
或者同时 put 时算出同一个桶位 → 后者覆盖前者
这不是理论问题——生产环境最经典的 HashMap 并发故障就是 CPU 100%。一堆线程卡在 resize 的死循环里,服务器直接卡死。
jstack dump 出来会看到大量线程卡在 java.util.HashMap.transfer() 方法里(JDK 7)。
现在你知道了为什么普通集合会翻车。这一章讲并发集合用什么手段解决的。
| 并发集合 | 替代谁 | 怎么保证安全 | 类比 |
|---|---|---|---|
| ConcurrentHashMap | HashMap | JDK 8: CAS + 锁单个桶节点(synchronized 锁 Node 槽位),不是锁整个 Map | 图书馆分了很多个阅览室,每个阅览室有自己的门锁,大家各去各的 |
| CopyOnWriteArrayList | ArrayList | 写时复制:每次 add 先复制整个数组,写完再替换引用。读操作完全无锁 | 公司公告:看的贴在墙上随便看,要改的时候先复印一份改完再贴上去 |
| Collections.synchronizedList | ArrayList | 给所有操作加 synchronized 锁(锁整个 List),粗暴但有效 | 公共厕所:一个人用,所有人等 |
| ConcurrentLinkedQueue | LinkedList (队列场景) | CAS 无锁实现,Michael & Scott 算法 | 超市排队:用 CAS 保证入队出队不冲突 |
| BlockingQueue 系列 | Queue (生产消费场景) | Lock + Condition(put 满了等,take 空了等) | 快递柜:满了就不投了(等),空了就不取了(等) |
普通 HashMap put 时没有任何保护,所以多线程下扩容会出事。ConcurrentHashMap 的思路是:不锁整个 Map,只锁当前操作的那一个桶(Node 数组的一个槽位)。
读:员工随时看公告栏,不需要排队,想看就看。
写:行政先复印一份当前公告,在复印件上贴新通知,然后把墙上的公告换成这份新的。读的员工不会看到"贴了一半"的状态——要么看到旧的完整版,要么看到新的完整版。
缺点:每次改公告都要复印全部内容,如果天天改就太费纸了。所以只适合"改得少、看得多"的场景。
这一章给你一个清晰的决策框架。
核心原则:看这个集合变量会不会被多个线程同时访问。
如果是方法内的局部变量 → 只有当前线程能碰到 → 用普通集合就行。
如果是实例变量、静态变量 → 所有线程共享 → 必须用并发集合或加锁。
| 场景 | 变量类型 | 会被多线程碰吗 | 用什么 | 原因 |
|---|---|---|---|---|
| 方法内临时拼数据 | 局部变量 | 否 | ArrayList / HashMap | 每个线程自己的,用完就扔 |
| Controller 里组装返回结果 | 局部变量 | 否 | ArrayList / HashMap | 请求结束,栈帧销毁 |
| Service 里做内存缓存 | 实例变量 | 是 | ConcurrentHashMap | 所有请求线程共享缓存 |
| 全局配置表 | static 变量 | 是 | ConcurrentHashMap | 所有线程读全局配置 |
| 监听器/回调列表 | 实例变量 | 是(读多写极少) | CopyOnWriteArrayList | 注册一次,频繁遍历触发 |
| 计数器(访问量统计) | 实例变量 | 是 | AtomicLong / AtomicInteger | CAS 无锁计数 |
| 生产者-消费者缓冲区 | 实例变量 | 是 | LinkedBlockingQueue | 线程间安全传递数据 |
| 限制并发访问数 | 实例变量 | 是 | Semaphore | 控制同时访问的线程数 |
局部变量 → 普通集合(ArrayList/HashMap)
共享缓存 → ConcurrentHashMap
读多写少列表 → CopyOnWriteArrayList
计数器 → AtomicLong/AtomicInteger
生产消费 → BlockingQueue
不确定 → 先问自己"这个变量会被多个线程同时碰吗?"
HashMap 的 put 和 resize 完全没有同步措施。多线程同时 put 时,JDK 7 的 resize 使用头插法,会导致链表节点形成环形链表,后续 get 操作陷入死循环,CPU 100%。JDK 8 改为尾插法解决了死循环,但仍会数据丢失。多线程场景必须用 ConcurrentHashMap。
JDK 7 用分段锁(Segment[]),把整个 Map 分成 16 段,每段一把锁,不同段的操作互不阻塞。JDK 8 改成 CAS + synchronized 锁单个桶节点:桶为空时用 CAS 无锁写入,桶不为空时 synchronized 锁住链表/红黑树的头节点。粒度从"段"细化到"桶",并发度更高。
读多写少的场景,比如事件监听器列表、配置列表。读操作完全无锁(volatile 读),写操作通过"复制整个数组 + 加锁"实现。缺点是每次写都 O(n) 复制,如果写频繁会浪费大量内存和 CPU。不适合写多的场景。
三个原则:① 不在 @Service 里定义可变实例变量,所有状态都通过方法参数传递。② 如果必须有共享状态,用线程安全的集合(ConcurrentHashMap、AtomicLong 等)。③ 如果操作不是原子的(多步组合),用 synchronized 或 ReentrantLock 保证整个操作块的原子性。
线程与集合并发安全详解 — 配套 01-java-concurrent.html 和 backend-java.html 深度理解