Java 集合这块知识很散,HashMap、ArrayList、HashSet、ConcurrentHashMap、CopyOnWriteArrayList、Arrays.asList 都能单独讲很久。但如果是日常开发和面试复盘,我更愿意先抓三条主线:底层结构决定性能,迭代语义决定能不能边遍历边改,工具方法的返回类型决定后面会不会踩坑。
这篇就按这个顺序,把源 note 里的集合笔记收成一篇更容易回看的版本。
HashMap 先看桶、负载因子和扩容
HashMap 的核心结构可以先理解成数组加链表,JDK 8 以后桶内链表太长时会树化成红黑树。
几个词要先分清:
capacity是桶数组长度。size是当前元素数量。loadFactor是负载因子,默认0.75。threshold可以理解成触发扩容的阈值,约等于capacity * loadFactor。
当元素数量超过阈值,HashMap 会扩容,通常是容量翻倍,然后把已有元素重新分布到新桶里。这个过程不是免费的,所以如果你大概知道会放多少元素,初始化时给一个合理容量能少一次运行期 rehash。
例如预期放 1000 个元素,默认负载因子是 0.75,容量最好至少大于 1000 / 0.75。实际还要取 2 的幂,因为 JDK 8 里用 (n - 1) & hash 定位桶。
put 和 get 到底做了什么
put 可以拆成几步:
- 根据 key 算 hash。
- 用 hash 定位桶下标。
- 如果桶为空,直接放进去。
- 如果桶里已有节点,逐个比较 hash 和
equals。 - 找到相同 key 就覆盖 value,找不到就追加新节点。
get 也是同一套思路:先定位桶,再在桶内用 equals 找 key。
所以 hashCode 相同不代表两个对象相等。它只说明两个 key 会落在同一个桶里,最终还要靠 equals 判断是不是同一个 key。
这也解释了为什么自定义 key 一定要同时重写 equals 和 hashCode。更进一步,key 最好是不可变对象。如果对象放进 map 以后参与 hash 的字段又变了,后续再 get 时可能定位到另一个桶,就像 key 被弄丢了一样。
为什么桶里会从链表变红黑树
大部分情况下,HashMap 的桶都很短,链表足够简单。只有 hash 冲突比较严重时,桶内元素才会越来越多。
链表查找是 O(n),桶内一长,查询就退化。JDK 8 引入树化,就是为了保护最坏情况。链表长度达到阈值后,会转成红黑树,查找从线性扫描变成树查找。
那为什么不一开始就用树?
因为树节点占用更多空间,插入和删除还要维护平衡。桶很短时,链表更轻;只有冲突真的变多,树化才值得。
为什么用红黑树而不是 AVL?
AVL 更严格,查询略快,但插入删除旋转更多。HashMap 的桶内既要查也要写,红黑树在整体成本上更均衡。Java 的 TreeMap 和 TreeSet 也用了红黑树,理由类似。
HashMap 不是线程安全集合
普通 HashMap 不能作为多线程共享写容器。并发 put 时,可能出现覆盖、丢数据,历史上 JDK 7 的头插法 resize 还可能导致链表成环。
JDK 8 改了实现,老问题缓解了,但并不等于它线程安全。多线程场景要换思路:
- 只读配置表:构造完以后不再修改,可以安全共享。
- 少量同步写:外层加锁或使用同步包装。
- 高并发读写:优先
ConcurrentHashMap。
Hashtable 虽然方法级别同步,但整张表一把锁,并发性能差。ConcurrentHashMap 的锁粒度更细,JDK 8 以后主要是 CAS 加桶级 synchronized,更适合并发场景。
HashSet 为什么 contains 快
HashSet 底层基本就是借 HashMap 存 key,所以 contains 平均是 O(1)。ArrayList.contains 则要从头扫到尾,是 O(n)。
如果你的需求是大量查重、判断是否存在,优先用 HashSet。如果你的需求是保持顺序、按下标访问,再考虑 ArrayList。
集合选型不要只看名字,先问访问模式:
- 频繁按下标读:
ArrayList。 - 频繁头尾操作或队列语义:
LinkedList/Deque。 - 频繁判重和存在性查询:
HashSet。 - key 到 value 的查找:
HashMap。 - 多线程共享读写:并发集合。
fail-fast:为什么遍历时删除会炸
很多人都踩过这个坑:
for (String item : list) {
if ("delete".equals(item)) {
list.remove(item);
}
}
增强 for 背后是 Iterator。ArrayList 里有一个 modCount 记录集合结构被改了多少次,迭代器创建时会保存一份 expectedModCount。
你直接调用 list.remove(item),集合自己的 modCount 变了,但迭代器里的 expectedModCount 没变。下一次迭代器检查时发现两者不一致,就抛 ConcurrentModificationException。
正确写法是用迭代器自己的 remove:
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String item = iterator.next();
if ("delete".equals(item)) {
iterator.remove();
}
}
这不是 Java 和你过不去,而是在提醒你:遍历过程里集合结构已经被外部改掉,继续跑可能得到不确定结果。
CopyOnWriteArrayList 适合读多写少
CopyOnWriteArrayList 的思路是写时复制。读的时候不加锁,写的时候复制一个新数组,改完后替换引用。
它适合读多写少,比如配置快照、监听器列表。它不适合写很多的场景,因为每次写都要复制数组。
还要记住一点:它遍历的是迭代开始时的快照。遍历过程中发生的修改,不会反映到当前这次遍历里。这是它能避免 fail-fast 的原因,也是它的一致性边界。
Arrays.asList 的三个坑
Arrays.asList 看起来像数组转 List,但它返回的是 java.util.Arrays 里的内部 ArrayList,不是常用的 java.util.ArrayList。
第一个坑:返回列表大小固定,不能 add / remove。
String[] arr = {"a", "b"};
List<String> list = Arrays.asList(arr);
list.add("c"); // UnsupportedOperationException
第二个坑:它和原数组共享底层。
String[] arr = {"a", "b"};
List<String> list = Arrays.asList(arr);
arr[0] = "x";
System.out.println(list.get(0)); // x
第三个坑:基本类型数组会被当成一个元素。
int[] arr = {1, 2, 3};
List<int[]> list = Arrays.asList(arr);
System.out.println(list.size()); // 1
更稳的写法是:
String[] arr = {"a", "b"};
List<String> list = new ArrayList<>(Arrays.asList(arr));
如果是基本类型数组,JDK 8 以后可以用 stream 转成包装类型:
int[] arr = {1, 2, 3};
List<Integer> list = Arrays.stream(arr)
.boxed()
.toList();
小结
Java 集合不用死背所有类。先抓住这几条就够用了:
HashMap的关键是桶、hash、负载因子、扩容和树化。- 自定义 key 必须处理好
equals、hashCode和不可变性。 - 普通
HashMap、ArrayList不是并发写容器。 - 遍历时要用迭代器自己的删除方式,别绕开 iterator。
CopyOnWriteArrayList是读多写少的快照模型。Arrays.asList不是完整可变的ArrayList。
集合问题最终都会落回两个问题:你的数据怎么访问,以及谁会在什么时候修改它。把这两个问题想清楚,选型和排障都会简单很多。




