集合是每天都要用的东西,但「天天用」不等于「用对了」。这篇把底层原理、选型和一个个真实的坑写在一起。
一、先搞清这张关系图
二、ArrayList:扩容是隐形开销
ArrayList 默认容量 10,满了按 1.5 倍扩容(oldCapacity + (oldCapacity >> 1)),扩容靠 Arrays.copyOf 拷贝整个数组。
- 能预估大小就一定要传初始容量:
new ArrayList<>(expectedSize)。往里塞 100 万条数据,不设初始容量会扩容约 25 次,每次拷贝一套数组,白白多占内存和 CPU。 subList()返回的是视图,不是新集合:对原 list 做结构性修改会抛ConcurrentModificationException;更坑的是subList持有原 list 引用,如果把一个很小的subList缓存起来,整个大 list 都释放不掉 —— 这就是隐蔽的内存泄漏。要新集合就new ArrayList<>(list.subList(...))。- 删除用
Iterator.remove()或removeIf():for循环里list.remove(i)会漏元素,因为下标在动。
三、HashMap:原理要懂,坑更要记
JDK 8 之后是 数组 + 链表 + 红黑树:
- 默认容量 16,负载因子 0.75,容量到
16 × 0.75 = 12时扩容为 2 倍。 - 哈希:
h ^ (h >>> 16),把高位参与运算减少碰撞。 - 链表长度 ≥ 8 且数组长度 ≥ 64 时树化(转红黑树);≤ 6 时退化回链表。
- 扩容在 JDK 8 后是尾插法,已经不会死循环了,但 JDK 7 的头插法在并发
put+ 扩容时会产生环形链表,CPU 直接 100%。老项目升级前值得确认一下 JDK 版本。
必踩的坑:
- key 必须正确实现
hashCode和equals,且用作 key 的对象要不可变。用了可变对象当 key,改了字段之后这个 entry 就再也找不回来了(内存泄漏)。 HashMap不是线程安全的,并发场景用ConcurrentHashMap,不要用Collections.synchronizedMap(全表锁,性能差太多)。- 能预估大小就设初始容量:
new HashMap<>(expected / 0.75 + 1)。避免 resize。
四、ConcurrentHashMap 怎么用对
JDK 8 的实现是 Node 数组 + CAS + synchronized(只锁单个桶),读操作基本无锁。
| 操作 | 行为 | 注意 |
|---|---|---|
get |
无锁,volatile 读 | 一定是最新值 |
put |
空桶 CAS;非空桶 synchronized 锁头节点 | 锁粒度是桶,并发度 ≈ 桶数 |
size() |
返回的是估算值 | 并发下不精确;要精确用 mappingCount()(也只是近似) |
putIfAbsent |
原子 | 不要用 get + put 自己实现,有竞态 |
computeIfAbsent |
原子,但要小心 | 回调里不要再对同一个 map 做写操作,会死锁 |
computeIfAbsent 死锁是我真实踩过的:在它的映射函数里又对同一个 map 做 computeIfAbsent(哪怕是不同的 key,只要落在同一个桶),JDK 8 下会直接死锁。规则:映射函数保持纯粹,不做复杂逻辑、不碰同一个 map。
五、选型速查表
| 需求 | 选这个 | 为什么 |
|---|---|---|
| 随机访问多、尾部增删 | ArrayList |
数组连续内存,缓存友好 |
| 头尾频繁增删 | ArrayDeque |
比 LinkedList 快,内存也省 |
| 需要去重 | HashSet |
底层就是 HashMap |
| 需要按插入顺序遍历 | LinkedHashMap / LinkedHashSet |
维护双向链表 |
| 需要排序 | TreeMap / TreeSet |
红黑树,O(log n) |
| LRU 缓存 | LinkedHashMap(accessOrder=true) + removeEldestEntry |
手写最小实现 |
| 并发 Map | ConcurrentHashMap |
分段锁 / CAS |
| 生产者消费者 | BlockingQueue(ArrayBlockingQueue / LinkedBlockingQueue) |
自带阻塞,别自己写 wait/notify |
| 优先级任务 | PriorityQueue |
堆,注意不是线程安全的,并发用 PriorityBlockingQueue |
六、几个零碎但重要的点
Arrays.asList()返回的是固定长度的 List,add/remove会抛UnsupportedOperationException。要可变就new ArrayList<>(Arrays.asList(...))。List.of()(JDK 9+)返回不可变集合,连set都不行,且不允许 null 元素。- 遍历时删除统一用
removeIf()或iterator.remove()。 Map遍历用entrySet(),不要keySet()再get(多一次哈希查找)。- 集合作为返回值不要返回 null,返回
Collections.emptyList(),避免调用方 NPE。 HashSet的remove依赖于hashCode:放进集合后修改参与哈希的字段,就删不掉了。
七、一个真实的小事故
某次线上定时任务内存缓慢上涨,几天后 OOM。dump 里看到几百万个 HashMap$Node,查下来是一个静态 Map 当缓存用,key 是自定义的复合对象,而那个对象里有个字段在业务里会被更新 —— key 变了,旧 entry 永远删不掉,Map 只进不出。
修法两条:一是 key 改成不可变(用 record 或只放不可变字段),二是缓存换 Caffeine 并设置 maximumSize 和过期时间。任何时候用 Map 当缓存,都要问一句:它会不会只增不减?
一起聊聊