ARTICLE DETAIL

资讯详情

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

Java ArrayList底层原理与高频方法实战解析

Java ArrayList底层原理与高频方法实战解析 1. 整体设计与底层原理写代码这么多年ArrayList 应该算是我用过最频繁的集合类没有之一。很多同学把它当成“可以动态扩容的数组”这种理解基本正确但要想真正用好它还是得先搞明白它底层到底是怎么工作的否则后面在性能排查和并发问题上很容易栽跟头。1.1 数组与 ArrayList从固定长度到自动扩容Java 原生的数组在声明时就必须指定长度比如String[] arr new String[10]一旦确定就改不了。这个设计在业务开发里非常难受因为大部分场景下我们事先并不知道数据的最终规模。ArrayList 做的事情本质上就是在内部维护一个 Object 数组然后在添加元素时判断容量是否够用不够就自动扩容。我这里先不说源码细节用一个生活化的例子类比数组就像买了一个固定座位的电影院票卖完了就不能再进人了ArrayList 就像一间可以临时加座的活动厅座位不够的时候管理员会去旁边仓库搬一批新椅子进来把原有观众挪过去然后继续接待新观众。这个“搬椅子”的过程在计算机里就是申请一块更大的连续内存空间把旧数组里的元素逐个拷贝过去再让内部引用指向新数组。所以 ArrayList 表面上用起来是“无限容量”实际上每次扩容都是有代价的代价就是内存申请和数据拷贝的时间。1.2 扩容机制ArrayList 性能的核心密码从源码里可以看到ArrayList 默认的初始容量是 10当然你也可以在构造时指定一个更大的初始容量。真正重要的是扩容策略当数组容量不够时grow方法会计算一个newCapacity oldCapacity (oldCapacity 1)也就是每次扩容到原来的 1.5 倍。这个 1.5 倍不是拍脑袋定的。扩容太频繁会白白消耗拷贝成本扩容太猛又会浪费内存。1.5 倍是一个比较均衡的选择——既保证均摊到每次 add 操作的时间复杂度接近 O(1)又不会像 2 倍扩容一样造成过大的内存跳跃。假设你连续添加 100 万个元素大概会发生多少次扩容呢从 10 开始每次乘以 1.5到容量超过 100 万时大约发生了 29 次扩容。最坏情况下某一次扩容需要一次性复制几十万个元素的引用。如果这个列表是高频写入的服务端数据扩容带来的 GC 压力其实不小。提示如果你的代码里能预估数据量比如从数据库查出 10 万条记录要放进 ArrayList合理做法是new ArrayList(100000)一次性把容量分配到位省掉中间所有扩容拷贝。这个习惯对长列表性能提升非常明显。1.3 适用场景与局限性ArrayList 的底层是数组所以它的强项和弱项都很鲜明。强项是按下标随机访问get(index)的时间复杂度是 O(1)这一点秒杀 LinkedList。弱项是中国插入和删除因为插入或删除位置之后的所有元素都要整体移动。比如在列表头部频繁插入元素每次操作都是 O(n)数据量一旦上来你会发现性能退化得非常厉害。另外ArrayList 还要求内存空间连续。如果元素数量巨大JVM 需要找到一整块足够大的连续区域来存放这时候即使堆内存还有空闲也可能因为碎片化而触发 Full GC。所以 ArrayList 适合读多写少、尾部追加为主的场景不适合频繁头插、中间插入或高频删除的业务。2. 常用方法实操拆解讲完底层原理接下来就是本次的核心内容ArrayList 的常用方法。我会按照日常开发的真实使用频率来组织从添加、获取、修改、删除到查找判断每类方法都会给到代码示例和容易忽略的细节。2.1 添加元素add 的几种姿势add(E element)是所有方法里用得最多的一个作用是往列表末尾追加元素。源码逻辑其实很直接先检查容量不够就扩容然后elementData[size] element。这里有个细节size和elementData.length是两个不同的概念前者是“当前已存元素个数”后者是“底层数组总容量”。ListString names new ArrayList(); names.add(张三); names.add(李四); System.out.println(names); // [张三, 李四]另一个常用重载是add(int index, E element)指定位置插入。这个方法有一个隐藏校验index必须在[0, size]范围内等于size时相当于末尾追加超出范围会抛IndexOutOfBoundsException。插入的逻辑是先把插入点之后的元素整体后移一位再在空出来的位置写入新元素最后size。names.add(1, 王五); // 现在顺序为 [张三, 王五, 李四]第三个是addAll(Collection? extends E c)批量添加。底层会在扩容时一次性按c.size()计算新容量避免多次单条 add 触发多次扩容。我在实际项目中批量组装数据时都会优先考虑addAll而不是 for 循环里一个个add。2.2 获取与遍历get、size 与迭代器get(int index)是最简单的方法底层就是return elementData[index]没有任何遍历过程。所以在循环里调用get的性能极好但是有一个前提循环的终止条件不能每次都用list.size()在循环体内重新计算吗其实size()本身也是 O(1)因为底层维护的就是一个 int 变量不会造成性能问题。for (int i 0; i list.size(); i) { System.out.println(list.get(i)); }除了最传统的 for 循环还有增强 for 循环和迭代器方式。严格来说增强 for 底层也是基于迭代器实现的编译后会自动转换成Iterator的hasNext()和next()调用。for (String name : names) { System.out.println(name); }这里必须提醒一点遍历过程中不要直接调用list.remove()或list.add()否则会抛出ConcurrentModificationException。原因是用迭代器遍历时迭代器会维护一个expectedModCount而list的增删操作会让modCount增加两者对不上就会直接抛异常。正确的删除方式是用迭代器自带的remove()方法或者干脆用 JDK 8 之后的removeIf。names.removeIf(name - name.startsWith(张));2.3 修改与删除set、removeset(int index, E element)是替换指定位置的元素返回被替换的旧值。这个操作时间复杂度是 O(1)因为只改数组的一个位置。String oldValue names.set(0, 赵六); System.out.println(oldValue); // 张三remove(int index)与remove(Object o)是两个截然不同的方法它们的区别在面试和笔试中都是经典考点。remove(int index)按索引删除返回被删除的元素remove(Object o)按值删除只删除第一个匹配项返回布尔值。因为 Java 的方法重载规则如果你的列表装的是 Integer调用remove(1)删除的是下标 1 的元素而不是值等于 1 的元素想删除值 1 得用remove(Integer.valueOf(1))。ListInteger numbers new ArrayList(); numbers.add(1); numbers.add(2); numbers.add(3); numbers.remove(1); // 删除下标1即数字2 numbers.remove(Integer.valueOf(3)); // 删除值3删除的底层实现是把删除点之后的所有元素整体向前移动一位然后把最后一个位置置为 null最后size--。末尾的置 null 操作是为了帮助 GC 回收否则即使size减了底层数组仍然保留着这个对象的引用会造成内存泄漏风险。2.4 查找与判断contains、indexOf、isEmptycontains(Object o)底层调用indexOf而indexOf说白了就是一个从 0 开始的线性查找循环遍历数组逐个用equals判断。所以 ArrayList 的contains时间复杂度是 O(n)如果列表很长又频繁做存在性判断可以改用HashSet。indexOf返回的是第一个匹配元素的下标找不到返回 -1lastIndexOf则是从尾部开始找。这里有一个很多人忽略的细节查找时用的equals方法是元素对象自己实现的。如果你的元素是自定义对象没有重写equals那默认比较的是对象引用两个内容相同的对象会被判定为不相等。所以需要根据值查找的时候务必确认元素类已经正确重写了equals和hashCode。isEmpty()判断列表是否为空底层的逻辑非常简单就是size 0不需要遍历。日常推荐用它而不是list.size() 0语义更清晰代码读起来也更舒服。3. 增删改查之外的实用方法除了最基本的 APIArrayList 还有一批批量操作、排序转换方法。这些方法看起来不起眼但在真实项目中能让代码精简很多也能帮我们避开一些性能陷阱。3.1 批量操作addAll、removeAll、retainAlladdAll前面已经提过这里重点讲removeAll和retainAll。removeAll(Collection? c)会从当前列表中删除所有“也存在于 c 中”的元素retainAll(Collection? c)则反过来只保留当前列表与 c 的交集。看起来这两个方法挺方便但实现上有一个隐藏的坑底层本质上是“先构建一个 filtered 列表再整体覆盖”期间会大量调用contains方法。如果传入的 c 是一个大的 ArrayList而当前列表又很大那双重线性查找就会把时间复杂度推到 O(n*m)数据量稍微大一点就很慢。实际开发中我更推荐把待删除集合c转成HashSet再调用removeAll这样底层contains的查找时间变成 O(1)整体速度快一个数量级。同理retainAll也可以采用这种思路。ListString toRemove new ArrayList(); toRemove.add(张三); SetString removeSet new HashSet(toRemove); list.removeAll(removeSet);3.2 排序与洗牌借助 Collections 工具类ArrayList 自身没有提供排序方法排序依赖Collections.sort(ListT list)。方法内部会把列表转成数组用效率更高的TimSort排序后再写回列表。对于元素数量较少的列表这个“转数组再写回”的额外开销几乎可以忽略。ListInteger numbers new ArrayList(); numbers.add(3); numbers.add(1); numbers.add(2); Collections.sort(numbers); // [1, 2, 3]如果希望自定义排序规则可以传入ComparatorCollections.sort(names, (a, b) - b.length() - a.length());JDK 8 之后还直接提供了list.sort(comparator)实例方法用法和Collections.sort基本一致。另外还有一个冷门但实用的Collections.shuffle(List? list)可以把列表元素顺序随机打乱在抽奖、随机出题、洗牌等场景非常方便。3.3 子列表与转换subList、toArraysubList(int fromIndex, int toIndex)返回的是原列表的视图而不是一份拷贝。这句话说再多遍都不为过因为太多线上事故都出在这个方法上。你拿到subList后修改元素原列表会跟着变反过来原列表做结构性修改subList再操作就会抛ConcurrentModificationException。ListString sub names.subList(0, 2); sub.set(0, 钱七); System.out.println(names); // 原列表第一个元素也变了如果需要一份独立的子列表正确做法是重新new ArrayList(names.subList(0, 2))这样底层会拷贝过去视图关联就断了。toArray()有两个重载无参版返回Object[]有参版toArray(T[] a)返回指定类型的数组。日常更推荐有参版因为无参版返回的 Object 数组在强转时很容易抛ClassCastException。String[] nameArray names.toArray(new String[0]);这里提一个小知识点new String[0]在早期版本会被诟病“多创建了一个数组对象”实际上 JDK 源码里判断传入数组长度小于 size 时会重新创建正确长度的数组所以传 0 长度的数组不仅没有性能问题反而是官方推荐写法语义上也非常清晰。4. 并发场景与线程安全选择我曾经在梳理一个线上偶发故障时排查到最后发现是多个线程同时往一个 ArrayList 里写数据某个时刻数组扩容与赋值发生了交错最终导致数据丢失和下标越界。群里不少人有“ArrayList 线程不安全”的耳闻但具体不安全在哪很多朋友说不清楚。4.1 为什么 ArrayList 在多线程下不安全ArrayList 的所有add和remove操作都分解为底层数组的读写而这些读写没有加锁也没有 volatile 保证可见性。典型的并发问题集中在两个地方扩容竞争两个线程同时add都发现容量不够都去执行扩容最终可能把新数组的引用互相覆盖导致一部分元素“丢失”。size 竞态size并非原子操作多个线程同时执行时可能把 size 少加后续遍历或get时漏掉元素甚至数组越界。即使只是单线程读、多线程写也可能因为内存可见性问题导致读线程看到未写入完全的数据。4.2 线程安全替代方案怎么选Java 里能替代 ArrayList 的线程安全类其实不少我按场景分了三类。Vector把每个方法都加上了 synchronized 锁功能基本一致但因为锁粒度太重并发高时竞争非常激烈现在几乎不推荐新项目使用。Collections.synchronizedList(new ArrayList())在方法级别加锁使用简单适合小规模并发。需要注意的是遍历时必须手动加锁否则遍历过程中被其他线程修改仍可能抛ConcurrentModificationException。CopyOnWriteArrayList写操作时复制一份新数组进行修改读操作完全不加锁。适合读多写少的场景比如配置项缓存、白名单列表但写操作成本较高不适合频繁写入。以我自己的经验如果并发写比较多优先考虑并发容器而不是简单的加锁集合如果只是读多写少用CopyOnWriteArrayList能获得非常好的读性能。关键还是要对业务读写比例做一个预判没有万能方案。5. 高频踩坑与性能优化最后这部分是含金量最高的我整理了这些年在使用 ArrayList 时踩过和见过的典型问题每条都附上了原因分析和解决方案方便直接对照自查。5.1 循环删除的经典陷阱很多初学者会这样写for (int i 0; i list.size(); i) { if (list.get(i).equals(删除)) { list.remove(i); } }这个写法的问题是删除元素之后后面的元素整体前移但下标i继续递增于是被前移的元素被跳过了很可能漏删。解决方案是从后往前遍历或者使用迭代器的remove也可以使用前面提到的removeIf。从后往前遍历有一个额外好处删除时前移的元素不会影响还未遍历到的位置逻辑最稳。如果使用增强 for 循环直接删除则会直接触发ConcurrentModificationException连“漏删”的机会都没有。所以规范做法是普通 for 循环从后往前删或者直接用Iterator.remove()。5.2 初始化容量与扩容优化我见过不少项目的构造写法是new ArrayList()之后疯狂add明明数据量能提前确定却懒得多写一个容量参数。在高频接口里这会导致每次上线扩容时都发生一次大数组复制白白消耗 CPU 与内存。经验法则是能确定数据量时务必用new ArrayList(expectedSize)。无法确定但知道大概是量级时可以给一个略大于预期的初始容量比如预期 800 条就给 1000。不要随手填一个Integer.MAX_VALUE或几千万分配超大数组会让 JVM 瞬间占用大量内存甚至触发 GC 停顿。5.3 subList 视图陷阱subList的设计意图是提供一个轻量视图避免无谓拷贝但它的视图特性经常让不熟悉的人踩坑。以下面这段代码为例ListString sub list.subList(0, 3); list.clear(); sub.size(); // 抛出 ConcurrentModificationException原列表一旦发生结构性变化视图的modCount检测就会失败。如果你只是用subList读取一段数据不要保留太久如果后续还会操作原列表建议立即拷贝一份独立列表再使用。5.4 常见问题速查表问题现象根本原因解决方案循环中删除漏元素删除后元素前移下标跳过从后往前删除或使用 Iterator.remove / removeIf增强 for 中删除抛异常modCount 与 expectedModCount 不一致使用迭代器删除避免直接 list.removeremove(1) 删除的不是值 1方法重载匹配了 int 索引删除值使用 remove(Integer.valueOf(1))自定义对象 contains 找不到未重写 equals / hashCode按业务值重写 equals 与 hashCodesubList 操作原列表受影响返回的是视图而非副本若要独立列表用 new ArrayList(subList)多线程同时写入数据丢失扩容与 size 更新竞态使用 CopyOnWriteArrayList 或同步容器大列表 contains 很慢线性查找 O(n)改用 HashSet 做存在性判断频繁扩容导致 GC 压力大默认容量 10多次扩容预估容量并指定初始容量5.5 不可变列表防御性编程的最后一步一个经常被忽略的细节是ArrayList 是可变的。如果方法里返回了一个内部维护的 ArrayList调用方可以直接修改它轻则数据错乱重则产生安全漏洞。我习惯的做法是对外暴露集合时用Collections.unmodifiableList()包一层这样任何修改都会抛UnsupportedOperationException从源头杜绝被意外改动。public ListString getNames() { return Collections.unmodifiableList(names); }JDK 9 之后的List.of()也能创建不可变列表但它不接受 null 且完全不能增删适合作为常量集合使用。这两者的区别要搞清楚Collections.unmodifiableList只是“不可修改”视图底层原列表仍然可能变化而List.of是不可变快照两者用途不同。在实际项目中我还经常把 ArrayList 和 HashMap 写进一个工具类里做缓存这时要注意 ArrayList 的非线程安全性是否会被传递到缓存结构中。只要缓存可能被多线程读写发音处理上要格外谨慎。最后再分享一个我个人的小习惯每当我在代码里看到new ArrayList()时我都会条件反射地问一句“这里到底有多少数据能不能把容量估出来”这个问题虽然简单却帮我在不少性能排查场合提前规避了隐患。ArrayList 作为 Java 开发里最基础的集合工具看似平平无奇但把这些细节真正嚼透之后写出来的代码会和以前明显不一样。
返回列表