Java 集合怎么抓重点:HashMap、fail-fast 和 Arrays.asList 的坑

2次阅读
没有评论

Java 集合这块知识很散,HashMapArrayListHashSetConcurrentHashMapCopyOnWriteArrayListArrays.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 可以拆成几步:

  1. 根据 key 算 hash。
  2. 用 hash 定位桶下标。
  3. 如果桶为空,直接放进去。
  4. 如果桶里已有节点,逐个比较 hash 和 equals
  5. 找到相同 key 就覆盖 value,找不到就追加新节点。

get 也是同一套思路:先定位桶,再在桶内用 equals 找 key。

所以 hashCode 相同不代表两个对象相等。它只说明两个 key 会落在同一个桶里,最终还要靠 equals 判断是不是同一个 key。

这也解释了为什么自定义 key 一定要同时重写 equalshashCode。更进一步,key 最好是不可变对象。如果对象放进 map 以后参与 hash 的字段又变了,后续再 get 时可能定位到另一个桶,就像 key 被弄丢了一样。

为什么桶里会从链表变红黑树

大部分情况下,HashMap 的桶都很短,链表足够简单。只有 hash 冲突比较严重时,桶内元素才会越来越多。

链表查找是 O(n),桶内一长,查询就退化。JDK 8 引入树化,就是为了保护最坏情况。链表长度达到阈值后,会转成红黑树,查找从线性扫描变成树查找。

那为什么不一开始就用树?

因为树节点占用更多空间,插入和删除还要维护平衡。桶很短时,链表更轻;只有冲突真的变多,树化才值得。

为什么用红黑树而不是 AVL?

AVL 更严格,查询略快,但插入删除旋转更多。HashMap 的桶内既要查也要写,红黑树在整体成本上更均衡。Java 的 TreeMapTreeSet 也用了红黑树,理由类似。

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 背后是 IteratorArrayList 里有一个 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 必须处理好 equalshashCode 和不可变性。
  • 普通 HashMapArrayList 不是并发写容器。
  • 遍历时要用迭代器自己的删除方式,别绕开 iterator。
  • CopyOnWriteArrayList 是读多写少的快照模型。
  • Arrays.asList 不是完整可变的 ArrayList

集合问题最终都会落回两个问题:你的数据怎么访问,以及谁会在什么时候修改它。把这两个问题想清楚,选型和排障都会简单很多。

正文完
 0
bdspAdmin
版权声明:本站原创文章,由 bdspAdmin 于2026-06-20发表,共计3300字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
评论(没有评论)