线程到底在干什么 & 集合为什么会翻车

从"一个请求到达 Spring Boot 后发生了什么" → 到"为什么 ArrayList 会丢数据" → 到"什么时候该用并发集合"

线程模型感性认知 HashMap 翻车现场 ConcurrentHashMap 原理 集合选型决策
📚 想要深入理解原理?推荐搭配专题图解学习

本页侧重面试考点速答。若需理解底层原理和运行机制,请看以下专题图解:

📘 Java集合与类加载图解 📘 Java并发编程图解

1. 先搞清楚:一个请求到达 Spring Boot 后,到底发生了什么?

你的困惑核心是"线程是什么、谁创建的、什么变量被共享"。我们从一个 HTTP 请求到达开始,一步步拆开看。

核心结论:你写的 Spring Boot 程序运行时,背后有一组由 Tomcat 管理的线程。每个 HTTP 请求 = 一个线程。你写的 @Service / @Controller 代码,就是这些线程在执行。

第1步:Spring Boot 启动时,Tomcat 就准备好了一个线程池

你启动 Spring Boot,它内嵌了 Tomcat。Tomcat 一上来就创建一个线程池(默认 200 个线程),这些线程都在等待请求到来。

类比:就像一家银行,开门营业前就把 200 个柜台窗口准备好了,柜员(线程)坐在里面等客户。

📁 spring-boot-app
📂 启动过程 (JVM 进程)
📄 main() 线程启动
📄 Tomcat 初始化
📄 创建 200 个工作线程
📂 运行时
📄 Tomcat 线程池 (200个线程)
📄 @Service 单例对象
📄 @Repository 单例对象
// ┌─────────────────────────────────────────────────────────┐ // │ JVM 进程(你启动的那个 Java 程序) │ // │ │ // │ main 线程(负责启动,启动完就结束) │ // │ │ │ // │ ├─ 创建 Spring 容器(IoC 容器) │ // │ │ ├─ new UserService() ← 创建单例对象,放堆内存 │ // │ │ ├─ new OrderService() ← 创建单例对象,放堆内存 │ // │ │ └─ new OrderMapper() ← 创建单例对象,放堆内存 │ // │ │ │ // │ └─ 启动 Tomcat │ // │ └─ 创建线程池(默认 corePoolSize=10, max=200) │ // │ ├─ http-nio-8080-exec-1 ← 工作线程1 │ // │ ├─ http-nio-8080-exec-2 ← 工作线程2 │ // │ ├─ http-nio-8080-exec-3 ← 工作线程3 │ // │ └─ ... (最多 200 个) │ // │ │ // │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ // │ 启动完成!main 线程结束,但 Tomcat 线程池还活着,等请求 │ // └─────────────────────────────────────────────────────────┘

SVG全景图:一个请求进来,JVM 里谁在执行什么

👇 图里的每个对象,都对应上面代码里 new OrderService()main线程启动时创建的。线程栈里的 createOrder() 是请求进来后才执行的方法——进每个线程自己的栈。

JVM 进程(你启动的那个 Spring Boot 程序) 📦 堆内存(Heap)— 所有线程共享! @Service OrderService 实例变量:stockMap ⚠️ ← main线程: new OrderService() @Service ExamService 实例变量:submissions ⚠️ ← main线程: new ExamService() @Controller OrderCtrl 无实例变量 → 安全 ✅ static HashMap config 全局唯一 ⚠️ ⚠️ 这些对象在堆内存中只有1份 → 100个线程操作的是同一个对象! ArrayList/HashMap 的 add/put 操作不是原子的 → 并发翻车 📋 方法区(Metaspace) 类的模板信息:OrderService.class / ExamService.class static 变量也在这里(JDK 8+在堆里) 🧵 线程栈区(Stack)— 每个线程私有 ✅ exec-1 线程的栈 ▸ createOrder() → goodsId=101 ✅ ▸ 局部变量 orderNo="ORD-001" ✅ ▸ 局部 List items = new ArrayList() ✅ ↑ 请求到来后,Tomcat分配线程执行此方法 exec-2 线程的栈 ▸ createOrder() → goodsId=205 ✅ ▸ 局部变量 orderNo="ORD-002" ✅ ▸ 局部 List items = new ArrayList() ✅ exec-3 线程的栈 ▸ createOrder() → goodsId=78 ✅ ▸ 各自的局部变量互不干扰 ✅ 操作 操作 操作

第2步:用户发了一个 HTTP 请求 → Tomcat 分配一个线程

⏱ 用户在浏览器点"提交订单"

HTTP 请求到达 Tomcat 的 8080 端口

⏱ Tomcat 从线程池拿出一个空闲线程

假设分配了 http-nio-8080-exec-1 这个线程来处理

这个线程的栈帧里会存储方法中的局部变量

⏱ 线程执行到 Controller

exec-1 线程调用 OrderController.createOrder()

OrderController 是单例对象,堆内存里只有一个

但方法的参数(goodsId、quantity)存在线程的里,每个线程一份

⏱ Controller 调用 Service

exec-1 线程接着调用 orderService.createOrder(goodsId)

OrderService 也是单例,整个 JVM 只有一个实例

exec-1 线程操作的就是这同一个对象

第3步:100 个用户同时请求 → 100 个线程同时执行

现在 100 个用户同时提交订单。Tomcat 从线程池拿出 100 个线程,它们同时执行同样的代码路径:

100 个请求同时到达 Tomcat 接收
分配 100 个线程 exec-1 ~ exec-100
100 个线程同时 执行同一个 OrderService
⚠️ 共享变量竞争 同一个对象的字段/集合
⚠️ 关键认知:这就是并发的源头

100 个线程同时执行同一个 OrderService 对象的 createOrder 方法。如果这个方法里有修改实例变量(比如一个 List、一个 Map、一个 int 计数器),100 个线程就是在同时修改这同一个东西。

这就是"并发"——不是你主动创建的,而是 Spring 单例 + 多用户访问天然产生的

2. 哪些变量是"大家都在碰的"?哪些是安全的?

这是理解一切并发问题的关键。一个变量到底会不会被多个线程同时碰,取决于它存在哪里、怎么声明的。

核心规则:方法内的局部变量 = 安全(每个线程自己一份);类的实例变量/静态变量 = 危险(所有线程共享同一个)。

🖥️ JVM 内存:两个关键区域

📦 栈(Stack)—— 线程私有,安全

  • 方法内的局部变量
  • 方法的参数(基本类型)
  • 方法内的对象引用(引用本身)
  • 每个线程有自己的栈,互不干扰

📦 堆(Heap)—— 所有线程共享,危险

  • new 出来的对象本身
  • Spring 单例 Bean 的实例变量
  • static 静态变量
  • List / Map / Set 里的元素
📁 order-service
📂 src/main/java
📂 service
📄 OrderService.java
📂 controller
📄 OrderController.java
@Service public class OrderService { // ═══════════════════════════════════════════════════ // ❌ 危险区域:实例变量(存在堆内存,所有线程共享!) // ═══════════════════════════════════════════════════ private List<String> orderCache = new ArrayList<>(); // ⚠️ 共享! private Map<String, Integer> stockMap = new HashMap<>(); // ⚠️ 共享! private int requestCount = 0; // ⚠️ 共享!100个线程同时 requestCount++ public static Map<String, String> config = new HashMap<>(); // ⚠️ 共享! @Autowired private OrderMapper orderMapper; // ⚠️ 共享(但 Mapper 无状态,安全) // ═══════════════════════════════════════════════════ // ✅ 安全区域:方法内的局部变量(存在栈内存,线程私有) // ═══════════════════════════════════════════════════ public void createOrder(Long userId, Long goodsId, int quantity) { // ✅ 参数:userId, goodsId, quantity → 存在当前线程的栈里 // 100 个线程各调各的,参数互不干扰 String orderNo = UUID.randomUUID().toString(); // ✅ 局部变量 // orderNo 存在当前线程的栈里,安全 List<OrderItem> items = new ArrayList<>(); // ✅ 局部变量 // 虽然 ArrayList 本身不安全,但这个 items 只有当前线程能碰到 // (new 出来的对象在堆里,但引用在栈里,其他线程拿不到引用) items.add(new OrderItem(goodsId, quantity)); orderMapper.insert(orderNo, userId, items); // ═══════════════════════════════════════════════════ // ❌ 危险操作:修改实例变量 // ═══════════════════════════════════════════════════ requestCount++; // ⚠️ 100 个线程同时执行这一行! // requestCount 是实例变量,存在堆内存,共享! orderCache.add(orderNo); // ⚠️ 100 个线程同时往同一个 ArrayList 加数据! // ArrayList 不是线程安全的! } }

一个超具体的对比:同一个方法里,安全的 vs 危险的

变量类型 声明位置 存在哪里 100个线程是否共享 安全吗
局部变量 方法内声明 线程栈 否(各线程独立) ✅ 安全
方法参数 方法签名 线程栈 ✅ 安全
局部 ArrayList 方法内 new 对象在堆,引用在栈 否(其他线程拿不到引用) ✅ 安全
实例变量 类内、方法外 堆内存 是(同一个单例对象) ❌ 危险
实例 ArrayList 类内 new 堆内存 ❌ 危险
static 变量 类内 static 方法区/堆 是(全局唯一) ❌ 危险
类比:银行大厅

局部变量 = 客户自己的手机:每个客户自己带一个手机进银行,你用你的、他用他的,互不干扰。对应方法内的局部变量,存在每个线程自己的栈里。

实例变量 = 银行大厅的叫号机:100 个客户共用同一台叫号机,大家都按同一个机器。对应 Spring 单例的实例变量,存在堆内存,所有线程操作同一个对象。

static 变量 = 银行门口的招牌:整个银行就一块招牌,所有客户看到的都是同一块。对应全局唯一。

同一份代码,每个线程各走一遍 → 栈里各一份,堆里共享 // OrderService.createOrder() public void createOrder (Long goodsId) { A // 参数 → 线程栈 ✅ B String orderNo = UUID...; ✅ C List items = new ArrayList(); 引用在栈✅对象在堆 D requestCount++; ⚠️ 堆内存共享! E orderCache.add(orderNo); ⚠️ 堆内存共享! // ═════════════════════════ 🟢 A,B 行 → 变量在栈里 🔵 C 行 → 引用在栈,对象在堆 🔴 D,E 行 → 操作堆里的共享实例变量 } exec-1 的栈 🟢A goodsId=101 🟢B orderNo="ORD-001" 🔵C items→[堆地址0x3A] (每个线程自己一份) exec-2 的栈 🟢A goodsId=205 🟢B orderNo="ORD-002" 🔵C items→[堆地址0x7F] (各线程的局部变量互不干扰 ✅) 📦 堆内存(共享!只有1份) 🔴 OrderService 单例对象(全局1份) 🔴D requestCount = ??? (两线程同时++) 🔴E orderCache = [???, null] (数据丢失) ✅ orderMapper (无状态,安全) 🔵C exec-1的ArrayList 0x3A: [{goods:101}] 🔵C exec-2的ArrayList 0x7F: [{goods:205}] 🔵C 虽然对象在堆里,但引用在栈里 其他线程拿不到这个引用 → 还是安全的 ✅ 🔴D,E 🔴D,E 🔵C
面试金句 判断一个变量是否有并发风险,看它存在哪里:局部变量在栈(线程私有,安全);实例变量和静态变量在堆(所有线程共享,有风险)。Spring 的 @Service / @Controller 默认是单例,所以它们的实例变量被所有请求线程共享。这就是为什么 Controller/Service 中不能定义可变的实例变量,除非用线程安全的集合或加锁。

3. ArrayList 在并发下到底怎么翻车的?

你说"不理解实际发生了什么"——这里我用一个具体的生产场景,逐步拆解 ArrayList 在多线程下是怎么丢数据的。

📋 真实场景:一个在线考试的"提交答案"接口

学生在考试系统中答题,每答完一道题点击"提交",后端把答案记录到一个 List 里。考试结束后统计这个 List。

系统同时有 50 个学生在线答题,每个人答完都提交。

开发者写了一个 @Service,里面用 实例变量 ArrayList 存储所有提交记录。

📁 exam-system
📂 src/main/java
📂 service
📄 ExamService.java
📂 controller
📄 ExamController.java
@Service public class ExamService { // ⚠️ 实例变量:存在堆内存,50 个线程共享 // ArrayList 不是线程安全的! private List<String> submissions = new ArrayList<>(); public void submit(String studentName, String answer) { // 50 个学生同时提交 → 50 个线程同时执行这一行 submissions.add(studentName + ": " + answer); // ArrayList.add() 内部做了什么? // 1. 检查数组容量是否够 size + 1 // 2. 把元素放入 array[size] // 3. size++ // 这三步不是原子操作!多线程下会出问题 } public List<String> getAllSubmissions() { return new ArrayList<>(submissions); } }

翻车现场重建:两个线程同时 add()

ArrayList 内部有一个 Object[] elementData 数组和一个 int size 计数器。add() 的核心逻辑是:

📋 代码行对照:ArrayList.add() 内部三步拆解
步骤编号
源码
实际CPU做了什么
Step ①
ensureCapacityInternal(size + 1)
读 size 当前值 → 检查容量够不够
Step ②
elementData[size] = e
把元素放到数组第 size 个位置(赋值)
Step ③
size++
size = size + 1(读旧值 → +1 → 写回)
⚠️ Step ②③ 写在同一行 elementData[size++] = e,但它不是原子操作!
JVM 实际把它编译成 4 条字节码:①读size ②放入数组 ③size+1 ④写回size → 中间可以被其他线程插入!
ArrayList.add() 并发翻车时序图 — 每一步对应代码哪行? 线程A(张三提交) 线程B(李四提交) ArrayList 共享状态 ① 读 size=5 ← ensureCapacityInternal(size+1) ② 也读 size=5 ⚠️ ← ensureCapacityInternal(size+1) ③ elementData[5]="张三" ← elementData[size++] = e 赋值 ④ elementData[5]="李四" ← 覆盖! ← elementData[size++] = e 赋值 数组内容: [..., "李四的答案"] 张三的答案被覆盖了! ⑤ size++ → size=6 ← elementData[size++] = e 自增 ⑥ size++ → size=7 ← elementData[size++] = e 自增 结果:1条数据丢失 + 第7位是null → NPE!
📁 JDK 源码
📂 java.util
📄 ArrayList.java
📂 java.util.concurrent
📄 CopyOnWriteArrayList.java
// ArrayList.add() 源码(JDK 简化版) public boolean add(E e) { ensureCapacityInternal(size + 1); // 步骤1: 确保数组容量够 elementData[size++] = e; // 步骤2: 放入元素,然后 size+1 return true; } // 问题1: size++ 不是原子操作! // 它等价于: // 读 size 的值 // 把元素放入 elementData[size] // size = size + 1 // 这三步之间,别的线程可以插入! // 问题2: elementData[size] = e 和 size++ 之间有间隙 // 如果线程A 放了元素但还没 size++, // 线程B 读到的 size 还是旧值,也往同一个位置放!
⏱ T1: 线程A(exec-1,学生张三提交)

进入 add(),读到 size=5

执行 ensureCapacityInternal(6) → 容量足够,不需要扩容

⏱ T2: 线程B(exec-2,学生李四提交)同时进入

也进入 add(),读到 size=5(线程A 还没执行 size++)

也执行 ensureCapacityInternal(6) → 容量足够

⏱ T3: 线程A 执行 elementData[5] = "张三的答案"

数组第 5 个位置被写入"张三"

⏱ T4: 线程B 执行 elementData[5] = "李四的答案"

也往第 5 个位置写入"李四"!覆盖了张三!

⏱ T5: 线程A 执行 size++,size 变成 6
⏱ T6: 线程B 执行 size++,size 变成 7

结果:张三的答案被覆盖了!

数组实际内容:[..., ..., ..., ..., ..., "李四的答案", null]

size=7 但第 6 个位置是 null → 遍历时 NullPointerException

⚠️ ArrayList 并发下的三种翻车模式

翻车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 —— 如果只是追加数据,用队列更合适

4. HashMap 在并发下比 ArrayList 更可怕

ArrayList 顶多丢数据,HashMap 在并发 put 时可能直接死循环(JDK 7)或数据丢失(JDK 8)。

📋 真实场景:本地缓存用 HashMap

开发者想在内存里做一个简单的用户信息缓存,key=用户ID,value=用户信息。为了简单,直接在 @Service 里放了一个 实例变量 HashMap

100 个请求同时查不同的用户,缓存没有就查 DB 再放入 HashMap。

📁 user-service
📂 src/main/java
📂 service
📄 UserCacheService.java
📂 controller
📄 UserController.java
@Service public class UserCacheService { // ⚠️ 实例变量 HashMap,所有线程共享 // HashMap 完全没有同步措施! private Map<Long, User> cache = new HashMap<>(); @Autowired private UserMapper userMapper; public User getUser(Long userId) { User user = cache.get(userId); if (user == null) { user = userMapper.selectById(userId); // ⚠️ 100 个线程可能同时执行 put()! cache.put(userId, user); } return user; } }

HashMap 翻车现场:扩容时的灾难

HashMap 内部是一个 Node[] table 数组。当元素个数超过阈值(容量 × 负载因子 0.75),会触发 扩容(resize):创建一个 2 倍大的新数组,把所有元素重新哈希搬到新数组。

问题就出在搬家的过程:

👇 下图对应 UserCacheService.java 中 cache.put(userId, user) 这一行——当 put 触发 resize 时,两个线程同时搬家,链表指针互相覆盖就成环了。

HashMap resize 并发 → 链表成环(JDK 7) 触发点: cache.put(userId, user) → size>阈值 → resize() → transfer()搬节点 → 头插法反转链表 正常状态 ← put()时链表结构: A→B→C→null Node A Node B Node C null 线程A搬了一半 Node C Node B (头插法:C→B) 线程B也在搬 Node B Node A 💥 结果:链表成环!A.next = B, B.next = A → 死循环! ← 对应 transfer() 代码: e.next = newTable[i] 两次执行后 B.next 回指 A 环形链表 = 无限循环 Node A Node B ← B.next 又指回 A!cache.get() 遍历到这个环 → CPU 100%
📁 JDK 源码
📂 java.util
📄 HashMap.java (JDK 7)
📂 java.util.concurrent
📄 ConcurrentHashMap.java (JDK 8)
// HashMap.resize() → transfer() 源码(JDK 7 简化版) // 这就是上面 SVG 图里"搬家"的代码! void transfer(Entry[] newTable, boolean rehash) { Entry[] src = table; int newCapacity = newTable.length; // 遍历旧数组的每个桶 for (int j = 0; j < src.length; j++) { Entry<K,V> e = src[j]; if (e != null) { src[j] = null; // 遍历这个桶的链表,逐个搬到新数组 do { Entry<K,V> next = e.next; // ⚠️ 记住下一个节点 int i = indexFor(e.hash, newCapacity); // 算新位置 // ⚠️ 头插法:新节点插到链表头部! // 这行代码就是 SVG 里"搬节点"的那一步! e.next = newTable[i]; // e.next 指向新桶的当前头 newTable[i] = e; // e 变成新桶的头 e = next; // 继续搬下一个 } while (e != null); } } } // ═══════════════════════════════════════════════════════ // 为什么头插法会成环? // // 旧链表: A → B → C → null // // 线程A搬了一步: C 变成新表头 (C→null) // 还没搬 B(因为A被挂起了) // // 线程B也来搬: B 变成新表头 (B→C→null) // 然后 A 变成新表头 (A→B→C→null) // // 线程A恢复: 继续从 B 开始搬 // B.next = newTable[i] → newTable[i] 此时是 A! // 所以 B.next = A // 而 A.next = B(线程A之前搬的) // → A↔B 环形!get() 永远转不出来! // // JDK 8 改成了尾插法(保持原顺序),解决了成环问题 // 但并发 put 仍然会丢数据 → 必须用 ConcurrentHashMap
⏱ 初始状态

HashMap 容量=16,已有 12 个元素(达到阈值 16×0.75=12)

线程A 和线程B 同时 put 第 13、14 个元素

⏱ 线程A 触发 resize()

线程A 发现 size=12 超过阈值,开始扩容

创建新数组 newTable[32],准备把旧数据搬过去

⏱ 线程B 也触发 resize()

线程B 也发现 size=12(因为线程A 还没搬完,size 还没更新)

两个线程同时在对同一个旧数组做搬家操作!

⏱ JDK 7:链表成环 → 死循环(CPU 100%)

JDK 7 搬家时用"头插法",多线程下链表节点会形成环形链表

之后 get() 时遍历到环形链表 → 无限循环 → CPU 飙到 100%

⏱ JDK 8:数据丢失

JDK 8 改成"尾插法"解决了死循环,但仍然有问题:

两个线程同时搬家,可能互相覆盖对方写入的数据 → 数据丢失

或者同时 put 时算出同一个桶位 → 后者覆盖前者

⚠️ HashMap 并发下的真实故障

这不是理论问题——生产环境最经典的 HashMap 并发故障就是 CPU 100%。一堆线程卡在 resize 的死循环里,服务器直接卡死。

jstack dump 出来会看到大量线程卡在 java.util.HashMap.transfer() 方法里(JDK 7)。

面试金句 HashMap 在多线程 put 时可能导致 CPU 100%(JDK 7 的 resize 头插法形成环形链表,get 死循环)或数据丢失(JDK 8 尾插法虽然解决了死循环,但并发 put/resize 仍会覆盖数据)。根本原因是 HashMap 的 put、resize 操作都没有任何同步措施。多线程场景必须用 ConcurrentHashMap

5. 并发集合到底做了什么别人没做的?

现在你知道了为什么普通集合会翻车。这一章讲并发集合用什么手段解决的。

三大并发集合的解决方案

并发集合 替代谁 怎么保证安全 类比
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 空了等) 快递柜:满了就不投了(等),空了就不取了(等)

重点:ConcurrentHashMap 的"锁分段"思想

普通 HashMap put 时没有任何保护,所以多线程下扩容会出事。ConcurrentHashMap 的思路是:不锁整个 Map,只锁当前操作的那一个桶(Node 数组的一个槽位)

ConcurrentHashMap:只锁你操作的那个桶! 每一步都对应下面 putVal() 源码的哪一行?→ 看蓝/红色小标签 桶位: 桶0 桶1 链表 桶2 桶3 🔒A锁 桶4 红黑树 桶5 链表 桶6 桶7 🔒B锁 桶8 桶9 链表 桶10 ... 桶15 线程A: put("apple", "苹果") hash("apple") % 16 = 3 → 只锁桶3 其他15个桶完全不受影响 ✅ ← putVal(): synchronized(f) 锁桶3头节点 线程B: put("banana", "香蕉") hash("banana") % 16 = 7 → 只锁桶7 和线程A互不阻塞!可以并行 ✅ ← putVal(): synchronized(f) 锁桶7头节点 ❌ 对比:Collections.synchronizedMap → 锁整个 Map 不管操作哪个桶,都要先拿到这一把全局锁 线程A在桶3操作时,线程B连桶7都碰不了 → 性能差! ← synchronizedMap源码: synchronized(mutex) { ... } 全局一把锁
📁 JDK 源码
📂 java.util.concurrent
📄 ConcurrentHashMap.java
📂 java.util
📄 HashMap.java
// ConcurrentHashMap.put() 源码(JDK 8 简化版) final V putVal(K key, V value, boolean onlyIfAbsent) { int hash = spread(key.hashCode()); for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; // 步骤1: 计算桶位置 = (n-1) & hash i = (n - 1) & hash; // 步骤2: 如果桶是空的,直接 CAS 放入(无锁!) if ((f = tabAt(tab, i)) == null) { if (casTabAt(tab, i, null, new Node<>(hash, key, value))) break; // CAS 成功,搞定 } // 步骤3: 如果桶不空,synchronized 锁住这个桶的头节点 else { synchronized (f) { // ← 只锁这一个桶! // 在这个桶的链表/红黑树里插入或更新 if (tabAt(tab, i) == f) { // ... 遍历链表,找到位置插入 ... } } break; } } return null; } // ═══════════════════════════════════════════════════════ // 为什么这样更安全? // // 假设 Map 有 16 个桶(slot 0 ~ 15) // 线程A put("apple", ...) → hash 计算后落到 slot 3 // 线程B put("banana", ...) → hash 计算后落到 slot 7 // // 两个线程操作的是不同的桶 → synchronized(f) 锁的是不同的对象 // → 互不阻塞!可以真正并发执行! // // 只有当两个线程的 key 刚好落到同一个桶,才会排队等待 // 概率很低(hash 分散后均匀分布),所以性能很高 // // 对比:Collections.synchronizedMap 是锁整个 Map // 不管你操作哪个桶,都要先拿到整把锁 → 性能差

CopyOnWriteArrayList:"写时复制"

📁 JDK 源码
📂 java.util.concurrent
📄 CopyOnWriteArrayList.java
// CopyOnWriteArrayList.add() 源码 public boolean add(E e) { synchronized (lock) { // 写操作加锁 Object[] elements = getArray(); int len = elements.length; // 关键:复制整个数组! Object[] newElements = Arrays.copyOf(elements, len + 1); newElements[len] = e; // 替换引用 setArray(newElements); return true; } } // 读操作:完全不加锁!直接读当前数组 public E get(int index) { return get(getArray(), index); // 无锁读! } // ═══════════════════════════════════════════════════════ // 为什么读不加锁也安全? // // 1. 数组引用用 volatile 修饰,写完后所有线程立即可见 // 2. 读线程拿到的是"当时的快照" // 即使写线程正在复制新数组,读线程读到的还是旧的完整数组 // → 不会读到"半个"数据 // // 缺点:每次写都要复制整个数组(O(n)),写多了性能很差 // 所以只适合"读多写少"的场景(如配置列表、监听器列表)
CopyOnWriteArrayList = 公司公告栏

:员工随时看公告栏,不需要排队,想看就看。

:行政先复印一份当前公告,在复印件上贴新通知,然后把墙上的公告换成这份新的。读的员工不会看到"贴了一半"的状态——要么看到旧的完整版,要么看到新的完整版。

缺点:每次改公告都要复印全部内容,如果天天改就太费纸了。所以只适合"改得少、看得多"的场景。

6. 什么时候该用普通集合?什么时候必须用并发集合?

这一章给你一个清晰的决策框架。

核心原则:看这个集合变量会不会被多个线程同时访问

如果是方法内的局部变量 → 只有当前线程能碰到 → 用普通集合就行。

如果是实例变量、静态变量 → 所有线程共享 → 必须用并发集合或加锁。

这个变量安全吗?—— 一张决策流程图 这个变量存在哪里? 栈(方法内局部变量) ✅ 安全!每个线程独享 → 用普通集合:ArrayList / HashMap 堆(实例/静态变量) ⚠️ 危险!多线程共享 需要进一步判断 会被多线程修改吗? 只读 ✅ 用 volatile 或 不可变对象即可 会修改 必须用并发安全方案: Map → ConcurrentHashMap List → CopyOnWriteArrayList 计数 → AtomicLong
场景 变量类型 会被多线程碰吗 用什么 原因
方法内临时拼数据 局部变量 ArrayList / HashMap 每个线程自己的,用完就扔
Controller 里组装返回结果 局部变量 ArrayList / HashMap 请求结束,栈帧销毁
Service 里做内存缓存 实例变量 ConcurrentHashMap 所有请求线程共享缓存
全局配置表 static 变量 ConcurrentHashMap 所有线程读全局配置
监听器/回调列表 实例变量 是(读多写极少) CopyOnWriteArrayList 注册一次,频繁遍历触发
计数器(访问量统计) 实例变量 AtomicLong / AtomicInteger CAS 无锁计数
生产者-消费者缓冲区 实例变量 LinkedBlockingQueue 线程间安全传递数据
限制并发访问数 实例变量 Semaphore 控制同时访问的线程数

实例代码对比:同一个功能,错误写法 vs 正确写法

📁 user-service
📂 service
📄 UserCacheService_BAD.java
📄 UserCacheService_GOOD.java
📂 controller
📄 UserController.java
// ═══════════════════════════════════════════════════════ // ❌ 错误写法:用 HashMap 做缓存(生产事故!) // ═══════════════════════════════════════════════════════ @Service public class UserCacheService_BAD { private Map<Long, User> cache = new HashMap<>(); // ⚠️ HashMap! public User getUser(Long id) { User u = cache.get(id); if (u == null) { u = mapper.selectById(id); cache.put(id, u); // ⚠️ 多线程 put → CPU 100% 或数据丢失 } return u; } } // ═══════════════════════════════════════════════════════ // ✅ 正确写法:用 ConcurrentHashMap(推荐) // ═══════════════════════════════════════════════════════ @Service public class UserCacheService_GOOD { private Map<Long, User> cache = new ConcurrentHashMap<>(); // ✅ CHM public User getUser(Long id) { // ✅ computeIfAbsent 是原子操作! // 不会出现两个线程同时发现 null 都去查 DB return cache.computeIfAbsent(id, k -> mapper.selectById(k)); } } // ═══════════════════════════════════════════════════════ // ✅ 计数器场景:不要用 synchronized int++ // ═══════════════════════════════════════════════════════ @Service public class RequestCounter { // ❌ 错误:private int count = 0; count++ 不是原子的 private AtomicLong count = new AtomicLong(0); // ✅ CAS 无锁计数 public void increment() { count.incrementAndGet(); // ✅ CAS 自旋,线程安全 } public long get() { return count.get(); } } // ═══════════════════════════════════════════════════════ // ✅ 监听器列表场景:读多写极少 // ═══════════════════════════════════════════════════════ @Service public class EventListenerManager { // ❌ 错误:private List listeners = new ArrayList<>(); private List<EventListener> listeners = new CopyOnWriteArrayList<>(); // ✅ 写时复制 public void register(EventListener l) { listeners.add(l); // 写操作:复制整个数组 } public void fireEvent(Event e) { for (EventListener l : listeners) { // 读操作:无锁遍历 l.onEvent(e); } } }
💡 一句话总结

局部变量 → 普通集合(ArrayList/HashMap)

共享缓存 → ConcurrentHashMap

读多写少列表 → CopyOnWriteArrayList

计数器 → AtomicLong/AtomicInteger

生产消费 → BlockingQueue

不确定 → 先问自己"这个变量会被多个线程同时碰吗?"

7. 面试速答

Q1: "为什么 HashMap 不能在多线程下用?"

HashMap 的 put 和 resize 完全没有同步措施。多线程同时 put 时,JDK 7 的 resize 使用头插法,会导致链表节点形成环形链表,后续 get 操作陷入死循环,CPU 100%。JDK 8 改为尾插法解决了死循环,但仍会数据丢失。多线程场景必须用 ConcurrentHashMap。

Q2: "ConcurrentHashMap 怎么保证线程安全的?"

JDK 7 用分段锁(Segment[]),把整个 Map 分成 16 段,每段一把锁,不同段的操作互不阻塞。JDK 8 改成 CAS + synchronized 锁单个桶节点:桶为空时用 CAS 无锁写入,桶不为空时 synchronized 锁住链表/红黑树的头节点。粒度从"段"细化到"桶",并发度更高。

Q3: "什么时候用 CopyOnWriteArrayList?"

读多写少的场景,比如事件监听器列表、配置列表。读操作完全无锁(volatile 读),写操作通过"复制整个数组 + 加锁"实现。缺点是每次写都 O(n) 复制,如果写频繁会浪费大量内存和 CPU。不适合写多的场景。

Q4: "Spring 的 @Service 单例模式下,怎么避免并发问题?"

三个原则:① 不在 @Service 里定义可变实例变量,所有状态都通过方法参数传递。② 如果必须有共享状态,用线程安全的集合(ConcurrentHashMap、AtomicLong 等)。③ 如果操作不是原子的(多步组合),用 synchronized 或 ReentrantLock 保证整个操作块的原子性。

终极总结 并发安全的核心问题:Spring 单例 Bean 的实例变量被所有请求线程共享。方法内局部变量在栈上是安全的;实例变量在堆上被多线程共享才危险。ArrayList 会覆盖/丢数据/越界,HashMap 会死循环(JDK7)/丢数据(JDK8)。解决方案:ConcurrentHashMap(锁单个桶)、CopyOnWriteArrayList(写时复制)、Atomic(CAS 无锁)。核心判断标准:这个变量会被多个线程同时碰吗?是 → 用并发集合;否 → 普通集合就行。
↑ 回到顶部

线程与集合并发安全详解 — 配套 01-java-concurrent.html 和 backend-java.html 深度理解