Java Set的理解

一、Set 集合总览

在 Java 的集合框架中,Set 是一个重要的接口,它代表了一个不包含重复元素的集合。这种特性使得 Set 在许多场景下都有着独特的用途,比如去除重复数据、存储无序的唯一元素集合等。Set 接口有多个实现类,每个实现类都基于不同的数据结构,拥有各自的特点,本文将重点介绍 HashSet、LinkedHashSet、TreeSet 和 CopyOnWriteArraySet 这四个常用的实现类。

二、HashSet:高效无序去重

2.1 特性剖析

HashSet 是 Set 接口的典型实现类,它基于哈希表(实际上是 HashMap 实例)实现,具有以下显著特性:

  • 无序性:HashSet 中的元素没有特定的顺序,它们不是按照插入的顺序排列,也不会遵循任何自然的排序规则,每次遍历得到的元素顺序可能都不一样。
  • 不包含重复元素:这是 Set 接口的核心特性之一,HashSet 利用哈希算法和 equals 方法严格确保集合中不会出现重复的元素。当试图添加一个已经存在的元素时,添加操作将被忽略。
  • 允许 null 元素:HashSet 可以容纳一个 null 值作为集合中的元素,不过需要注意,只能有一个 null 元素存在。
  • 高效性能:基于哈希表的存储结构,使得 HashSet 在执行查找、插入和删除等操作时,具有非常高的效率,时间复杂度接近常数级别 O (1),在大数据量下优势尤为明显。

2.2 底层原理

HashSet 的底层哈希表结构本质上是一个数组加链表(在 Java 8 中,链表长度超过阈值 8 时会转换为红黑树以优化查询性能)的组合。当向 HashSet 中添加元素时,首先会调用元素的 hashCode () 方法获取哈希值,通过对哈希值进行一系列运算,确定元素在数组中的存储位置(桶索引)。若该桶为空,则直接将元素存入;若桶中已有元素,就会进一步调用 equals () 方法来判断元素是否真正相等。只有当两个元素的 hashCode 值相同且 equals 方法返回 true 时,才认定为重复元素,不进行添加。例如,有自定义类 Person,向 HashSet 添加多个 Person 对象时,需重写 hashCode 和 equals 方法,以确保根据对象的关键属性判断是否重复,避免因默认的对象内存地址比较方式导致错误的重复存储。

2.3 使用示例

以下是 HashSet 的一些常见操作示例:

java 复制代码
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
public class HashSetExample {
    public static void main(String[] args) {
        // 创建HashSet对象
        Set<String> hashSet = new HashSet<>();
        // 添加元素
        hashSet.add("apple");
        hashSet.add("banana");
        hashSet.add("orange");
        hashSet.add("apple"); // 重复元素,不会被添加
        // 删除元素
        hashSet.remove("banana");
        // 判断是否包含元素
        boolean contains = hashSet.contains("orange");
        System.out.println("是否包含orange:" + contains);
        // 获取元素个数
        int size = hashSet.size();
        System.out.println("元素个数:" + size);
        // 遍历HashSet
        System.out.println("使用迭代器遍历:");
        Iterator<String> iterator = hashSet.iterator();
        while (iterator.hasNext()) {
            String element = iterator.next();
            System.out.println(element);
        }
        System.out.println("使用增强for循环遍历:");
        for (String element : hashSet) {
            System.out.println(element);
        }
    }
}

在上述代码中,创建了一个 HashSet 并进行添加、删除、判断包含、获取大小以及遍历等操作。需要注意,遍历顺序是不确定的,因为 HashSet 不保证元素顺序。同时,在实际应用中,如果存储自定义对象,务必正确重写 hashCode 和 equals 方法,以保证 HashSet 对重复元素的准确判断。

三、LinkedHashSet:有序的 HashSet

3.1 独特特性

LinkedHashSet 是 HashSet 的子类,它继承了 HashSet 的所有特性,同时又额外具备一个显著特点:它使用链表维护元素的添加顺序。这使得 LinkedHashSet 在遍历集合时,元素的顺序与添加顺序保持一致,具有可预测的迭代次序。虽然在插入性能上略低于 HashSet,因为每次插入元素时需要额外维护链表的顺序,但在迭代访问全部元素时,性能表现良好,特别是当程序逻辑对元素顺序有要求时,LinkedHashSet 能很好地满足需求。

3.2 实现原理

LinkedHashSet 的底层数据结构是哈希表(数组 + 链表 / 红黑树)与双向链表的结合。哈希表用于保证元素的唯一性,其原理与 HashSet 相同,通过元素的 hashCode 值来确定存储位置,利用 equals 方法判断元素是否重复。而双向链表则用于记录元素的插入顺序,每当有新元素添加时,不仅将元素存入哈希表相应位置,还将其插入到链表的末尾,这样就完整地保存了元素的添加顺序。

3.3 代码演示

以下是 LinkedHashSet 的使用示例:

java 复制代码
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Set;
public class LinkedHashSetExample {
    public static void main(String[] args) {
        // 创建LinkedHashSet对象
        Set<String> linkedHashSet = new LinkedHashSet<>();
        // 添加元素
        linkedHashSet.add("apple");
        linkedHashSet.add("banana");
        linkedHashSet.add("orange");
        linkedHashSet.add("apple"); // 重复元素,不会被添加
        // 删除元素
        linkedHashSet.remove("banana");
        // 判断是否包含元素
        boolean contains = linkedHashSet.contains("orange");
        System.out.println("是否包含orange:" + contains);
        // 获取元素个数
        int size = linkedHashSet.size();
        System.out.println("元素个数:" + size);
        // 遍历LinkedHashSet,可观察到元素按添加顺序输出
        System.out.println("使用迭代器遍历:");
        Iterator<String> iterator = linkedHashSet.iterator();
        while (iterator.hasNext()) {
            String element = iterator.next();
            System.out.println(element);
        }
        System.out.println("使用增强for循环遍历:");
        for (String element : linkedHashSet) {
            System.out.println(element);
        }
    }
}

在上述代码中,创建 LinkedHashSet 并执行一系列操作,与 HashSet 不同的是,遍历 LinkedHashSet 时,元素顺序始终是 "apple""banana""orange",这体现了它有序的特性,在实际应用中,如需要记录操作日志集合且保证日志顺序与执行顺序一致时,LinkedHashSet 就非常实用。

四、TreeSet:自动排序的集合

4.1 显著特性

TreeSet 是一个基于红黑树(Red - Black Tree)数据结构实现的有序集合,它实现了 NavigableSet 接口和 SortedSet 接口。TreeSet 的最大特点就是其元素的有序性,与 HashSet 的无序和 LinkedHashSet 的添加顺序不同,TreeSet 中的元素会自动按照一定的顺序排列,要么是元素的自然顺序(若元素类实现了 Comparable 接口),要么是依据创建 TreeSet 时传入的 Comparator 比较器所指定的顺序。同时,它也严格遵循 Set 接口的规则,不允许存在重复元素,一旦试图添加重复元素,添加操作将失败。

4.2 排序规则

元素的排序规则主要通过以下两种方式确定:

  • 自然排序:当元素类实现了 java.lang.Comparable 接口时,TreeSet 会调用元素的 compareTo () 方法来比较元素之间的大小关系,进而按照升序排列元素。例如,对于 Integer、String 等 Java 内置类,它们已经实现了 Comparable 接口,所以可以直接放入 TreeSet 中进行自然排序。像存储一系列整数的 TreeSet,添加元素后,遍历输出会自动按照数字从小到大的顺序展示。
  • 定制排序:若元素类没有实现 Comparable 接口,或者需要自定义特殊的排序逻辑,就可以在创建 TreeSet 时传入一个实现了 java.util.Comparator 接口的比较器。这个比较器的 compare () 方法定义了具体的排序规则,通过返回值来表明两个元素的大小关系(返回值小于 0 表示第一个元素小于第二个元素,等于 0 表示两元素相等,大于 0 表示第一个元素大于第二个元素)。例如,要按照字符串的长度对一组字符串进行排序,就可以创建一个自定义的 Comparator 并传入 TreeSet 的构造函数。

TreeSet 的无参构造函数默认使用自然排序方式,如果元素无法比较(未实现 Comparable 接口且未提供 Comparator),在添加元素时会抛出 ClassCastException 异常。

4.3 操作实例

以下是 TreeSet 的一些操作示例:

java 复制代码
import java.util.Iterator;
import java.util.TreeSet;
public class TreeSetExample {
    public static void main(String[] args) {
        // 创建TreeSet对象,使用默认自然排序(元素需实现Comparable接口)
        TreeSet<Integer> treeSet = new TreeSet<>();
        // 添加元素
        treeSet.add(5);
        treeSet.add(3);
        treeSet.add(8);
        treeSet.add(1);
        treeSet.add(3); // 重复元素,不会被添加
        // 遍历TreeSet,元素将按升序输出
        System.out.println("使用迭代器遍历:");
        Iterator<Integer> iterator = treeSet.iterator();
        while (iterator.hasNext()) {
            Integer element = iterator.next();
            System.out.println(element);
        }
        System.out.println("使用增强for循环遍历:");
        for (Integer element : treeSet) {
            System.out.println(element);
        }
        // 判断是否包含元素
        boolean contains = treeSet.contains(5);
        System.out.println("是否包含5:" + contains);
        // 获取元素个数
        int size = treeSet.size();
        System.out.println("元素个数:" + size);
        // 删除元素
        treeSet.remove(3);
        // 获取集合中的第一个元素
        Integer first = treeSet.first();
        System.out.println("第一个元素:" + first);
        // 获取集合中的最后一个元素
        Integer last = treeSet.last();
        System.out.println("最后一个元素:" + last);
    }
}

在上述代码中,创建了一个存储整数的 TreeSet,元素自动按升序排列,展示了添加、遍历、判断包含、获取大小、删除以及获取首尾元素等操作。需注意,TreeSet 不是线程安全的,如果在多线程环境下并发操作,可能会导致数据不一致等问题,必要时需要通过外部同步手段(如使用 Collections.synchronizedSortedSet 方法对 TreeSet 进行包装)来保证线程安全。

五、CopyOnWriteArraySet:线程安全的选择

5.1 优势特性

CopyOnWriteArraySet 是 Java 并发包(java.util.concurrent)中的一个线程安全的 Set 实现类,它可以看作是线程安全的 HashSet。其具有以下突出特性:

  • 线程安全:这是它最重要的特性,在多线程环境下,多个线程可以同时对 CopyOnWriteArraySet 进行读操作,而无需额外的同步措施,写操作(如添加、删除元素)也能保证数据的一致性,不会出现数据污染、丢失等并发问题。这得益于其底层采用的写时复制(Copy-On-Write)策略。
  • 适用于读多写少场景:由于可变操作(写操作)需要复制整个底层数组,开销较大,而读操作不需要加锁,性能极高,所以当集合的读操作远远多于写操作时,CopyOnWriteArraySet 能展现出良好的性能表现,避免频繁的锁竞争带来的性能损耗。
  • 迭代器遍历快且无冲突:迭代器遍历集合时,依赖于创建迭代器那一刻的数组快照,后续即使集合被修改,迭代器也不受影响,不会抛出 ConcurrentModificationException 异常,能快速、稳定地遍历集合,不会与其他线程的写操作产生冲突。

5.2 实现机理

CopyOnWriteArraySet 的底层是通过 CopyOnWriteArrayList 来实现的。它包含一个 CopyOnWriteArrayList 类型的成员变量,利用 CopyOnWriteArrayList 已经实现的线程安全机制以及数组操作特性来构建自身功能。当执行添加元素操作时,调用 add (E e) 方法,实际上是委托给内部的 CopyOnWriteArrayList 的 addIfAbsent (E e) 方法,这个方法会先检查要添加的元素是否已存在于当前数组快照中,若不存在才执行添加。例如,在多线程环境下,多个线程同时尝试向 CopyOnWriteArraySet 添加元素,第一个获取锁的线程会复制当前数组,在副本上检查元素是否重复并添加,添加完成后将原数组引用指向新数组,后续线程重复此过程,确保不会添加重复元素,保证了 Set 的特性。

5.3 多线程场景示例

以下是一个多线程场景下使用 CopyOnWriteArraySet 的示例:

java 复制代码
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
public class CopyOnWriteArraySetExample {
    public static void main(String[] args) {
        // 创建CopyOnWriteArraySet对象
        Set<String> copyOnWriteArraySet = new CopyOnWriteArraySet<>();
        // 创建并启动多个线程对集合进行操作
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                copyOnWriteArraySet.add("Thread1-" + i);
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                copyOnWriteArraySet.add("Thread2-" + i);
            }
        });
        thread1.start();
        thread2.start();
        // 等待线程执行完毕
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 遍历集合
        Iterator<String> iterator = copyOnWriteArraySet.iterator();
        while (iterator.hasNext()) {
            String element = iterator.next();
            System.out.println(element);
        }
    }
}

与之对比,如果将上述代码中的 CopyOnWriteArraySet 换成 HashSet,在多线程并发添加元素并遍历的过程中,很容易出现 ConcurrentModificationException 异常,因为 HashSet 是非线程安全的,在遍历过程中若其他线程修改了集合结构,就会导致迭代器失效。而 CopyOnWriteArraySet 通过其独特的实现机制,保证了在多线程环境下的稳定运行,适用于如配置信息缓存、系统白名单等读多写少且对线程安全有要求的场景。

六、总结与选择建议

在 Java 的集合框架中,HashSet、LinkedHashSet、TreeSet 和 CopyOnWriteArraySet 各有千秋,它们适用于不同的业务场景:

  • HashSet:适用于对元素顺序没有要求,追求高效的添加、删除和查找操作,且数据量较大的场景。例如在大数据去重、缓存数据存储(不考虑数据顺序时)等场景下,HashSet 能够以出色的性能快速处理。像电商系统中对大量用户浏览过的商品 ID 进行去重,HashSet 可以快速过滤重复元素,节省存储空间。
  • LinkedHashSet:当需要保持元素的插入顺序,同时又希望利用 HashSet 的快速存取特性时,LinkedHashSet 是不二之选。在一些需要记录操作顺序的日志系统、缓存淘汰策略实现(按照插入顺序淘汰)等方面,它能在保证有序的前提下提供高效操作。比如记录用户操作的历史记录集合,按操作发生顺序存储,方便后续回溯查看。
  • TreeSet:如果业务场景对元素的排序有严格要求,无论是自然排序还是定制排序,TreeSet 都能满足。在数据统计分析场景,如对学生成绩排序、员工工资排序等,或者实现范围查询(利用 TreeSet 的导航方法)时,TreeSet 的有序特性使其能够轻松应对。例如,查找成绩排名前 10% 的学生信息,TreeSet 可以快速定位到对应的元素范围。
  • CopyOnWriteArraySet:专为多线程环境下读多写少的场景设计,它能保证线程安全,避免并发修改异常,同时在读操作频繁的场景下性能卓越。像系统中的全局配置信息缓存,多个线程可能随时读取配置,偶尔修改配置,使用 CopyOnWriteArraySet 既能保证数据一致性,又不会因频繁加锁影响读性能。

总之,在实际编程中,需要根据具体的业务需求、数据特点以及并发场景,综合考虑选择最合适的 Set 实现类,以充分发挥 Java 集合框架的优势,提升程序的性能与稳定性。

相关推荐
Lee川20 小时前
从异步迷雾到优雅流程:JavaScript异步编程与内存管理的现代化之旅
javascript·面试
晴殇i1 天前
揭秘JavaScript中那些“不冒泡”的DOM事件
前端·javascript·面试
绝无仅有1 天前
Redis过期删除与内存淘汰策略详解
后端·面试·架构
绝无仅有1 天前
Redis大Key问题排查与解决方案全解析
后端·面试·架构
AAA梅狸猫1 天前
Looper.loop() 循环机制
面试
AAA梅狸猫1 天前
Handler基本概念
面试
Wect1 天前
浏览器缓存机制
前端·面试·浏览器
掘金安东尼1 天前
Fun with TypeScript Generics:玩转 TS 泛型
前端·javascript·面试
掘金安东尼1 天前
Next.js 企业级落地
前端·javascript·面试
掘金安东尼1 天前
React 性能优化完全指南 2026
前端·javascript·面试