ARTICLE DETAIL

资讯详情

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

Java List从入门到进阶:ArrayList、源码机制与并发容器实战

Java List从入门到进阶:ArrayList、源码机制与并发容器实战 1. List不是一个类是三个实现撑起的容器江湖很多初学者学了List之后会觉得它就是ArrayList的别名底下一个数组查得快、插得慢完事。但真正被业务代码毒打、被面试官追问之后你才会意识到List是一个接口站在它背后的至少有ArrayList、LinkedList、Vector三个主力实现它们的行为差异、适用场景、性能边界完全不同。把这三者的脾气摸透才是真正会用List的第一步。先看一个最常见的误区。网上铺天盖地的说法是ArrayList查询快、增删慢LinkedList增删快、查询慢。这句话听起来对但它粗糙到会误导人。LinkedList的增删快是有前提的前提是你已经拿到了目标位置的节点引用比如用listIterator在遍历过程中边找边删这种情况下LinkedList确实是O(1)的删除。可如果你是用list.remove(index)去删中间某个元素LinkedList依然要花O(n)从头遍历找到那个节点删除本身是O(1)不假但前置查找已经是O(n)整体根本谈不上快。更糟的是LinkedList每个节点还要额外存前驱和后继两个引用内存开销比ArrayList大得多。我在一个实际项目里测过存100万个Integer对象ArrayList底层的Object数组一次连续分配而LinkedList要创建100万个Node对象GC压力直接上一个台阶。所以LinkedList增删快这句话真实适用面非常窄绝大多数业务场景里ArrayList都是更稳的选择。再看Vector。这个类现在基本属于历史遗留它所有公开方法都用synchronized修饰线程安全但代价是全局锁。单线程环境下Vector比ArrayList慢多线程环境下Vector的粗粒度锁又比专门设计的并发容器差。我见过一些老项目中还在用Vector大概率是从Java 1.x时代迁移过来的代码没什么特殊理由的话新代码完全没有理由再碰它。下面这张表我把三种实现的关键差异列出来是我在实际选型时真正会参考的维度不是教科书上那种泛泛而谈维度ArrayListLinkedListVector底层结构Object数组双向链表Object数组方法加锁随机访问复杂度O(1)O(n)O(1)尾部插入复杂度均摊O(1)O(1)均摊O(1)指定位置插入需要移动后续元素需要先遍历定位需要移动后续元素内存占用连续内存有少量闲置容量每个节点多两个引用同ArrayList线程安全否否是全局锁适用场景绝大多数业务场景频繁在头部操作或实现队列几乎不推荐选型结论就一句话99%的场景无脑ArrayList剩下的1%是当你确定要频繁地在List头部插入删除、且数据量很大时才考虑LinkedList或者如果你需要有界队列行为用ArrayDeque都比LinkedList更轻量。2. 源码视角下的扩容陷阱为什么性能问题总藏在看不见的地方ArrayList最容易被忽略的两个细节一个是扩容机制一个是modCount。这两个东西表面上看是源码层面的事情但实际上它们决定了你在业务代码里写出来的循环、批量插入、甚至遍历删除是流畅还是卡顿、是安全还是抛异常。先说扩容。ArrayList初始容量是10当元素个数达到容量上限时会用grow方法扩容到原来的1.5倍也就是int newCapacity oldCapacity (oldCapacity 1)。这里用右移一位实现除以2是典型的位运算优化。为什么是1.5倍而不是2倍扩容之后旧的数组要废弃如果扩得太大内存浪费多如果扩得太小频繁扩容导致频繁的数组拷贝。1.5倍是开销和空间利用率的折中。但这里有个实战问题如果你不断用add逐条往里面塞100万条数据ArrayList会从容量10一路扩容10→15→22→33→……总共扩容大约log1.5次方次每次都触发一次System.arraycopy这个拷贝成本虽然均摊下来是O(1)但GC和内存抖动的开销是实打实的。所以如果你事先能估算出数据规模直接用new ArrayList(expectedSize)或者ensureCapacity把容量预分配到位性能提升非常明显。我自己压测过预分配容量比不预分配在插入100万条时能快30%~50%在GC耗时上的改善更显著。再讲modCount。这个字段记录的是结构性修改次数。所谓结构性修改就是改变List大小的操作比如add、remove而单纯的set替换元素不算。迭代器在创建时会记住当时的modCount每次调用next或remove都会校验当前modCount是否和预期一致不一致就抛ConcurrentModificationException。这就是为什么下面这段代码会炸ListString list new ArrayList(Arrays.asList(a, b, c, d)); for (String s : list) { if (s.equals(b)) { list.remove(s); // 触发 ConcurrentModificationException } }foreach语法糖编译后用的是迭代器迭代器发现modCount变了立刻抛异常。这就是fail-fast机制——一有风吹草动马上暴露问题而不是等到数据错乱的那一天才崩溃。但要强调一点modCount是单线程内的自我保护机制它不保证多线程下的原子性所以它本质上查的是迭代过程中有没有人动过集合结构不是集合是否线程安全。正确删除方式有这么几种按推荐程度排使用Iterator.remove()这是唯一在迭代过程中安全的删除方法因为它会把expectedModCount同步更新。使用Java 8引入的removeIf(Predicate)内部通过索引遍历并批量删除一次性处理完再调整结构效率很高。倒着遍历索引从size()-1递减到0然后remove(index)这样可以避免删除后索引错位的问题但每次都触发现有元素的移动性能一般。还有一个我在代码评审里经常见到的坑就是subList。List.subList(0, 5)返回的不是一个独立的List而是原List的视图。对这个子List做任何结构性修改都会反映到原List上并让原List以及所有其他subList的modCount状态失效。很多人不知道这一点拿subList去删除子区间元素删完之后再去操作原List莫名其妙抛ConcurrentModificationException查半天都查不到原因。如果你确实需要一份独立的子集合正确做法是new ArrayList(list.subList(0, 5))拷贝一份出来。这些源码细节平时写CRUD代码时你觉得无所谓但一旦涉及大数据量批量处理或者线上偶现ConcurrentModificationException回头来查的时候知道这些原理的人五分钟定位不知道的人排查一天。基本功这东西平时看不见出事时就见高下了。3. 多线程环境下的ListCopyOnWriteArrayList和Collections.synchronizedList的博弈多线程并发修改同一个ArrayList最直接的结果不只是抛异常的问题而是数据错乱两个线程同时扩容各自拷贝各自的数组然后互相覆盖最后大小对不上、元素凭空消失这种问题一旦发生现场通常已经不可复现只能靠日志硬猜。所以多线程环境下用List必须换思路。Java给我们的并发List选择说白了就两条路要么用CopyOnWriteArrayList要么用Collections.synchronizedList(new ArrayList())。这两者的取舍很多人在面试时背得滚瓜烂熟但真正落到代码层面就分不清了。我帮你把逻辑理一遍。CopyOnWriteArrayList的核心思想是写时复制。每次add、remove这类修改操作都会把底层数组完整复制一份在新数组上做修改然后把volatile修饰的数组引用切换为新数组。读操作不加锁直接读因为数组引用是volatile的写线程对数组内容的修改在读线程切换引用后是可见的。这个设计使得读操作性能极高非常适合读多写少的场景典型例子是监听器列表可能被很多线程同时读取遍历但注册和移除监听器的事件频率低。代价呢每一次写都要O(n)的数组复制。如果你在一个循环里往CopyOnWriteArrayList里塞一万条数据那就是一万次全量复制复杂度直接从O(n)变成O(n^2)。所以CopyOnWriteArrayList绝不适合频繁写的场景。我见过有人拿它当普通业务列表用结果线上CPU飙高一看GC日志全是年轻代晋升失败就是因为复制太频繁。Collections.synchronizedList的思路更简单粗暴它用synchronized块把所有读写方法都锁住。这在写多读也多的场景下更实用但它有两个隐蔽问题。第一它的迭代器不是线程安全的遍历的时候依然要手动加锁否则可能抛ConcurrentModificationException。官方源码注释里明确写了这一点但很多人没注意。第二它锁的是整个List并发程度低一旦数据量大、操作频繁锁竞争会非常严重。那有没有兼具两者优点的方案说实话没有银弹。如果读远大于写选CopyOnWriteArrayList如果读写比较平均、数据量不大选synchronizedList如果并发度要求很高、数据量又大你就得考虑换别的数据结构了比如ConcurrentLinkedDeque或者用分段思路自己设计。顺便说一句Vector也是线程安全的但它直接用synchronized修饰方法和synchronizedList其实是同一类思路只是Vector是JDK原生的、没有额外的迭代器加锁提示所以在新代码里没有任何理由选Vector。我在实际项目里测过一组数据4个线程并发写、8个线程并发读每个线程操作5万次CopyOnWriteArrayList的总耗时大约是synchronizedList的2.3倍因为写操作的复制成本压过了读操作的无锁优势。反过来如果改成1个线程写、20个线程读CopyOnWriteArrayList就反超了快大概40%。所以别再背结论了先明确你业务的读写比例再做选型。4. 从老式for循环到StreamList操作的方式进化与性能得失Java 8之后List的操作方式发生了一次很大的变化。以前我们要过滤、转换、分组一个List要么写for循环要么写一堆临时变量代码又长又容易出错。现在用Stream API几行流式操作就结束了。但这里有个常见分歧很多人觉得Stream就是优雅的玩具性能不如传统for循环也有很多人觉得Stream天下无敌所有集合操作都应该用Stream。这两种观点都过于极端。我做了不少基准测试我的结论是数据量不大几千条以内的时候两者性能差距微乎其微根本构不成选型理由但代码可读性和维护性的差距是肉眼可见的。举例来说从一个订单List里找出金额超过1000的订单并按时间排序用传统写法是这样ListOrder result new ArrayList(); for (Order order : orders) { if (order.getAmount() 1000) { result.add(order); } } result.sort(Comparator.comparing(Order::getCreateTime));用Stream写法是这样ListOrder result orders.stream() .filter(o - o.getAmount() 1000) .sorted(Comparator.comparing(Order::getCreateTime)) .collect(Collectors.toList());哪个更直观显然是后者它把过滤和排序的操作意图直接写在方法名上。尤其是团队协作时Stream的可读性能让接手的人一眼看懂这段代码在干什么而for循环需要一行一行读逻辑。所以除非是性能敏感的热点路径我建议优先用Stream写集合操作。再补充几个Stream时代的高频操作都是实际业务里特别常用的分组统计list.stream().collect(Collectors.groupingBy(Order::getStatus))直接得到一个Mapstatus, List 。如果你想要每个key的计数用Collectors.groupingBy(Order::getStatus, Collectors.counting())。转成Map并处理key冲突list.stream().collect(Collectors.toMap(Order::getId, Function.identity(), (oldVal, newVal) - newVal))。第三个参数是关键遇到重复key时保留新值不写这个参数只要id重复就抛IllegalStateException。拆分为两个集合list.stream().collect(Collectors.partitioningBy(o - o.getAmount() 1000))返回一个MapBoolean, ListOrdertrue键和false键分别对应满足和不满足条件的元素。比你自己写两个for循环分别add省事得多。但Stream也不是万能药。有几个场景我不建议用Stream第一个是循环内部涉及复杂的状态累积比如遍历时既要根据上一个元素做判断又要维护多个中间变量这种逻辑写成Stream会非常绕第二个是性能极端敏感、数据量百万级以上、并且需要控制在毫秒级的场景Stream的lambda装箱和额外的中间操作会有可测的开销第三个是老到不能再老的代码风格统一问题如果整个项目都是Java 7风格只有你一个人用Stream那维护成本反而上升。从Java 9开始List还多了个List.of工厂方法可以快速创建不可变列表。注意是不可变任何add、remove操作都会抛UnsupportedOperationException。这个API在初始化常量列表时特别方便比如配置项、枚举值列表比Arrays.asList更安全因为Arrays.asList返回的是固定大小的List但可以通过set修改元素。这一点经常有人搞混单独拎出来说一下。5. 业务场景高频操作去重、排序、取差集的一网打尽方案实战篇来点真东西。在我的代码评审经验里List相关的操作无非是这么几类高频需求去重、排序、批量转换为其他结构、求交集差集。这些操作看起来简单但每类都有两三个隐蔽的坑。先看去重。最简单的方法是list.stream().distinct().collect(Collectors.toList())这个方法依赖元素的equals方法。如果你的元素是String、Integer这种基础类型直接用没问题如果是自定义对象比如Order你要么重写equals和hashCode要么用指定字段去重。指定字段去重有一个很经典的技巧用Collectors.toMap收集器ListOrder deduplicated orders.stream() .collect(Collectors.collectingAndThen( Collectors.toMap(Order::getId, Function.identity(), (oldVal, newVal) - oldVal), map - new ArrayList(map.values()) ));这个写法用toMap的merge函数控制保留哪个然后取values收集成List。注意一点Collectors.toMap默认返回的是HashMap不保证放入顺序。如果你需要保持原始List的相对顺序就要用LinkedHashMap作为Map工厂也就是Collectors.toMap(Order::getId, Function.identity(), (oldVal, newVal) - oldVal, LinkedHashMap::new)。这个细节很容易被忽略一旦你去重后的顺序乱了线上排查半天。再来看排序。List自身的sort方法在Java 8之后可以直接传入Comparator不用再经过Collections.sort了写法是list.sort(Comparator.comparing(Order::getAmount))。如果你想多字段排序可以链式调用Comparator.comparing(Order::getAmount).thenComparing(Order::getCreateTime)。注意空指针问题如果排序字段可能为null要先写Comparator.nullsLast(Comparator.comparing(Order::getAmount))否则排序时一遇到null就抛NPE。我见过不少线上问题就是排序字段里混了几个null值直接导致整个批量任务失败。最后说交集差集。很多人第一反应是用list.retainAll和list.removeAll但这两个方法是会改动原List的而且内部是双层循环效率是O(n*m)。数据量小的时候无所谓数据量大了就很慢。更优雅的方案是先把一个List转成HashSet然后用另一个List去stream过滤这样复杂度降为O(nm)。比如两个用户ID列表要找出A有B没有的SetString idInB new HashSet(listB); ListString onlyInA listA.stream() .filter(id - !idInB.contains(id)) .collect(Collectors.toList());这个模式我几乎每天都在用屡试不爽。核心思路就是集合判断用HashSetList只做有序存储的容器不要让List自己承担集合运算。另外还有一个经常出现在业务代码里的需求把List转成用逗号分隔的字符串。老式写法是for循环拼接然后去掉末尾逗号Java 8之后用String.join(,, list)一行搞定或者如果你需要对每个元素做格式化后再拼接可以用list.stream().map(String::valueOf).collect(Collectors.joining(,))。这个API简单到容易被人忽略但在生成SQL的IN子句、拼接日志、导出CSV头的时候非常常用。6. 从List出发重新理解Java集合框架的设计哲学聊了这么多List的细节最后想跳出来看一眼整个集合框架的设计。你会发现List接口的设计其实贯穿着Java容器的一个核心思想接口和实现分离行为和性能解耦。我们面向List编程不考虑底层是数组还是链表这就是多态的意义。理解这一点你写代码时才会自然地用ListString list new ArrayList()而不是ArrayListString list new ArrayList()。前者让代码依赖抽象后续替换实现类不需要改动业务代码后者把实现细节暴露给所有依赖方一换实现类可能牵连一片。集合框架里还有一条隐藏的设计主线快速失败fail-fast和安全失败fail-safe。List的迭代器是fail-fast的一旦迭代过程中结构被修改立即抛异常而java.util.concurrent包下的容器比如CopyOnWriteArrayList的迭代器是fail-safe的它迭代的是创建时的快照所以迭代过程中其他线程修改集合不会抛异常但也读不到这些修改。这两者的取舍没有对错只是设计目标的差异。fail-fast是尽早暴露编程错误fail-safe是保证并发遍历的稳定。理解这个哲学背景你就不容易在使用时产生迷思。还有一点很重要equals和hashCode契约。List的contains、indexOf、remove(Object)都依赖equalsHashSet、HashMap的依赖更加强hashCode和equals必须保持一致否则相同的逻辑对象可能被当成不同元素。业务中经常遇到的情况是自定义对象只重写了equals没重写hashCode或者两者都没重写导致去重和包含判断全错。这一点在List操作中尤其容易踩雷因为很多人默认List的contains是比较引用其实不然它调用的就是element.equals所以是否重写equals直接决定contains的行为。从我个人经验来说学集合框架最有效的方式不是背API而是去看源码。ArrayList三百行核心代码CopyOnWriteArrayList两百行LinkedHashMap两百行看透了这几个类你会对Java容器的并发策略、扩容思想、迭代机制有直觉级别的理解。之后无论是在面试中聊集合还是在线上排查和List相关的问题都能做到心里有数而不是临时查文档。我在实际干活的时候有一个习惯凡是遇到循环里有集合操作的代码都会多问一句这个操作的时间复杂度是多少有没有办法用Set或者Map降低复杂度。这不是矫情而是线上数据量一上来O(n*m)的代码就是事故的种子。List作为门槛最低的容器从来不是会add、get、remove就算会了关键是在合适的场景做出正确的设计选择。希望这篇基于实操经验的梳理能帮你把List从会用的工具变成用得好的武器。
返回列表