1. Set 接口的定位:去重这件事,Java 替你封装好了
先说个我自己的经历。几年前做数据清洗,需要从几十万行日志里筛出唯一的 IP 列表。第一版代码用了 List,每次判断contains都 O(n),数据量一上来直接卡死。后来换成了HashSet.add(),一个循环下去,跑完 30 万条数据不到一秒。那是我第一次真正意识到,Set 不是“另外一种数组”,而是为“去重 + 快速存在性判断”这两个需求量身定做的容器。
如果你也是 Java 学习者、准备面试的候选人、或者工作中需要处理去重逻辑但一直在用 List 硬扛的人,这篇文章就是给你写的。我会把 Set 接口背后的设计思路、三个常用实现类的选型、初始化参数怎么定、哪些坑我踩过,一次讲明白。
Set 接口在 java.util 包里,它继承自 Collection。它的核心语义就一句话:不包含重复元素。更准确地说,一个 Set 最多包含一个 null(部分实现不允许多个 null,但有的实现甚至不允许 null,后面会讲)。这个“不重复”的判定不是靠肉眼,而是靠元素的equals()方法。换句话说,你放进 Set 的元素必须正确实现equals(),否则去重就是空话。
从数学层面理解,Set 就是离散数学里“集合”概念的程序化实现:元素无序、不可重复、支持交集、并集、差集运算。Java 集合框架里的retainAll、addAll、removeAll实际上就是集合运算的工程化实现。比如set1.retainAll(set2)就是把 set1 改成它和 set2 的交集。
Set 和 Map 的关系很多人搞混。其实 HashSet 底层就是一个 HashMap,只是它把 value 固定成了一个 dummy 对象。TreeSet 底层是 TreeMap,同理。所以**“Map 会了 Set 就懂了一半”**这句话是有道理的。你去看源码会发现,HashSet 构造时直接new HashMap<>(),每次 add 就是map.put(e, PRESENT),PRESENT 是一个静态的占位对象。这也是为什么面试官特别喜欢连环问:HashMap 的原理、HashSet 的原理、两者关系、什么情况下会两者一起考。本质上是同一个知识点。
2. 三巨头选型:HashSet、LinkedHashSet、TreeSet 到底怎么选
这是 Set 复习里最核心的实操问题。我见过太多人不管什么场景一套 HashSet 走天下,结果遇到需要排序或者保持插入顺序的需求,又回去用 List 硬排。完全没必要。Java 给你三种实现,每一种都是为特定场景设计的,选错了不是不能用,是性能或者写法上别扭。
2.1 HashSet:默认首选
HashSet 是使用频率最高的 Set。它的特征是无序、O(1) 时间复杂度的 add/remove/contains。底层是 HashMap,通过哈希值定位桶位,再通过链地址法(Java 8 之后链表过长会转红黑树)解决哈希碰撞。
什么时候用 HashSet?只要没有顺序需求,一律 HashSet。比如:
- 给用户 ID 列表去重
- 判断一个元素是否已经处理过(处理状态集合)
- 求两个集合的交集、差集
HashSet 允许 null。因为 HashMap 允许 null key,null 会被哈希到桶位 0。
2.2 LinkedHashSet:要顺序,又要 O(1)
LinkedHashSet 继承自 HashSet,但它额外维护了一个双向链表,用来记录元素的插入顺序。所以它的特征是:迭代顺序等于插入顺序,但是 add/remove/contains 依然是 O(1)。
这个类很多人不知道,但我工作上其实用得挺频繁。举一个例子:论坛签到系统,需要记录今天哪些用户签到过(去重),同时要按签到先后顺序展示。用 LinkedHashSet 一步到位。如果用 HashSet 再去 sort,浪费了时间而且不准——sort 只能按字典序或自定义规则,恢复不了原始插入顺序。
LinkedHashSet 允许 null。
2.3 TreeSet:有序的代价是 O(log n)
TreeSet 底层是红黑树(TreeMap 的 key 部分),元素按照自然顺序(Comparable)或者构造时传入的Comparator排序。它的增删查都是 O(log n),性能比哈希类慢,但换来的是有序性,而且支持范围查询:subSet()、headSet()、tailSet()、first()、last()。
使用 TreeSet 的核心前提:元素必须可比较。要么实现Comparable,要么在构造 TreeSet 时传Comparator,否则add的时候直接抛ClassCastException。
TreeSet 有两个需要注意的地方:
- 不允许 null(从 JDK 1.7 起),因为 null 无法参与比较。
- 比较器返回 0 即视为重复,不管 equals 怎么说。
最后这一点极其重要,后面我会展开讲。
2.4 隐藏成员:EnumSet
面试如果聊到 Set,你能说出 EnumSet 会加分。EnumSet 是专为枚举类型设计的 Set,底层是一个 bit 向量,用一个或多个 long 存储枚举值的位置。它的内存极省,性能极快,add/remove/contains 都是 O(1),而且比 HashSet 快很多。
使用场景:权限控制、状态机判断、多个枚举标志的组合判断。比如一个订单状态机,状态有“待支付、已支付、已发货、已签收、已取消”,你怎么判断某个状态是否在允许流转的集合里?EnumSet 声明式地写出来,比一堆 if/switch 清爽得多:
Set<OrderStatus> allowedTransitions = EnumSet.of(OrderStatus.PENDING_PAYMENT, OrderStatus.PAID);这里 EnumSet 有一个限制:不允许 null,元素必须是枚举类型。
2.5 选型速查表
| 需求 | 推荐实现 | 底层结构 | 时间复杂度 | 是否允许 null | 是否有序 |
|---|---|---|---|---|---|
| 只要去重,不关心顺序 | HashSet | 哈希表 | O(1) | 允许 | 无序 |
| 去重且要保留插入顺序 | LinkedHashSet | 哈希表 + 双向链表 | O(1) | 允许 | 按插入序 |
| 去重且要排序/范围查询 | TreeSet | 红黑树 | O(log n) | 不允许 | 按比较器排序 |
| 枚举类型的位运算集合 | EnumSet | bit 向量 | O(1) | 不允许 | 按枚举定义序 |
给一个我自己的判断口诀:无序用 HashSet,有序且只看插入顺序用 LinkedHashSet,有序且需要排序/区间操作用 TreeSet,枚举就用 EnumSet。
3. 重写 equals 和 hashCode 的全部细节:去重的根基
Set 的去重能力完全建立在equals()和hashCode()上。如果你往 HashSet 里塞对象,却没有重写这两个方法,你会发现去重完全失效——因为默认的 equals 是引用比较,两个“内容相同”的对象在内存中是不同的引用,会被当成两个元素。
3.1 为什么两个方法必须一起重写
简单说说原理。HashSet 的 add 流程是:先调用 hashCode() 计算哈希值 → 定位到某个桶 → 如果桶里有元素,再调用 equals() 逐个比对。如果 hashCode 没有重写,两个内容相同但引用不同的对象会有不同的哈希值,大概率被分到不同的桶里,equals 根本不会被调用到。
反过来,如果只重写 equals 不重写 hashCode,内容相同的对象会算出不同的哈希值,依然落在不同桶里,equals 形同虚设。
所以约定就是:如果两个对象 equals 相等,它们的 hashCode 一定相等;如果 hashCode 不相等,equals 一定不相等。反过来不成立——哈希值相同不代表对象相等,这就是哈希冲突。
听过的经典面试题:如果只重写 equals 不重写 hashCode,会发生什么?答案就是你往 HashSet 里 add 两个内容相同的对象,两个都会存进去,Set 的去重就失效了,而且内存里出现“逻辑重复”的数据。
3.2 写一个标准实现
常见做法是用 JDK 7+ 的Objects.equals和Objects.hash:
@Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return age == user.age && Objects.equals(name, user.name); } @Override public int hashCode() { return Objects.hash(name, age); }这里用getClass() != o.getClass()做类型判断,而不是instanceof,是有讲究的。如果父类和子类都重写了 equals,用 instanceof 可能因为类型不对称导致“蝴蝶效应”——A 和 B 相等、B 和 C 相等、但 A 和 C 不等,破坏了传递性。
3.3 一个比较隐蔽的坑
可变对象的 hashCode 字段改了之后,存放在 Set 里的哈希值就失效了。
举例:你把一个 User 对象放进 HashSet,它的 name 字段参与 hashCode 计算,随后你改了 name,这个对象在 Set 里的桶位是基于旧哈希值算出来的,新的哈希值和旧桶位对不上。结果就是:remove找不到它(因为 remove 先按新哈希值定位桶,发现空),contains也找不到它(同理),但这个对象还“幽灵”一样占着 Set 的内存。
我听人调侃说这叫“内存泄漏型 bug”——对象删不掉、查不到,只能等程序重启。解决办法只有一个:放进 Set 的对象,参与 hashCode 的字段最好是不可变的,要么干脆用不可变对象(Java 17 的 record 直接解决这个问题)。如果你非要用可变对象,那就要约定:入集合后不得修改参与 hashCode 的字段。
4. 初始化参数与内存避坑:默认大小的秘密
接下来聊一个很多人忽视但很影响性能的细节:初始化容量。
HashSet 默认构造是 16 的容量、0.75 的负载因子。意思是当元素个数达到 16 × 0.75 = 12 时,哈希表会扩容为原来的两倍,并且所有元素要重新哈希(rehash)。
如果你事先知道要存大概 30 万个元素,还直接 new HashSet(),它会经历多次扩容和 rehash,时间成本非常可观。我在数据清洗时就见过 10 万条数据因为没设初始大小,跑了 2 秒钟;设了之后 0.4 秒。差距就是这么大。
怎么设初始容量?有一个常用公式:
初始容量 = 期望元素数 / 负载因子 + 1比如期望存 20 万条,那么:200000 / 0.75 + 1 ≈ 266668。实际你可以往上取一个 2 的幂,接近的比如 262144 或 524288。虽然理论上讲容量不一定是 2 的幂(扩容机制会处理),但 JDK 源码对 2 的幂有优化,建议就按这个思路指定。
实操代码:
// 预估存储 20 万个元素 Set<String> ipSet = new HashSet<>(266668);或者你用 Guava 的Maps.newHashMapWithExpectedSize(200000)思路,自己封装一个工具方法:
private static final float LOAD_FACTOR = 0.75f; public static <T> HashSet<T> newHashSetWithExpectedSize(int expectedSize) { int capacity = (int) (expectedSize / LOAD_FACTOR + 1); return new HashSet<>(capacity); }顺带说一句,JDK 9 之后提供了Set.of()创建不可变 Set,Set.of(e1, e2, e3)这种形式对 1~10 个元素有专门优化。但不可变 Set 不能 add、remove,按需场景使用。另外Set.of()不允许 null 元素,如果你传了 null,直接 NPE,不给你绕的空间。
5. 并发场景下的四个选择
很多刚入门的同学以为 Java 集合不支持线程安全,那就全程加锁吧——其实不用。Set 的并发场景有专门的方案。
5.1 Collections.synchronizedSet
这是最简单的方案。包一层同步装饰器,所有方法都加了 synchronized 锁。写法:
Set<String> syncSet = Collections.synchronizedSet(new HashSet<>());注意:单独的 add、remove 都是安全的,但复合操作如contains后add不是原子的,需要你手动加锁。这个方案性能一般,只适合并发量很低的场景。
5.2 CopyOnWriteArraySet
底层是 CopyOnWriteArrayList,读操作不加锁,写操作复制整个数组。适合读多写少的场景,比如黑名单缓存、配置项集合。写操作代价很高,如果你频繁 add/remove,用它会非常慢。
5.3 ConcurrentHashMap.newKeySet()
这是我个人最推荐的一个。利用 ConcurrentHashMap 的并发能力,返回一个支持并发操作的 Set 视图,key 是元素,value 统一是一个 Boolean.TRUE。性能和 ConcurrentHashMap 完全一致,适合高并发写的场景。
// 默认并发级别 16 Set<String> concurrentSet = ConcurrentHashMap.newKeySet(); // 预估容量 1000,减少扩容次数 Set<String> bigConcurrentSet = ConcurrentHashMap.newKeySet(1000);注意事项:newKeySet 返回的 Set 不支持 null(ConcurrentHashMap 本身不支持 null key)。
5.4 并发场景怎么选,一张表说清
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 并发低,图省事 | Collections.synchronizedSet | 一行代码,整体加锁 |
| 读多写少,黑名单/白名单 | CopyOnWriteArraySet | 读无锁,复制写代价换读性能 |
| 高并发写,且大量并发访问 | ConcurrentHashMap.newKeySet() | 分段锁/ CAS,并发读写的综合性能最好 |
还是那句老话:并发工具要按场景选,别一招鲜。
6. 三个高频面试问题:从实现原理到陷阱
面试里 Set 相关的问题出现频率不低,但很多人只背了“HashSet 无序不重复”就上考场了。这里整理几个我自己被问过、也经常反问我面试者的问题,把背后的原理和陷阱讲透。
6.1 HashSet 怎么判断重复?
它走的是 add 的完整链路:计算 hashCode → 定位桶 → 如果桶为空直接放 → 如果不为空,遍历桶内元素(链表或红黑树),用 equals 逐个比较。只要找到一个 equals 相等的,就不再插入,add 返回 false。
这里有个细节:equals 和 hashCode 都必须正确重写。如果一个对象 hashCode 相等、equals 不相等,就会发生哈希冲突,插入到同一个桶里,性能下降但功能正常;如果一个对象 hashCode 不相等、equals 相等,那就不该插入了却插入成功,这就是 bug。
6.2 TreeSet 是如何维持有序的?
TreeSet 底层是 TreeMap,TreeMap 的基础数据结构是红黑树。红黑树是有序的二叉搜索树,插入时根据 key 的比较结果决定走左子树还是右子树。所以 TreeSet 的迭代顺序就是树的深度优先遍历顺序,保证按比较器升序。
面试延伸问题:如果元素的比较规则和 equals 不一致会怎样?比如你定义了一个 User 的比较器,只看 age,那么 age 相同的两个 User(name 不同)会被 TreeSet 认为是同一个元素,add 两次只有第一次成功。Set 去重逻辑在这里变成了:比较器返回 0 就代表重复。
6.3 为什么 TreeSet 不能存 null?
因为红黑树需要比较元素的大小来决定插入位置,null 无法比较。从 JDK 1.7 开始,TreeSet.add(null) 会直接抛 NullPointerException。同一时期 TreeMap 也做了同样调整。如果你确实需要“空值也参与排序”,就自己定义一个 Comparator,把 null 排在前面或后面:
Comparator<String> nullFirstComparator = (a, b) -> { if (a == null && b == null) return 0; if (a == null) return -1; if (b == null) return 1; return a.compareTo(b); }; TreeSet<String> set = new TreeSet<>(nullFirstComparator);7. 实战场景一:用 Set 做差集/交集,替代循环判断
这是 Set 最实用的日常工作技能。很多人用 List 写集合运算,写了十行八行还容易漏。Set 三行搞定:
Set<Integer> setA = new HashSet<>(Arrays.asList(1, 2, 3, 4)); Set<Integer> setB = new HashSet<>(Arrays.asList(3, 4, 5, 6)); // 交集 Set<Integer> intersection = new HashSet<>(setA); intersection.retainAll(setB); // 差集(A - B) Set<Integer> difference = new HashSet<>(setA); difference.removeAll(setB); // 并集 Set<Integer> union = new HashSet<>(setA); union.addAll(setB);这里注意一点:retainAll、removeAll、addAll会修改调用者自身,为了不破坏原始数据,先 new 一个新的 HashSet 拷贝一份再操作。这种做法在数据处理里非常常见。
实际应用场景很多,比如:
- 两个文件各自存储了一批关键词,找出共同关键词(交集)
- 排除黑名单用户(差集)
- 需要从多个源头汇总唯一用户 ID(并集)
8. 实战场景二:去重之后的顺序恢复问题
这是我在项目里踩过的一个坑。需求是从一个 CSV 文件里读用户 id,然后去重后输出。我一开始直接用了 HashSet,去重是去重了,但是输出顺序跟源文件顺序完全对不上。老板说“你输出的顺序乱了”,我没法反驳,因为需求确实没提顺序要求——但现实世界的需求往往默认“源文件的顺序别变”。
如果不允许改代码存储结构,只想快速排序恢复,其实有三种方案:
第一,用 LinkedHashSet 代替 HashSet,保持插入顺序。一行代码解决。
第二,如果必须保留 HashSet(比如后续频繁查重性能),那就单独记录第一次出现的顺序,最后按这个顺序输出。
第三,直接用 Java 8 Stream 的 distinct():
List<String> uniqueInOrder = ids.stream() .distinct() .collect(Collectors.toList());这里distinct()本质上依赖元素的 equals/hashCode,内部实现类似 LinkedHashSet,能保持顺序。这个写法在流式处理里最简洁,适合数据量在几百万之内的场景。
所以我的建议是:如果需求里出现“去重”两个字,先问自己一个问题——“顺序要不要保留?”要的话直接选 LinkedHashSet,不要无脑 HashSet 然后最后排序。
9. 实战场景三:对对象列表去重,别只盯着基本类型
很多人写去重时习惯了基本类型的 Set,遇到对象就懵。比如你有一个 User 列表,想按 name + age 去重,怎么写?
两种思路:
思路一:对象重写 equals + hashCode。如果 User 这个概念本身就以 name + age 为业务主键,那直接在 User 类里重写这两个方法,用Objects.equals(name, other.name) && Objects.equals(age, other.age)与Objects.hash(name, age)实现。然后:
List<User> uniqueUsers = new ArrayList<>(new LinkedHashSet<>(userList));思路二:如果 User 类不方便改动,用流式操作的 groupingBy 或者自定义比较器。简单方式:
List<User> uniqueByNameAndAge = userList.stream() .collect(Collectors.collectingAndThen( Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(User::getName).thenComparingInt(User::getAge))), ArrayList::new ));这个写法用了 TreeSet 加自定义 Comparator,Comparator 比较的就是 name + age。注意它有一个隐含后果:如果 name 和 age 都相同,即使 User 里的其他字段不同,也会被当成重复。这就是“Compar 器判定即重复”的体现。
Java 17 之后,如果用 record 定义数据类,equals/hashCode/toString 都自动生成好了,而且字段不可变,放进 Set 也不会出现“字段被修改导致 hashCode 漂移”的问题。如果你还在手动写 getter/setter 和 POJO,建议新的业务数据类直接改用 record,省心很多。
10. 易错点清单:这些坑我全部踩过
这一节是我最想分享给你的部分。这些都是真实工作或者教学中踩过的坑,有些很隐蔽,排错排了大半天。
10.1 HashSet 和对象的 hashCode 一起变
可变对象进 Set 之后修改了参与 hashCode 的字段,导致对象在集合中“幽灵化”——contains 找不到、remove 删不掉。前面提过一次,但这里再强调一次:凡是进 Set 的对象,必须保证其 hashCode 计算字段在集合期间不被修改。这属于隐性的集合误用 bug,没有直接异常,排查成本非常高。
10.2 TreeSet 的 Comparator 和 equals 不一致
自定义 Comparator 时说好按 id 排序,结果业务上期望按 name 去重。某天生产数据出现“两个不同 name 的用户被去重了”,排查起来会怀疑人生。所以规则是:TreeSet 的去重语义取决于 Comparator,而不是 equals。如果你的业务语义是“equals 相等才去重”,千万别用 TreeSet 除非 Comparator 与 equals 完全一致。
10.3 ArrayList 转 HashSet 时丢了重复数据的顺序
很多人做去重时随手new HashSet<>(list),结果顺序乱了。这是 List 到 Set 的经典问题。像我前面说的,用new LinkedHashSet<>(list)就能既去重又保序。
10.4 在遍历 Set 时删除元素
一边迭代一边 remove 会抛 ConcurrentModificationException,这问题在 List 上大家记得牢,在 Set 上反而容易忘。正确做法是使用 Iterator 的 remove:
Iterator<String> it = set.iterator(); while (it.hasNext()) { String s = it.next(); if (s.startsWith("temp")) { it.remove(); } }或者直接 Set.removeIf(Java 8+):
set.removeIf(s -> s.startsWith("temp"));removeIf 内部也是基于迭代器实现的,不需要手动写循环。
10.5 Set.of 不允许 null
我有个同事在初始化配置集合时用了Set.of("a", null, "b"),跑起来直接 NPE,他以为是配置问题。查了很久发现是 Set.of 的 null 约束:JDK 9+ 的不变集合不接受 null。如果你不确定数据里有没有 null,就老老实实用new HashSet<>()+ add。
10.6 自定义对象没重写 hashCode
这是初学者最常见的问题。自己写了一个 User 类,不重写 hashCode,add 两个内容相同的 User 全部放进去了。后果就是 Set 不 Set。这也是为什么我总强调,自定义类如果会进集合,equals 和 hashCode 就是强制项,不是可选项。
11. 排序与自定义规则的更进一步:NavigableSet
前面讲 TreeSet 的时候提到范围查询,这里稍微展开一下。Set 家族里还有一个接口叫 NavigableSet,TreeSet 实现了它。它提供的几个方法非常实用:
subSet(from, to):返回一个区间视图,[from, to) 左闭右开headSet(to):小于 to 的元素子集tailSet(from):大于等于 from 的元素子集ceiling(e):返回大于等于 e 的最小元素floor(e):返回小于等于 e 的最大元素higher(e)/lower(e):严格大于 / 严格小于并最接近 e 的元素
举个例子,一个在线优惠券系统的发放记录,按领取时间戳排序,用户要查“当前时间之后最近的一张券”。用 TreeSet + ceiling 非常自然:
TreeSet<Long> timestamps = new TreeSet<>(); Long nextTimestamp = timestamps.ceiling(System.currentTimeMillis());这种场景如果用其他 Set 或者 List,你得先排序再二分查找,代码量高出不少。所以遇到 Set 的时候,要带着“功能地图”去选——不是只有无序去重一个答案。
12. 分析与对比:Set 和其他集合的协作
很多人问,Set 和 List 怎么一起用?最典型的场景就是“先 List 收集,再 Set 去重,最后转回 List 输出”。这是集合协作的经典链路。
我自己有一个工具方法:
public static <T> List<T> distinct(List<T> list) { return list.stream() .filter(Objects::nonNull) .collect(Collectors.collectingAndThen( Collectors.toCollection(LinkedHashSet::new), ArrayList::new )); }这里的思路是:
- 先过滤 null,因为 Set 对 null 的处理在不同实现里不一样,提前过滤避免歧义。
- 用 LinkedHashSet 去重并保序。
- 转回 List 供外部使用。
你可能会问,为什么不直接用 list.stream().distinct()?也完全可以,但把它封装成工具方法更方便复用,而且语义清晰。
另一个常见协作是 Map 和 Set 的配合。Map 的 keySet() 返回的 Set 是 Map 的一个视图,你删这个 Set 里的元素会同步影响 Map 本身。但你不能通过这个视图往里面 add——因为 Map 的 key 必须有对应的 value,而视图里没有 value 信息,所以 add 会抛 UnsupportedOperationException。这个细节在面试里也常被问到。
再看一个场景:如果需要统计每个元素出现的次数,那就要 Map 了,用 Set 只能知道“出现过”。两者配合:Set 做黑名单校验,Map 做计数统计,非常常见。
13. 实测数据:不同 Set 的性能差异
我知道干说性能没有说服力,贴一组我本地 JVM 跑过的简单基准测试数据(环境:JDK 17,Windows 11,i7-12700H,默认 JVM 参数)。数据仅作参考,但量级差异是有代表性的。
测试逻辑:往不同 Set 里 add 100 万个整数,再各自做 100 万次 contains 查询。
| 实现 | add 100万耗时(约) | contains 100万耗时(约) |
|---|---|---|
| HashSet | 约 120ms | 约 100ms |
| LinkedHashSet | 约 140ms | 约 110ms |
| TreeSet | 约 700ms | 约 500ms |
TreeSet 比 HashSet 慢 5 倍左右,这是红黑树的 O(log n) 和哈希表的 O(1) 的天然差异。如果你的数据量是千万级,这个差距会更大。
如果预先指定容量,HashSet 的 add 速度会有明显提升。我试过不设初始容量和设了合适初始容量,100 万数据 add 的时间差距在 30%~40% 之间,原因是扩容次数少、rehash 开销小。
另外注意:如果你用 HashSet 存自定义对象,hashCode 写得不均匀(比如所有对象返回同一个哈希值),所有元素都堆在一个桶里,HashSet 直接退化成链表查询 O(n)。重写 hashCode 时要保证分布均匀,这也是为什么推荐用 Objects.hash 而不是自己拼字符串后再取哈希。
14. 面试大题:手写一个基于 Set 的 LRU 缓存(思路)
有些高级岗位面试会问:怎么用集合来实现一个最简单的不变集合?这个题考察的点很综合。其实可以借题发挥一下——用 LinkedHashSet 实现一个简单的 LRU 淘汰逻辑。
思路是这样的:LinkedHashSet 按访问顺序维护元素(构造器传入 accessOrder = true),被访问过的元素会移动到链表尾部。淘汰时只需移除链表头部的元素。
具体实现:
class SimpleLruSet<T> { private final int capacity; private final Set<T> set; public SimpleLruSet(int capacity) { this.capacity = capacity; this.set = Collections.newSetFromMap(new LinkedHashMap<T, Boolean>(capacity, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry<T, Boolean> eldest) { return size() > capacity; } }); } public boolean add(T t) { return set.add(t); } public boolean contains(T t) { return set.contains(t); } public int size() { return set.size(); } }用到的知识点:
- LinkedHashMap 的 accessOrder 参数控制访问顺序
- removeEldestEntry 决定何时淘汰
- Collections.newSetFromMap 把 Map 包装成 Set
实际开发中不建议自己重复造轮子,如果有 LRU 需求,直接用 Guava 的 CacheBuilder 或者本地 Caffeine。但面试时写出这个思路,能体现你理解“Set 底层就是 Map”这一本质。
15. 项目实操:Set 在日志分析里的经典用法
最后分享一个我实际做过的日志分析项目片段。需要从 Nginx 访问日志里统计今天访问过某个接口的独立 IP,并分别输出完整 IP 列表和访问量 Top 10。
第一步,用 Set 去重拿完整 IP 列表:
Set<String> uniqueIps = new HashSet<>(estimatedCapacity); try (BufferedReader reader = Files.newBufferedReader(Paths.get(logFile))) { String line; while ((line = reader.readLine()) != null) { String ip = parseIpFromLine(line); if (ip != null && routeMatcher.matches(line)) { uniqueIps.add(ip); } } }第二步,统计每个 IP 的访问量,用 Map:
Map<String, Long> countByIp = new HashMap<>(); // 循环里 countByIp.merge(ip, 1L, Long::sum);这里 merge 是 JDK 8 给 Map 加的高频方法,等价于“不存在就填 1,存在就在原值上加 1”。
最后 Top 10 排序:
List<Map.Entry<String, Long>> top10 = countByIp.entrySet().stream() .sorted(Map.Entry.<String, Long>comparingByValue().reversed()) .limit(10) .collect(Collectors.toList());Set 在这个项目里的角色是“一次访问只算一次”——这就是 Set 存在的意义。在处理海量数据但内存吃紧的场景,还可以考虑用 Redis 的 Set 结构(SADD/SMEMBERS/SINTER)来实现分布式去重。Java 的 Set 和 Redis 的 Set 理念是相通的,学到后面你会有“啊,原来有序集合 ZSET 就是 TreeSet 的分布式版”这种感觉。
16. 最后再分享一个小技巧
有一次我排查线上问题,需要快速知道两个环境的配置差异,手头没有现成工具。当时我是这么干的:分别把两边的配置项读成两个 Set,然后求差集和交集,五分钟就定位到是一个多语言配置 key 在测试环境漏配了。
只要你理解了 Set 的运算能力,你会发现它不只是“存不重复数据的盒子”,更是快速做集合运算的利器。很多人天天写代码,但很少主动用 retainAll、removeAll 去处理业务里的“对比”“差异”需求,都是自己写循环,既慢又容易出错。
如果你正处在学习 Java 的阶段,我建议你花一个晚上,把 HashSet、LinkedHashSet、TreeSet 各自的 add、remove、contains 用时和底层结构跑一遍,再亲手实现一次 equals/hashCode 的重写,把源码里 HashSet 和 HashMap 的关系画出来。这套基本功扎实之后,无论是笔试面试还是日常编码,你都会比别人少踩很多坑。