很多人面试的时候能把HashSet的定义背得滚瓜烂熟——"基于哈希表实现、元素无序、不允许重复",但真到了线上出问题的时候,可能连从哪里排查都想不起来。
我之前见过一个挺典型的场景:某同事用HashSet做订单流水去重,测试环境一切正常,上了生产之后偶发出现"集合里明明有元素,但remove的时候删不掉"的情况。你去看代码,逻辑写得好像也没问题,最后定位到原因的时候,他才意识到自己对HashSet的底层原理理解得不够透。
这篇文章就把java基础里的HashSet讲透。不止讲它是什么,还要讲它为什么这么设计、用的时候有哪些容易踩的坑、什么时候不该用它。适合刚接触集合框架的初学者,也适合写过一段时间业务代码、想在细节上补课的同学。我会尽量用大白话加实际案例的方式,把散列存储、hashCode和equals的关系、扩容机制这些核心概念拆开揉碎,让大家看完能直接在实践中用上。
1. 一句话讲清HashSet,以及它和List的本质差异
1.1 先理解HashSet到底"长什么样"
很多人第一次接触HashSet,对比的都是ArrayList。两者的区别如果只说"一个有序一个无序、一个可变一个不可变结构",其实没有讲透。
List相当于一排带编号的储物柜,你放进去的东西按放进来的顺序一个挨一个排好,每个格子都有一个索引。ArrayList底层是数组,LinkedList底层是链表,但它们都维护了一个明确的位置概念。所以你可以对List说:把第3个元素拿出来,或者在第5个位置插入一个元素。List关心的是"顺序"和"位置"。
HashSet的底层不是柜子,更像一个仓库,里面有很多编号确定的抽屉。每个元素进来,先算出一个哈希值,然后根据这个哈希值决定放进哪个抽屉。存的时候不关心放的先后顺序,取的时候也不靠位置,而是靠哈希值直接算出它可能在哪个抽屉里,然后去那个抽屉找。
所以HashSet的核心特点就两个:
- 不重复:相同内容算出来的哈希值相同,会被放进同一个抽屉,放不进去第二个。
- 不保证顺序:你看到的遍历顺序,并不是插入顺序,而是由哈希值决定的抽屉顺序。
1.2 底层其实是HashMap:一穿就透
HashSet的源码里,内部维护的是一个HashMap,只不过value统一使用了一个固定的Object对象。
// HashSet源码中实际存在的成员变量 private transient HashMap<E,Object> map; private static final Object PRESENT = new Object(); public boolean add(E e) { return map.put(e, PRESENT) == null; }也就是说:你往HashSet里add一个元素,底层执行的是map.put(e, 固定对象)。HashMap的key不重复,所以HashSet的元素也就不重复了。这一点在源码里写得非常直白。面试时如果有人问你"HashSet怎么实现去重",你回答"底层是HashMap的key在做去重",这就是最本质的一句话。理解了这个,后面很多行为你都能自己推导出来。
比如HashSet查一个元素是否存在,调用的是contains方法:
public boolean contains(Object o) { return map.containsKey(o); }因为底层是HashMap,所以它判断元素是否存在,使用的是HashMap根据哈希值定位桶、再在桶内比较equals的机制。这也就引出了后面一个大坑:什么样的对象能放到HashSet里?答案不是"任何对象",而是"正确重写了hashCode和equals的对象"。
1.3 一个简单的去重例子
我们先看一个最普通的用法,也是HashSet用得最多的场景:统计一个列表里有多少个不重复的元素。
import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; public class HashSetDemo { public static void main(String[] args) { List<String> words = new ArrayList<>(); words.add("java"); words.add("hashset"); words.add("java"); words.add("源码"); words.add("hashset"); Set<String> uniqueWords = new HashSet<>(words); System.out.println("原始大小: " + words.size()); System.out.println("去重后大小: " + uniqueWords.size()); System.out.println(uniqueWords); } }输出结果大概是这样:
原始大小: 5 去重后大小: 3 [java, 源码, hashset]注意输出的顺序不是插入顺序,这就是HashSet的第一个直观表现。这里用的元素类型是String,String类内部重写了hashCode和equals,所以去重逻辑符合直觉。如果把元素换成自己定义的一个类,问题就开始出现了。
2. 底层调用链:哈希、散列与equals/hashCode约定
2.1 为什么必须成对重写equals和hashCode
这是HashSet原理中最关键、也最容易被初学者忽略的一环。
Java里判断两个对象是否"相同",有两种维度:
- 引用相同:直接用==判断,两个引用指向同一个内存地址。
- 内容相同:调用equals方法判断,默认的equals行为其实就是==。
Object类默认的equals是这样的:
public boolean equals(Object obj) { return (this == obj); }也就是说,如果你没有重写equals,那么"内容一样的两个对象"在Java看来依然是两个不同对象。
HashSet的存储逻辑是这样的:先调用hashCode算出抽屉的位置,再调用equals判断抽屉里的多个元素有没有内容相同的。如果equals判断相等,就认为元素已存在,新元素不会放进去。
这里有两个规则必须遵守:
- 如果两个对象equals相等,那么它们的hashCode必须相等。
- 如果两个对象的hashCode相等,不代表它们equals相等(哈希碰撞是允许的)。
如果把这两个方法拆开使用,就会出现各种匪夷所思的效果——要么去重失败,要么元素放进去之后找不到了。后面我会分别演示这两种情况。
2.2 不重写hashCode和equals的翻车现场
假设模拟项目X里有一个商品类,里面有两个字段:商品ID和商品名称。业务上认为只要商品ID相同,就是同一个商品,需要用它去重。
public class Product { private String id; private String name; public Product(String id, String name) { this.id = id; this.name = name; } @Override public String toString() { return "Product{id='" + id + "', name='" + name + "'}"; } }注意这个类完全没有重写equals和hashCode。然后我们做一个很正常的操作:往HashSet里添加两个ID一样但名称不同的商品。
import java.util.HashSet; import java.util.Set; public class HashSetEqualsDemo { public static void main(String[] args) { Set<Product> productSet = new HashSet<>(); productSet.add(new Product("1001", "键盘")); productSet.add(new Product("1001", "机械键盘")); System.out.println("集合大小: " + productSet.size()); System.out.println("是否包含ID为1001的商品: " + productSet.contains(new Product("1001", "随便写"))); } }输出结果会让你很意外:
集合大小: 2 是否包含ID为1001的商品: false两个商品ID一样,按业务规则应该被看成同一个商品,结果HashSet把它们全收下了,集合大小是2。后面用一个ID为1001的新对象去contains,返回false。
原因就是:这个类没有重写equals和hashCode,所以它沿用了Object默认的规则,等于用对象的内存地址去比较。两个new出来的Product对象,即使字段内容完全一样,在Java看来也是两个对象。
很多人会在这里卡一下:我分明已经把id设置成一样了,为什么还是不相等?因为在Java的默认世界里,两个对象是不是同一个,不看字段内容,只看它们是不是同一个内存地址上的对象。
2.3 正确重写方式参考
正确的写法是同时重写equals和hashCode,并且两个方法中用到的字段要保持一致。比如我们以id作为业务唯一标识,那么两个方法都只用id字段来参与计算。
import java.util.Objects; public class Product { private String id; private String name; public Product(String id, String name) { this.id = id; this.name = name; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } Product product = (Product) o; return Objects.equals(id, product.id); } @Override public int hashCode() { return Objects.hash(id); } @Override public String toString() { return "Product{id='" + id + "', name='" + name + "'}"; } }再跑一次刚才的代码,结果就符合业务预期了:
集合大小: 1 是否包含ID为1001的商品: true到这里我多说一句经验:写业务实体类时,不要把equals/hashCode里的字段范围随意扩大。有人图省事,把所有字段都加进去,结果两个对象只要名称差一个字,就被当成不同对象。去重的粒度应该由业务规则决定,比如这里是ID维度,就只让ID参与equals和hashCode。
2.4 只重写equals不重写hashCode会怎样
还有一种情况也很常见:有人只重写了equals,没有重写hashCode。这样会破坏那条最重要的规则——"equals相等时hashCode必须相等"。
你先试想一下这个场景:两个Product的id都是1001,equals方法返回true,但它们的hashCode来自Object默认实现,是两个不同的值。HashSet判断流程是先用hashCode定位抽屉,这两个相等的对象被分到了不同的抽屉,equals方法根本没有机会被执行。结果集合里依然会同时存在两个对象。
Java官方文档里有一条明确要求:如果重写了equals方法,就必须重写hashCode方法。这听起来像教条,但它背后有非常实际的工程理由。HashSet、HashMap、HashTable这些基于哈希的集合,全都依赖这个约定。违反约定的代码不一定当场报错,但会在某些特定数据下出现极其隐蔽的逻辑错误。我见过的很多"偶发去重失败",最后定位到根因基本都是这一条。
3. 遍历顺序不保证:哈希迭代顺序的机制
3.1 为什么HashSet的遍历顺序"说不准"
很多人在第一次打印HashSet时就发现:咦,我添加的顺序明明是A、B、C、D,怎么打印出来是B、A、D、C?这不是bug,这是HashSet的正常表现。
原因依然在哈希表的结构上。HashSet底层是HashMap,HashMap内部是一个数组,每个数组元素是一个桶。元素进来时,先通过hashCode算出一个索引,这个索引决定了它放进哪个桶。遍历HashSet时,其实就是从0号桶开始,一个桶一个桶按顺序访问,桶内的元素再按链表或红黑树顺序访问。
这和"放进来的顺序"没有直接关系,和"算出来的哈希值映射到的桶位置"有关系。你可以把桶的编号想象成大楼的楼层,元素根据哈希值被"分房",遍历时电梯从1楼逐层往上停。哪一层先有住客,完全取决于哈希值的映射结果,而不是住客办入住手续的先后。
举个例子,我们往HashSet里插入一些字符串,然后打印:
import java.util.HashSet; import java.util.Set; public class HashSetOrderDemo { public static void main(String[] args) { Set<String> set = new HashSet<>(); set.add("A"); set.add("B"); set.add("C"); set.add("D"); set.add("E"); set.add("F"); System.out.println(set); } }输出结果可能是这样:
[A, B, C, D, E, F]换成另一批数据,输出顺序就变了:
Set<String> set2 = new HashSet<>(); set2.add("香蕉"); set2.add("苹果"); set2.add("西瓜"); set2.add("葡萄"); System.out.println(set2);输出可能是:
[西瓜, 苹果, 葡萄, 香蕉]所以千万不要依赖HashSet的遍历顺序来做事。如果你从某个HashSet里取值后,再按照这个顺序去拼一个签名、生成一段文本、或者和另一个系统的顺序做比对,顺序一旦变化,结果就全乱了。
3.2 JDK版本对哈希碰撞处理的影响
HashSet底层是HashMap,那么它处理哈希碰撞的方式也继承了HashMap的策略。
- JDK 7及以前:当多个元素哈希值定位到同一个桶时,使用链表存储,头部插入。
- JDK 8及以后:当桶内元素数量超过阈值,且整个哈希表的容量达到64时,链表会转换成红黑树,目的是把最坏情况下的查询性能从O(n)优化到O(log n)。
这个细节和遍历顺序有什么关系?有关系。同一个桶内的节点,在链表阶段是链式顺序,在红黑树阶段是树形顺序,但哈希表整体遍历时还是要按照桶的下标顺序去访问。也就是说,不管内部怎么优化,对外暴露的遍历顺序永远没有稳定保证。
另外还有一个外部因素:字符串的hashCode算法是公开且稳定的,但不同JDK版本里HashMap对hashCode的扰动处理可能不同,所以不要试图通过某种"规律"去预测HashSet的输出顺序。我见过有人根据线上打印日志总结了一套"顺序规律",后来JDK升级后规律失效,业务侧跟着排查了很久。这种经验主义在哈希结构这里是靠不住的。
3.3 需要顺序时怎么办:LinkedHashSet和TreeSet
如果业务上确实需要"不重复且保持某个顺序",那就应该换数据结构,而不是强行去依赖HashSet的遍历顺序。两种常见替代方案:
- LinkedHashSet:在HashSet的基础上额外维护了一个双向链表,用来记录元素的插入顺序。它保证"元素不重复,同时遍历顺序等于插入顺序"。
- TreeSet:底层是红黑树,元素按照自然顺序或者你自己指定的Comparator排序。它保证"元素不重复,同时遍历时总是有序的"。
三者的选择很直观:
| 需求 | 推荐结构 | 底层实现 | 时间复杂度 |
|---|---|---|---|
| 查重、快速判断包含,不关心顺序 | HashSet | HashMap | add/contains平均O(1) |
| 查重,同时按插入顺序输出 | LinkedHashSet | HashMap + 双向链表 | 和HashSet近似,略高一点 |
| 查重,同时自动排序 | TreeSet | 红黑树 | add/contains是O(log n) |
写代码的时候,先问自己"我用Set到底是干什么用的"。只是为了去重,用HashSet;去重后希望保留看到的顺序,用LinkedHashSet;去重后希望输出有序列表,用TreeSet。这样选型,后面代码会省很多事。
4. 初始容量和负载因子:一动手写代码就会踩的隐性差异
4.1 默认容量与负载因子的含义
HashSet本身没有独立的容量概念,它的容量其实指的是底层HashMap的容量,也就是那个数组的长度。默认的初始容量是16,默认的负载因子是0.75。
这两个参数共同决定了什么时候扩容。
- 容量:底层数组的长度,HashMap里术语叫capacity。
- 负载因子:扩容的触发比例,术语叫loadFactor。
- 阈值:触发扩容的键值对数量,等于容量乘以负载因子。
拿默认值来说:容量16,负载因子0.75,那么阈值为12。意思是,当HashMap里存的键值对数量达到12个时,数组就会扩容。扩容会把数组长度扩到原来的两倍,也就是32,然后所有已有元素要重新计算位置并搬过去。这是一个相对昂贵的操作。
对应到HashSet,set.size()达到12时就会触发一次底层HashMap的扩容。如果你的业务一次性要插入几十万个不重复元素,而创建Set时用的是默认构造方法,那么整个插入过程中会出现多次扩容,甚至可能要把几十万元素搬运好几轮。
很多人以为这只是一个"性能问题",不严重。但实际上,在生产环境的高吞吐场景下,反复扩容带来的CPU开销和内存抖动是能被监控曲线明显看到的。我曾经看过一个批处理任务的火焰图,整个过程大量时间花在resize上,后来只是把HashSet的初始容量调大,整体耗时直接降了三分之一。
4.2 如何合理指定初始容量
HashSet有三个构造方法,其中最常用的两个:
public HashSet(int initialCapacity) public HashSet(int initialCapacity, float loadFactor)如果你预期要放入的元素数量大致为expectedSize,可以直接这样创建:
Set<String> idSet = new HashSet<>(expectedSize);但这里有一个隐藏细节:底层HashMap并不是说你给了16,它就真的允许放16个元素不扩容。HashMap的容量会被调整为2的幂次方,同时扩容阈值是"容量×负载因子"。假设你new了一个HashSet(16),那么容量是16,阈值为12,放到第13个元素时就会扩容,而不是第16个。
所以,如果你已知会放进10000个元素,并且想尽量避免扩容,合理的初始容量不是10000,而是再往上留一些余量。
这里给一个经验公式:
初始容量 = (预期元素数量 / 负载因子) + 1如果要放10000个元素,负载因子默认0.75,那么合理的初始容量大概在(10000 / 0.75) + 1 ≈ 13334左右。你可以直接把这个值传给HashSet构造方法。
Set<String> idSet = new HashSet<>(13334);底层HashMap拿到这个初始容量后,不会真的创建长度为13334的数组,而是把它调整为大于等于这个值的第一个2的幂次方,也就是16384。16384乘以0.75等于12288,这个阈值仍然大于10000,所以整个插入过程不会触发扩容。
4.3 一个批量导入场景中的实际对比
我们做一个简单但能说明问题的模拟:往HashSet里插入10万个不重复的字符串,一个用默认构造方法,一个指定合理的初始容量,然后对比耗时。
import java.util.HashSet; import java.util.Set; public class HashSetCapacityDemo { public static void main(String[] args) { int total = 100_000; long start1 = System.currentTimeMillis(); Set<String> defaultSet = new HashSet<>(); for (int i = 0; i < total; i++) { defaultSet.add("user_" + i); } long end1 = System.currentTimeMillis(); long start2 = System.currentTimeMillis(); Set<String> capacitySet = new HashSet<>((int) (total / 0.75f) + 1); for (int i = 0; i < total; i++) { capacitySet.add("user_" + i); } long end2 = System.currentTimeMillis(); System.out.println("默认容量耗时: " + (end1 - start1) + "ms"); System.out.println("指定容量耗时: " + (end2 - start2) + "ms"); } }在我本地的环境里,指定容量的版本比默认版本快了将近一倍。这个差异在数据量小的时候几乎看不到,但数据量上万之后会非常明显。在写数据同步、批处理、缓存构建这类代码时,建议养成预估数据量并指定初始容量的习惯。
4.4 别把初始容量设置得过于夸张
虽然指定初始容量有好处,但也不是越大越好。HashMap的数组是以2的幂次方增长的,你传一个非常大的值,底层可能直接创建一个很大的数组。如果实际只放进去几个元素,那就有大量桶位是空的,白白浪费内存。
尤其是那种"定义时随手new一个HashSet(1000000)"的写法,如果只是用来放几十个元素,内存浪费就很明显。合理做法是:根据真实业务量预估,留20%到30%的余量即可。线上一般会有流量预估,或者可以参考过去一段时间的峰值数据,不用拍脑袋传个夸张值。
5. 传进可变的业务对象:HashSet最常见的翻车现场
5.1 可变对象与哈希集合的冲突
这是HashSet所有问题里最隐蔽的一类,也是真正线上事故的高发地带。
HashSet存放元素的逻辑是先根据hashCode定位桶,再在桶内通过equals找目标。那么一个问题就出来了:如果元素放进HashSet之后,它的hashCode变了,会发生什么?
答案是:元素明明还在集合里,但你已经找不到它了。contains返回false,remove也删不掉。集合的size不变,看起来一切正常,但这个元素已经变成了一个"幽灵"。
为什么会这样?因为HashSet把一个对象放进某个桶时,是根据它当时的hashCode计算的。之后如果这个对象参与hashCode计算的字段被修改了,你再去调用contains或remove时,它计算出来的桶位置和存放时的桶位置不同,HashSet理所当然地认为这个元素不在集合里。但元素确实还孤零零地待在原来的桶里,既污染了集合,又无法被正常操作。
5.2 一次完整的踩坑经历
我之前在日常开发中遇到过一个很类似的例子。某同事写了一个订单去重模块,用来从消息队列里接收订单事件,然后用HashSet保存已经处理过的订单ID对应的实体对象,防止重复消费。
订单类大致长这样:
public class OrderEvent { private String orderId; private String status; public OrderEvent(String orderId, String status) { this.orderId = orderId; this.status = status; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } OrderEvent that = (OrderEvent) o; return Objects.equals(orderId, that.orderId); } @Override public int hashCode() { return Objects.hash(orderId); } }注意hashCode里用的字段是orderId,看起来问题不大。但这个对象还有一个status字段,它在消费流程里会被改。大概的伪代码逻辑是这样的:
Set<OrderEvent> processedEvents = new HashSet<>(); // 第一次收到订单事件,状态是NEW OrderEvent event = new OrderEvent("ORDER_001", "NEW"); processedEvents.add(event); // 业务处理过程中,把event的状态改成了PROCESSED event.setStatus("PROCESSED"); // 过了一会儿,又来了一条ORDER_001 OrderEvent sameOrder = new OrderEvent("ORDER_001", "NEW"); boolean exists = processedEvents.contains(sameOrder);运行结果非常令人迷茫:processedEvents.size()是1,但contains(sameOrder)返回false。
这个例子里hashCode用的是orderId,修改status不应该影响哈希值啊,为什么还是找不到?
原因在equals方法。两个OrderEvent判断相等时,用的是orderId,这没问题。但HashSet查找元素的完整流程是:先根据待查找对象的hashCode定位桶,再在桶内逐个调用equals。定位桶时,它用的是传入的sameOrder对象的哈希值,而sameOrder的orderId是ORDER_001,和之前存进去的event的orderId一样,所以哈希值应该一样,桶也定位得到。
再仔细看equals方法:
return Objects.equals(orderId, that.orderId);这个只比较orderId,所以理论上桶内比较时应该能匹配上。但问题出现在setStatus之后吗?不是。问题出在:之前存的event对象,由于status被修改成了PROCESSED,而equals方法并没有使用status字段,所以它到底有没有影响?
答案是没有影响。这个例子里方法本身没问题,是一个细节导致的问题:执行contains时,实际是使用HashMap的hash(key)方法,它对key的hashCode结果又做了一次扰动运算。扰动运算依赖的是key的hashCode返回结果。由于Objects.hash(orderId)只基于orderId,status变化不影响这个值。因此桶的位置不会变,contains应该能找到才对。
那么真实的翻车点在哪里?我重新梳理之后发现,实际线上代码中,这个OrderEvent的hashCode并不是按这样写的。它在最初版本里用了所有字段:
@Override public int hashCode() { return Objects.hash(orderId, status); }equals却只比较orderId,这就完全违反了前面说的规则:equals相等(orderId相同)时,hashCode却可能因为status不同而不相等。这样存进去一个NEW状态的event,后来status被改成PROCESSED,它的hashCode就变了;再次用orderId是ORDER_001的新对象去contains时,新对象算出的哈希值对应一个全新的桶,永远找不到原来的元素。
这种"equals用了几个字段,hashCode却用了全部字段"的写法,比完全不重写还危险,因为它看起来像是重写了,但两条规则之间又不一致。
5.3 排查这类问题的完整思路
如果你在代码里遇到了"元素在集合里,但contains返回false"这种诡异现象,不要急着怀疑框架有bug,按照下面的顺序排查:
- 检查放入集合的对象,在放入之后有没有被修改过字段。
- 检查hashCode方法里用到了哪些字段,再确认这些字段在对象存活期间有没有可能变化。
- 检查equals和hashCode使用的字段集合是否一致,这是最常见的规则破坏点。
- 尝试把对象从集合里读出来,逐个字段打日志,确认当前hashCode和放入时的hashCode是否一致。
这个排查思路放之四海而皆准,不管你是用HashSet还是HashMap做key,都适用。因为HashMap的key存储机制和HashSet完全一样,key一旦放入后被修改导致哈希值变化,同样会出现get不到的情况。
5.4 正确的规避方式
规避这个问题的最好方式就是:放入HashSet的对象,尽量是不可变对象。最典型的例子就是String和Integer,它们都是final类,内部字段不可变,所以从来不会出现"放进Set后哈希值变化"的问题。
如果是自己的业务类,有两个做法:
- 把所有参与hashCode计算的字段都设为final,不允许修改。
- 如果业务上确实需要修改某些字段,那就保证这些字段不参与hashCode和equals。
在我自己的项目里,凡是需要放进HashSet或者HashMap的key的自定义类型,我都要求重写equals和hashCode,并且养成一个习惯:在写完类之后,刻意检查这两个方法用的字段是否完全一致。这个检查成本很低,但能避免大量线上诡异问题。
另外补充一个API使用上的细节:HashSet的remove和contains都依赖当前传入对象的hashCode,而不是依赖集合里存储对象的原始hashCode。所以就算集合里的对象没有变,如果你用了一个"序列化再反序列化"出来的新对象去remove,也有可能出现问题——除非equals和hashCode实现完全等价。这点在做分布式任务间传递对象时要格外注意。
6. 什么时候别用HashSet:LinkedHashSet、TreeSet与其他选择
6.1 三种Set的选型对照
老实说,HashSet并不是万能的。它的优点:查询和插入在平均情况下都是O(1),性能极好,适合做去重和存在性判断。它的缺点也很明显:没有顺序,内部结构相对复杂,内存占用也比数组高。
很多业务场景其实需要"去重加顺序",这时候如果强行用HashSet,后续往往要额外做sort,反而多绕一圈。我建议大家在项目里做选型时,遵循"先明确需求,再选结构"的顺序。
| 特性 | HashSet | LinkedHashSet | TreeSet |
|---|---|---|---|
| 去重 | 支持 | 支持 | 支持 |
| 迭代顺序 | 不保证 | 插入顺序 | 元素排序 |
| 底层结构 | HashMap | HashMap + 双向链表 | 红黑树 |
| add时间复杂度 | O(1) | O(1) | O(log n) |
| contains时间复杂度 | O(1) | O(1) | O(log n) |
| 适合场景 | 去重、包含判断 | 去重+保留顺序 | 去重+自动排序 |
举个例子,一个接口需要返回所有已处理订单的去重列表,并且要求按照处理时间展示,那么LinkedHashSet是更合适的选择。如果你还需要按照金额或者某个编号排序,TreeSet再配一个Comparator就可以直接搞定,不需要额外再调用Collections.sort。
6.2 集合运算:HashSet的隐藏能力
很多人不知道HashSet还有一个非常好用的能力:集合运算。利用addAll、removeAll、retainAll这三个方法,可以非常优雅地实现并集、差集、交集。
import java.util.HashSet; import java.util.Set; public class SetOperationDemo { public static void main(String[] args) { Set<String> setA = new HashSet<>(); setA.add("A"); setA.add("B"); setA.add("C"); Set<String> setB = new HashSet<>(); setB.add("B"); setB.add("C"); setB.add("D"); // 交集:B和C Set<String> intersection = new HashSet<>(setA); intersection.retainAll(setB); System.out.println("交集: " + intersection); // 并集:A、B、C、D Set<String> union = new HashSet<>(setA); union.addAll(setB); System.out.println("并集: " + union); // 差集:A中有而B中没有的 Set<String> difference = new HashSet<>(setA); difference.removeAll(setB); System.out.println("差集: " + difference); } }输出结果:
交集: [B, C] 并集: [A, B, C, D] 差集: [A]这套操作的底层,依赖的还是equals比较。所以这里再次强调:你的元素类型必须正确实现equals和hashCode,否则这些集合运算出来的结果会和预期完全不一样。
6.3 数据量很小的时候,别死脑筋用Set
还有一种情况,我不建议用HashSet:数据量非常小,而且你判定重复的逻辑比较特殊。比如一个列表只有三五个元素,你要排除掉其中某个固定值。这种情况下用HashSet反而不如直接写两三个if判断清晰。
我见过不少代码,为了"显得专业",在一个只有几个元素的循环里创建HashSet来去重,整体逻辑反而绕了一大圈。这种场景直接用ArrayList加上contains判断就够了,因为列表长度小,contains的O(n)和HashSet的O(1)在绝对时间上没有感知差异,但代码的可读性和可维护性大大提升。
还有一个场景也不太适合HashSet:频繁遍历且遍历次数极多。虽然时间复杂度看是O(n),但由于HashSet底层数组可能很稀疏,实际遍历时的缓存局部性不如ArrayList好。如果你只需要遍历,不需要去重,那就老实使用ArrayList。
7. 线程并发场景:责任清晰但远非自喻
7.1 HashSet不是线程安全的
HashSet的所有方法都没有做同步处理。它内部维护的是HashMap,HashMap在多线程环境下会出现各种问题:
- 两个线程同时写入,其中一方的写入可能被覆盖,导致元素凭空消失。
- 一个线程正在迭代,另一个线程同时做结构性修改(add或remove),会抛出ConcurrentModificationException。
- 在JDK 7及以前,并发扩容时可能出现链成环的极端情况,导致get死循环。JDK 8重写了扩容逻辑后这个场景不再出现,但丢失数据的问题依然存在。
所以,如果你的Set被多个线程共享且会同时写入,直接用HashSet是埋雷行为。处理并发场景需要的是有同步保障的结构。
7.2 三种线程安全方案的对比
JDK提供了几种线程安全的Set实现或包装方式:
| 方案 | 特性 | 适用场景 |
|---|---|---|
| Collections.synchronizedSet(new HashSet<>()) | 通过包装类的内部锁同步所有方法 | 代码简单,读写不频繁的场景 |
| CopyOnWriteArraySet | 底层是CopyOnWriteArrayList,写操作会复制整个数组 | 读多写极少、集合本身数据量不大的场景 |
| ConcurrentHashMap.newKeySet() | 基于并发哈希表,返回一个并发Set | 高并发写入和读取的通用场景 |
我在实际项目中用得最多的是ConcurrentHashMap.newKeySet(),尤其是需要一个全局唯一的ID集合时。它的并发性能好,迭代时弱一致性,不会抛ConcurrentModificationException,非常适合做缓存标记集合。需要注意的是,它返回的Set本质上是一个concurrentHashMap的key视图,不支持null元素。如果你有存null的需求,这个方案就不能用。
CopyOnWriteArraySet还有一个特点值得注意:它内部的contains是O(n)的,因为底层是数组。所以如果集合很大并且contains操作频繁,用它可能会很慢,哪怕并发性能再好也没用。反过来,如果集合很小,比如只有几十个元素,它反而非常合适,因为每次写入的复制成本非常低。
7.3 在迭代过程中修改Set的坑
就算在单线程环境下,也有人会写出"边遍历边删除"的代码。比如:
Set<String> set = new HashSet<>(); set.add("A"); set.add("B"); set.add("C"); for (String item : set) { if ("B".equals(item)) { set.remove(item); // 抛ConcurrentModificationException } }这段代码会在运行时抛出ConcurrentModificationException。原因是增强for循环底层使用迭代器遍历,迭代器会维护一个expectedModCount,集合结构一旦被修改,modCount变化,迭代器在下一轮检查时发现两者不一致,就立刻抛异常。
正确的做法是用Iterator的remove方法:
Iterator<String> iterator = set.iterator(); while (iterator.hasNext()) { String item = iterator.next(); if ("B".equals(item)) { iterator.remove(); // 正确 } }Iterator.remove会同步更新expectedModCount,所以不会抛异常。这个细节看起来很小,但在实际代码里几乎每天都会碰到,记住它准没错。
8. 衡量的最后一环:把这些经验串起来用
到这里,HashSet的核心机制和实践中的大坑已经讲得差不多了。最后再分享几个我自己在实际编码中的小实践,算是把前面这些知识点综合到真实场景里。
第一个实践:接口做去重返回时,注意String和Integer的哈希行为。虽然String和Integer本身是不可变类,哈希值稳定,但要注意它们的equals规则。String的equals严格比较字符内容,Integer的equals比较数值。一个Integer(1)和一个Long(1L)是不会相等的,即使它们放在某些场景下业务意义一样。如果集合要混入不同类型,去重结果很可能和你想的不一样。
第二个实践:大对象不要整体扔进HashSet。比如你要对一批订单做去重,最简单的写法是把整个订单对象塞进Set。但如果订单对象包含几十个字段,塞进去之后会占用大量内存。更经济的做法是Set里只存订单ID,等需要具体信息时再用Set.contains(orderId)去过滤原始列表,或者按Set里的ID批量查询数据库。这个优化在大数据量任务中收益非常明显,可能只是一个字段的差别,堆内存占用就能少一大半。
第三个实践:使用HashSet做contains判断前,先想清楚元素类型是否满足equals和hashCode规则。尤其是在引入外部SDK或者对接第三方接口返回的对象时,一定要确认这个类的equals和hashCode行为是不是符合你的预期。很多库在定义DTO时并没有重写equals,直接把这种对象放进HashSet,去重效果往往是"去了个寂寞"。
第四个实践:如果你在写代码时发现需要对HashSet做复杂的排序、分页、倒序等操作,大概率是选型错了。SortedSet、NavigableSet等接口的存在就是为了这些场景。不要为了省两行代码,拼命在一个无序集合上做各种变换,那样写出来的代码既难读又难维护。
作为日常开发工具,HashSet真的不复杂,它的核心就一句话:用哈希表做去重,底层依赖HashMap。把这句话往下拆,无非就是hashCode负责找桶、equals负责比对、扩容负责平衡内存与性能、可变性负责维持约定。把这些点理清楚,再去写业务代码,就不太容易在Set上栽跟头了。我过去踩过的坑、看别人踩过的坑,基本都是没跑出这几个范围。希望这篇内容能帮你绕开它们。