从源码角度看 Java 集合三剑客:ArrayList、HashMap、ConcurrentHashMap

写给那些不满足于只会用 .add().put() 的人。

大家好,我是你们的老朋友——一个写了快十年 Java 的架构师。今天咱们不聊微服务、不扯 Kubernetes,就坐下来泡杯茶,翻开 JDK 源码,看看我们天天打交道的那几个集合类到底长啥样。

你可能会说:“哎呀,ArrayList 不就是个动态数组嘛,HashMap 就是哈希表,ConcurrentHashMap 是线程安全的 HashMap……这有啥好讲的?”
但真要问你一句:“扩容的时候怎么搬数据?HashMap 的链表什么时候转红黑树?ConcurrentHashMap 到底是怎么做到高并发还不死锁的?”
很多人就卡壳了。

所以,今天我们就从源码出发,把这三个“熟面孔”扒个底朝朝天。


一、ArrayList:看似简单,细节满满

先说 ArrayList。它底层就是一个 Object[] 数组,对吧?但你有没有注意过它的初始容量?

public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

很多人以为 new ArrayList() 出来就是空数组,其实不是。JDK 8 之后做了懒初始化优化:只有第一次 add 的时候才会真正分配长度为 10 的数组。这是为了省内存——如果你只是 new 了一个 list 但没用,就不浪费那 10 个引用的空间。

再看扩容逻辑:

private void grow(int minCapacity) {
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5 倍扩容
    // ...
}

注意:不是翻倍,是 1.5 倍。为什么?因为翻倍太激进了,容易造成内存浪费。1.5 倍是个经验值,在增长速度和内存利用率之间取得平衡。

另外,System.arraycopy 是 native 方法,效率极高。所以 ArrayList 的随机访问快(O(1)),但中间插入/删除慢(O(n))——这些你都知道,但你知道它背后是靠 arraycopy 在默默扛着吗?


二、HashMap:不只是“键值对”,而是一场工程权衡

HashMap 可谓是 Java 里最复杂的集合之一(别笑,真不算夸张)。JDK 8 之后,它融合了数组 + 链表 + 红黑树三种结构。

先看 put 流程:

  1. 计算 key 的 hash(注意:不是直接用 hashCode,而是做了扰动:h ^ (h >>> 16),减少低位冲突)
  2. 根据 (n-1) & hash 定位桶位置(n 是 2 的幂,所以位运算快)
  3. 如果桶为空,直接放;否则遍历链表
  4. 如果链表长度 ≥ 8 数组长度 ≥ 64,才转红黑树

这里有个经典误区:不是链表长度到 8 就立刻转树! 必须同时满足数组长度 ≥ 64。否则,它会优先扩容。因为小数组时,冲突多是正常的,扩容能更高效地分散元素。

再看 resize(扩容):

  • 新容量 = 旧容量 * 2
  • 元素要么留在原位置,要么移到 原位置 + 旧容量 的位置
  • 这个设计妙在哪?不需要重新计算 hash! 因为 (n-1) & hash 的高位决定了是否偏移,低位不变。

这种“分而治之”的迁移策略,让扩容效率大幅提升。

还有一点:HashMap 不是线程安全的。两个线程同时 put,可能造成链表成环(JDK 7 的经典 bug),JDK 8 虽然用尾插法缓解了,但依然不安全。所以——别在并发场景瞎用!


三、ConcurrentHashMap:高并发下的“精密仪器”

如果说 HashMap 是一把瑞士军刀,那 ConcurrentHashMap 就是一台数控机床——复杂,但精准高效。

JDK 7 用的是 分段锁(Segment),JDK 8 直接干掉了 Segment,改用 CAS + synchronized 锁单个桶头节点

为什么敢这么干?因为:

  • synchronized 在 JVM 层面已经极度优化(偏向锁、轻量级锁等)
  • 锁粒度更细:只锁当前桶,而不是整个 Segment
  • CAS 用于无竞争情况下的快速插入

看下 putVal 的关键逻辑(简化版):

if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    if (casTabAt(tab, i, null, newNode(hash, key, value, null)))
        break; // CAS 成功,直接退出
} else {
    synchronized (f) { // 锁住桶头
        // 正常插入或树化
    }
}

注意:只有在桶非空时才加 synchronized,而且只锁那个 Node。这意味着,16 个线程同时操作不同桶,完全可以并行!

另外,size() 方法也不再需要全局锁。它通过 CounterCell + baseCount 实现类似 LongAdder 的机制,高并发下统计更高效。

还有一点很多人忽略:ConcurrentHashMap 不允许 key 或 value 为 null。为什么?因为在并发环境下,null 无法区分“没存过”还是“存了 null”。HashMap 允许 null 是因为它是单线程语义,而 CHM 必须保证语义清晰。


结语:用得好,不如懂其所以然

我们每天都在用这些集合,但真正理解它们的人不多。作为架构师,我常说一句话:

“你不需要记住每一行源码,但你得知道它在什么情况下会崩。”

比如:

  • 在高频写入场景用 ArrayList?小心频繁扩容拖垮性能。
  • 用 HashMap 当缓存又不设初始容量?等着 resize 把你 CPU 打满吧。
  • 在多线程里图省事用 HashMap?等着半夜被 PagerDuty 叫醒吧。

读源码不是为了装逼,而是为了在关键时刻做出正确选择

下次当你写下 new HashMap<>() 的时候,不妨想一想:这个括号背后,藏着多少工程师的智慧和妥协?


延伸建议

  • 自己 clone 一份 OpenJDK 源码,用 IDEA 打开 java.util 包,亲手 trace 一次 put 流程
  • 对比 Guava 的 ImmutableMap、Eclipse Collections,看看不同设计哲学
  • 在压测中观察 ArrayList 扩容 vs LinkedList 遍历的实际性能差异

代码世界很大,但根基往往就在这些“基础类”里。稳扎稳打,方能走得更远。

—— 一个还在看源码的老 Java 人

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐