写点什么

Java 王者修炼手册【集合篇 -ArrayList】:从 ArrayList 数据结构到避坑指南

作者:DonaldCen
  • 2025-12-05
    广东
  • 本文字数:4416 字

    阅读完需:约 14 分钟

Java 王者修炼手册【集合篇-ArrayList】:从ArrayList数据结构到避坑指南

在 Java 的 王者峡谷 里,ArrayList 绝对是集合阵营中必练的 热门英雄。


想在代码对战中稳操胜券,就得先在练习场把这位英雄的 技能细节 摸透!


如果还行想把 ArrayList 玩到 国服水准,那以下这些核心要点必须吃透:


  • 底层数据结构:elementData 用 transient 的原因,与 LinkedList 的核心差异及随机访问 / 增删的时间复杂度,能否存储 null,及与 Vector 的核心区别

  • 扩容全逻辑:初始容量、负载因子、1.5 倍扩容公式、JDK 版本差异、缩容技巧,及初始化指定容量的必要性

  • subList:返回类型、隐藏坑点,修改对原集合的影响

  • 线程安全问题:ConcurrentModificationException 触发原因、替代方案,及 Fail-Fast 机制的细节(触发条件、局限性、迭代器 remove 特性)


那接下来我们就一起逐帧拆解 它的 技能机制 ~

底层数据结构

ArrayList 与 LinkedList 区别

ArrayList 的底层是动态数组(可以自动扩容的数组),就像王者里按固定位置排列的英雄阵容表


每个英雄都有明确的 下标座位,比如第 0 位是韩信、第 3 位是妲己


它的细节很简单:**内部用一个 elementData 数组存元素,**还有一个 size 变量记录实际元素数量


TIP


size≠数组长度,数组长度是 容量,size 是 已坐人数


而 LinkedList 底层是双向链表,类似王者里玩家手拉手组队


每个英雄(节点)只知道前一个和后一个是谁,没有固定 座位

时间复杂度

  • 随机访问(get (index))

  • ArrayList 是 O(1):数组下标直接定位,就像按编号找座位,一步到位

  • LinkedList 是 O(n): 链表得从头遍历到第 index 个节点,比如找第 100 个元素,要走 100 步。

  • 中间插入 / 删除

  • ArrayList 的瓶颈是元素移动。比如在第 5 位插入元素,第 5 到 size-1 位的元素都要往后移(删除则往前移),数据量越大,搬家 越慢,时间复杂度 O(n)

  • LinkedList 中间增删只需改前后节点的指针,O(1)(但前提是已经找到位置,找位置本身是 O(n))

核心区别

  • 查指定位置的英雄(随机访问):ArrayList 直接按下标找到,像喊 第 5 个出列;LinkedList 得从第一个开始挨个问 你后面是谁,直到找到第 5 个。

  • 中间插英雄:ArrayList 要把插入位置后面的英雄全往后挪一个位(比如在第 3 位插新英雄,第 3、4、5... 位都得动);LinkedList 只需让前后两个英雄 松手,新英雄夹在中间就行。

为什么 elementData 要加 transient

transient 的作用是 阻止该变量被默认序列化


ArrayList 这么做,是因为 elementData 数组的长度(容量)往往比实际元素数量(size)大(比如容量 10,只存了 3 个元素)。


如果默认序列化,会把数组里的空位置也存进去,浪费空间。


所以 ArrayList 自己写了 writeObject 方法,只序列化前 size 个实际元素(像只存背包里真正有的装备,空格子不存)

扩容机制

就像王者背包满了要扩容,ArrayList 当元素数量(size)超过容量时,会自动扩容。


初始容量与负载因子是多少?


  • JDK6:初始容量直接是 10(不懒加载),扩容时会先判断是否需要最小容量,逻辑稍复杂。

  • JDK7 及以上:默认初始容量是 懒加载 的 —— 刚创建时 elementData 是空数组,添加第一个元素时才扩容到 10。

  • 负载因子默认 0.75:当 size > 容量×0.75 时,触发扩容(比如容量 10,存到 8 个元素就准备扩容,提前留缓冲)。


为什么 负载因子默认 0.75?


负载因子默认 0.75,本质是时间成本和空间成本的 最优妥协


就像王者里的 蓝 buff 刷新时间,既不能太频繁(浪费资源),也不能太滞后(影响节奏)。


先明确负载因子的作用:它是触发扩容的 预警线


比如 ArrayList 容量是 100,负载因子 0.75,那么当元素存到 75 个时,就会触发扩容(而不是等存满 100 个)。


为什么不设成 1.0(存满才扩容)?


如果负载因子是 1.0,意味着 数组满了才扩容


此时插入新元素时,不仅要触发扩容(复制旧数组到新数组,耗时 O(n)),还可能因为数组已满,每次插入都要移动大量元素(比如在中间位置插入,后面的元素全要后移)。


类比王者场景:假设背包容量 100 格,负载因子 1.0 就是 装满 100 格才扩容


此时想塞一个新装备,得先把背包里的装备挪来挪去腾位置(如果插中间),腾完还得花时间扩容,整个过程卡顿明显。


为什么不设成 0.5(半满就扩容)?


如果负载因子是 0.5,意味着 存到一半就扩容


比如容量 100,存 50 个就扩到 150,此时实际只用了 50 个,剩下 100 个格子空着,内存浪费严重。


类比王者:背包 100 格,存 50 个就强行扩到 150 格,剩下的 100 格空着不用,相当于 占着背包位却不用,后期可能导致背包格子不够用(内存不足)


扩容公式是怎么样的?


新容量 = 旧容量 + 旧容量 >>1(即旧容量 ×1.5)


比如容量 10,扩容后是 10 + 10 >> 1 = 15;15 扩容后是 15 + 15 >>1 = 22


TIP


在 Java 里,<<(左移)、>>(带符号右移)、>>>(无符号右移)是对数字的二进制位进行操作的运算符,底层效率比乘法 / 除法高得多(CPU 直接操作二进制,不用复杂计算)


左移 <<:【二进制位左移,右边补 0,相当于 乘以 2 的 n 次方


举个例子:


  • 3 的二进制是 11,3 << 1(左移 1 位)→ 二进制变成 110(即 6),相当于 3 × 2¹ = 6;

  • 5 的二进制是 101,5 << 2(左移 2 位)→ 二进制变成 10100(即 20),相当于 5 × 2² = 20。


带符号右移 >>:【二进制位右移,左边补符号位,相当于 除以 2 的 n 次方(向下取整)】


举个例子:


  • 正数 8 的二进制是 1000,8 >> 1(右移 1 位)→ 二进制变成 100(即 4),相当于 8 ÷ 2¹ = 4;

  • 正数 15 的二进制是 1111,15 >> 1(右移 1 位)→ 二进制变成 111(即 7),相当于 15 ÷ 2¹ = 7(向下取整);

  • 负数-8 的二进制是 11111111 11111111 11111111 11111000(补码形式),-8 >> 1 → 左边补 1,结果是 11111111 11111111 11111111 11111100(即 - 4),相当于-8 ÷ 2¹ = -4。


无符号右移 >>>:【二进制位右移,左边补 0,忽略符号位】


举个例子:


  • 正数 8 >>> 1 和 8 >> 1 结果一样(都是 4),因为正数符号位是 0,补 0 不影响;

  • 负数-8 的二进制是 11111111 11111111 11111111 11111000,-8 >>> 1 → 左边补 0,结果是 01111111 11111111 11111111 11111100(即 2147483644),这是一个很大的正数(因为符号位的 1 被当作普通位了)。


为什么 ArrayList 扩容用>>1 而不是/2?因为位移是直接操作二进制位,CPU 执行速度比乘法 / 除法快得多(除法需要复杂的计算逻辑,位移只需要移动位)


为什么是 1.5 倍而非 2 倍?


2 倍扩容可能导致 空间浪费:比如某次扩容到 100,实际只再存 1 个元素,剩下 99 个位置空着。


1.5 倍是平衡 扩容频率空间利用率 的结果


既不会因为扩容太频繁影响性能,也不会过度占用内存。


既然能扩容,那能缩容吗?


调用 trimToSize()会把数组容量压缩到 size(实际元素数量),比如容量 15 但只存 3 个元素,缩容后容量变 3,节省内存(像把背包空格子删掉),能实现 缩容的效果


为什么建议初始化时指定容量?


比如已知要存 100 个英雄,直接 new ArrayList(100),能避免多次扩容(每次扩容都要复制旧数组到新数组,耗时)。


不指定的话,可能从 10→15→22→33... 多次扩容,浪费性能。

subList ()存在问题

subList(from, to)返回的不是新集合,而是原 ArrayList 的视图(类似 “原数组的一个切片窗口”),底层和原集合共享同一个 elementData 数组


这样就造成了很多隐藏坑点:


  • 修改互相影响:改 subList 的元素(比如 subList.set(0, "新英雄")),原集合会跟着变;

  • 原集合结构修改会 崩:如果原集合增删元素导致容量变化(比如 add 或 remove),再操作 subList 会抛 ConcurrentModificationException(就像原阵容换了座位,切片窗口的 **坐标 **全乱了)。

  • 生命周期绑定:subList 依赖原集合存在,原集合被回收,subList 也会失效。

  • 迭代器也会触发异常:如果用 subList 的迭代器遍历,此时原集合改了结构,迭代时会抛 ConcurrentModificationException。


既然这样,那解决方案也很明确了:独立操作子列表,切断与原集合的关联,主动复制一份


具体做法:用 subList 获取子列表后,直接用这个子列表初始化一个新的 ArrayList


List<String> list = new ArrayList<>(Arrays.asList("妲己", "韩信", "鲁班", "貂蝉"));  // 先获取子列表(视图),再复制到新集合  List<String> subList = new ArrayList<>(list.subList(1, 3)); // 子列表是["韩信", "鲁班"]
复制代码


这样一来,subList 就是一个全新的集合,有自己的底层数组,和 list 彻底独立了


  • 修改 subList(比如 subList.add("赵云")),list 不会变;

  • 原集合 list 增删元素(比如 list.add("后羿")),subList 也不受影响,更不会抛异常。

线程安全

ArrayList 适合 单排上分,多排容易 翻车


因为 ArrayList 线程不安全


多线程同时增删元素时,可能导致 elementData 数组越界,或 modCount(记录结构修改次数)不一致,从而抛出 ConcurrentModificationException。


解决方案有如下几种(ps:后面文章,每个知识点都会涉及到,敬请期待~):


  • Vector:古老的线程安全版 ArrayList,方法加了 synchronized,但性能差(像 5 个玩家抢一个装备,每次只能一个人动)。现在很少用这种方式了

  • Collections.synchronizedList:把 ArrayList 包装成同步集合,原理是给所有方法加锁,比 Vector 灵活点。

  • CopyOnWriteArrayList:写时复制,修改时会复制一份新数组,适合读多写少场景(像王者公告,读的人多,改的人少)

Fail-Fast 机制

Fail-Fast 机制,简单说就是 Java 集合的一种 防坑预警系统


当你正在遍历集合时,如果有人偷偷修改了集合的结构(比如增删元素),它会立刻抛一个 ConcurrentModificationException 异常,告诉你 集合被改了,遍历不安全了!

核心原理

ArrayList(以及 HashMap 等集合)里藏着一个叫 modCount 的变量,翻译过来就是 修改次数


每次集合发生结构变化(比如 add、remove 元素,或者扩容导致底层数组更换),modCount 就会 + 1(相当于 记账,记一次修改)


TIP


不是所有修改都会触发 Fail-Fast,只有 结构修改 才会让 modCount 增加


  • 结构修改:增删元素(add/remove)、清空集合(clear)、扩容(底层数组更换)等改变元素数量或底层存储的操作

  • 非结构修改:用 set(index, value)替换元素值,不会让 modCount 变化,所以遍历中这么改,不会抛异常。


当你用迭代器(比如 Iterator)遍历集合时,迭代器会先 抄一份账把当前的 modCount 存到自己的 expectedModCount 里(相当于 预期修改次数


遍历过程中,迭代器会不断检查:modCount expectedModCount 是不是一样


  • 如果一样:说明没人改集合,继续遍历;

  • 如果不一样:说明有人偷偷改了集合结构,立刻抛 ConcurrentModificationException快速失败


那如果真实存在这种修改的需求,如何操作?


  • 对应删除需求,可以通过 迭代器自带的 remove()方法删除元素,不会触发异常;因为迭代器在删除时,会同步更新自己的 expectedModCount = modCount

  • 对应 add 或者清空操作需求,还是建议使用线程安全的集合来操作~

局限性

Fail-Fast 机制不能保证在多线程下 100% 触发异常,只能说是 尽力而为。


多线程环境下的 指令交错内存可见性问题,可能导致迭代器的 expectedModCount 与集合的 modCount 巧合一致,从而跳过异常检查,但此时集合结构可能已经被破坏

发布于: 刚刚阅读数: 5
用户头像

DonaldCen

关注

有个性,没签名 2019-01-13 加入

跟我在峡谷学Java 公众号:程序员悟空的宝藏乐园

评论

发布
暂无评论
Java 王者修炼手册【集合篇-ArrayList】:从ArrayList数据结构到避坑指南_线程安全_DonaldCen_InfoQ写作社区