1. 为什么要深入理解Java集合
做Java开发这么多年,我越来越觉得集合框架就是这门语言最实用的“基础设施”,没有之一。几乎每一个类里面都躺着集合的身影:方法参数要传列表,业务结果要放Map,去重要用Set,异步任务要扔进队列。你从哪个角度切入都躲不开Java集合,它就像工具箱里的扳手和螺丝刀,可以不用,但必须得心应手。
很多人刚接触集合时觉得简单,无非就是List、Map、Set几个接口,背一背实现类就完事了。但实际跑起业务来,问题接踵而至:为什么遍历的时候删除元素会抛异常?为什么HashMap在数据量一大就变慢?为什么多线程环境下明明用了HashMap就莫名的丢数据?这些问题的答案,全都藏在集合框架的设计原理和实现细节里。不把这一层搞清楚,写出来的代码要么性能隐患巨大,要么并发场景直接翻车。
集合框架解决的第一个痛点,是数组长度固定带来的尴尬。数组在声明时就要确定长度,后面想塞新元素,只能新建更长的数组再逐个复制,这活又琐碎又容易出错。集合里的ArrayList帮我们把扩容、复制的逻辑全部封装起来,往里add就完了,底层自己管容量。第二个痛点是数据结构多样性,不同业务对数据组织方式的要求完全不同,有的要唯一、有的要排序、有的要顺序、有的要高效随机访问,一套数组根本应付不过来。集合框架把常见数据结构都封装成现成类型,开发者需要哪种就选哪种,不必自己从零实现链表、哈希表或者树。第三个痛点是算法支撑,排序、二分查找、反转、求最值这些常用操作,在Collections工具类里都有现成封装,而且和一整套集合体系无缝衔接。
从面试角度来说,集合也是必考的重点区域。面试官几乎必问HashMap的底层原理、扩容机制、哈希碰撞,以及ArrayList和LinkedList的区别。从实际开发角度来说,集合选型更是代码质量的试金石。同样是一个去重需求,用HashSet还是ArrayList配合contains()手动判断,性能差距可以是数量级的。所以我一直给新人的建议是:别停留在“会用”的层面,把集合框架当成一门数据结构和算法的实践课来啃,收益会伴随你整个开发生涯。
这套框架的整体结构也很清晰,两大核心体系:Collection接口衍生出List(有序可重复)、Set(无序不可重复)、Queue(队列)三大子接口;Map是单独的键值对体系,不继承Collection,但同样属于集合框架的核心组成。搞明白这些分类,后面选型就顺理成章了。
2. 核心接口与实现类的选型思路
2.1 List家族:ArrayList与LinkedList的取舍
List家族最常碰到的两个实现类就是ArrayList和LinkedList,网上口水仗也打得最多。很多人喜欢直接问“哪个性能更好”,这种提问方式本身就有点偷懒。两个类的底层数据结构完全不同,性能优势各对应各的使用场景,没有绝对的快慢。
ArrayList底层是动态数组,默认初始容量是10。它最大的优势是随机访问,get(index)直接通过数组下标计算内存偏移量,时间复杂度O(1)。但插入和删除就没那么爽了,尤其是往头部插入,所有元素都要向后挪一位,时间复杂度O(n)。扩容也是一个隐形开销,当元素数量超过当前容量时,ArrayList会创建一个新数组,容量扩到原来的1.5倍,再把旧元素复制过去,数据量一大,这个过程肉眼可见的卡顿。
LinkedList底层是双向链表,每个节点保存着数据、前驱引用和后继引用。它的插入和删除只要修改前后节点的指针引用,时间复杂度O(1),但前提是你已经定位到了目标位置。随机访问就拉了胯,get(index)得从头或者尾沿着链表一步步走,时间复杂度O(n)。要是你图省事,用LinkedList写了一个for (int i = 0; i < list.size(); i++)的循环,每次get(i)都在链表里从头遍历,整体复杂度直接变成O(n²),这是个极其隐蔽的性能陷阱。
我在实际项目里的判断标准比较固定:读多写少、按下标取数据,选ArrayList;频繁在头部做插入删除、且以迭代为主,选LinkedList。但要泼一盆冷水,LinkedList在中间插入时虽然指针修改是O(1),但你要先遍历到那个位置,定位过程是O(n),所以它真正的高光场景是头部尾部的双端操作。如果是做队列,ArrayDeque很多时候比LinkedList更合适,底层环形数组更省内存,双端操作也快。
提示:不要在一开始就陷入“ArrayList一定比LinkedList快”的思维定式。所谓的快与慢,一定要绑定具体操作和数据结构来谈。
get(index)前者完胜,addFirst()后者完胜,各打五十大板才是真相。
2.2 Map家族:HashMap、LinkedHashMap、TreeMap怎么选
Map的家族比List庞大多了,但日常开发里高频出现的就那几个:HashMap、LinkedHashMap、TreeMap,以及并发场景下的ConcurrentHashMap。
HashMap是绝对的主角,底层是数组加链表,JDK 8引入了红黑树优化后,在大量哈希碰撞的场景下也有了保底性能。put和get的平均时间复杂度O(1),但它有两个天生短板:无序、非线程安全。无序意味着遍历顺序和插入顺序没有必然关系,很多业务场景其实无所谓,但如果你要求遍历顺序与插入顺序一致,就得请出LinkedHashMap。
LinkedHashMap在HashMap基础上,额外维护了一个双向链表记录元素的插入顺序或访问顺序。它的遍历顺序有保证,但代价也不小:每个节点都要多存两个链表引用,插入的时候多了一些指针操作。另一个很实用的能力是,把构造参数accessOrder设为 true 后,LinkedHashMap会按照访问顺序排列元素,配合重写removeEldestEntry,这就是一个开箱即用的LRU缓存骨架。很多面试题和缓存组件源码里,都能看到这个套路。
TreeMap则是另一个极端的代表,底层是红黑树,按键的自然顺序或者自定义Comparator排序存储。它的get、put、remove时间复杂度都是O(log n),比不过HashMap的O(1),但它有一票HashMap给不了的能力:范围查询。subMap()能取子区间,tailMap()能取大于某个键的所有数据,floorKey()能找小于等于目标键的最大键。这些方法在做时间区间统计、价目表查询、字典序排序时,好用得不行。
我记这三者的方式很简单:高效键值存取选HashMap,要保插入顺序选LinkedHashMap,要按键排序做范围查询选TreeMap。理解到这一层,大部分业务选型都不会跑偏。
2.3 Set家族:HashSet、LinkedHashSet和TreeSet
Set的核心语义是“元素不重复”,但它的实现类并不像想象中那么独立,底层几乎全都站在Map的肩膀上。HashSet内部维护了一个HashMap,把元素放在key的位置,value统一填充一个无意义的静态对象。往HashSet里加元素,本质就是map.put(element, PRESENT),去重逻辑完全依赖Map的key比较机制。
LinkedHashSet也很直白,内部把HashMap换成了LinkedHashMap,所以它在去重的同时保留了插入顺序。这个特性在某些场景特别好用,比如处理用户提交的一批订单号,既要去掉重复,又必须保持原来的提交顺序,用LinkedHashSet一行代码搞定,不需要额外再排序或者做二次处理。
TreeSet内部对应的是TreeMap,元素在key的位置,按自然顺序或者自定义Comparator排好序。它能支持first()、last()、subSet()等一堆范围操作。如果数据需要去重且要排序,用TreeSet比“放进HashSet再Collections.sort()”要自然得多,而且TreeSet本身是动态维护有序的,插入时就已经排好了,读的时候直接有序遍历。
判断口诀顺下来:去重选HashSet,去重还要保持顺序选LinkedHashSet,去重还要排序选TreeSet。记住这几个关系,Set系列基本就通了。
3. 源码层面解析核心实现原理
3.1 HashMap的哈希策略与碰撞处理
HashMap是整个集合框架里最值得精读的源码类,它的设计思路可以概括成一条链:通过哈希函数把key映射到数组下标,用链表或红黑树解决哈希碰撞,依靠扩容机制保证数组负载在合理范围。
先看哈希函数本身。JDK 8的hash()方法会将key.hashCode()的高16位与低16位做异或,也就是h = key.hashCode(); h ^ (h >>> 16)。这步操作叫扰动函数,目的是让高位的随机性也参与进数组下标的计算。因为当数组长度比较小时,参与下标计算的只有哈希值的低位,比如数组长度16,二进制是1111,哈希值和它按位与时,高位完全被掩掉,这样不同对象可能因为低位恰好相同而产生大量碰撞。把高16位异或到低16位,哈希值的低位就混合了高位的随机性,碰撞概率显著下降。计算桶下标时用的是(n - 1) & hash,而不是hash % n,这要求数组长度必须是2的幂,二进制表示全1的n-1恰好能充当取模掩码,性能比取模更高,这就是为什么HashMap扩容总是往2倍扩。
再看碰撞处理。JDK 7时期HashMap只有数组加链表,极端情况下所有元素都落在同一个桶里,查找就退化成遍历链表,复杂度O(n),攻击者甚至能利用这点构造大量同哈希字符串触发DoS。JDK 8引入了红黑树优化:当链表节点数超过8,且数组长度达到64时,链表转成红黑树,单次查找降到O(log n)。当节点数降到6以下时,红黑树又会退化成链表,避免小规模树结构带来额外开销。8和6之间特意留了下差值,就是防止元素在临界点反复横跳,触发无谓的结构转换。
扩容机制同样值得细看。默认负载因子0.75,阈值等于容量 × 负载因子,当元素数量超过阈值,容量翻倍,所有元素要重新计算桶下标并迁移。这个“rehash”过程在大数据量下成本很高,所以如果能预估数据规模,提前设置合适的初始容量能省掉很多扩容开销。比如要存1万条数据,直接new HashMap<>(10000)其实不够优,因为容量还没达到2的幂,实际容量会被调整成16384,负载因子0.75对应的阈值是12288,这样1万条数据进来不会触发扩容。如果直接用默认容量,存到第7500条就会扩容一次,白白付出一次全量复制代价。
注意:
HashMap的键最好使用不可变对象。像String、Integer这类天然不可变的类型作为key,哈希值稳定,不会因为键对象内部状态变化导致查不到数据。如果一定要用自定义对象,hashCode的计算最好只依赖final字段。
3.2 ArrayList的扩容机制详解
ArrayList的源码相对亲民,核心就是数组管理。它的grow()方法承载着扩容逻辑,实现是int newCapacity = oldCapacity + (oldCapacity >> 1),也就是旧容量加上旧容量的一半,相当于1.5倍扩容。乘1.5倍而不是翻倍,背后是空间与时间的权衡:翻倍扩容意味着潜在的空间浪费更大,特别是大列表场景下,多出来的容量可能长期用不上;扩得太少,比如固定增加10个,又会导致频繁扩容,每次都触发Arrays.copyOf全量复制。1.5倍是经验平衡点。
ArrayList的初始容量默认是10,如果我们能提前知道要存多少数据,一定要显式传入初始容量。比如从数据库查出10万条记录,需要装进List再处理,new ArrayList<>(100000)和new ArrayList<>()的性能差距极其明显。后者在添加过程中会经历多次扩容,每次都复制全部已有元素,时间成本成倍叠加。前者一步到位,底层数组只创建一次,添加过程纯粹是赋值操作。
从内存角度分析一下也能感受到扩容的代价。假设列表有8万个元素,默认容量10的时候,每次扩容都要把原数组复制到新数组,旧数组随即失去引用等待GC回收。扩容次数多了,GC压力也会增加。用容量预分配把扩容次数降到0,不光是CPU时间减少,GC和内存碎片问题也一并缓解。
ArrayList还有一个细节是subList(),它返回的是内部视图而不是独立的列表,对subList的修改会直接影响到原列表。这个特性可能导致极其隐蔽的bug,如果你不知道这个机制,在subList上做add或remove,原列表结构同样被改了,有时候自己都反应不过来。
3.3 TreeMap与红黑树的平衡逻辑
TreeMap是集合框架里结构最“有层次”的实现类之一,它的底层是红黑树。红黑树是一种自平衡二叉搜索树,它不追求绝对的左右子树高度相等,而是通过颜色约束保证最长路径不超过最短路径的两倍,从而把树高控制在O(log n)量级。
红黑树五条核心性质要烂熟于心:每个节点要么红色要么黑色;根节点是黑色的;每个叶子节点(NIL节点)是黑色的;红色节点的子节点必须是黑色的,也就是说不能有连续的红色节点;从任一节点到其每个叶子的所有路径,包含相同数量的黑色节点。正是这第五条性质,保证了树高不会失衡。
往TreeMap里插入一个节点时,默认涂成红色,因为红色比黑色对性质的破坏更小。如果插入后破坏了红黑树性质,就需要通过变色和旋转来修复。左旋、右旋是局部的结构重排,它不会改变二叉搜索树的中序遍历有序性,只是把局部节点关系重新组织。变色则是调整节点的红黑状态,配合旋转一起把破坏的性质修复回来。理解红黑树不必强推所有旋转场景,但要知道两个关键点:旋转保证有序性不变,整棵树的增删查操作始终保持在O(log n)。
TreeMap的迭代器按键升序输出,这是因为中序遍历二叉搜索树天然有序。它还实现了NavigableMap接口,提供lowerKey、floorKey、ceilingKey、higherKey等边界方法。比如业务上要找到距离某个时间点最近的记录,floorKey(timestamp)一条调用就完事,不用自己遍历集合维护临时变量。
TreeMap的比较器是另一个容易踩坑的点。如果自定义对象没有实现Comparable,构造TreeMap时必须传入Comparator,否则添加第一个元素就抛ClassCastException。而且比较逻辑必须满足传递性,否则红黑树会进入错误状态,查询结果可能完全不对。这个坑我曾经在线上的配置类场景踩过,某字段的排序规则定义模糊,导致同一个key能反复插入,树结构直接乱了。
4. 并发集合与线程安全
4.1 同步容器与并发容器的差别
Java集合框架里有两类顶着“线程安全”名号的容器,一类是早期的同步容器,代表是Vector、Hashtable,或者用Collections.synchronizedList()包装出来的各种同步集合;另一类是JDK 5以后进入的并发容器,代表有ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue。两者的线程安全策略有着本质差别。
同步容器走的是最直白的路子:所有公开方法都加synchronized锁。单线程下锁竞争为零,一切正常;多线程下,所有读操作和写操作争抢同一把锁,并发度直接退化为串行执行。更尴尬的是,即使每个方法都加了锁,复合操作场景依然不安全。比如一个线程遍历集合同步检查,另一个线程恰好执行remove,迭代器检测到modCount变化,立刻抛出ConcurrentModificationException。原因在于同步容器只保证了每个方法内部的原子性,并没有保证“迭代加删除”这个组合操作的原子性。
并发容器则换了思维,不再用一把大锁锁住整个结构。ConcurrentHashMap在JDK 7采用分段锁,把哈希表分成多个段,每一段独立加锁,两个线程操作不同段时可以并行,锁粒度比全局锁细了一个维度。JDK 8又进一步演进,去掉分段锁,改为对单桶的头节点加锁,配合CAS完成无锁插入。锁粒度从“段”缩小到“桶”,并发能力自然水涨船高。
实际项目里我几乎不用Hashtable,读多写少的场景它完全扛不住压力。替换成ConcurrentHashMap之后,读操作基本无阻塞,写操作最多锁一个桶,吞吐量能有质的提升。如果只是需要一个不可变集合作常量配置,直接走List.of()、Map.of()这种不可变集合就行,根本不用考虑锁的问题。
4.2 ConcurrentHashMap的CAS与细粒度锁
ConcurrentHashMap是并发编程里的必修课。JDK 8版本的核心思路是“CAS + 节点级同步”。
CAS(Compare And Swap)是一种无锁的原子操作,它先比较内存当前值是否与预期值相等,相等就更新,不相等就说明被并发修改了,继续重试。ConcurrentHashMap插入元素时,如果对应桶为空,就会用CAS直接把新节点写进桶里,整个操作不需要加锁。如果桶不为空,才对这个桶的头节点加synchronized,然后执行链表插入或者树插入。
这个设计的高明之处在于并发冲突的概率被压得非常低。不同线程通常操作的是不同的哈希桶,CAS就足够应付,无需锁竞争;即使撞到同一个桶,加锁也只影响这一个桶,其他桶的数据完全不受牵连。这种细粒度锁策略让ConcurrentHashMap在极高并发下依然保持不错的吞吐量。
扩容逻辑也很有看点。HashMap扩容时所有线程都得停下来等,ConcurrentHashMap则设计成多线程协助迁移。扩容任务被拆分成多个步长任务,数组中的桶逐个迁移,迁移完的桶放上一个ForwardingNode标记。读线程遇到ForwardingNode就知道数据已经迁到新数组,直接去新数组继续读。这样读写和新旧数组的总能保持一致,不用全局停顿等待扩容完成。
用ConcurrentHashMap的另一个重点是多步复合操作。很多人习惯先get再put,这在多线程下并不能保证原子性,比如“如果key不存在就插入”这种逻辑,两个线程可能同时判断出不存在,然后都去插入,结果互相覆盖。正确做法是用computeIfAbsent()、compute()、merge()这些方法,它们内部保证了复合操作的整体原子性。我曾经在统计场景吃过这个亏,不用merge()硬是手动getAndPut,数据丢了都毫无察觉。
4.3 CopyOnWriteArrayList和阻塞队列
CopyOnWriteArrayList是一个反直觉但极其有效的并发容器。它的写入操作会复制一份新的底层数组,在副本上完成修改,再把引用指向新数组;读操作则永远在旧的不可变数组上进行。这样读写之间天然隔离,读操作完全无锁,连复制的开销都由写者承担。
这个机制带来一个漂亮的结果:迭代器读取时不需要额外同步。因为迭代器是基于“写入那一刻的快照”遍历,即使另一个线程同时往集合里添加元素,迭代器看到的还是旧数组,不会抛ConcurrentModificationException,也不会读到半截数据。它非常适合读多写极少的场景,典型应用是事件监听器列表、系统配置或者频繁读取的注册表。但也要知道它的软肋,每次写入都复制整个数组,数据量大且写频繁时,会有大量垃圾对象产生,GC压力剧增。
BlockingQueue是另一个并发利器,专门服务生产者-消费者模式。ArrayBlockingQueue底层是循环数组,必须指定容量,是有界队列;LinkedBlockingQueue底层是链表,不指定容量时默认事务无界,但也可以显式传入容量限制排队数量。put()方法在队列满时自动阻塞,take()方法在队列空时自动阻塞,这比手动写wait和notify简洁太多了。线程池内部的任务队列就是这种机制,队列大小直接决定了线程池的缓冲能力和拒绝策略。我在做异步任务处理时,经常用一个有界ArrayBlockingQueue配合手动线程池,既保证了任务排队,又防止任务积压拖垮内存。
5. 实际开发中的常见问题与排查技巧
5.1 ConcurrentModificationException的来龙去脉
谈到集合,几乎每个开发者都撞上过ConcurrentModificationException。这个异常在单线程里也会出现,根源是ArrayList、HashMap这类非线程安全集合内部维护了一个modCount计数器,每次结构被修改,modCount就自增。创建迭代器时会记录当时的modCount快照,每次调用next()都检查当前modCount和快照是否一致,不一致就直接抛异常。这套机制叫 fail-fast,设计初衷就是在遍历过程中尽早发现并发的结构性修改,宁可在不确定状态前停下来,也不要在脏数据上继续操作。
单线程环境最常见的触发代码是:
List<String> list = new ArrayList<>(); for (String item : list) { if (item.startsWith("temp")) { list.remove(item); } }增强for循环的底层是迭代器,循环内部调用list.remove(item)把modCount改了,迭代器一检查发现对不上,立刻抛异常。正确的删除姿势是使用迭代器自己的remove():
Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String item = iterator.next(); if (item.startsWith("temp")) { iterator.remove(); } }迭代器的remove()会同步更新迭代器内部的expectedModCount,所以不会触发异常。更简洁的路线是直接用JDK 8的removeIf(),内部就是按迭代器方式实现的,一行代码完成过滤删除。
多线程环境的排查思路不同。优先判断这个集合是不是被多个线程共享,如果是,线性安全的ArrayList再怎么改都有风险。方案要么加锁,要么换成并发容器。ConcurrentHashMap不会因为并发修改抛出这个异常,但也不能认为它支持任意复合操作;CopyOnWriteArrayList的迭代器基于快照,遍历过程中即使数据被别处修改也不会炸,这才是我在监听器列表场景用它的理由。
5.2 hashCode与equals:集合元素设计的第一道防线
HashMap和HashSet判断两个key是否相等,走的是“hashCode先决”的流程:先比较hashCode,不同直接判定不相等;相同再调用equals做最终确认。这个流程背后隐含一个硬规范:两个对象equals为 true 时,hashCode必须相等;反过来 hashCode 相等不代表equals为 true,只能说明发生了哈希碰撞。
实际项目中最常见的bug之一就是只重写equals忘了hashCode。业务对象丢进HashSet时,两个逻辑上相等的对象因为hashCode不同,被当成不同元素同时存进去了,去重功能完全失效。另一个更隐蔽的问题是hashCode计算不稳定,比如基于对象里的可变字段计算哈希值。对象放进HashMap作为key后,字段一变,hashCode就变了,这个key在桶里的位置也随之改变,get的时候按新hashCode查过去的桶,根本找不到原数据。
我踩过特别深的一个坑是订单对象作为缓存key参与hashCode,订单状态字段从“待支付”变“已支付”之后,整个map的key位置彻底错乱,缓存命中率归零。解决方案很直接:hashCode只依赖不可变字段,比如主键ID、创建时间戳。更保守的思路是干脆不重写hashCode,用Object默认的内存地址实现,只要equals也不重写,整条链路反而不会出这种“内容变了找不到”的幺蛾子。
注意:
HashMap的key是否有必要定义为自定义对象?如果只是需要一张映射表,直接用业务主键的字符串或者Long作为key,往往省心得多。自定义对象做key只在多字段联合主键非做不可时才有必要,此时hashCode设计必须慎之又慎。
5.3 集合容量预估与内存优化
集合性能优化里,容量预估是最容易被忽略也最容易见效的环节。ArrayList默认容量10,数据少没事,数据量大就麻烦了,每次扩容都触发一次Arrays.copyOf全量复制。HashMap默认容量16,负载因子0.75,默认阈值12,超过12个键值对就会扩容。这些默认值是为了“通用场景下不浪费内存”,但你的业务一旦数据量有数,就必须主动打破默认。
预估容量时,ArrayList好说,直接new ArrayList<>(expectedSize)。HashMap稍有讲究,因为容量必须是2的幂,且容量×负载因子要大于等于预估数据量。假设要存1万条,那就new HashMap<>(16384),16的14次方,阈值12288,足够用还不触发扩容。很多经验帖建议“直接传 expectedSize / 0.75f”,这个公式也是对的,只是要注意结果向上取整到2的幂。
内存优化方面,集合的结构本身也很占空间。HashSet存10万个字符串,底层哈希表数组加上每个节点的引用和对象头,开销比字符串本身还要大。如果能换成针对基础类型优化的专用集合——比如基于 int、long 的原始类型集合,能省掉大量装箱对象。这类场景我一般推荐用一些开源的原始类型集合库,而不是直接用HashSet<Integer>。
还有一个容易犯的错误是无谓的集合转换。接口返回ArrayList给下层,下层又转成LinkedList,再被上层包一层TreeSet,每一次转换都是时间和内存的双重浪费。集合的形态应当在业务入口处就想清楚,不要因为“统一类型”这种理由做多余转换。
6. 性能对比与调优实战
6.1 常用集合的时间复杂度速查
集合选型是否合理,落到最后都体现在时间复杂度上。我整理了一张速查表,常见的几个集合类型都在里面:
| 集合类型 | 底层结构 | 平均插入 | 平均删除 | 平均查找 | 随机访问 |
|---|---|---|---|---|---|
| ArrayList | 动态数组 | O(1)(尾部)/ O(n)(头部) | O(n) | O(n) | O(1) |
| LinkedList | 双向链表 | O(1)(定位后) | O(1)(定位后) | O(n) | O(n) |
| HashMap | 数组+链表+红黑树 | O(1) | O(1) | O(1) | — |
| TreeMap | 红黑树 | O(log n) | O(log n) | O(log n) | — |
| HashSet | 哈希表(底层HashMap) | O(1) | O(1) | O(1) | — |
| TreeSet | 红黑树(底层TreeMap) | O(log n) | O(log n) | O(log n) | — |
这里必须强调“平均”两个字。HashMap的O(1)依赖于哈希函数散列均匀,如果大量key发生碰撞,链表变长、树化启动,单次查找可能从O(1)退化到O(n)或者O(log n)。设计key对象的hashCode时,散列质量直接影响整体性能。
我用10万条数据做过一次直观对比:ArrayList做10万次随机访问,耗时不到10毫秒;LinkedList做同样操作,耗时超过2秒,两个差了上百倍。反过来,头部插入场景,ArrayList每插入一次就要把剩余元素全部右移,10万次插入耗时可观;LinkedList在头部插入只需要新建节点改指针,耗时几乎可以忽略。所以我说选型本身才是性能优化的第一层,类型选对了,后面的雕花才有意义。
6.2 初始化容量、迭代方式与批处理优化
集合调优的第二层是初始化容量的精准控制。HashMap扩容要重新计算每个键的桶下标,数据量越大,rehash成本越高,并发环境下这一过程还会影响其他线程的读写。能预估数据规模时,必须提前把容量定到位。ArrayList的容量预估直接填size就行;HashMap的容量要考虑到负载因子,用expectedSize / 0.75f的结果向上取2的幂,这个值作为初始容量最稳。
迭代方式也是常见的性能黑洞。ArrayList的迭代器遍历和下标遍历差距不大,但LinkedList如果还用get(i)下标访问,复杂度直接O(n²)。我见过不少线上事故,就是数据量破万之后,某个接口突然变慢,查下来都是这种低效遍历惹的祸。用for-each或者显式迭代器,才是LinkedList的正确打开方式。
批处理操作同样值得讲究。ArrayList批量塞数时用addAll()并且提前ensureCapacity(),可以避免逐条添加过程中的反复扩容。批量putConcurrentHashMap时,也可以用putAll()减少循环里单条put的开销。但要注意,并发容器那里复合原子性还是要自己保证,该用merge和compute的不要手写 getThenPut。
最后提一个和Stream集合结合的陷阱。Stream的filter、map是惰性求值,链式调用不会创建中间集合,这点很友好。但一旦在尾部collect(Collectors.toList()),就会创建真实的列表,下游如果又要去重或者排序,就会再生成新的集合。每一层转换都是复制,链越长开销越大。所以写流之前先想清楚结果形态,不要循环往复做无谓的集合对倒。
7. 实战经验:从集合框架到系统设计的思考
集合框架的知识点零零散散,真正把它们串起来的,是解决真实问题的能力。前阵子排查一个线上接口变慢的案例,最后问题落在我刚说过的LinkedList低效遍历上:历史代码里有人图省事,随机访问用了get(i),数据量小时毫无存在感,数据量翻倍后接口耗时直线上升。换回迭代器之后,性能直接回到正常水平,整个过程没有改一行业务逻辑。
还有一次是并发统计场景,多线程往ConcurrentHashMap里累加计数,最初写法是get出来加一再put回去,结果两个线程同时get到同一个旧值,各自加一又put回去,数据少算了一倍。换成merge之后,保证原子累加,数据才准确。这种案例听上去简单,但如果你没有系统理解并发容器提供的方法语义,很容易在“看起来没问题”的代码里翻车。
所以我的建议一直是:打开JDK源码,认真读一遍HashMap、ArrayList、LinkedList,不需要逐行记忆,只要理解每个方法背后的设计动机。读完以后再看自己项目里到处使用的集合,先前觉得理所当然的选择,多少都有优化空间。比如该用computeIfAbsent缓存初始化的地方,是不是还在手动判断并put?该用不可变集合的地方,是不是还在小心翼翼地避免意外改动?
集合框架的学习应该是个阶梯式过程:先理清接口和实现类的对应关系;再深入源码揣摩哈希、树、链表的细节;然后把并发容器和调优手段变成手到擒来的肌肉记忆;最后在真实系统中反复验证,遇到问题能快速定位到集合层面。到了这个阶段,你在技术方案评审时再提到集合选型,会明显更有底气。
按我个人的经验,想验证对集合框架的掌握程度,可以尝试实现一个简单的LRU缓存:用LinkedHashMap的accessOrder配合removeEldestEntry是入门版;自己用ConcurrentHashMap加双向链表实现一个线程安全版本是进阶版;如果还想再深一层,可以再研究一下定时清理和过期淘汰的逻辑怎么和集合操作优雅地融合。这个练习能把Map、队列、迭代器、并发控制甚至时间轮的概念全部串起来,做完之后你对Java集合的认知会上一个台阶。