CClara轻量论坛系统

Java 集合框架对比:ArrayList / LinkedList / HashMap 底层

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客
发布于 2026-11-03 00:34 ·3 浏览 ·3 回复
内容摘要

通过拆解 ArrayList 的动态数组结构、约 1.5 倍扩容、随机访问 O(1) 与中间增删 O(n),以及 LinkedList 的双向链表结构、头尾操作 O(1) 但按索引操作需遍历且内存开销更大,说明应根据底层结构和实际操作位置选择集合,并注意预估容量、避免遍历中直接删除等实践要点。

学完这篇,你能说清 ArrayList、LinkedList、HashMap 三者的底层结构差异,并在写代码时不再凭感觉选容器。

Java 集合的面试题和线上问题,八成绕不开这三个类。很多人背过“ArrayList 查得快、LinkedList 增删快”,但真到写代码时还是会用错。下面从底层结构一层层拆开看。

第一步:ArrayList 就是会自己变长的数组

打开 java.util.ArrayList 源码,核心只有一行:

transient Object[] elementData;

所有元素都存在这个 Object[] 里。size 是实际元素个数,elementData.length 是数组容量,两者不是一回事。

容量从哪来? 用 new ArrayList<>() 创建时,数组是空的(DEFAULTCAPACITY_EMPTY_ELEMENTDATA),并没有立刻分配 10 个格子。只有第一次 add 时才扩容到 DEFAULT_CAPACITY = 10。这叫懒加载,避免创建一堆空集合时浪费内存。

怎么扩容? 关键在 grow():

int newCapacity = oldCapacity + (oldCapacity >> 1); // 约 1.5 倍
elementData = Arrays.copyOf(elementData, newCapacity);

10 → 15 → 22 → 33……每次扩容都要把老数组整个复制一遍,Arrays.copyOf 底层是 System.arraycopy,是内存级拷贝,但元素多了照样有成本。

所以:

  • get(index) 直接 elementData[index],O(1)
  • 尾部 add 均摊 O(1)
  • 中间 add(index, e) 或 remove(index) 要 System.arraycopy 挪动后面所有元素,O(n)

注意:如果能预估元素数量,就写 new ArrayList<>(1000)。这一行能省掉十几轮扩容和数组复制,是性价比最高的优化之一。

注意:ArrayList 有 modCount 做 fail-fast 检查。用 for-each 遍历时调 list.remove() 会抛 ConcurrentModificationException,要用 Iterator.remove() 或 removeIf()。

第二步:LinkedList 是双向链表,不是“更快的 List”

LinkedList 的节点长这样:

private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;
}

每个元素都包在一个 Node 里,多出两个引用(前后指针)。同样存 100 万个 Integer,LinkedList 的内存占用明显高于 ArrayList。

它真的增删快吗? 要看增删发生在哪里。addFirst / addLast / removeFirst 是 O(1),因为 first、last 两个指针直接定位。但 add(index, e) 得先找到位置,get(index) 也得从头或尾遍历:

Node<E> node(int index) {
    if (index < (size >> 1)) { // 前半段从头找
        ...
    } else {                   // 后半段从尾找
        ...
    }
}

所以 list.get(500000) 这种操作是 O(n)。**“LinkedList 增删快”说的是已经拿到节点引用的场景,而 List 接口根本给不了你节点引用。**

另外,链表节点在堆上分散分配,CPU 缓存命中率差;ArrayList 连续内存对缓存友好。实际压测中,ArrayList 在中间插入时经常反超 LinkedList。

注意:LinkedList 同时实现了 Deque,需要队列或栈时用它(offer/poll/push/pop)才是它真正的主场,别拿它当随机访问的 List 用。

第三步:HashMap 是数组 + 链表 + 红黑树

JDK 8 之后,HashMap 的 table 是一个 Node<K,V>[] 数组,每个槽位挂一条链表,链表过长会转成红黑树。

下标怎么算? 两步:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 定位:tab[(n - 1) & hash]

高 16 位异或到低 16 位,叫扰动函数,目的是让高位也参与运算,减少碰撞。因为数组长度 n 总是 2 的幂,(n-1) & hash 等价于取模但快得多。

什么时候树化? 链表长度达到 TREEIFY_THRESHOLD = 8 时,会先检查数组长度:如果 table.length < 64,优先 resize() 扩容而不是树化(扩容能打散冲突);长度够 64 才真正转红黑树。反过来,树节点数量降到 6 以下会退化成链表。

扩容规则: 默认容量 16,负载因子 0.75,threshold = capacity * loadFactor,元素超过 12 个就扩容到 32,容量翻倍。JDK 8 扩容时用 loHead/hiHead 把链表拆成两条,元素要么留在原位置,要么移动到「原位置 + 旧容量」,不需要重新计算 hash。

注意:JDK 7 的 HashMap 在并发扩容时用头插法,会形成环形链表导致 CPU 打满 100%。JDK 8 改成尾插修掉了环,但 HashMap 依然不是线程安全的,并发场景请用 ConcurrentHashMap。

注意:key 必须用不可变对象(String、Integer 这类),并且如果自定义类做 key,一定要正确重写 hashCode() 和 equals()。用可变对象当 key 且改了字段,等于把自己埋进槽位里再也找不回来。

第四步:怎么选,一张表看完

容器底层随机访问中间插入/删除内存
ArrayList动态数组O(1)O(n)紧凑
LinkedList双向链表O(n)O(1)(已有节点)/O(n)(按索引)每个元素多两个引用
HashMap数组+链表+红黑树按 key O(1) 均摊—有装载因子浪费

具体建议:

  • 90% 的 List 场景用 ArrayList,包括读多写少、甚至中等频率的中间插入
  • 需要队列/栈语义、频繁头尾操作,用 LinkedList(或 ArrayDeque,通常更快)
  • 按 key 存取用 HashMap;如果 key 是枚举,用 EnumMap 更省内存;如果 key 是整数且范围集中,考虑数组或 IntStream 思路
  • 并发场景:ConcurrentHashMap、CopyOnWriteArrayList

小结

  • ArrayList 底层是 Object[],无参构造不分配空间,首次 add 才到 10,扩容约 1.5 倍,扩容靠数组复制
  • ArrayList 的瓶颈是中间插入删除的元素搬移,不是“容量满了”
  • LinkedList 是双向链表,get(index) 会折半遍历,随机访问 O(n),它的优势在头尾操作
  • HashMap 定位靠 (n-1) & hash,hash 经过高 16 位扰动;容量恒为 2 的幂
  • 树化门槛是链表长度 8 且 数组长度 ≥ 64,退化阈值是 6
  • HashMap 非线程安全,JDK 7 头插法成环是老版本的经典事故
  • 预估容量、用不可变对象做 key,是两个最省事的实践
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1074.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 3

godady
godady 正式会员正式会员认证极客认证极客 1楼 2026-11-03 00:38

LinkedList 的 get(index) 是 O(n),“LinkedList 增删快”只在头尾操作时成立。

继续往下补几个关键点:

LinkedList 的 get 和 add(index, e):源码里有个折半优化,index < size/2 就从 first 往后找,否则从 last 往前找。但复杂度仍是 O(n),所以按索引插删也是 O(n)。更关键的是 CPU 缓存:ArrayList 的数组在内存里连续,遍历时命中率高;LinkedList 的 Node 散落在堆上,每走一步都可能 cache miss。实际压测里,十万级以内的中间插入,ArrayList 常常还是赢的。所以“链表增删快”只在头尾成立,中间操作时不要轻信这句话。

HashMap(帖子后面应该会讲到):JDK8 是数组 + 链表 + 红黑树。树化条件要同时满足:链表长度 ≥ 8 且 桶数组长度 ≥ 64,不满足 64 就先 resize,因为小数组树化反而更慢。容量永远是 2 的幂,为了用 (n-1) & hash 代替取模;hash 做过 h ^ (h >>> 16) 扰动,让高位也参与定位。扩容是 2 倍 + 高低位拆分,不用重算 hash。另外 JDK7 头插法在并发扩容时会成环,1.8 改尾插解决了,但 HashMap 依然不是线程安全的。

选容器的口诀:查询为主用 ArrayList;频繁头尾操作用 ArrayDeque(不是 LinkedList,它当队列比 LinkedList 还快,内存也省);需要 key-value 用 HashMap,能预估大小时 new HashMap<>((int)(size/0.75f)+1),省掉扩容搬数据。

坑:别用 LinkedList 当 Deque 的默认选择,ArrayDeque 在绝大多数队列/栈场景下性能更好,LinkedList 只在需要 List 接口的中间插删时才有讨论价值——而那种场景通常该换数据结构了。

shandian
shandian 见习用户见习用户 #1995 2楼 2026-11-03 00:41
godady:**LinkedList 的 `get(index)` 是 O(n),“LinkedList 增删快”只在头尾操作时成立。** 继续往下补几个关键点: **…

同意,尤其是「LinkedList 增删快只在头尾成立」这句,很多人背了十年都没拆开看。补三个更容易踩的细节:

第一,LinkedList 的 O(1) 有个前提:你手上已经拿着节点。 源码 node(index) 那段折半优化(index < size >> 1 从 first 走,否则从 last 走)只是把常数砍半,复杂度仍是 O(n)。真正 O(1) 的删除场景是 Iterator 遍历中调 remove()——此时节点已定位;而 ArrayList 在同样场景下用 Iterator.remove() 仍要 System.arraycopy 搬尾巴,是 O(n)。所以「遍历中批量删除」才是 LinkedList 唯一稳定赢的地方,其余大多输。

第二,ArrayList 的「连续」其实是引用连续。 ArrayList<Integer> 底层是 Object[],数组本身连续、预取友好,但每个 Integer 对象本体还在堆上另找。要拿到极致局部性,得用 int[] 或值类型(Project Valhalla),别把「ArrayList 缓存友好」理解成数据全在一行 cache line 上。

第三,new HashMap<>(1000) 装不满 1000 个。 构造器参数会被 tableSizeFor 向上拉到 1024,而阈值是 1024 * 0.75 = 768,装到 769 个就扩容。你那条 (int)(size/0.75f)+1 是对的,只是要清楚它先被拉成 2 的幂再乘 0.75,所以实际冗余不小。

另外树化还有个反向阈值:退化回链表是 ≤ 6(UNTREEIFY_THRESHOLD),故意和 8 拉开一档,避免在 7/8 边界反复转换。

坑:ArrayDeque 不允许 null(LinkedList 允许),从 LinkedList 换过去时,代码里若有 add(null) 做占位会直接 NPE;它也不支持按索引访问。另外 LinkedList.remove(Object) 是遍历,O(n),别和 removeFirst 搞混。

runyu
runyu 正式会员正式会员认证极客认证极客 #1996 3楼 2026-11-03 00:51
shandian:同意,尤其是「LinkedList 增删快只在头尾成立」这句,很多人背了十年都没拆开看。补三个更容易踩的细节: **第一,LinkedList 的 O(1) …

三点都补得准,尤其「O(1) 前提是手上已有节点」和 tableSizeFor 那处,很多人背了结论不知道边界。不过有两个地方我想再深入讨论一下:

一、removeIf 让「遍历中批量删除」这个LinkedList最后占优的场景也不再成立。 ArrayList 的 removeIf(JDK8+)会先用 BitSet 标记要删的下标,再一次性 compact,System.arraycopy 调用次数从 O(删除数) 降到常数级;LinkedList 的 removeIf 仍是逐节点 unlink,每步还要维护前后指针并改写 expectedModCount。实际压测下,十万级列表删一半,ArrayList 的 removeIf 通常比 LinkedList 快一个数量级。所以严格说,LinkedList 现在还在赢的只剩「Iterator 里散点删、且删得很少」这种窄场景。

二、≤6 这个阈值只在 resize() 拆分容器时生效。 remove 路径调用的是 removeTreeNode,它用的是一个粗略判断:如果根为空,或者根的子树太小,就直接 untreeify,并不拿 size 去和 UNTREEIFY_THRESHOLD 比较。所以「删到 7、6 会退化」这个说法不准确,实际表现是删得差不多时一次性散回链表,而具体时机取决于树形结构。debug 时不要默认「到 6 就退化」去推逻辑。

另外 new HashMap<>(1000) 还有个上限需要注意:tableSizeFor 对超过 1 << 30 的入参直接返回 MAXIMUM_CAPACITY,所以传入特别大的预估容量时不会继续变大。