Java Set集合详解:从入门到实战,避坑指南全搞定
今天咱们来聊聊集合里的“高冷选手”——Set。我猜不少刚接触的同学会有疑问:这Set跟List到底有啥不一样啊?为啥有时候存数据非得用它?别着急,这篇文章咱们就用最接地气的话,把Set的那些事儿讲明白,从基础特性到常用实现类,再到实际开发里的坑,一次说透!
一、先搞懂:Set到底是个啥?跟List差在哪儿?
首先得明确一点,Set也是Java集合框架里的一员,属于 java.util 包下的。但它跟咱们常用的List比,性格可太不一样了——最大的特点就是“不允许有重复元素”,而且很多实现类还不保证元素的存储顺序(比如HashSet)。
举个生活里的例子:咱们手机里的联系人列表,同一个人不能存两个一模一样的号码吧?这时候Set的“去重”特性就特别合适。而List呢,更像咱们的购物清单,同一个商品可以写好几遍,还得按顺序来。
再总结几个Set的核心特性,记牢了:
1. 元素唯一:不管你存多少次相同的元素,Set里最终就留一个,重复的会被“踢掉”
2. 无序性(部分实现类):像HashSet是真无序,存进去的顺序和取出来的顺序可能完全不一样;但TreeSet和LinkedHashSet是有序的,这个后面咱们细说
3. 没有索引:跟List最大的区别之一!Set不能像List那样用 get(index) 获取元素,想遍历只能用迭代器或者增强for循环
4. 允许存null值:但注意,大部分Set只能存一个null,因为重复的会被去重(比如HashSet、LinkedHashSet),TreeSet则不允许存null,存了会报错
二、常用Set实现类:三个“兄弟”各有千秋
Set家族里咱们平时用得最多的就是三个实现类:HashSet、LinkedHashSet、TreeSet。这哥仨各有各的本事,适用场景也不一样,咱们一个一个说。
2.1 HashSet:最常用,但“无序”是硬伤
HashSet应该是Set里出场率最高的了,底层是靠“哈希表”(也就是HashMap)实现的。它的优点很明显:添加、删除、查询元素的速度都特别快,时间复杂度基本是O(1)。
但缺点也很突出——完全不保证元素的存储顺序。比如你按“张三、李四、王五”的顺序存进去,取出来可能就变成“李四、张三、王五”了,而且每次运行的顺序还可能不一样。
给大家看段简单的代码示例,感受下:
java
Set<String> hashSet = new HashSet<>();
// 添加元素
hashSet.add("张三");
hashSet.add("李四");
hashSet.add("王五");
hashSet.add("张三"); // 重复元素,会被忽略
// 遍历
for (String name : hashSet) {
System.out.println(name);
}
// 输出结果可能是:李四、张三、王五(顺序不确定)
这里有个超级重要的坑,必须跟大家强调:用HashSet存自定义对象时,一定要重写equals()和hashCode()方法!
为啥呢?因为HashSet判断元素是否重复,是先比较哈希值(hashCode()),如果哈希值不一样,直接认为是不同元素;如果哈希值一样,再调用equals()比较。要是你不重写这两个方法,默认用的是Object类的方法,会导致即使两个对象的属性完全一样,也会被当成不同元素存进去,等于“去重”失效了。
举个反例,比如自定义一个User类:
java
class User {
private String name;
private int age;
// 只写了构造器和getter/setter,没重写equals和hashCode
}
// 测试
Set<User> userSet = new HashSet<>();
userSet.add(new User("张三", 20));
userSet.add(new User("张三", 20)); // 这俩对象属性一样,但会被当成不同元素存进去
System.out.println(userSet.size()); // 输出2,而不是预期的1
所以记住:存自定义对象到HashSet,务必重写equals和hashCode!IDE(比如IDEA)可以自动生成,不用自己手写,避免出错。
2.2 LinkedHashSet:有序版HashSet,兼顾速度和顺序
如果你既想要HashSet的快速操作,又想保证元素的“插入顺序”,那LinkedHashSet就是你的菜!
它的底层其实是“哈希表 + 双向链表”实现的——哈希表保证查询速度,双向链表保证元素的插入顺序。也就是说,你存进去的顺序是啥样,取出来的顺序就是啥样,而且去重特性也还在。
看代码感受下:
java
Set<String> linkedHashSet = new LinkedHashSet<>();
linkedHashSet.add("张三");
linkedHashSet.add("李四");
linkedHashSet.add("王五");
linkedHashSet.add("张三"); // 重复元素被忽略
for (String name : linkedHashSet) {
System.out.println(name);
}
// 输出结果一定是:张三、李四、王五(跟插入顺序一致)
那LinkedHashSet的缺点呢?就是比HashSet稍微占点内存,因为多维护了一个双向链表。但在大部分场景下,这点内存开销换来的顺序保证是值得的。比如做“历史记录”功能,用户操作的顺序很重要,又不能有重复记录,用LinkedHashSet就很合适。
2.3 TreeSet:能排序的Set,但速度稍慢
最后咱们说TreeSet,它跟前面俩不一样,底层是基于“红黑树”实现的。它的核心特点是能自动对元素进行排序,不管你怎么存,取出来的时候都是排好序的。
比如存数字,它会按从小到大排;存字符串,会按字典顺序排。看代码:
java
Set<Integer> treeSet = new TreeSet<>();
treeSet.add(3);
treeSet.add(1);
treeSet.add(2);
treeSet.add(3); // 重复元素被忽略
for (Integer num : treeSet) {
System.out.println(num);
}
// 输出结果:1、2、3(自动升序)
那如果想自定义排序规则呢?比如存User对象,想按年龄降序排。这时候就需要让User类实现 Comparable 接口,或者在创建TreeSet的时候传入一个 Comparator 比较器。
举个按年龄降序的例子:
java
// 方式1:User类实现Comparable接口
class User implements Comparable<User> {
private String name;
private int age;
// 构造器、getter/setter省略
@Override
public int compareTo(User o) {
// 按年龄降序:当前对象年龄 - 传入对象年龄,结果为正就交换
return o.getAge() - this.getAge();
}
}
// 测试
Set<User> userTreeSet = new TreeSet<>();
userTreeSet.add(new User("张三", 20));
userTreeSet.add(new User("李四", 25));
userTreeSet.add(new User("王五", 18));
for (User user : userTreeSet) {
System.out.println(user.getName() + ":" + user.getAge());
}
// 输出结果:李四:25、张三:20、王五:18(按年龄降序)
不过TreeSet也有缺点:因为要排序,所以添加、查询元素的速度比HashSet慢,时间复杂度是O(log n)。而且它还有个坑:不能存null值,一存就报 NullPointerException ,这点跟HashSet和LinkedHashSet不一样,要特别注意。
三、实战场景:到底该选哪个Set?
讲完三个实现类,肯定有同学问:我开发的时候该咋选啊?别急,给大家总结个“选型指南”,对照着用就行:
1. 只需要去重,不关心顺序,追求速度 → 选HashSet(大部分场景的首选)
- 比如:存储用户ID、商品编号,避免重复
2. 需要去重,还得保证插入顺序 → 选LinkedHashSet
- 比如:用户浏览历史记录、操作日志,要按顺序展示,还不能重复
3. 需要去重,还得自动排序 → 选TreeSet
- 比如:存储成绩排名、按日期排序的订单号,不需要手动排序
四、常见问题&避坑点:这些坑千万别踩!
最后,跟大家聊聊实际开发中容易踩的坑,都是我踩过的血泪教训,记牢了能少走很多弯路:
4.1 坑1:HashSet存自定义对象,没重写equals和hashCode
这个前面已经强调过了,再啰嗦一句:如果不重写,HashSet无法识别重复元素,会导致去重失效。解决办法就是用IDE自动生成这两个方法,别自己写,容易出错。
4.2 坑2:TreeSet存null值,直接报错
TreeSet因为要排序,而null无法参与比较,所以一存null就报空指针异常。如果你的数据可能有null,就别用TreeSet,换HashSet或者LinkedHashSet。
4.3 坑3:认为Set是线程安全的
不管是HashSet、LinkedHashSet还是TreeSet,都不是线程安全的!如果在多线程环境下操作Set,可能会出现ConcurrentModificationException(并发修改异常)。
解决办法:
- 用 Collections.synchronizedSet() 包装一下,比如: Set<String> safeSet = Collections.synchronizedSet(new HashSet<>());
- 或者用Java并发包下的 CopyOnWriteArraySet ,它是线程安全的,不过性能会稍差一点,适合读多写少的场景。
4.4 坑4:遍历Set的时候用for循环(带索引的)
Set没有索引,所以不能像List那样用 for (int i=0; i<size(); i++) 遍历。正确的遍历方式有两种:
1. 增强for循环: for (String s : set) { ... }
2. 迭代器(Iterator): Iterator<String> it = set.iterator(); while (it.hasNext()) { ... }
五、总结
好了,关于Java Set集合的内容就讲到这儿了。咱们来回顾一下:
- Set的核心是“元素唯一”,部分实现类有序,没有索引
- 三个常用实现类:HashSet(快、无序)、LinkedHashSet(快、有序)、TreeSet(排序、稍慢)
- 选型看需求:去重选HashSet,有序去重选LinkedHashSet,排序去重选TreeSet
- 避坑点:自定义对象重写方法、TreeSet不存null、多线程注意安全、遍历不用索引
希望这篇文章能帮大家把Set集合彻底搞明白,下次开发的时候能准确选型,少踩坑!
更多推荐


所有评论(0)