死磕JDK源码这个系列写到现在,已经翻完了一大半常用集合类。今天要聊的是Set家族里一个非常容易被跳过的类——AbstractSet。它是HashSet、TreeSet、LinkedHashSet这些天天打交道的集合的公共父类,也是Set集合语义最核心的骨架实现。整个类不到100行,比ArrayList、HashMap的源码短多了,但就是这几行逻辑,决定了Set判等、哈希计算和批量删除这三件大事。如果你只想学会用HashSet,确实可以不看它;但如果你想搞明白两个Set为什么equals、HashSet的hashCode到底怎么算、removeAll为什么比简单遍历更快,这篇必须接着往下读。内容不算难,适合所有已经会用集合、想往源码层面走一步的Java开发者。
1. 从Set到AbstractSet:这个骨架类是怎么来的
1.1 Set接口留下的“待办清单”
在集合框架里,Set接口的定位非常明确:一个不允许包含重复元素的集合。注意这里的“重复”用的是equals语义,而不是==,所以哪怕两个对象引用不同,只要equals相等,Set就认为它们是同一个元素。这直接决定了Set那套比较逻辑和List完全不同。
打开Set接口看,方法其实不少:size、isEmpty、contains、add、remove、clear这些基础操作,addAll、removeAll、retainAll、containsAll这些批量操作,还有iterator、toArray、spliterator等视图方法。但真正需要实现类自己决定语义的,是下面几件事:
- contains:Set去重的第一道关卡,必须根据equals判断元素是否已存在。
- add:加入前要判断是否重复,这奠定了Set的“不重复”契约。
- 迭代顺序:Set不保证顺序,各个实现类可以自由发挥。
- equals和hashCode:两个Set相等的标准是包含完全相同的元素,与你是什么实现类无关。
前面的文章说过,AbstractCollection已经把Collection接口的大部分方法都铺好了路,add、contains、remove这些都能基于iterator推导出默认实现。但AbstractCollection毕竟是给List和Set共用的大骨架,它对equals、hashCode、removeAll的处理并不适合Set场景。Set接口留给AbstractSet的“待办”,其实就是这三个方法。这就是为什么AbstractSet敢自称骨架实现:它只补全Set区别于其他Collection的关键差异。
1.2 一个没有抽象方法的抽象类
第一次看到AbstractSet源码的人,大概率会愣一下:这个类声明为abstract,但里面一个抽象方法都没有。它只有一个protected构造器,外加equals、hashCode、removeAll三个具体方法。
public abstract class AbstractSet<E> extends AbstractCollection<E> implements Set<E> { protected AbstractSet() { } public boolean equals(Object o) { ... } public int hashCode() { ... } public boolean removeAll(Collection<?> c) { ... } }这其实是很多人的知识盲区:抽象类不一定必须有抽象方法。只要这个类被声明为abstract,它就是不允许被new出来的。AbstractSet这样做的意图很直接——它并不想独立工作,它的存在就是让HashSet、TreeSet、LinkedHashSet等子类去继承。换句话说,它用abstract关键字表达了一个语义:这个类本身不是完整的集合,完整集合必须由后代实现。
你可能想问,Java 8之后接口也能写默认方法了,为什么还要这种骨架类?最核心的原因是历史包袱:集合框架在Java 1.2时代就定型了,那时接口里根本不能写实现。另外抽象类能持有实例字段、提供protected辅助方法、对泛型擦除的处理也更从容,这些能力接口至今给不了。所以在现代Java里,骨架实现类的地位依然稳固,只是对新读者来说,它像个藏在角落里的老前辈,需要主动去翻才能发现。
再看类里的注释和代码风格,AbstractSet没有把注释写得天花乱坠,每个方法的意图靠代码本身说清楚。这种“少即是多”的写法,在源码阅读时特别劝退没耐心的人,但一旦逐行读进去,你就能体会到设计者在下笔前的取舍。
2. 三个核心方法逐句精读
2.1 equals:先校验类型,再比较元素
AbstractSet的equals实现,是所有Set实现类判等的基础。源码如下:
public boolean equals(Object o) { if (o == this) return true; if (!(o instanceof Set)) return false; Collection<?> c = (Collection<?>) o; if (c.size() != size()) return false; try { return containsAll(c); } catch (ClassCastException unused) { return false; } catch (NullPointerException unused) { return false; } }第一步的o == this是性能优化。两个集合引用相同,内容一定相同,没必要再走后面的比较。很多人觉得这段多余,但在递归toString、循环引用的场景下,这个短路能省下一大波调用栈开销。
第二步是o instanceof Set,注意这里要求的是Set接口,而不是要求o.getClass() == this.getClass()。比如HashSet和TreeSet,一个基于HashMap,一个基于TreeMap,底层结构完全不同,但只要包含的元素集合一样,它们就该判等。这就是Set语义比实现类更重要的一次体现。换成List写这种逻辑就不行:ArrayList和LinkedList虽然内容可以相同,但List的equals并不要求实现类一致,其实Set和List的equals规范本质上都是“内容比较”,但Set比较的是集合,List比较的是有序序列。
第三步的size不相等直接返回false,这个判断必须放在containsAll之前。试想一下,如果两个Set元素数量都不同,却还逐一contains,浪费的时间是实打实的。源码里随处可见这种快失败判断,本质上是用一个O(1)的比较换掉一整个O(n)的遍历。
接下来try-catch是整个方法最容易被忽略的细节。containsAll遍历o里的每个元素,对this逐个调用contains。如果this是一个TreeSet,o里却混入了类型不兼容的元素,TreeSet的contains在比较时可能会抛ClassCastException;如果this不允许null,o里却包含null,contains又可能抛NullPointerException。按equals的契约,这些情况都不应该让异常穿透出去,更不应该让两个集合因为类型不匹配就“判等失败还抛异常”,正确做法是返回false。于是AbstractSet用catch把这类边缘情况全部兜住,保证equals方法在任何输入下都只返回布尔值,绝不失控。
时间复杂度上,这个equals是O(n * m)量级,n是o的元素数,m是this.contains的复杂度。HashSet的contains是O(1),所以HashSet做equals大体验证是O(n);如果this是一个链式结构、contains是O(m)的Set实现,那equals成本就会明显偏高。这也提醒我们,自定义Set子类时最好把contains设计成O(1)或O(log n),否则判等操作会成为性能瓶颈。
2.2 hashCode:简单累加也是一种设计
AbstractSet的hashCode实现短到可以背下来:
public int hashCode() { int h = 0; Iterator<E> i = iterator(); while (i.hasNext()) { E obj = i.next(); if (obj != null) h += obj.hashCode(); } return h; }逻辑就是遍历所有元素,把每个元素的hashCode加起来,null按0处理。这套规则写在了Java集合框架规范里,所有Set实现类只要继承AbstractSet或者自己重写hashCode,都要遵守“总和”这个定义,不能自由发挥。
为什么不能用List那套31*x+hashCode的迭代方式?因为List有序,同样的元素,排列顺序不同,List的hashCode就不同,这符合List的语义。但Set强调无序,两个内容相同、遍历顺序不同的Set必须拥有相同的hashCode。加法满足交换律和结合律,1加2和2加1结果完全一样,天然契合这个要求。
那为什么不用异或?异或也满足交换律,似乎也行。我在读这段注释时就特意想过这个问题。异或有一个尴尬的特性:两个相同的数异或会变成0。假设一个Set里两个元素的hashCode恰好都是5,异或结果是0,累加结果是10。累加显然更能拉开差距,降低哈希冲突概率。集合里元素不重复,但hashCode重复是完全允许的,所以设计者选用加法而不是异或,是在用最低成本减少不同Set之间的冲突。
还有一个细节:hashCode累加时两数相加超出int范围会溢出,这是允许的,HashSet的哈希桶本身就靠这个int做二次散列。equals相等则hashCode必须相等,这个约束在AbstractSet里也被顺理成章地满足了:两个Set相等,意味着元素集合完全一致,逐个元素的hashCode之和当然一致。
2.3 removeAll:size判断背后的性能功夫
removeAll是AbstractSet里最有“操作含量”的方法,源码如下:
public boolean removeAll(Collection<?> c) { Objects.requireNonNull(c); boolean modified = false; if (size() > c.size()) { for (Iterator<?> i = c.iterator(); i.hasNext(); ) modified |= remove(i.next()); } else { for (Iterator<?> i = iterator(); i.hasNext(); ) { if (c.contains(i.next())) { i.remove(); modified = true; } } } return modified; }这个方法做的事情很简单:把this里所有在c中出现的元素删掉。但写法上做了一个关键优化——先比较this.size()和c.size(),谁小遍历谁。
如果this比c大,就遍历c。对c中的每个元素,直接调用this.remove(id.next())依次尝试删除。删除成功说明这个元素同时在两个集合里,删除失败说明它本来就属于this,不影响结果。这样遍历次数是c.size(),避免了遍历更大的this集合。
反过来,如果this不比c大,就遍历this。对this的每个元素,用c.contains去判断是否需要删除,需要就通过迭代器的remove安全删除。遍历次数是this.size(),同样控制了成本。
为什么不直接用AbstractCollection里的removeAll?看父类的实现:
public boolean removeAll(Collection<?> c) { Objects.requireNonNull(c); boolean modified = false; Iterator<?> it = iterator(); while (it.hasNext()) { if (c.contains(it.next())) { it.remove(); modified = true; } } return modified; }父类永远是遍历this,不做size对比。在List场景里,this和c的size差异可能不大,这种简单实现也能用。但Set是去重集合,实现类里的contains往往是O(1)或O(log n),若能在遍历方向上下功夫,性能收益非常明显。这正是AbstractSet重写removeAll的根本原因。
再往细里看,第一分支里用的是modified |= remove(i.next()),而不是modified = modified || remove(i.next())。Java没有||=运算符,而现实的||操作符有短路特性:一旦左侧是true,右侧根本不会执行。如果改成||逻辑,第一次删除成功之后,后面的remove(i.next())就再也不会被调用了,剩下的交集元素删不掉,整个方法就崩了。|=是位运算,左右两侧都会求值,既能累计删除结果,又能保证每个c元素都被尝试删除,这个细节极其精妙。
第二分支使用i.remove()而不是this.remove(i.next()),是为了绕开迭代过程中修改集合导致的ConcurrentModificationException。迭代器的remove会在删除后同步内部modCount,直接调集合的remove则可能让迭代器状态不一致。写集合代码时,在遍历中删除元素永远优先选Iterator.remove,这段源码就是最好的示范。
3. removeAll细节深挖:那些容易踩的坑
3.1 传参为空与Objects.requireNonNull
removeAll第一行就来了个Objects.requireNonNull(c),这个方法的作用是参数为null时抛出NullPointerException,而不是让异常在下游某个诡异的地方冒出来。为什么非要显式检查?因为在不同的Set子类里,null参数的表现可能不一致。有的实现遍历c,c为null时iterator()直接抛NPE;有的实现走contains分支,可能直到执行到某个元素才暴露问题。统一在入口做检查,让行为可预期,也让错误信息指向明确。这和equals里的catch逻辑其实是一个思路:边界情况必须被显式覆盖,不能指望运气。
3.2 c==this的效果
c等于this的情况,也就是调set.removeAll(set)。此时size() > c.size()为false,必然走第二分支。遍历this的迭代器,对每个元素执行c.contains,自己当然包含自己,于是逐个删除,最终集合被清空。结果等价于clear()。
在很多不熟悉源码的人眼里,removeAll自己可能被认为会抛异常或行为异常,但看实现就知道,它天然把这种情况处理成“清空自己”。虽然实际开发中你不会用removeAll去清空集合,但了解这个行为对理解集合框架的一致性设计有帮助。同类问题还有addAll把自己加进自己,会得到什么结果,那是另一个话题,但思路是一致的:JDK集合类在自引用场景下大多有明确行为。
3.3 不可变集合的UnsupportedOperationException
如果this是Collections.unmodifiableSet包装的集合,或者自定义Set里add/remove直接抛UnsupportedOperationException,那removeAll执行到需要删除元素时,就会把异常往上抛。需要注意一点:异常不一定立刻出现。走第一分支时,第一次remove调用可能是在循环里迭代到第一个交集元素才抛;如果两个集合根本没有交集,removeAll可能正常返回false。这就很坑,调用方不能在removeAll前假设它一定不抛异常,而应该在构造不可变集合时就意识到removeAll这类修改操作本身是禁止的。
3.4 与迭代器remove相关的CME问题
很多新手在遍历Set时习惯这么写:
for (String s : set) { if (condition) { set.remove(s); } }这几乎必然触发ConcurrentModificationException。AbstractSet的removeAll两个分支都在刻意避免这个坑:第一分支遍历的是c,修改的是this,两边互不干扰;第二分支主动用迭代器的remove,保证modCount同步。从源码角度理解,CME并不可怕,它是集合在迭代过程中被结构性修改时抛出的保护机制。只要像removeAll一样遵循“迭代器删除”原则,就能避开。
4. 站在AbstractSet肩膀上的子类们
4.1 HashSet:只专注HashMap包装
HashSet的继承声明是extends AbstractSet,底层是一张HashMap。add、remove、contains、clear、size这些方法都是对HashMap操作的薄封装,但equals、hashCode、removeAll三个方法一个字都没重写,直接继承AbstractSet的实现。
这里有一个容易被记错的点:HashSet基于HashMap,但HashSet的equals并不是HashMap的equals。HashMap的equals比较的是键值对映射关系,HashSet的equals比较的是元素集合。两者语义完全不同,所以HashSet不能直接复用HashMap的判等逻辑,只能回到AbstractSet来定义自己的集合语义。这就是骨架类存在的意义:它替HashSet这类适配器模式下的子类兜了底,让子类可以只关心“怎么把HashMap包装成Set”,而不用操心Set的通用契约。
4.2 TreeSet:比较器的天然扩展
TreeSet同样是extends AbstractSet,内部用TreeMap实现排序容器。它同样把equals、hashCode、removeAll留给了AbstractSet。
TreeSet的一个特点是允许null的规则和contains比较方式都依赖Comparator。如果TreeSet和HashSet做equals比较,按AbstractSet的逻辑,遍历的是传入集合(可能是HashSet)的元素,对TreeSet逐个contains。一旦传入集合里混入了TreeSet比较器无法处理的类型,contains内部抛出的ClassCastException会被AbstractSet的catch捕获,最终返回false。所以两个不同实现类的Set比较,得到的结果永远是“内容是否相同”,而不是“类型是否匹配”或者“能不能互相排序”。这一点在跨实现类比较时特别实用。
4.3 LinkedHashSet:继承链上的“中间人”
LinkedHashSet的继承链路是AbstractSet -> HashSet -> LinkedHashSet。它自己没有多少方法实现,语义完全继承HashSet,只是通过HashSet内部一个带哨兵参数的构造器,把底层HashMap换成了LinkedHashMap,从而保留插入顺序。
在这条链上,AbstractSet的equals、hashCode、removeAll被一路传递下来。因为HashSet没有重写它们,LinkedHashSet也没有理由重写。对于LinkedHashSet来说,迭代顺序从“任意”变成了“插入顺序”,但元素的数学集合属性没变,所以判等和哈希逻辑依然适用。这也解释了为什么LinkedHashSet虽然有序,它的hashCode却和HashSet一模一样——Set的语义压过了迭代顺序差异。
4.4 CopyOnWriteArraySet:骨架之外的重写者
CopyOnWriteArraySet继承AbstractSet,但它的实现思路完全不同:内部用CopyOnWriteArrayList保证线程安全和快照迭代。equals和hashCode还是继承AbstractSet的,但removeAll被重写了,因为它的修改操作是整体复制底层数组再替换,走AbstractSet那套逐元素删除反而会破坏CopyOnWrite的原子性语义。这提醒我们,骨架方法不是万能药,当底层数据结构对修改有特殊要求时,子类必须按需重写。
看JDK里的各种集合类,你会发现一个规律:凡是与其他实现行为一致的方法就放给骨架类,凡是底层容器有特殊定制的操作就自己重写。这种“能继承就继承,不需要就重写”的策略,让整个集合框架没有陷入继承沼泽。
4.5 不走寻常路的EnumSet
EnumSet是Set家族里最特立独行的成员,它不继承AbstractSet,而是继承AbstractCollection。原因也很直白:它用位向量实现,每个枚举常量只占一个位,size、contains、add这些操作都被优化成位运算,性能极佳。如果强行继承AbstractSet,removeAll、containsAll这些通用实现反而会牺牲位运算的优势。所以EnumSet宁可放弃骨架类,也要把整个实现控制在自己手里。
这说明骨架类设计有一个隐含边界:它只能服务“底层结构是通用容器”的那类集合。一旦实现机制特殊到和抽象语义相差太远,骨架方法就会变成负担。读源码时看到不走寻常路的子类,往往能反向理解骨架方法的适用范围。
| 类 | 继承链 | equals/hashCode/removeAll |
|---|---|---|
| HashSet | AbstractSet | 继承 |
| TreeSet | AbstractSet | 继承 |
| LinkedHashSet | AbstractSet -> HashSet | 继承 |
| CopyOnWriteArraySet | AbstractSet | equals/hashCode继承,removeAll重写 |
| EnumSet | AbstractCollection | 全部自行实现 |
5. 常见问题与排查经验速查
5.1 Set判等踩坑:hashCode与equals不同步
实战里最常见的Set相关bug,其实是自定义对象的问题。如果你往HashSet里放一个业务对象,重写了equals但没重写hashCode,或者hashCode里用了可变字段,那么两个逻辑相等的对象可能被放到不同桶里,导致set.contains找不到元素、set.size()比预期大、两个Set明明内容相同但equals返回false。
结合AbstractSet的hashCode实现看,问题更严峻:整个Set的hashCode是所有元素hashCode的和,某个元素的hashCode一变,整体Set的hashCode就跟着变。如果把对象放进Set之后又修改了参与hashCode的字段,那么这个Set的hashCode在不同时间点可能完全不同,后续的一切查找全部混乱。稳妥做法是:放入HashSet、LinkedHashSet这类基于哈希的实现时,把对象视为不可变,不要在放入之后修改其hashCode相关字段。
5.2 removeAll与clear的选择
清空一个Set,永远用clear,不要用removeAll传入一个包含所有元素的集合。clear对HashSet来说是直接清空哈希桶,复杂度O(1),而removeAll即使在AbstractSet优化分支下也要遍历和contains判断,效率天差地别。反过来,如果你要做的是“从A集合中删除B集合里存在的元素”,那removeAll是对的,而且你不需要关心A和B谁大谁小,源码内部已经做了优化。
5.3 自定义Set子类:该继承什么、重写什么
如果你打算自己实现一个Set,最简单的套餐是继承AbstractSet,只实现iterator()、size()、contains(),再按需要实现add()和remove()。这样equals、hashCode、removeAll三个骨架方法直接白拿。contains如果能做到O(1),equals和removeAll都有不错的性能表现。如果底层结构比较特殊,比如是快照结构、位向量、排序树,就要像CopyOnWriteArraySet和EnumSet那样,评估一下哪些骨架方法该重写,哪些该直接绕过。
我自己写过一个基于数组的轻量Set,就是继承AbstractSet,只维护一个Object[]和size,同时实现了contains的线性扫描。因为数据量很小,性能够用,代码量比手写整个Set接口少了将近一半。骨架类在小项目里的复用价值,远超很多人的预期。
5.4 源码版本差异提醒
JDK版本迭代对AbstractSet改造不大,但有几个值得留意的点:Objects.requireNonNull是在JDK 7之后加入的,早期版本没有显式空参检查;JDK 8新增的removeIf默认方法为集合批量删除提供了新路径,很多子类现在优先用removeIf而不是removeAll;如果阅读老版本源码,你会发现有些方法写法上略有不同,但核心逻辑一致。建议阅读时锁定一个JDK版本,先把主链路读懂,再横向对比版本差异,否则容易陷入细节漩涡。
源码读到AbstractSet这种骨架类,最容易收获的反而是方法论:源码里真正决定一个家族形态的,往往不是那些上千行的大类,而是这些不起眼的抽象中间层。AbstractSet用不到一百行代码,就把Set的判等规范、哈希约定、批量删除优化全部定死,让HashSet这些子类可以心无旁骛地去包装底层容器。我个人在实际读码时养成的一个习惯是:先看继承树,找到那个把公共逻辑沉淀下来的骨架类,再看子类到底重写了什么方法。一旦发现某个方法被重写,就说明底层结构对这个操作有特殊诉求,顺着这条线去抓源码重点,比从头逐行读效率高得多。后面我还会继续沿着这棵继承树往下挖,聊聊HashMap的哈希桶和HashSet迭代器的具体实现。