☕ Java Map / 集合问题排查全纪实

8个线上实战故事 — 什么时候出问题 → 怎么发现 → 怎么排查 → 怎么修 → 怎么验证

HashMap并发死循环 内存泄漏 ConcurrentHashMap误用 ArrayList OOM 线程安全 高并发场景
🔴 场景1: HashMap 并发死循环 — CPU 100%

🔴 HashMap 并发死循环 — 多线程put导致CPU飙到100%

🎬 什么时候出: 多个线程同时往同一个 HashMap 里 put 数据,恰好触发了扩容(resize)。在 Java 7 及之前的 HashMap 中,并发扩容可能导致链表形成环形结构,造成死循环。
🚨 线上告警:订单统计服务 CPU 持续 100%,所有接口超时!

📋 问题代码

OrderStatsService.java
OrderController.java
📁 service
☕ OrderStatsService
📁 controller
☕ OrderController
📁 model
☕ Order
1public class OrderStatsService {
2
3 // ❌ 线程不安全!多个线程同时写
4 private Map<String, Integer> statsMap = new HashMap<>();
5
6 // 多个线程同时调用这个方法
7 public void recordOrder(String product) {
8 Integer count = statsMap.get(product);
9 statsMap.put(product, count == null ? 1 : count + 1);
10 }
11}
等一下... HashMap 不是线程安全的!如果有 10 个线程同时 put,而且恰好 HashMap 需要扩容(从 16 扩到 32),两个线程同时做 resize,链表节点可能反转形成环...

📊 怎么发现

💬 技术排障群
14:30
运维
监控机器人
⚠️ order-service CPU使用率 99.8%,持续10分钟,接口全部超时!
TL
TL老王
@小张 看下什么情况,用户投诉下单不了
小张
收到,先SSH上去看

🔍 排查过程

14:32 第一步:找到高CPU的线程

$ top -Hp 12345 # 找到Java进程中CPU最高的线程 PID USER %CPU COMMAND 6789 app 98.2 java 6790 app 95.7 java 6791 app 2.1 java $ printf "%x\n" 6789 1a85 $ jstack 12345 | grep -A 30 "0x1a85" "http-nio-8080-exec-3" #58 daemon prio=5 os_prio=0 tid=0x00007f8c40123000 nid=0x1a85 runnable [0x00007f8c15e5e000] java.lang.Thread.State: RUNNABLE at java.util.HashMap.putVal(HashMap.java:662) at java.util.HashMap.put(HashMap.java:611) at com.milktea.service.OrderStatsService.recordOrder(OrderStatsService.java:9) at com.milktea.controller.OrderController.createOrder(OrderController.java:23)

14:35 发现多个线程都卡在 HashMap.putVal()!

# 多个线程都卡在 HashMap 里 $ jstack 12345 | grep -c "HashMap.putVal" 12 # 12个线程卡在HashMap!

🧠 原因分析 — 链表怎么成环的?

📍 HashMap 扩容时链表反转导致环形结构

正常状态(HashMap table[3] 的链表):

table[3]
键盘→
鼠标→
音箱→
null

并发扩容时(两个线程同时 transfer):

线程A扩容到一半被挂起,线程B完成了扩容(链表反转为 音箱→鼠标→键盘→null)

线程A醒来继续用旧引用工作,结果 B→A(已反转)和 A→B(旧引用)同时存在

形成环形链表:

键盘
鼠标
音箱
死循环!
⚠️ 注意:Java 8 改进了 resize 逻辑(用尾插法代替头插法),不会形成环形链表了。但 HashMap 仍然不是线程安全的!并发 put 仍然会导致数据丢失、size 不对、NullPointerException 等问题。

✅ 修复

❌ 修复前 — HashMap(不安全)
1private Map<String,Integer> statsMap = new HashMap<>();
✅ 修复后 — ConcurrentHashMap(安全)
1private Map<String,Integer> statsMap = new ConcurrentHashMap<>();
但等一下... 即使换了 ConcurrentHashMap,recordOrder 里的 get + put 也是复合操作,不是原子的!还是用 compute 方法吧:
OrderStatsService.java (修复版)
📁 service
☕ OrderStatsService
1public class OrderStatsService {
2 private Map<String, Integer> statsMap = new ConcurrentHashMap<>();
3
4 // ✅ 用 compute 保证原子性
5 public void recordOrder(String product) {
6 statsMap.compute(product, (k, v) -> v == null ? 1 : v + 1);
7 }
8}

🧪 验证修复

# 重启服务后,压测1000并发 $ wrk -t12 -c1000 -d30s http://localhost:8080/order/create Thread Stats Avg Stdev Max Latency 12ms 5ms 89ms ✅ 不再超时 $ jstack 12345 | grep -c "HashMap" 0 ✅ 没有线程卡在HashMap了 $ top -Hp 12345 PID USER %CPU COMMAND 6789 app 12.3 java ✅ CPU正常
✅ 结果:CPU从99.8%降到12.3%,接口响应正常,statsMap数据正确无丢失。

🟡 场景2: HashMap 内存泄漏 — Key对象被修改了

🟡 HashMap 内存泄漏 — 用可变对象做Key,改了以后再也拿不出来

🎬 什么时候出: 你用一个自定义对象(比如Store)做HashMap的Key,但put之后有人改了这个对象的字段。因为hashCode()基于字段计算,改了字段后hashCode变了,这个entry就"丢了"——get不到、remove不掉,但还占着内存。

📋 问题代码

Store.java
StoreService.java
📁 model
☕ Store
📁 service
☕ StoreService
1public class Store {
2 private String name; // 店名 — 会变!
3 private String address;
4
5 // hashCode 基于 name 计算
6 @Override
7 public int hashCode() { return name.hashCode(); }
8 @Override
9 public boolean equals(Object o) { ... name.equals(...) ... }
10}
StoreService.java
📁 service
☕ StoreService
1public class StoreService {
2 private Map<Store, List<Order>> storeOrderCache = new HashMap<>();
3
4 public void cacheOrders(Store store, List<Order> orders) {
5 storeOrderCache.put(store, orders); // put时 hashCode=store.name的hash
6 }
7}
8
9// ❌ 某天运营改了店名
10store.setName("朝阳旗舰店"); // hashCode变了!
11storeOrderCache.get(store); // → null!找不到
12storeOrderCache.size(); // → 1(还在,但永远拿不出来)

📊 原因图解

📍 put 时 vs get 时的 hashCode 不一样了

Put 时:name="朝阳店",hashCode=12345 → 存在 table[7]

table[7]
Store(朝阳店)→[orders]

改名字后 get 时:name="朝阳旗舰店",hashCode=67890 → 去 table[3] 找

table[3]
null ← 找不到!
而真正的数据还在 →
table[7]
Store(朝阳旗舰店)→[orders] ← 孤儿!

🔍 怎么发现

# 服务运行几周后,内存持续增长 $ jmap -histo 12345 | head -20 num #instances #bytes class name 1: 489213 624587200 java.util.HashMap$Node 2: 28456 45892312 com.milktea.model.Store 3: 15623 12567890 java.util.ArrayList # 49万个HashMap$Node!但业务上应该只有几百个门店 # 每次改店名都产生一个"孤儿"entry,永远无法GC

✅ 修复方案

❌ 用可变对象做Key
1// Store的name会变,hashCode不稳定
2Map<Store, List<Order>> cache = new HashMap<>();
✅ 用不可变的ID做Key
1// 用storeId(String不可变)做Key
2Map<String, List<Order>> cache = new HashMap<>();
3cache.put(store.getId(), orders);
✅ 验证:改完后重启,jmap观察一周,HashMap$Node数量稳定在200+,不再增长。内存恢复正常。

🏗️ HashMap vs ConcurrentHashMap 结构对比
HashMap — 无同步保护
单个数组 + 链表/红黑树,多线程竞争同一张表
[0]
k1
k2
[1]
k3
[2]
[3]
k4
k5
k6
❌ 无同步 → 多线程不安全,并发扩容可死循环
ConcurrentHashMap — 分段锁 / CAS
JDK7: Segment分段锁  |  JDK8: Node[] + CAS + synchronized
Segment 0 / 桶[0-3]
[0]
k1
[1]
k3
Segment 1 / 桶[4-7]
[4]
k4
k5
[5]
✅ 分段锁/CAS → 线程安全,高并发读写
⚠️ 但注意:ConcurrentHashMap 保证单次操作的线程安全,复合操作仍需额外同步!
if(map.get(key)==null) map.put(key,val) 这种"先查再改"不是原子的 → 详见下方场景3
🟣 场景3: ConcurrentHashMap 误用 — 库存扣成负数

🟣 ConcurrentHashMap 误用 — 复合操作不是原子的!库存扣成负数

🎬 什么时候出: 你以为用了 ConcurrentHashMap 就线程安全了,但你的代码是"先get再判断再put"三步走。虽然每一步都是线程安全的,但三步之间其他线程可以插进来,导致最终结果不对。
🚨 线上问题:商品"无线耳机"库存出现 -3!明明库存只有5杯,却卖了8杯!

📋 问题代码

InventoryService.java
📁 service
☕ InventoryService
1public class InventoryService {
2 private ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
3
4 // ❌ 以为线程安全,其实不是!
5 public boolean deductStock(String product, int qty) {
6 Integer current = stock.get(product); // 步骤1:读(原子)
7 if (current != null && current >= qty) { // 步骤2:判断
8 stock.put(product, current - qty); // 步骤3:写(原子)
9 return true;
10 }
11 return false;
12 }
13}

📊 竞态条件图解

📍 两个用户同时买"无线耳机",库存剩5杯,每人买3杯

T1
线程A
stock.get("无线耳机") → 5
T2
线程B
stock.get("无线耳机") → 5
T3
线程A
判断 5 >= 3 → ✓ 通过
T4
线程B
判断 5 >= 3 → ✓ 通过
T5
线程A
stock.put → 5-3 = 2
T6
线程B
stock.put → 5-3 = 2(应该是-1但覆盖了)
T7
结果
stock = 2,但实际卖了6杯(5杯库存卖了6杯 = 超卖1杯

如果3个用户同时买呢?库存5杯卖9杯 = 库存变 -1

✅ 修复方案

InventoryService.java (修复版)
📁 service
☕ InventoryService
1public class InventoryService {
2 // 方案1:用 compute 保证原子性(推荐)
3 public boolean deductStock(String product, int qty) {
4 AtomicBoolean success = new AtomicBoolean(false);
5 stock.compute(product, (k, v) -> {
6 if (v != null && v >= qty) {
7 success.set(true);
8 return v - qty;
9 }
10 return v;
11 });
12 return success.get();
13 }
14
15 // 方案2:用 AtomicLong 更简洁
16 private ConcurrentHashMap<String, AtomicLong> stock2 = new ConcurrentHashMap<>();
17
18 public boolean deductStock2(String product, int qty) {
19 AtomicLong count = stock2.get(product);
20 while (true) {
21 long cur = count.get();
22 if (cur < qty) return false;
23 if (count.compareAndSet(cur, cur - qty)) return true;
24 }
25 }
26}
✅ 验证:压测100并发同时扣库存,库存始终不为负数,所有成功扣减数量之和 = 初始库存。

📦 ArrayList vs LinkedList 内存布局对比
ArrayList — 连续内存数组
[0]
[1]
[2]
[3]
[4]
[5]
[6]
[7]
[8]
[9]
内存地址连续,CPU缓存友好
随机访问 O(1) 尾部插入 O(1)* 中间插入 O(n)
✅ 连续内存 → O(1) 随机访问,适合读多写少
LinkedList — 分散链式节点
prev|val|next
prev|val|next
prev|val|next
prev|val|next
节点分散在堆内存各处,无缓存局部性
随机访问 O(n) 头尾插入 O(1) 中间插入 O(1)*
⚠️ 分散节点 → 只能从头遍历 O(n),频繁get()极慢
💡 选型建议
绝大多数场景用 ArrayList(读多写少、随机访问)。只有频繁在头/尾插入删除时才考虑 LinkedList。
🟢 场景4: ArrayList ConcurrentModificationException

🟢 ArrayList 并发修改 — 遍历时另一个线程 add,直接报错

🎬 什么时候出: 一个线程用 for-each 遍历 ArrayList,另一个线程同时 add 或 remove。ArrayList 内部有个 modCount 计数器,遍历时检查发现 modCount 变了,直接抛 ConcurrentModificationException。

📋 问题代码

OrderProcessor.java
📁 service
☕ OrderProcessor
1public class OrderProcessor {
2 // ❌ 线程不安全的ArrayList
3 private List<Order> pendingOrders = new ArrayList<>();
4
5 // 线程A:定时处理待处理订单(每5秒)
6 @Scheduled(fixedRate = 5000)
7 public void processOrders() {
8 for (Order order : pendingOrders) { // ❌ for-each遍历
9 process(order);
10 }
11 }
12
13 // 线程B:用户下单添加新订单(HTTP请求线程)
14 public void addOrder(Order order) {
15 pendingOrders.add(order); // ❌ 同时修改!modCount++
16 }
17}

📊 异常日志

java.util.ConcurrentModificationException at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909) at java.util.ArrayList$Itr.next(ArrayList.java:859) at com.milktea.service.OrderProcessor.processOrders(OrderProcessor.java:8) # ArrayList的迭代器检查:expectedModCount != modCount # 创建迭代器时 expectedModCount = 5 # 另一个线程add后 modCount = 6 # 迭代器发现不一致 → 抛异常!

✅ 修复

✅ 方案1:CopyOnWriteArrayList
1// 读多写少时推荐
2private List<Order> pending = new CopyOnWriteArrayList<>();
3// add时复制整个数组,遍历用旧数组
4// 不会ConcurrentModificationException!
✅ 方案2:加锁
1synchronized(pendingOrders) {
2 for (Order o : pendingOrders) {
3 process(o);
4 }
5}
💡 单线程也可能出这个错:在 for-each 里删除元素也会 CME!正确做法是用迭代器的 remove() 方法,或者用 removeIf()。

📈 HashMap 扩容过程图
容量16
负载因子 0.75
元素 > 12
触发扩容阈值
创建新数组
新容量 = 32
重新hash分配
所有元素重新计算位置
扩容完成
继续正常操作
扩容前 — 容量16
[0] k1
[1] k2→k7
[2]
[3] k3→k5→k9
[4]
[5] k6
扩容后 — 容量32
[0] k1
[1] k2
[2]
[3] k3
[19] k5
[17] k7→k9
⚠️ 扩容要重新计算所有元素位置(rehash),很耗时!如果在并发环境下,多个线程同时扩容可能导致链表成环 → 死循环(场景1)
🔴 场景5: ArrayList 大数据 OOM — 一次性加载500万条

🔴 ArrayList 大数据 OOM — 一次性查500万订单,直接OOM

🎬 什么时候出: 导出功能直接 selectList(null) 把全表数据加载到内存。表里500万行,每个Order对象约500字节,500万 × 500字节 = 2.5GB。而JVM堆才2GB,直接OOM。

📋 问题代码

OrderExportService.java
OrderMapper.xml
📁 service
☕ OrderExportService
📁 mapper
📄 OrderMapper.xml
1public class OrderExportService {
2
3 // ❌ 直接查全部!
4 public void exportAll() {
5 List<Order> all = orderMapper.selectList(null);
6 // 数据库500万订单 → ArrayList要2.5GB内存
7 // JVM -Xmx2g → 💥 OOM!
8 for (Order o : all) {
9 writeToExcel(o);
10 }
11 }
12}

📊 OOM日志

java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3210) at java.util.ArrayList.grow(ArrayList.java:248) at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:181) # ArrayList内部是数组,扩容时 Arrays.copyOf 分配大数组 # 500万 × 500字节 ≈ 2.5GB,超过-Xmx2g

✅ 修复:三种方案

方案1: 分批查询
📁 service
☕ OrderExport
1// ✅ 方案1:分批查询(最常用)
2int pageSize = 1000;
3int lastId = 0;
4while (true) {
5 List<Order> batch = orderMapper.selectBatch(lastId, pageSize);
6 if (batch.isEmpty()) break;
7 writeToExcel(batch);
8 lastId = batch.get(batch.size()-1).getId();
9 batch = null; // 帮助GC
10}
方案2: MyBatis游标
1// ✅ 方案2:MyBatis Cursor(推荐)
2try (Cursor<Order> cursor = orderMapper.selectCursor()) {
3 cursor.forEach(order -> writeToExcel(order));
4 // Cursor每次只加载一条到内存!
5}
方案3: EasyExcel流式写入
1// ✅ 方案3:EasyExcel(导出大文件最佳)
2String file = "/tmp/orders.xlsx";
3EasyExcel.write(file, Order.class).sheet("订单")
4 .registerWriteHandler(new ChunkWriteHandler())
5 .doWrite(() -> new OrderDataProvider(orderMapper));

🔵 场景6: LinkedList 误用 — 随机访问 O(n) 很慢

🔵 LinkedList 误用 — 当队列用却频繁 get(position)

🎬 什么时候出: 用 LinkedList 当队列,这没问题(addLast/removeFirst 都是 O(1))。但有人在代码里调了 get(index) 或 indexOf(),LinkedList 的随机访问是 O(n),从头遍历,数据多了就巨慢。

📋 问题代码

QueueService.java
📁 service
☕ QueueService
1public class QueueService {
2 private LinkedList<Order> queue = new LinkedList<>();
3
4 public void enqueue(Order o) { queue.addLast(o); } // O(1) ✓
5 public Order dequeue() { return queue.removeFirst(); } // O(1) ✓
6
7 // ❌ 新需求:查看第N个人的订单
8 public Order peekAt(int pos) {
9 return queue.get(pos); // O(n)!从头遍历到pos
10 }
11
12 // ❌ 查找某个订单在队伍中的位置
13 public int findPosition(Order o) {
14 return queue.indexOf(o); // O(n) 遍历整个链表
15 }

📊 Arthas 性能分析

# 用Arthas追踪方法耗时 $ trace com.milktea.service.QueueService peekAt +---[452.3ms] java.util.LinkedList:get() +---[449.8ms] java.util.LinkedList:node() # node()从头遍历5000个节点! # 对比ArrayList的get $ trace com.milktea.service.QueueService peekAtArrayList +---[0.02ms] java.util.ArrayList:get() # 直接通过下标访问数组,O(1)

✅ 修复

选型原则:
• 纯队列操作(addLast/removeFirst)→ LinkedList 或 ArrayDeque 都行
• 需要随机访问 get(index) → 用 ArrayList
• 需要队列+随机访问 → 用 ArrayList + 头指针
• Java 推荐用 ArrayDeque 代替 LinkedList 当队列(性能更好,缓存更友好)

🟠 场景7: TreeMap 排序异常 — 同分产品被去重了

🟠 TreeMap / TreeSet 排序异常 — compareTo 不一致导致数据丢失

🎬 什么时候出: 往 TreeSet 里放对象,对象的 compareTo 只比较了一个字段(比如评分rating)。两个不同产品评分相同,compareTo 返回 0,TreeSet 认为"相等"只保留一个。结果你的产品列表少了好多数据!

📋 问题代码

Product.java
MenuService.java
📁 model
☕ Product
1public class Product implements Comparable<Product> {
2 private String name;
3 private double rating;
4
5 @Override
6 public int compareTo(Product other) {
7 return Double.compare(this.rating, other.rating);
8 // ❌ 只比较了rating!
9 // 如果两个产品rating相同,compareTo返回0
10 // TreeSet认为它们"相等",只保留一个
11 }
12}
// 测试 $ TreeSet<Product> sorted = new TreeSet<>(); sorted.add(new Product("键盘", 4.5)); sorted.add(new Product("蓝牙鼠标", 4.5)); // rating也是4.5 sorted.add(new Product("机械键盘", 4.5)); // rating也是4.5 sorted.size(); → 1 // 应该是3!只剩了"键盘" // 因为 compareTo("键盘", "蓝牙鼠标") == 0 // TreeSet认为它们是同一个元素,后面的add被忽略了

✅ 修复

修复版 compareTo
1@Override
2public int compareTo(Product other) {
3 int cmp = Double.compare(this.rating, other.rating);
4 if (cmp != 0) return cmp;
5 return this.name.compareTo(other.name); // ✅ 加上第二排序条件
6}

🟢 场景8: unmodifiableList 误用 — 返回了"不可变"但数据还是变了

🟢 Collections.unmodifiableList 误用 — 包装了但原List变了,"不可变"也跟着变

🎬 什么时候出: 你用 Collections.unmodifiableList() 包装后返回,以为外部改不了。但如果有人修改了原始的 ArrayList,"不可变"的包装视图也跟着变了!因为它们共享同一个底层数组。

📋 问题代码

MenuService.java
📁 service
☕ MenuService
1public class MenuService {
2 private List<Product> products = new ArrayList<>();
3
4 // 返回"不可修改"的菜单
5 public List<Product> getMenu() {
6 return Collections.unmodifiableList(products);
7 // ❌ 只是包装了一层,底层数组还是共享的!
8 }
9
10 public void addProduct(Product p) {
11 products.add(p); // 修改原list
12 // 之前返回的"不可变列表"也跟着变了!
13 }
14}

📊 验证这个问题

$ // 测试代码 MenuService service = new MenuService(); List<Product> menu = service.getMenu(); // 拿到"不可变"菜单 menu.size(); → 5 service.addProduct(new Product("新品技术")); menu.size(); → 6!变了! // menu是"不可变"的,但底层products变了,menu也跟着变了 // 外部调用方看到的菜单突然多了个产品,但没人调用过修改接口!

✅ 修复

MenuService.java (修复版)
1public List<Product> getMenu() {
2 // ✅ 创建新副本再包装
3 return Collections.unmodifiableList(new ArrayList<>(products));
4 // new ArrayList<>(products) 创建了副本,和原list无关
5}
💡 更好的方案:List.of()(Java 9+)或 Guava 的 ImmutableList.copyOf(),它们创建的是真正不可变的副本。

🗂️ 集合选型速查图
集合类型 有序 线程安全 查找复杂度 适用场景
ArrayList ✅ 插入序 O(1) 随机访问 读多写少,遍历为主,大多数场景首选
LinkedList ✅ 插入序 O(n) 随机访问 频繁头尾增删,队列/双端队列场景
HashMap ❌ 无序 O(1) 平均 键值查找,缓存,单线程统计
ConcurrentHashMap ❌ 无序 O(1) 平均 多线程共享Map,并发缓存,计数器
TreeMap ✅ 排序序 O(log n) 需要排序/范围查询,如排行榜、区间查找
HashSet ❌ 无序 O(1) 平均 去重,集合运算(交/并/差),存在性判断
✅ 安全选型口诀
单线程 → ArrayList + HashMap
多线程 → ConcurrentHashMap + CopyOnWriteArrayList
要排序 → TreeMap / TreeSet
❌ 常见踩坑
HashMap多线程put → 死循环/数据丢失
LinkedList频繁get(position) → O(n)极慢
TreeMap的compareTo不一致 → 数据"丢失"

📝 总结 — 集合选型速查表

场景 ❌ 错误选择 ✅ 正确选择 问题
多线程写MapHashMapConcurrentHashMap死循环/数据丢失
可变对象做KeyStore对象String id内存泄漏
原子性操作get+判断+putcompute/CAS超卖/库存负数
遍历+修改ArrayList+for-eachCopyOnWriteArrayListCME异常
大数据加载selectList全查分批/CursorOOM
需要随机访问LinkedListArrayList/ArrayDequeO(n)慢
排序去重compareTo只比一字段多字段比较数据丢失
返回只读列表unmodifiable(原list)unmodifiable(new ArrayList)数据偷偷变了
💡 记忆口诀:
多线程共享? → ConcurrentHashMap,别用HashMap
复合操作? → compute/cas,别get-then-put
遍历时修改? → CopyOnWriteArrayList或加锁
大数据? → 分批/游标,别selectList全查
Map的Key? → 不可变对象(String/Integer),别用可变对象
返回只读? → 先copy再包装,别直接包原list

🔥 补充案例 — 更多工作中真实遇到的问题

📌 补充1: HashMap 初始容量不设 — 频繁扩容拖慢性能

🎬 什么时候出: 你知道要往HashMap里放10000条数据,但没指定初始容量。HashMap默认容量16,负载因子0.75。每次到阈值就扩容(16→32→64→128→...),每次扩容都要重新计算所有key的hash、重建数组。放10000条数据要扩容 10次

📋 问题代码

ProductBatchService.java
📁 service
☕ ProductBatchService
1// ❌ 知道要放10000条,但不设容量
2Map<String, Product> map = new HashMap<>();
3for (Product p : allProducts) { // allProducts.size() = 10000
4 map.put(p.getId(), p); // 16→32→64→128→...→16384 扩容10次!
5}
// 扩容过程:每次达到 threshold = capacity * 0.75 就扩容 16 × 0.75 = 12 → put第13个时扩到32 32 × 0.75 = 24 → put第25个时扩到64 64 × 0.75 = 48 → put第49个时扩到128 128 × 0.75 = 96 → put第97个时扩到256 ... 总共扩容 10 次!每次扩容都要重新hash所有已有元素

✅ 修复

修复版 — 指定初始容量
1// ✅ 指定初始容量 = 预期大小 / 0.75 + 1
2// 10000 / 0.75 = 13333,取2的幂次 = 16384
3Map<String, Product> map = new HashMap<>(13334);
4// 一次扩容都不用!
💡 记忆公式:如果知道要放N个元素,初始容量设 N / 0.75 + 1(向上取2的幂次),这样就不用扩容。

📌 补充2: synchronizedMap vs ConcurrentHashMap — 性能差很多

🎬 什么时候出: 有人知道HashMap不安全,用了 Collections.synchronizedMap()。虽然线程安全了,但每次get/put都锁整个Map,并发一高就全堵住了。ConcurrentHashMap 用分段锁(Java 7)或 CAS + synchronized(Java 8),只锁一个桶,并发性能好得多。
❌ synchronizedMap — 锁整个Map
1Map<String,Integer> map =
2 Collections.synchronizedMap(new HashMap<>());
3// get → synchronized(map) { ... }
4// put → synchronized(map) { ... }
5// 100个线程全部排队等同一把锁!
✅ ConcurrentHashMap — 只锁一个桶
1Map<String,Integer> map =
2 new ConcurrentHashMap<>();
3// get → 无锁读取(volatile)
4// put → 只锁hash冲突的那个桶
5// 100个线程可以同时写不同的桶!
// 性能对比:100线程并发put 100万条 Collections.synchronizedMap: 8.5s ConcurrentHashMap: 1.2s // 快7倍!

📌 补充3: Arrays.asList() 返回的List不能add/remove

🎬 什么时候出: 你用 Arrays.asList() 快速创建了一个List,然后往里add元素,直接 UnsupportedOperationException!因为 Arrays.asList() 返回的是 Arrays 内部的 ArrayList,不是 java.util.ArrayList,它没有实现 add/remove 方法。
问题代码
1// ❌ Arrays.asList 返回的是固定大小的List
2List<String> products = Arrays.asList("键盘", "鼠标", "音箱");
3products.add("机械"); // 💥 UnsupportedOperationException!
4
5// ✅ 修复:包装成真正的ArrayList
6List<String> products = new ArrayList<>(Arrays.asList("键盘", "鼠标", "音箱"));
7products.add("机械"); // ✅ OK!

📌 补充4: ArrayList.subList() 导致原List无法GC

🎬 什么时候出: 你用 list.subList(0, 10) 取前10个元素,然后用完了subList不要了。但你不知道 subList 返回的是原List的一个"视图",它内部持有着原List的引用!原List有100万条数据,但只通过subList"暴露"了10条,原List无法被GC回收。
问题代码
1List<Order> allOrders = orderMapper.selectAll(); // 100万条
2
3// ❌ subList是视图,持有allOrders的引用
4List<Order> top10 = allOrders.subList(0, 10);
5allOrders = null; // 你以为allOrders可以被GC了?
6// ❌ 不能!top10内部还持有100万条的引用!
7
8// ✅ 修复:创建新List,断开引用
9List<Order> top10 = new ArrayList<>(allOrders.subList(0, 10));
10allOrders = null; // ✅ 现在allOrders可以被GC了

📌 补充5: HashSet 也是线程不安全的!别在多线程用

🎬 什么时候出: HashSet 内部就是 HashMap(value是一个固定的Object),所以 HashSet 有 HashMap 一样的问题:并发 add 可能死循环、数据丢失。但很多人不知道,以为 Set 没问题。
❌ 并发用HashSet
1// ❌ HashSet = new HashMap<>()
2Set<String> processed = new HashSet<>();
3// 多线程 add → 和HashMap一样的问题!
✅ 用ConcurrentHashMap.newKeySet
1// ✅ 线程安全的Set
2Set<String> processed =
3 ConcurrentHashMap.newKeySet();
4// 或者用 CopyOnWriteArraySet