今天咱们来聊聊集合里的“高冷选手”——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集合彻底搞明白,下次开发的时候能准确选型,少踩坑!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐