☰
Java集合框架全解析:接口设计、底层原理与实战选型
2026/10/15 7:03:53 网站建设 项目流程

1. 集合框架的顶层设计:接口优先,而不是实现优先

Java集合框架是日常开发中绕不开的基础设施,从数据缓存到接口参数传递,几乎每个类里都会出现它的身影。很多人用ArrayList和HashMap用了好几年,简历上写着“熟悉Java集合”,可一旦被问到“为什么设计成两套体系”“HashMap为什么是红黑树而不是二叉搜索树”“并发场景下到底该选谁”,就难免卡壳。这篇文章不打算从头抄一遍Java文档,而是从一个实际写代码、调过性能、踩过内存坑的开发者角度,把集合框架的核心逻辑、底层机制和实战选择掰开揉碎讲一遍。

先说清楚一个基本认知:Java集合框架的核心不是那些具体的类,而是接口。我们平时写的List<String> list = new ArrayList<>();,左边是接口,右边是实现类,这一个看似简单的写法背后,隐藏着整个框架最重要的设计思想——面向接口编程,而不是面向实现编程。

1.1 两大体系的划分逻辑

Java集合框架从宏观上分成两大体系:Collection和Map。这种划分不是拍脑袋想的,而是基于数据组织方式的本质区别。

Collection体系管理的是“单个元素的集合”,它下面又分出三条支线:List是有序可重复的列表,Set是无序不可重复的集合,Queue是队列。这套继承关系用一句话可以概括:Collection是把一堆独立的元素装进一个容器里,至于这个容器怎么排列、允不允许重复、怎么进出,由子接口分别定义。

Map体系管理的是“键值对的映射关系”,它并不继承Collection。Map的核心抽象是“一个键对应一个值”,就像字典里一个词条对应一个解释。HashMap是Map体系的代表,也是实际开发中出现频率最高的集合类之一。

这两套体系在内存组织方式上有根本性的不同:Collection里每个元素是独立的“个体”,而Map里每个元素是“键-值”的绑定关系。这种不同决定了它们内部存储结构、遍历方式、性能特征都有明显差异。

举个生活化的例子来理解:List就像一条排队等候的队伍,人挨着人,每个人有固定序号,你可以通过序号直接找到这个人;Set就像一间不允许重复面容的会议室,每个进去的人都要先“验明身份”,重复的人进不来;Map就像一部电话簿,你查名字,系统返回对应的号码,你不需要知道号码存在电话簿的第几页。

1.2 接口抽象为什么重要

面向接口编程的价值,写得多了自然会有体感。当一个方法的参数声明为List时,调用方可以传ArrayList、LinkedList,甚至是一个只读的Collections.unmodifiableList()返回的包装列表,完全不影响方法内部的逻辑。这种解耦让代码可替换性大大增强,也让单元测试更容易构造数据。

我见过不少同学在代码里到处直接声明具体类,比如HashMap<String, Object> map = new HashMap<>()。这样做在短期内没问题,可一旦需求变化,要把HashMap换成TreeMap来支持按key排序,就得动一堆调用点。反过来,如果你写的是Map<String, Object> map = new HashMap<>(),换成TreeMap只需要改一行。别小看这一行的差距,在大项目里“改不动”往往就是从这些细节开始的。

但接口也不是越多越好。Java的集合框架经过多年演进,接口层次其实已经有些“冗”了,比如List和AbstractList之间的抽象层,以及后来为了兼容Java 8的Stream而加入的默认方法,这些对初学者来说都是理解成本的增加。不过从整体设计角度看,接口优先带来的可扩展性,远远大于它造成的理解负担。这也是为什么Java集合框架能持续演化二十多年,到现在依然是Java生态里最稳定的基础设施之一。

2. 核心接口的细节与选型逻辑

理解了顶层设计之后,接下来需要把每个核心接口的行为约定和适用场景说透。很多线上Bug的根源,不是代码语法错误,而是对接口语义的理解偏差。比如明明需要去重,却用了List导致重复数据堆积,等到下游统计时报错才发现;再比如把Map当List用,遍历时发现顺序完全不可控,而业务恰好依赖插入顺序。

2.1 List:有序、可重复,但底层的取舍完全不同

List接口定义的核心语义是:元素之间有先后顺序,且允许重复元素。这个语义本身非常清晰,具体实现却走上两条完全不同的技术路线:ArrayList基于动态数组,LinkedList基于双向链表。

ArrayList的底层是一个Object数组,当容量不够时会自动扩容。因为底层是连续内存,所以按下标访问的效率是O(1),这使它成为大多数场景下的默认选择。但数组的代价在于:中间插入或删除元素时,需要把后续所有元素整体向前或向后搬移,最坏情况下的时间复杂度是O(n)。如果你在一个大型List的头部反复插入元素,性能会非常难看。

LinkedList使用节点之间的引用链来维系顺序,每个节点持有前后两个节点的引用。插入和删除只需要修改相邻节点的指针,所以头尾插入的时间复杂度是O(1)。但它放弃了随机访问的天然优势,按下标获取元素需要从头或尾开始遍历,时间复杂度达到O(n)。

实践中我的建议很直接:如果业务以按下标读取为主,选ArrayList;如果业务以频繁的头部或中间插入删除为主,并且元素数量很大,才考虑LinkedList。但要注意,LinkedList在Java 8之后引入的Deque接口实现给了它另一个身份,LinkedList同时也实现了Deque,所以它也可以被当作双端队列来用。如果你需要的其实是一个紧凑、高效的队列结构,优先考虑ArrayDeque,它综合了数组的紧凑性和双向扩展能力,性能通常优于LinkedList。

2.2 Set:去重背后是equals和hashCode的约定

Set接口的定义是“不包含重复元素”。这个定义听起来简单,实际实现里对“重复”的判定,直接决定了数据能不能正确去重。

HashSet是Set体系里最常用的实现。它内部直接用HashMap来存储,元素作为key,一个固定的Object对象作为value。当调用add()时,HashSet先把元素交给HashMap去put,如果该key已经存在,就返回false,表示添加失败。这里的“已经存在”靠的是hashCode()先定位,再用equals()精确比对。

这个机制意味着一个铁律:如果你要往HashSet里放自定义对象,必须同时重写equals()和hashCode(),而且这两个方法的行为必须一致。只要hashCode不同但equals相同,或者equals相同但hashCode不同,就会出现去重失败或查找不到元素的诡异现象。这个问题在本文后面的“常见问题”部分会详细展开,这里先记住一句话:HashSet的去重能力,从根本上说,是你自己的对象有没有正确实现这两个方法的问题。

TreeSet则是另一种维度上的“不重复”,它要求元素要么实现了Comparable接口,要么在创建TreeSet时传入一个Comparator比较器。TreeSet的元素排序跟在HashMap上遍历的“无序”完全不同,它做到的是局部有序的二叉搜索树结构。如果你需要一个自动有序的结果集,比如按字符串长度排序,或者按业务对象的某个字段排序,TreeSet能省掉很多后续排序的代码。

2.3 Queue:不止是“排队”

Queue接口代表的是一种FIFO(先进先出)规则,但它的真实能力远不止排队。Queue接口的方法分为两组:一组是添加和移除元素时抛异常(add、remove、element),另一组是返回特殊值(offer、poll、peek)。这两组方法的区别初学者容易忽略,但在生产环境里,用offer()而不是add()往往可以避免一次未处理的异常。

Deque是Queue的一个扩展接口,它支持双端操作,既可以头部进、尾部出,也可以反过来。ArrayDeque是它的典型实现,底层用一个循环数组来维护两端的数据。循环数组的好处是首尾插入删除只需要移动头尾指针,不需要搬移元素,配合数组的缓存友好特性,性能比LinkedList这种纯链表结构更高。所以当你需要的只是一个栈(后进先出)时,优先用ArrayDeque,而不是Stack类。Stack类是旧集合框架里的产物,它继承了Vector,线程安全从设计上就带了不必要的同步开销。

Java集合框架里的Queue实现远不止这些,还包括PriorityQueue这种可以做优先级调度的小顶堆结构,以及和并发绑定的BlockingQueue接口,但这些放到本文的并发部分一起说。

2.4 Map:键值对的王者,HashMap的底层原理

Map是独立于Collection之外的一套体系,最核心的实现无疑是HashMap。理解HashMap等于理解Map体系的半壁江山,这也是几乎所有Java面试都会问到的点。

HashMap的底层结构是数组加链表(JDK 8后链表超过阈值会树化成红黑树)。当你执行put(key, value)时,系统会先调用key的hashCode(),然后对这个哈希值做一轮扰动,再用扰动后的值和数组长度做位运算,得到一个数组下标。如果该下标位置为空,直接放进去;如果不为空,说明发生了哈希冲突,用链表(或红黑树)把新节点挂上去。

为什么要有扰动?因为直接拿hashCode()去和数组长度做运算,高位信息就丢失了。为了让哈希值的高位也能参与数组下标计算,JDK 8里的写法是hash ^ (hash >>> 16),也就是把高16位与低16位异或。这样即使哈希值的低位相同、高位不同,也能大概率分散到不同的数组下标上,减少冲突。

数组默认初始容量是16,负载因子是0.75f。这两个参数决定了HashMap什么时候扩容:当已有元素数量超过容量 * 负载因子时,数组扩容为原来的两倍,所有元素重新计算下标并搬移。扩容是一个代价高昂的操作,频繁的扩容会让写入性能明显下降。所以如果你预判到map里会有大量数据,最好在构造时就指定合适的初始容量。这里有个小技巧:HashMap在扩容时的阈值是容量 * 0.75,如果你想要存入n个元素而不触发扩容,初始容量应该设置为n / 0.75 + 1,向上取整到2的幂。比如你预计存500个元素,500除以0.75约等于667,那就直接设初始容量768(2的9次方)或1024,这样实际存储不太会触发扩容。

HashMap不保证遍历顺序,这是它的一个显著特征。如果你的业务要求按key的自然顺序遍历,用TreeMap;如果要求保持插入顺序遍历,用LinkedHashMap。LinkedHashMap在HashMap的基础上维护了一个双向链表来记录插入顺序,代价是额外的少量内存和插入时多两步指针操作。查看缓存淘汰机制的LinkedHashMap重写removeEldestEntry()后的用法也是高频场景,但一般业务里直接用现成的缓存框架更省心。

3. 典型实现类的底层机制与实战要点

接口讲完了,具体实现类的底层机制更需要仔细看。很多时候你以为在用一个“List”,实际上底层的数组扩容、指针链接、哈希碰撞和树化策略已经悄悄决定了你这个程序的性能上限。

3.1 ArrayList的扩容机制与动态数组的秘密

ArrayList的默认初始容量是10,当调用add()且元素数量已经到达当前容量时,它会按照旧容量的1.5倍进行扩容。具体计算方法是int newCapacity = oldCapacity + (oldCapacity >> 1),这是JDK 8之后更高效的位运算写法。

扩容的本质是新建一个更大的数组,然后把旧数组里的元素用System.arraycopy()批量拷贝过去。这个过程是O(n)的,但如果摊还来看,因为扩容次数随着容量增大成对数增长,平均每次add的代价仍然是O(1)。这也是为什么ArrayList在绝大多数场景下的表现都非常靠谱。

但有一个细节很多人没关注过:ArrayList的remove操作。当你删除中间某个元素时,不仅要更新size,还需要把该元素之后的所有元素整体向前移动一个位置。这个操作的时间复杂度是O(n)。如果你在循环里一边遍历一边删除元素,性能会非常差,而且如果不小心用了list.remove()而不是迭代器的remove(),还会抛ConcurrentModificationException。后面会讲到怎么正确处理这个场景。

3.2 LinkedList真的更慢吗

网上很多文章会告诉你“LinkedList插入快、ArrayList插入慢”,这话只对了一半。LinkedList在插入时确实只需要改指针,但它需要先“找到”插入的位置。如果你的插入位置是头部,那确实O(1);如果是中间,你得从头部或尾部一步步遍历过去,这一步本身就是O(n)。所以从整体来看,ArrayList和LinkedList在“任意位置插入”上的时间复杂度和空间局部性其实差别不大,真正拉开差距的是“头尾操作”和“随机访问”这两个场景。

还有一个容易忽略的点是空间占用。LinkedList每个节点除了存储一个元素之外,还额外存储了前驱和后继两个引用,在64位系统上每个节点可能多占用16字节以上。如果你存的是几百万个元素,LinkedList的内存开销会明显高于ArrayList。这也是我倾向于在绝大多数业务场景使用ArrayList的原因之一。

那LinkedList还有生存价值吗?当然有。它实现了Deque接口,可以作为队列或栈来用;它也是“需要频繁在头部和尾部插入删除”的滑动窗口类算法的常用数据结构。但在做通用的列表数据存储时,我几乎不会选它。

3.3 HashMap的哈希算法、红黑树与扩容

HashMap是Java集合框架里检验开发者功力的试金石。它涉及的底层操作非常多,这里挑三个关键点展开:哈希算法、树化、扩容迁移。

其一,哈希算法。当key的hashCode()被调用后,HashMap并不是直接拿这个值来定位数组下标,而是先做了一次扰动运算:(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)。这个扰动让高位信息参与低位运算,有效降低了哈希冲突率。然后在下标计算时用(n - 1) & hash,其中n是数组长度。因为数组长度始终是2的整数次幂,所以n-1的二进制位全是1,按位与等价于取模,而且比取模更快。这也是为什么HashMap扩容始终是以2倍进行——保持数组长度是2的幂,比直接用取模运算符高效得多。

其二,树化。当某个数组下标的链表长度超过TREEIFY_THRESHOLD(8)时,链表会转换为红黑树。为什么要转红黑树?因为极端情况下哈希冲突会让某个位置上的元素线性堆积,查找效率退化成O(n),而红黑树的查找效率是O(log n)。但红黑树的节点内存占用是普通链表节点的两倍左右,所以转换不是无条件的:只有当整个HashMap的元素总数达到MIN_TREEIFY_CAPACITY(64)时才会触发树化,否则优先扩容。这说明设计者也在平衡“内存开销”和“退化风险”。

其三,扩容迁移。JDK 7时代,HashMap扩容时会遍历每个桶里的元素,重新计算哈希并把元素插入新桶,这个过程在并发场景下会形成环形链表,导致get()时出现死循环。JDK 8把这个实现改成高低位分拆:因为扩容是翻倍,原来的元素要么留在原来的下标位置,要么移动到“原下标 + 原容量”的位置,不需要重新计算哈希。这个优化不仅修复了死循环问题,也让扩容过程明显更快。需要注意的是,虽然JDK 8的HashMap在并发put时不再死循环,但它依然不是线程安全的,数据丢失、覆盖问题依旧存在,并发场景请使用ConcurrentHashMap。

3.4 TreeMap与LinkedHashMap的特殊定位

TreeMap和LinkedHashMap分别解决了“排序”和“顺序”的问题,它们对应了Map体系里两个特殊需求。

TreeMap基于红黑树实现,key必须可比较。你可以让key实现Comparable接口,也可以在构造时传入一个Comparator。TreeMap的put和get时间复杂度都是O(log n),这比HashMap的O(1)要慢,但它保证遍历时key是有序的。如果业务需要“给定一个key,找它前面最近的一个较小的key”,TreeMap的floorKey()、lowerKey()等方法就很有用。不过请注意,TreeMap不允许key为null,因为null无法参与比较。这个细节在线上容易踩坑:从HashMap改成TreeMap后,某条数据key为null导致运行时NPE。我建议在使用TreeMap之前先评估数据里有没有null值,或者统一用Optional/NPE检查来兜底。

LinkedHashMap在HashMap的基础上维护了一个双向链表,这个链表既可以按插入顺序,也可以按访问顺序记录元素。按访问顺序开启的方式很简单:构造第三个参数传入true,即new LinkedHashMap<>(16, 0.75f, true)。开启之后,每次get()都会把命中的节点移到链表尾部,这样如果你从头部遍历,得到的就是“最近最少使用到最多使用”的顺序。基于这个特性,LinkedHashMap可以很方便地实现一个简单的LRU缓存,只需要重写removeEldestEntry()方法,在容量超过限制时返回true,让最久未访问的元素被自动移除。比如要写一个最多只保留100个最近访问记录的缓存,可以这样:

LinkedHashMap<String, Object> cache = new LinkedHashMap<>(100, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry<String, Object> eldest) { return size() > 100; } };

这个方法返回true时,LinkedHashMap会在插入新元素后自动删除链表头部的最老节点。用起来舒服,但要注意:LinkedHashMap不是线程安全的,而且这个LRU实现是“近似LRU”,因为它只维护到容量上限,不会主动淘汰未被访问但还在头部的节点。

4. 并发环境下的集合选型

并发场景是Java集合框架的另一个重要战场。单线程下表现完美的集合类,一旦放到多线程环境中,立刻可能露出各种问题。面试也好,实际项目也罢,这部分十分重要。

4.1 线程安全包装类的原理与局限

Collections.synchronizedList()、Collections.synchronizedMap()这类工具方法,把非线程安全的集合包装成所有公开方法都加了synchronized锁的安全版本。包装类的实现思路很简单:每个操作方法上锁,读操作和写操作互斥。这保证了线程安全,但对并发度是极大的打击——任何时刻只有一个线程能访问集合,高并发场景下这把“大锁”会成为瓶颈。

更重要的问题在于,包装类的“原子方法安全”并不等于“复合操作安全”。比如if (!list.contains(item)) { list.add(item); }这个判重再添加的操作,即使list是线程安全的,整个复合操作依然可能出错:两个线程同时通过contains检查,然后同时add,结果加入了两份相同的元素。这种问题不能用包装类解决,需要用锁把整个复合操作包起来,或者换一个面向并发的新集合类。

4.2 ConcurrentHashMap的锁粒度演进

ConcurrentHashMap是并发Map里最值得研究的类。JDK 8的ConcurrentHashMap对比旧版JDK 7在锁粒度上有了明显调整。

JDK 7版本,它是分段锁设计,把整个Map分成若干个Segment,每个Segment内部维护自己的HashEntry数组,锁加在Segment上,不同Segment之间的读写互不影响。分段数量的上限是65536,理论上并发度可以达到段数级别,但因为锁粒度和Map容量绑定,实现比较复杂。

JDK 8版本,它放弃了分段锁,改用CAS(比较并交换)加synchronized的方式:对table数组中的每个桶进行了优化,数据写入时先通过CAS尝试完成,冲突时才用synchronized锁住桶的首节点。这样锁的粒度从“一段”细化为“一个桶”,在高并发场景下,不同线程写入不同桶几乎完全无冲突,写入相同桶时也只阻塞同样范围的线程。

实际使用中我对ConcurrentHashMap的建议是:能直接用就直接用,别自己再额外加锁。它的putIfAbsent()、computeIfAbsent()、merge()这些方法本身就是为并发场景设计的复合操作,用好了能省掉大量显式加锁的代码。比如统计单词频率时,map.merge(word, 1L, Long::sum)一行就完成了“如果key不存在就初始化,存在就累加”的原子操作,比先get后put再锁要简洁得多。

另外要提醒一点:ConcurrentHashMap不允许key和value为null,这一点和HashMap不同。为什么不允许?设计者的解释是:如果用null作为value,get()返回null时无法区分是“key不存在”还是“value就是null”,而这在多线程环境下会让情况变得异常复杂。如果业务确实需要null值,要么换用ConcurrentHashMap以外的手段,要么约定好null的含义,在使用时做明确的逻辑区分。

4.3 CopyOnWriteArrayList的使用场景

CopyOnWriteArrayList是“写时复制”思想的典型实现。它内部用一个volatile数组来保存数据,读操作不加锁,直接读当前数组;写操作则先复制一份新数组,修改新数组的内容,然后用volatile写把新数组发布出去。这样写操作不会阻塞读操作,读多写少的场景下性能极佳。

但“写时复制”有一个明显的代价:每次写操作都会复制整个底层数组,如果集合很大,写入操作的开销非常可观。所以CopyOnWriteArrayList只适合“遍历读取远远多于修改”的场景,典型场景包括监听器列表、配置项缓存、订阅关系集合等。

我在项目里用它维护过一个“全局事件监听器列表”,读操作被频发调用,而注册和注销监听器的频率很低,一次可能几小时才发生一次。这种情况下CopyOnWriteArrayList非常合适,读线程可以并发遍历且完全不阻塞,写线程偶尔付出一次数组复制的代价,完全可接受。反过来,如果写操作也很频繁,比如每分钟几千次,每次复制一个几万元素的数组,GC压力和CPU消耗都会失控,这种情况应该考虑别的手段,比如用并发队列加批量处理。

5. 时间复杂度和选型对照

集合类的性能差异从根本上取决于底层数据结构,数据结构的差异又直接反映在时间复杂度上。我整理了一份常用集合的时间复杂度对照表,方便你在做技术方案时快速决策。

5.1 常用操作的时间复杂度速查

集合类底层结构查找插入删除按下标访问
ArrayList动态数组O(1)尾部O(1),中间O(n)尾部O(1),中间O(n)O(1)
LinkedList双向链表O(n)头尾O(1),中间O(n)头尾O(1),中间O(n)O(n)
HashSet哈希表O(1)O(1)O(1)不支持
TreeSet红黑树O(log n)O(log n)O(log n)不支持
HashMap哈希表O(1)O(1)O(1)不支持
TreeMap红黑树O(log n)O(log n)O(log n)不支持
ArrayDeque循环数组不支持头尾O(1)头尾O(1)不支持
PriorityQueue堆O(1)查找极值O(log n)O(log n)不支持

这张表里有几个细节值得注意。首先是“查找”列里的时间都是平均复杂度,哈希表在极端哈希冲突时可能退化到O(n),红黑树则能保证最差情况下仍是O(log n)。其次是ArrayList的“尾部插入”是摊还后的O(1),因为扩容虽然有成本,但平摊到多次插入后每次的成本可以忽略。一定不要把“按下标访问O(1)”当成“按下标查找元素O(1)”,前者指的是给定下标直接取元素,后者通常没有这个操作。

5.2 什么时候选List、Set、Map

实际开发中,选型往往不是“哪个性能高就选哪个”,而是要结合业务语义。我的习惯是先从“接口语义”出发,再考虑性能:

  • 如果容器里的元素有意义,需要顺序读取,选List。
  • 如果需要去重,或者要判断“某个元素是否存在且唯一”,选Set。
  • 如果元素是键值对,需要通过一个key来获取对应的value,选Map。

再进一步细分:

  • List里,读取多、修改少,选ArrayList;头尾操作多、栈或队列场景,选ArrayDeque。
  • Set里,不要求顺序,选HashSet;要求排序,选TreeSet;要求插入顺序,选LinkedHashSet。
  • Map里,不要求顺序,选HashMap;要求key排序,选TreeMap;要求插入顺序或LRU行为,选LinkedHashMap;并发场景,直接上ConcurrentHashMap。

这些选型规则背后其实渗透着一个通用的设计原则:先明确“需要容器的什么能力”,再选择“用什么数据结构来实现这个能力”。很多开发者的直觉是先想到一个类,比如“HashMap什么都好”,然后硬把业务需求往里套。这一开始觉得没问题,等遇到排序、顺序、并发的问题时才知道当初选型欠了债。

6. 实战中的常见问题与排查经验

写集合类的代码,谁没遇到过几个诡异的Bug呢?我把自己踩过的、帮别人排查过的典型问题集中整理一下,这些都是文档里很难找到第一手经验的部分。

6.1 ConcurrentModificationException到底为什么触发

这个异常太经典了。第一次遇到的时候,很多人以为是“并发环境才会出”,其实不是。它真正检测的是“在迭代过程中,集合被结构性修改”。

Iterator在创建时保存了一个expectedModCount,这个值是集合的修改次数。当你用list.remove()或者map.put()修改集合时,modCount会变化。迭代器每次next()时都会检查modCount是否等于expectedModCount,不等就抛异常。所以,即使是在单线程里,一个for-each循环中调用list.remove(item),也一样会炸。

正确做法是使用迭代器的remove()方法:

Iterator<String> it = list.iterator(); while (it.hasNext()) { String item = it.next(); if (item.equals("bad")) { it.remove(); } }

为什么it.remove()没问题?因为它会同步更新expectedModCount,保持迭代器内部计数和集合一致。如果使用的是Java 8之后的Collection.removeIf()方法,也安全,它在内部已经处理了modCount同步。

6.2 equals和hashCode不匹配导致的“幽灵”Bug

先看一段代码:

public class User { private String name; private int age; @Override public boolean equals(Object obj) { if (!(obj instanceof User)) return false; User other = (User) obj; return this.name.equals(other.name) && this.age == other.age; } }

这个类只重写了equals,没有重写hashCode。如果你把它放进HashSet或作为HashMap的key,会怎么样?两个内容完全相同的User对象,hashCode大概率不同,HashSet在定位时把它们放到不同的桶里,于是“去重”完全失效。更麻烦的是检查“是否存在”的时候——明明有个对象内容一模一样,contains()却返回false。

反过来的坑也有:hashCode基于可变字段,对象放入HashSet后再修改那个字段,哈希值变了,但它在集合里的位置还是按旧哈希值算的,之后再contains()就是死胡同。所以一个实用原则是:放进集合里的对象,equals和hashCode应基于不变字段。JDK 17里提供了一种更稳妥的做法,可以用records这种紧凑类型来做集合元素,它的equals和hashCode天然按组件值计算,且字段是不可变的,避免了一大堆手写的失误。

6.3 Arrays.asList和subList的隐藏陷阱

数组转List,很多人直接写List<String> list = Arrays.asList(arr)。这段话看起来简单,有几个隐藏设定需要清楚:它返回的并不是ArrayList,而是一个java.util.Arrays$ArrayList的私有静态内部类。这个内部类的底层仍然指向原始数组,所以改list的元素会直接影响原数组。更重要的是,这个List本身没有add和remove方法,调用会直接抛UnsupportedOperationException。所以如果你后续要往里面加元素,必须包一层new ArrayList<>(Arrays.asList(arr))。

另一个隐藏陷阱是List.subList()。它返回的视图不是“截取后独立的一份数据”,而是原List在指定范围上的一个视图。视图的修改会映射回原List,反之原List的修改也会影响视图。如果你在持有subList视图期间对原List做了add()或remove(),再访问视图时大概率抛ConcurrentModificationException。这是我在项目里真实遇到过的:有人从一个大list里切出一个小list,然后循环处理小list时发现元素有增有减,排查半天才发现根因在“改动原list”上。

6.4 自定义对象放入HashSet/HashMap前必须做的三件事

把自定义对象放进HashSet或HashMap的key位置之前,务必做三件事:

第一,正确重写equals()。判断逻辑应该基于你认为“唯一”的业务字段,别把对象地址拿来做比较。

第二,正确重写hashCode()。hashCode的计算字段必须和equals用到的字段一致。最简单的方式是基于Objects.hash(field1, field2)生成,别手写复杂的公式,容易出问题。

第三,确认这些字段在对象放入集合后不会改变。这一点最容易忘,建议通过设计来约束:要么字段尽量设为final,要么不要对外暴露修改这些字段的方法。

还有一个容易被忽略的点:HashSet和HashMap对key的hasCode和equals的调用时机不同。HashMap在get和put时要先对比key的hashCode定位桶,再在桶内用equals比较。也就是说,即使两个对象的equals返回true,只要hashCode不一致,HashMap也认不出来。反过来,如果hashCode一致但equals不一致,HashMap会认为它们是不同的key(挂在同一个桶下的链表或红黑树里)。所以要保证“equals一致则hashCode一致”,这是集合类正确工作的前提。

行文到这里,我想起自己第一次做大型重构时的经历:那个系统里到处是裸的HashMap,后需求改了字段并重新定义了“用户唯一性”,结果因为某处equals没跟上新定义,线上重复数据一下就冒出来了。之后我每次引入自定义对象作为集合元素,都会先写个单元测试,验证两个字段相同的对象在HashSet里的去重行为,花几分钟,却能在上线前挡掉一整天的排查。集合类的坑,很多都是这种“表面正常、深层错位”的状态,平时不会发作,数据量一上来或者需求一变就爆出来。把它当成一个老朋友去摸透脾气,写代码时就安心的多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询