ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ArrayList、HashSet、HashMap底层原理与生产环境避坑实战

ArrayList、HashSet、HashMap底层原理与生产环境避坑实战 最近带团队做代码 review 的时候发现一个挺普遍的现象很多写了三四年 Java 的同学对ArrayList、HashSet、HashMap这三个集合类的使用还停留在“API 调用”层面——知道怎么 add、怎么 put但一旦问到“ArrayList 扩容到底扩多大”“HashSet 为什么能去重”“HashMap 的 get 方法是怎么找到那个元素的”就开始支支吾吾了。更麻烦的是线上真的出问题的时候比如批量插入导致频繁扩容、重写了 equals 但没重写 hashCode 导致 HashSet 去重失效、多线程操作 HashMap 直接死循环很多人第一反应是“这怎么可能”然后排查半天找不到原因。这篇文章不打算像教科书那样把所有方法罗列一遍而是围绕这三个集合最核心的底层机制、最常用的操作细节以及我实际项目中踩过的坑来写。你会搞清楚它们各自的数据结构、扩容逻辑、查找原理也会知道在什么场景下选谁、不选谁。无论你是刚入门的新人还是写了几年想补基础的老兵这篇都能给你一些能直接用的东西。1. ArrayList不只是“自动扩容的数组”那么简单1.1 扩容机制的真相什么时候扩容、扩容多大很多人知道ArrayList底层是Object[]也知道它会自动扩容但扩容的具体规则经常记混。先看关键源码逻辑// JDK 8 中的扩容核心方法我简化了一下 private void grow(int minCapacity) { int oldCapacity elementData.length; int newCapacity oldCapacity (oldCapacity 1); // 相当于 1.5 倍 if (newCapacity - minCapacity 0) { newCapacity minCapacity; } if (newCapacity - MAX_ARRAY_SIZE 0) { newCapacity hugeCapacity(minCapacity); } elementData Arrays.copyOf(elementData, newCapacity); }注意oldCapacity (oldCapacity 1)这句右移一位等价于除以 2所以扩容后新容量大约是旧容量的1.5 倍。比如默认容量 10第一次满的时候会扩容到 15第二次满的时候扩容到 22第三次到 33不是很多人以为的“翻倍”。这个设计和Vector扩容为原容量的 2 倍有明显区别1.5 倍的好处是既避免了频繁扩容浪费空间又不会像 2 倍那样在数据量大时造成太多内存浪费。这里有一个特别容易忽略的细节ArrayList的扩容是不可逆的。哪怕你删掉了很多元素底层数组的长度也不会自动缩小它只会维持已经扩容到的长度。如果你一次性 add 了 12 万个元素之后删到只剩 10 个这个ArrayList依然占着十几万容量的数组。如果这种情况持续存在建议用trimToSize()手动把数组容量收缩到当前元素个数或者直接重新new ArrayList(list)。我见过一个实际案例一个定时任务每次从数据库读出 5000 条记录放进ArrayList处理后清空但由于没有重建 list容量一直停留在 5000 以上。后来数据量涨到 8 万任务每次运行都会触发多次扩容系统出现明显卡顿。把new ArrayList()改成new ArrayList(expectedSize)之后直接省掉了扩容和复制的时间。1.2 初始化时机与懒加载new ArrayList() 不等于创建了数组另一个让人意外的点new ArrayList()并不会立刻创建一个容量为 10 的数组。看 JDK 8 的源码private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA {}; public ArrayList() { this.elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA; }也就是说默认构造函数只是把一个空数组赋值给了elementData真正创建容量为 10 的数组是在第一次add的时候通过ensureCapacityInternal判断当前数组是不是那个默认空数组如果是就取DEFAULT_CAPACITY10和minCapacity中的较大值。这种“懒加载”策略在很多集合类里都有比如StringBuilder的toString()缓存以及HashMap的桶数组初始化。这个特性带来一个实践经验如果确定会存入较多元素不要使用无参构造直接给一个预估容量。比如new ArrayList(1000)可以避免在数据量不断增长时反复扩容复制数组。我自己习惯在写接口时先估算返回值大小就算估不准给一个偏大的值也比默认 10 开始一次次扩容省得多。1.3 实战中的三个习惯指定初始容量、批量添加、频繁删除场景结合这几年用ArrayList的经验有几个操作习惯值得牢记第一能用addAll(Collection)就不要循环add。比如要把另一个集合src的数据追加进来// 推荐 ListItem target new ArrayList(src.size() 100); target.addAll(src);addAll内部会先计算出最终需要的容量并一次性扩容避免循环add过程中反复触发grow。如果 src 是另一个ArrayList内部的System.arraycopy效率远高于逐个赋值。第二频繁在头部或中间插入、删除元素时重新考虑数据结构。ArrayList的add(int index, E element)和remove(int index)需要移动后续所有元素时间复杂度是 O(n)。如果你经常在列表前面插入元素比如实现一个“最近浏览记录”每次往 index0 插数据量大了以后性能会很难看。这时候可以考虑LinkedList虽然它也有坑如随机访问是 O(n)或者干脆用ArrayDeque在两端操作。我一般的原则是读多写少用 ArrayList头尾操作多用 Deque中间频繁增删要谨慎评估。第三subList()返回的是视图不是副本。很多人以为list.subList(0, 2)返回了一个新列表改它不影响原列表。实际上它返回的是同一个ArrayList内部数组的视图对子列表的结构性修改比如add、remove会反映到原列表同时会把原列表的modCount改变导致接下来原列表的迭代直接抛ConcurrentModificationException。我有一次就是在这上面吃了亏排查了半天才发现是subList的视图问题。如果确实需要独立副本请这样写ListItem copy new ArrayList(list.subList(0, 2));2. HashSet它到底是怎么保证元素“不重复”的2.1 底层就是HashMap理解成员变量HashSet表面上是个独立的集合实际上它的内部只是包装了一个HashMap。看 JDK 8 的源码片段private transient HashMapE, Object map; public HashSet() { map new HashMap(); } private static final Object PRESENT new Object(); public boolean add(E e) { return map.put(e, PRESENT) null; }没错HashSet的每个元素都是作为HashMap的 key 存在的value 统一是一个名叫PRESENT的静态 Object 占位符。所以HashSet的去重逻辑本质上就是HashMap的 key 去重逻辑。这解释了两件事一是为什么HashSet的迭代顺序不稳定因为HashMap的桶位由 hash 决定而 hash 又与对象的hashCode()有关二是为什么自定义对象放进HashSet时必须正确处理equals和hashCode。这里有个经常被忽略的点HashSet允许且只允许存一个null。因为HashMap支持null作为 key且HashMap会把null放到第 0 个桶实际上在 JDK 8 中有专门处理key null的逻辑。如果你尝试向HashSet添加两个null第二次添加时会因为 key 已存在而返回 false集合里始终只有一个 null。2.2 equals和hashCode的约定重写equals必须重写hashCode这是整个 Java 集合框架里最经典的约定也是实际开发中踩坑最多的地方。为什么HashSet去重必须同时依赖equals和hashCodeHashSet.add(e)的流程是先根据e.hashCode()找到对应的桶数组下标如果这个桶是空的直接放入如果桶里已经有元素就要检查“有没有一个元素和我相等”——这个“相等”判断用的是equals而不是地址比较。所以你看hashCode 决定元素被分到哪个桶equals 决定同一个桶里是否有重复元素。为什么不直接用equals全列表比较因为那需要 O(n) 的时间哈希表的优势就在于用 hash 快速定位把查找范围缩小到一个桶里的少数几个元素。这正是“先比 hash 再比 equals”的意义。那么约定是什么如果两个对象通过equals比较是相等的那么它们的hashCode()必须相等。反过来不要求——不同的对象可以有相同的 hashhash 碰撞是允许的。如果你重写了equals但不重写hashCode就会出现一个致命问题两个逻辑上相等的对象比如相同的 id、相同的用户名由于hashCode不同被分到不同的桶HashSet永远发现不了它们是重复的去重功能直接失效。2.3 一个实际踩坑案例用户对象去重失败我印象很深的一次线上事故业务方要从多个数据源合并用户列表用HashSetUser来去重结果发现同一个人出现了三次。查了代码才发现User类只重写了equals没有重写hashCode导致每个对象继承自Object的hashCode是内存地址不同内存地址产生的 hash 值基本不同即使equals判断相等也落不到同一个桶里。当时的修复代码很简单public class User { private Long id; private String name; Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof User)) return false; User user (User) o; return Objects.equals(id, user.id) Objects.equals(name, user.name); } Override public int hashCode() { return Objects.hash(id, name); } }需要特别注意的是Objects.hash(id, name)内部会创建一个数组如果这个对象被频繁加入集合或作为 key 使用会有一定的性能开销。如果确认id不为 null更高效的做法是return id.hashCode()甚至return (int) (id ^ (id 32))。另外hashCode里参与计算的字段应该和equals里的字段一致否则可能出现“equals 相等但 hashCode 不同”或者“hashCode 相同但 equals 永远不相等”的诡异情况。如果字段是可变的话还有个大坑把对象放进HashSet之后又修改了参与hashCode/equals的字段会导致对象的 hash 改变但它在集合中存储的桶位还是原来那个。之后再去contains、remove它就会找不到甚至可能造成两个对象 hash 一样但 equals 不相等卡在桶里。所以我一般建议放进 HashSet 或作为 HashMap key 的对象尽量设计成不可变的至少不能修改参与 hash 计算的字段。3. HashMapput和get的完整旅程HashMap是这三个集合里最复杂、最重要的一个也是面试必问、线上必用的类。它的核心是数组加链表JDK 8 之后在链表过长时转为红黑树但你真的理解一次put和一次get走完的完整路径吗我建议所有开发者都把自己的理解在纸上画一遍这一步比背十个面试题都有用。3.1 put方法从key到entry的完整链路假设你执行map.put(name, 张三)JVM 里到底发生了什么第一步计算 key 的 hash。注意HashMap不会直接用key.hashCode()而是对 hash 做了扰动static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }这里把高 16 位和低 16 位做异或目的是让高位的信息也参与到底层数组下标的计算中。因为数组长度通常比较小默认 16如果直接用原始的 hash 值取模高位信息会被丢弃碰撞概率会增大。这种扰动设计是一个很经典的位运算优化。第二步根据 hash 找桶下标。在 JDK 8 中下标是(n - 1) hash相当于对 n 取模但位运算更快。前提是 n 是 2 的幂所以HashMap的容量总是 2 的幂比如你传new HashMap(17)实际容量会被调整成 32。第三步判断桶位是否为空。如果为空直接 new 一个Node放进去。如果不为空说明发生了碰撞此时要检查“是否 key 相同”。这里用到的判断逻辑是if (p.hash hash ((k p.key) key || (key ! null key.equals(k))))先比较 hash再用或equals比较 key。注意用于判断是不是同一个对象引用而equals用于判断逻辑上是否相等。这也是标题中“什么情况用 equals 比较”这个热词的关键答案。第四步如果检查到链表上有和新 key 相同的节点就直接覆盖 value。如果链表中没有相同 key就在链表尾部插入尾插法并检查链表长度是否超过阈值TREEIFY_THRESHOLD 8如果超过且数组长度达到 64就转成红黑树。第五步put完成后检查size thresholdthreshold 容量 * 负载因子 0.75如果超过就触发resize()扩容容量翻倍并将所有节点重新分配桶位。3.2 get方法如何定位并比较元素get的逻辑就是put的反过程。核心代码类似final NodeK,V getNode(int hash, Object key) { NodeK,V[] tab table; int n tab.length; int index (n - 1) hash; NodeK,V first tab[index]; if (first ! null) { if (first.hash hash ((k first.key) key || (key ! null key.equals(k)))) return first; // 如果第一个不是则遍历链表或红黑树 } return null; }看到没它不会遍历整个数组只是根据 hash 定位到某个桶然后在那个桶里线性或对数复杂度查找。这就是为什么HashMap的平均复杂度接近 O(1)。整个过程里equals只在 hash 相同、引用不同时才会被调用。所以如果你自定义对象作为 key那么必须确保 hash 计算得足够分散否则大量元素挤到同一个桶里性能就会退化到 O(n)。3.3 为什么用equals而不用比较key这是很多人面试时被问到的问题“HashMap 中判断 key 相等用还是equals”其实两处都用分情况。源码中的条件是先判断hash是否相同不同直接跳过。然后判断key k如果是同一个对象引用直接认为相等。如果不是同一个引用再判断key.equals(k)也就是看业务逻辑上是否相等。为什么要用equals因为两个内容相同的String对象可能是不同内存地址上的两个对象比如new String(abc)和abc取决于编译期优化或者new String(abc)和new String(abc)如果用比较它们是 false但业务上显然是同一个 key。equals让用户可以自定义“什么才叫相同”这也是面向对象里多态在集合框架中的体现。同时要注意如果重写了 key 对象的equals就必须重写hashCode正如 HashSet 部分所说否则即使equals判断为 truehash 不同也定位不到同一个桶。3.4 为什么HashMap线程不安全从扩容与链表头插说起热词里有“hashmap为什么不安全”这个问题在 JDK 7 和 JDK 8 中的表现不完全一样。先说 JDK 7它的链表插入用的是头插法在并发扩容时两个线程同时 rehash 同一个桶里的链表可能会出现循环链表。一旦循环链表形成下一次get那个桶里的 key 时循环遍历会死循环CPU 直接飙到 100%这就是著名的“HashMap 死循环”问题。JDK 8 改成了尾插法解决了扩容时的死循环问题但并发下仍然不安全主要问题变成数据覆盖两个线程同时put到同一个空桶时都判断桶为空然后各自写入后写的会把先写的覆盖掉导致丢失数据。此外size也不是原子的并发 put 时 size 会偏小。这里要强调一个结论永远不要在多线程环境下共享同一个 HashMap 实例。如果确实需要并发可以使用ConcurrentHashMap或者用Collections.synchronizedMap包一层。但要注意synchronizedMap只是对每个方法加锁迭代时依然需要外部加锁。4. 三个集合怎么选场景决定方案4.1 核心差异对照数据结构、顺序性、重复性、性能很多时候选择纠结是因为没想清楚业务对“顺序、重复、查找性能”的要求。我习惯用一张表来做初步决策维度ArrayListHashSetHashMap底层结构Object[] 动态数组HashMap 的 key 集合Node[] 数组 链表/红黑树元素是否可重复是否key 不允许重复value 可重复是否保持插入顺序是按索引否无序否JDK 8 之前无序JDK 8 无序但有规律类似 hash 分布允许 null是允许一个 null 元素允许一个 null key多个 null value按索引随机访问O(1)不支持不支持按元素查找O(n)线性遍历O(1)平均依赖哈希O(1)key 查找典型使用场景有序列表、索引访问、数据展示去重、集合运算交集、并集通过 key 快速查找对应的 value注意一点HashSet和HashMap的 O(1) 是平均情况前提是哈希函数分布均匀且没有大量碰撞。如果自定义对象的hashCode写得很糟糕比如所有对象返回同一个 hash合法但低效那么查找性能会退化为链表遍历 O(n) 或红黑树 O(log n)。4.2 常见误区和我的选择建议我有一个很朴素的选择思路需要“列表”就用 ArrayList需要“集合去重”就用 HashSet需要“映射关系”就用 HashMap。但实际项目中常遇到一些模糊边界误区一以为HashSet可以当成“有序且去重”的集合用。LinkedHashSet才是保持插入顺序的去重集合如果既要排序又要去重可以考虑TreeSet。误区二用HashMap来统计某个 key 的数量时写了一大堆 if-else 判断是否包含。推荐使用merge方法MapString, Integer countMap new HashMap(); for (String word : words) { countMap.merge(word, 1, Integer::sum); }merge会在 key 不存在时放入默认值 1存在时用 lambda 把原值与新值合并。这个 API 在很多场景下可以让代码简洁很多。误区三在for-each循环里删除元素直接map.remove或list.remove导致ConcurrentModificationException。正确的删除方式是用迭代器的remove()JDK 8 之后可以用removeIf比如list.removeIf(item - item.isExpired());这比自己写迭代器安全得多。还有一个关于初始容量的建议HashMap的扩容阈值是“容量 * 负载因子”默认情况下容量 16阈值 12。如果你知道要存 100 个数据new HashMap(100)并不会直接给你一个容量 100 的数组它会把容量调整为 1282 的幂阈值变成 96这样插入 100 个元素时会在第 97 个时触发扩容。如果希望零扩容可以按expected / 0.75f 1来估算容量比如new HashMap((int) (100 / 0.75f) 1)实际容量会被调整为 256足够容纳 100 个元素不扩容。5. 并发场景替代方案与改造思路5.1 别慌先想清楚要什么很多人一听说“HashMap 线程不安全”就把所有代码改成ConcurrentHashMap。其实没必要因为不同并发需求有不同的解法。先问自己几个问题是读多写少还是写多读少是要求绝对的强一致性还是能容忍短暂的中间状态集合的 size 是否对业务关键如果只是多个线程同时读完全没有写操作那用HashMap也没有问题因为只读不写不会产生数据竞争。如果存在写操作但你能保证写操作通过锁串行化那么一个普通 HashMap 加 synchronized 也够用。只有在“读多写少且希望尽量少的锁竞争”的场景下ConcurrentHashMap才是首选。5.2 推荐替代方案CopyOnWriteArrayList、ConcurrentHashMap、Collections.synchronizedCopyOnWriteArrayList很适合“读多写极少”的场景它底层通过“在写时复制整个数组”来保证线程安全读操作不加锁写操作加锁并复制数组。代价是写操作开销很大如果频繁写就不要用。ConcurrentHashMap是并发场景下 HashMap 的替代品。JDK 8 开始它抛弃了分段锁而是利用 CAS 加 synchronized 对桶节点加锁锁粒度更细并发度更高。它的get方法完全无锁因为通过 volatile 保证可见性。注意ConcurrentHashMap不允许 null key 和 null value所以旧代码里如果用containsKey判断后再get的写法在并发下被打破要改用getOrDefault等方式。Collections.synchronizedMap则是简单粗暴地给每个方法加 synchronized 锁对所有操作串行化。它适合代码改动最小、并发量不高的内部管理场景。关于ArrayList的线程安全版本很多人只知道Vector但在 JDK 1.2 之后官方更推荐Collections.synchronizedList或CopyOnWriteArrayList。特别是在遍历场景下CopyOnWriteArrayList的迭代器不会抛ConcurrentModificationException因为迭代器遍历的是“快照”数组。这个特性在做事件监听器列表遍历时特别好用比如事件分发器里保存了一堆监听器某个监听器添加或移除时其他线程正在遍历传统的 ArrayList 会立刻挂掉而 CopyOnWriteArrayList 完全没问题。最后分享一个关于 JDK 版本差异的小经验JDK 8 之后HashMap引入了红黑树链表长度超过 8 且数组容量达到 64 时会树化树化后查找复杂度从 O(n) 降到 O(log n)。但树的维护是有成本的如果元素经常增减树化和退化红黑树在节点数小于 6 时退回链表的来回切换反而会带来额外开销。所以如果你的业务数据哈希碰撞极多先不要急着优化算法回头检查一下自定义 key 的hashCode是否足够分散有时候只是漏了一个字段参与 hash 计算整体性能就天差地别。我见过一个案例某服务 key 对象的 hashCode 只取了 id 的低 16 位结果大量数据落在同一个桶里CPU 直接被拖高。把 hashCode 改成完整参与后问题立刻消失。这类问题用 JFR 或 JVisualVM 的“对象统计”很容易发现但前提是你得先有“哈希分布值得检查”的意识。
返回列表