Java 与 JVM 列表全部文章
Java 与 JVM
技术笔记›Java 与 JVM第 5 / 6 篇

Java 集合笔记:HashMap 原理、ArrayList 扩容与那些线上踩过的坑

把 ArrayList、HashMap、ConcurrentHashMap、LinkedHashMap、PriorityQueue 的底层原理、扩容机制和选型建议讲清楚,重点记录 ConcurrentHashMap 的 size 不准、HashMap 死循环、subList 导致 OOM、 Arrays.asList 不可变这些真实踩过的坑。

更多

集合是每天都要用的东西,但「天天用」不等于「用对了」。这篇把底层原理、选型和一个个真实的坑写在一起。

一、先搞清这张关系图

Collection List(有序可重复) ArrayList 数组 LinkedList 双向链表 Vector 同步(已过时) Set(无序去重) HashSet = HashMap TreeSet 红黑树有序 LinkedHashSet 保序 Queue(队列) PriorityQueue 堆 ArrayDeque 双端 BlockingQueue 阻塞 Map HashMap LinkedHashMap TreeMap ConcurrentHashMap

二、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 版本。

必踩的坑:

  1. key 必须正确实现 hashCode 和 equals,且用作 key 的对象要不可变。用了可变对象当 key,改了字段之后这个 entry 就再也找不回来了(内存泄漏)。
  2. HashMap 不是线程安全的,并发场景用 ConcurrentHashMap,不要用 Collections.synchronizedMap(全表锁,性能差太多)。
  3. 能预估大小就设初始容量: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 当缓存,都要问一句:它会不会只增不减?

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人