在 Java 编程中,Set集合是处理唯一元素的重要工具。本文将深入探讨Set接口及其常见实现类的使用、区别以及如何在多线程环境下安全操作。
一、Set接口
Set接口代表一个无序的集合,它不允许重复元素。具体来说,Set存储一组唯一且无序的对象(尝试添加重复元素时,add方法会返回false)。它不保证迭代顺序的恒定性,但某些实现类如TreeSet会按元素排序,LinkedHashSet会按插入顺序排序。Set接口允许一个null元素,但注意TreeSet等实现可能禁止null。
Set接口继承自Collection,其典型实现包括HashSet、LinkedHashSet和TreeSet。
二、HashSet实现类
HashSet基于哈希表(实际是HashMap)实现,利用哈希算法存储元素。它不保证元素顺序,但提供了优异的存取和查找性能,允许null元素,并且在多线程环境下是非线程安全的。
当向HashSet添加元素时,它会调用对象的hashCode()方法获取哈希值,并据此决定存储位置。如果两个元素通过equals()比较为true,但hashCode()返回值不同,HashSet会将它们存储在不同位置,仍可添加成功。换言之,HashSet判断元素相等的标准是:equals()返回true且hashCode()返回值相等。这依赖于元素正确重写hashCode()和equals()方法以确保唯一性。
那么,如果不重写这些方法会发生什么?hashCode()是Object类的方法,默认返回对象在内存中的唯一标识。如果只重写hashCode(),当向HashSet添加多个属性相同的对象时,可能无法达到去重效果。同样,如果只重写equals()方法(默认比较对象地址),也无法实现完全去重。只有同时重写hashCode()和equals()方法,HashSet才能正确去重,这是 算法/数据结构 中对象比较的基础。
以下是一个代码示例,展示如何通过重写这两个方法来实现Student对象的去重:
public class Student {
private String name;
private int age;
public Student() {
}
public Student(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
//判断两个对象是否相等,对象是否存在,对象的name和age是否相等
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return age == student.age &&
Objects.equals(name, student.name);
}
//返回对象的name和age的hash值
@Override
public int hashCode() {
return Objects.hash(name, age);
}
public static void main(String[] args) {
Student s1 = new Student("刘亦菲", 23);
Student s2 = new Student("杨幂", 24);
Student s3 = new Student("刘亦菲", 23);
Student s4 = new Student("邓紫棋", 25);
Student s5 = new Student("刘诗诗", 29);
Student s6 = new Student("刘诗诗", 30);
HashSet<Student> hashSet = new HashSet<>();
hashSet.add(s1);
hashSet.add(s2);
hashSet.add(s3);
hashSet.add(s4);
hashSet.add(s5);
hashSet.add(s6);
for (Student student : hashSet) {
System.out.println(student.getName()+"=="+student.getAge());
}
}
}
需要注意的是,向HashSet添加可变对象时必须小心。如果修改了集合中的对象,可能导致该对象与其他对象相等,从而影响HashSet的正常访问。
三、LinkedHashSet实现类
LinkedHashSet继承自HashSet。它同样根据元素的hashCode值决定存储位置,但额外使用链表维护元素的插入次序。这使得遍历LinkedHashSet时,元素会按照添加顺序被访问。输出元素时,顺序总是与添加顺序一致。它同样不允许重复元素,允许null,并且在多线程下是非线程安全的。
以下是LinkedHashSet的示例,使用与前文相同的Student类:
public static void main(String[] args) {
Student s1 = new Student("刘亦菲", 23);
Student s2 = new Student("杨幂", 24);
Student s3 = new Student("刘亦菲", 23);
Student s4 = new Student("邓紫棋", 25);
Student s5 = new Student("刘诗诗", 29);
Student s6 = new Student("刘诗诗", 30);
LinkedHashSet<Student> hashSet = new LinkedHashSet<>();
hashSet.add(s1);
hashSet.add(s2);
hashSet.add(s3);
hashSet.add(s4);
hashSet.add(s5);
hashSet.add(s6);
for (Student student : hashSet) {
System.out.println(student.getName()+"=="+student.getAge());
}
}
四、TreeSet实现类
TreeSet基于TreeMap实现,使用红黑树结构存储元素。它实现了NavigableSet接口,元素会按照自然顺序或指定的比较器(Comparator)进行排序。TreeSet不允许null元素(因为需要比较大小),并且在多线程下是非线程安全的。
向TreeSet添加元素时,它会调用对象的compareTo()方法(或比较器)与容器中其他元素比较大小,然后在红黑树中找到合适位置。如果两个元素相等,则新元素无法加入。元素必须实现Comparable接口,或在构造TreeSet时传入Comparator,否则会抛出ClassCastException。排序规则在使用过程中不能改变,否则会导致集合状态混乱。
1. 元素实现Comparable接口
public class Student implements Comparable<Student>{
private String name;
private int age;
public Student() {
}
public Student(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public int compareTo(Student o) {
//比较age
int num = this.age - o.age;
//如果age相等则比较name长度
int num1 = num == 0 ? this.name.length() - o.name.length() : num;
//如果前两者都相等则比较name字符串
int num2 = num1 == 0 ? this.name.compareTo(o.name) : num1;
return num2;
}
public static void main(String[] args) {
Student s1 = new Student("刘亦菲", 23);
Student s2 = new Student("杨幂", 24);
Student s3 = new Student("刘亦菲", 23);
Student s4 = new Student("邓紫棋", 25);
Student s5 = new Student("刘诗诗", 29);
Student s6 = new Student("刘诗诗", 30);
Student s7 = new Student("刘试试", 30);
TreeSet<Student> hashSet = new TreeSet<>();
hashSet.add(s1);
hashSet.add(s2);
hashSet.add(s3);
hashSet.add(s4);
hashSet.add(s5);
hashSet.add(s6);
hashSet.add(s7);
for (Student student : hashSet) {
System.out.println(student.getName()+"=="+student.getAge());
}
}
}
2. 构造TreeSet时传入Comparator
public class Student {
// ... 属性、getter、setter等同上,无需实现Comparable
public static void main(String[] args) {
Student s1 = new Student("刘亦菲", 23);
Student s2 = new Student("杨幂", 24);
Student s3 = new Student("刘亦菲", 23);
Student s4 = new Student("邓紫棋", 25);
Student s5 = new Student("刘诗诗", 29);
Student s6 = new Student("刘诗诗", 30);
Student s7 = new Student("刘试试", 30);
TreeSet<Student> hashSet = new TreeSet<>(new Comparator<Student>() {
@Override
public int compare(Student o1, Student o2) {
// 比较age
int num = o1.age - o2.age;
//如果age相等则比较name长度
int num1 = num == 0 ? o1.name.length() - o2.name.length() : num;
//如果前两者都相等则比较name字符串
int num2 = num1 == 0 ? o1.name.compareTo(o2.name) : num1;
return num2;
}
});
hashSet.add(s1);
hashSet.add(s2);
hashSet.add(s3);
hashSet.add(s4);
hashSet.add(s5);
hashSet.add(s6);
hashSet.add(s7);
for (Student student : hashSet) {
System.out.println(student.getName()+"=="+student.getAge());
}
}
}
3. TreeSet的NavigableSet接口方法示例
TreeSet实现了NavigableSet接口,提供了一系列范围查询和操作:
public static void main(String[] args) {
TreeSet<Integer> set = new TreeSet<>(Arrays.asList(1, 3, 5, 7, 9));
// 范围查询
System.out.println("First: " + set.first()); // 1
System.out.println("Last: " + set.last()); // 9
System.out.println("Ceiling(4): " + set.ceiling(4)); // 5(>=4的最小元素)
System.out.println("Floor(4): " + set.floor(4)); // 3(<=4的最大元素)
System.out.println("Higher(4): " + set.higher(4)); // 5(>4的最小元素)
System.out.println("Lower(4): " + set.lower(4)); // 3(<4的最大元素)
// 子集操作
System.out.println("HeadSet(5): " + set.headSet(5)); // [1, 3] (<5)
System.out.println("HeadSet(5, true): " + set.headSet(5, true)); // [1, 3, 5] (<=5)
System.out.println("TailSet(5): " + set.tailSet(5)); // [5, 7, 9] (>=5)
System.out.println("SubSet(3, 7): " + set.subSet(3, 7)); // [3, 5] (>=3, <7)
// 获取和移除
System.out.println("PollFirst: " + set.pollFirst()); // 移除并返回第一个元素 1
System.out.println("PollLast: " + set.pollLast()); // 移除并返回最后一个元素 9
}
五、EnumSet实现类
EnumSet是专为枚举类型设计的集合类,所有元素都必须是指定枚举类型的值。EnumSet元素有序(按枚举定义顺序),内部以位向量形式存储,内存占用小且效率高。它不允许加入null元素。
// EnumSet 专为枚举类型设计
enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}
public class EnumSetDemo{
public static void main(String[] args) {
// 创建 EnumSet
EnumSet<Day> weekdays = EnumSet.allOf(Day.class); // 所有枚举值
EnumSet<Day> weekend = EnumSet.of(Day.SATURDAY, Day.SUNDAY);
EnumSet<Day> workdays = EnumSet.range(Day.MONDAY, Day.FRIDAY);
EnumSet<Day> empty = EnumSet.noneOf(Day.class);
// 添加元素
weekend.add(Day.FRIDAY); // 现在包含周五、周六、周日
// 删除元素
weekdays.remove(Day.SUNDAY);
// 集合运算
EnumSet<Day> allDays = EnumSet.allOf(Day.class);
EnumSet<Day> weekendDays = EnumSet.of(Day.SATURDAY, Day.SUNDAY);
EnumSet<Day> workDays = EnumSet.complementOf(weekendDays); // 补集
// 交集、并集、差集
EnumSet<Day> set1 = EnumSet.of(Day.MONDAY, Day.TUESDAY);
EnumSet<Day> set2 = EnumSet.of(Day.TUESDAY, Day.WEDNESDAY);
set1.addAll(set2); // 并集
set1.retainAll(set2); // 交集
set1.removeAll(set2); // 差集
// 遍历
for (Day day : weekendDays) {
System.out.println(day);
}
}
}
六、线程安全的Set实现
HashSet、LinkedHashSet和TreeSet都是线程不安全的。为什么?因为它们的添加元素方法底层调用了非线程安全的Map操作(如HashMap.put)。在多线程环境下,我们需要采取额外措施来保证线程安全。
1. 使用Collections.synchronizedSet
Collections.synchronizedSet(Set<T> s)方法可以返回一个线程安全的Set包装器,但它在所有操作(读和写)上都进行同步加锁,在高并发场景下可能成为性能瓶颈。
// Collections.synchronizedSet 源码示例
public boolean add(E e) {
synchronized (mutex) {return c.add(e);}
}
public boolean remove(Object o) {
synchronized (mutex) {return c.remove(o);}
}
// ... 其他方法类似,均使用 synchronized 块
2. 使用CopyOnWriteArraySet
CopyOnWriteArraySet基于CopyOnWriteArrayList实现,适用于读多写少的并发场景。它在写入时复制底层数组,因此读取操作完全无锁,性能极高。
// CopyOnWriteArraySet 构造方法源码
public CopyOnWriteArraySet() {
al = new CopyOnWriteArrayList<E>();
}
总结与选择建议
Set是Java集合框架的核心组件,合理选择能提升代码可读性和性能。以下是一些实用建议:
- 快速查找,不关心顺序:使用
HashSet。
- 需要维护插入顺序:使用
LinkedHashSet。
- 需要元素排序:使用
TreeSet。
- 枚举类型集合:使用
EnumSet以获得最佳性能。
- 多线程环境:
- 读多写少:考虑
CopyOnWriteArraySet。
- 通用场景:使用
Collections.synchronizedSet包装,或考虑ConcurrentHashMap.newKeySet()。
掌握这些Set实现类的特性和适用场景,能帮助你在实际开发中做出更优的选择。希望本文能为你理清Java Set集合的使用思路。想了解更多技术干货和实战经验,欢迎访问云栈社区与广大开发者交流探讨。