MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java HashSet与其他Set集合的性能对比分析

2021-11-065.6k 阅读

Java HashSet简介

在Java集合框架中,HashSet是一个非常常用的实现了Set接口的类。HashSet基于HashMap来实现,它不允许集合中存在重复元素,并且其内部元素的存储顺序是无序的。

HashSet的主要特点包括:

  1. 唯一性:确保集合中每个元素都是唯一的,当试图添加一个已经存在于集合中的元素时,add方法会返回false,并且集合的大小不会改变。
  2. 无序性HashSet并不保证元素的迭代顺序,这意味着每次迭代HashSet时,元素的顺序可能会不同。这是因为HashSet是基于哈希表实现的,元素的存储位置取决于其哈希值。

下面是一个简单的HashSet使用示例:

import java.util.HashSet;
import java.util.Set;

public class HashSetExample {
    public static void main(String[] args) {
        Set<String> set = new HashSet<>();
        set.add("apple");
        set.add("banana");
        set.add("cherry");
        set.add("apple");// 尝试添加重复元素

        System.out.println(set.size());// 输出集合大小
        for (String s : set) {
            System.out.println(s);
        }
    }
}

在上述代码中,我们创建了一个HashSet并添加了几个字符串元素,其中"apple"是重复添加的。最终输出集合大小为3,并且在迭代集合时,元素的顺序是不确定的。

Java Set集合体系概述

在深入对比HashSet与其他Set集合的性能之前,我们先来了解一下Java中Set集合体系的整体结构。

Set接口是Java集合框架的一部分,它继承自Collection接口,主要用于存储不重复的元素。Set接口有几个重要的实现类,除了HashSet外,还包括TreeSetLinkedHashSet

  1. HashSet:如前文所述,基于哈希表实现,提供了快速的插入、删除和查找操作。它允许null元素,并且是非线程安全的。
  2. TreeSet:基于红黑树实现,保证集合中的元素是有序的(默认按照自然顺序排序,也可以通过Comparator指定排序规则)。由于其基于树结构,插入、删除和查找操作的时间复杂度为O(log n),相比HashSet的O(1)(平均情况)在性能上会稍逊一筹,但在需要有序遍历的场景下有优势。TreeSet不允许null元素,并且也是非线程安全的。
  3. LinkedHashSet:继承自HashSet,同时维护了一个双向链表来记录元素的插入顺序或访问顺序(可以通过构造函数指定)。它既具有HashSet的快速查找性能,又能保持元素的插入顺序或访问顺序。插入和删除操作的性能略低于HashSet,因为需要额外维护链表结构,但在需要按特定顺序遍历元素时非常有用。LinkedHashSet允许null元素,并且是非线程安全的。

了解了这些不同Set实现类的基本特点后,我们可以更有针对性地对它们进行性能对比分析。

插入操作性能对比

  1. HashSet的插入操作 HashSet的插入操作通过add方法实现。当调用add方法时,HashSet首先计算要插入元素的哈希值,然后根据哈希值确定元素在哈希表中的存储位置。如果该位置为空,则直接将元素插入;如果该位置已经存在元素,则会调用元素的equals方法来判断是否为重复元素。如果是重复元素,则不插入并返回false;否则,将元素插入到该位置的链表(在哈希冲突的情况下,使用链地址法解决冲突)中。

由于哈希表的特性,在理想情况下(哈希分布均匀,冲突较少),HashSet的插入操作平均时间复杂度为O(1)。但是,如果哈希冲突严重,链表长度变长,插入操作的时间复杂度可能会接近O(n)。

以下是一个简单的性能测试代码,用于对比HashSet的插入性能:

import java.util.HashSet;
import java.util.Set;

public class HashSetInsertPerformanceTest {
    public static void main(String[] args) {
        Set<Integer> set = new HashSet<>();
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            set.add(i);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("HashSet插入100万个元素耗时:" + (endTime - startTime) + " 毫秒");
    }
}
  1. TreeSet的插入操作 TreeSet的插入操作通过add方法实现。由于TreeSet基于红黑树结构,插入元素时,首先要找到合适的插入位置以维护树的有序性。这个查找位置的过程类似于二叉搜索树的插入操作,时间复杂度为O(log n)。找到位置后,将新元素插入并调整红黑树的结构以保持平衡。

因此,TreeSet的插入操作时间复杂度始终为O(log n),相比HashSet在平均情况下的O(1)性能稍差,特别是在数据量较大时,这种差距会更加明显。

下面是TreeSet插入性能测试代码:

import java.util.TreeSet;
import java.util.Set;

public class TreeSetInsertPerformanceTest {
    public static void main(String[] args) {
        Set<Integer> set = new TreeSet<>();
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            set.add(i);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("TreeSet插入100万个元素耗时:" + (endTime - startTime) + " 毫秒");
    }
}
  1. LinkedHashSet的插入操作 LinkedHashSet继承自HashSet,其插入操作基本与HashSet相同,也是基于哈希表来确定元素的存储位置。但是,LinkedHashSet在插入元素时,还需要维护双向链表,将新插入的元素添加到链表的尾部(如果是按照插入顺序)。

由于额外的链表维护操作,LinkedHashSet的插入操作性能略低于HashSet,但仍然比TreeSet在平均情况下要快。在理想情况下,LinkedHashSet的插入操作平均时间复杂度为O(1),但实际性能会因为链表维护操作而稍有下降。

以下是LinkedHashSet插入性能测试代码:

import java.util.LinkedHashSet;
import java.util.Set;

public class LinkedHashSetInsertPerformanceTest {
    public static void main(String[] args) {
        Set<Integer> set = new LinkedHashSet<>();
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            set.add(i);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("LinkedHashSet插入100万个元素耗时:" + (endTime - startTime) + " 毫秒");
    }
}

通过运行上述三组代码,我们可以得到在相同数据量下,不同Set集合插入操作的大致耗时,从而直观地对比它们的插入性能。在实际运行中,由于计算机环境和JVM优化等因素的影响,具体的时间数值可能会有所不同,但整体的性能趋势是符合理论分析的。

删除操作性能对比

  1. HashSet的删除操作 HashSet的删除操作通过remove方法实现。当调用remove方法时,HashSet首先计算要删除元素的哈希值,根据哈希值找到对应的哈希桶。然后在该哈希桶的链表中查找要删除的元素,如果找到则将其从链表中移除。在理想情况下,哈希分布均匀时,删除操作的平均时间复杂度为O(1)。但如果哈希冲突严重,链表较长,删除操作的时间复杂度可能会接近O(n)。

下面是HashSet删除性能测试代码:

import java.util.HashSet;
import java.util.Set;

public class HashSetRemovePerformanceTest {
    public static void main(String[] args) {
        Set<Integer> set = new HashSet<>();
        for (int i = 0; i < 1000000; i++) {
            set.add(i);
        }
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 500000; i++) {
            set.remove(i);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("HashSet删除50万个元素耗时:" + (endTime - startTime) + " 毫秒");
    }
}
  1. TreeSet的删除操作 TreeSet的删除操作同样通过remove方法实现。在TreeSet中删除元素时,首先要在红黑树中找到要删除的元素,这个查找过程的时间复杂度为O(log n)。找到元素后,将其从树中删除,并调整红黑树的结构以保持平衡。所以,TreeSet的删除操作时间复杂度始终为O(log n)。

以下是TreeSet删除性能测试代码:

import java.util.TreeSet;
import java.util.Set;

public class TreeSetRemovePerformanceTest {
    public static void main(String[] args) {
        Set<Integer> set = new TreeSet<>();
        for (int i = 0; i < 1000000; i++) {
            set.add(i);
        }
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 500000; i++) {
            set.remove(i);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("TreeSet删除50万个元素耗时:" + (endTime - startTime) + " 毫秒");
    }
}
  1. LinkedHashSet的删除操作 LinkedHashSet的删除操作与HashSet类似,基于哈希表找到要删除的元素。但由于LinkedHashSet维护了双向链表,在删除元素时,还需要将元素从链表中移除,以保证链表结构的完整性。虽然额外的链表操作会带来一定的性能开销,但在平均情况下,其删除操作的时间复杂度仍然接近O(1),略低于TreeSet的O(log n)。

下面是LinkedHashSet删除性能测试代码:

import java.util.LinkedHashSet;
import java.util.Set;

public class LinkedHashSetRemovePerformanceTest {
    public static void main(String[] args) {
        Set<Integer> set = new LinkedHashSet<>();
        for (int i = 0; i < 1000000; i++) {
            set.add(i);
        }
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 500000; i++) {
            set.remove(i);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("LinkedHashSet删除50万个元素耗时:" + (endTime - startTime) + " 毫秒");
    }
}

通过运行上述三组删除性能测试代码,可以对比出不同Set集合在删除操作上的性能差异。同样,实际运行结果会受到多种因素影响,但从理论和测试结果的整体趋势来看,HashSetLinkedHashSet在平均情况下的删除性能优于TreeSet

查找操作性能对比

  1. HashSet的查找操作 HashSet的查找操作通过contains方法实现。当调用contains方法时,HashSet首先计算要查找元素的哈希值,根据哈希值找到对应的哈希桶。然后在该哈希桶的链表中查找要查找的元素,调用元素的equals方法进行比较。在理想情况下,哈希分布均匀时,查找操作的平均时间复杂度为O(1)。但如果哈希冲突严重,链表较长,查找操作的时间复杂度可能会接近O(n)。

以下是HashSet查找性能测试代码:

import java.util.HashSet;
import java.util.Set;

public class HashSetContainsPerformanceTest {
    public static void main(String[] args) {
        Set<Integer> set = new HashSet<>();
        for (int i = 0; i < 1000000; i++) {
            set.add(i);
        }
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 500000; i++) {
            set.contains(i);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("HashSet查找50万个元素耗时:" + (endTime - startTime) + " 毫秒");
    }
}
  1. TreeSet的查找操作 TreeSet的查找操作通过contains方法实现。在TreeSet中查找元素时,由于其基于红黑树结构,查找过程类似于二叉搜索树的查找操作,时间复杂度为O(log n)。通过比较元素的大小(按照自然顺序或指定的Comparator),逐步缩小查找范围,直到找到目标元素或确定元素不存在。

以下是TreeSet查找性能测试代码:

import java.util.TreeSet;
import java.util.Set;

public class TreeSetContainsPerformanceTest {
    public static void main(String[] args) {
        Set<Integer> set = new TreeSet<>();
        for (int i = 0; i < 1000000; i++) {
            set.add(i);
        }
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 500000; i++) {
            set.contains(i);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("TreeSet查找50万个元素耗时:" + (endTime - startTime) + " 毫秒");
    }
}
  1. LinkedHashSet的查找操作 LinkedHashSet的查找操作与HashSet基本相同,也是基于哈希表进行查找。因为LinkedHashSet继承自HashSet,所以在查找性能上,平均情况下时间复杂度也为O(1)。虽然维护链表结构会带来一定的空间开销,但对查找操作的性能影响较小。

以下是LinkedHashSet查找性能测试代码:

import java.util.LinkedHashSet;
import java.util.Set;

public class LinkedHashSetContainsPerformanceTest {
    public static void main(String[] args) {
        Set<Integer> set = new LinkedHashSet<>();
        for (int i = 0; i < 1000000; i++) {
            set.add(i);
        }
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 500000; i++) {
            set.contains(i);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("LinkedHashSet查找50万个元素耗时:" + (endTime - startTime) + " 毫秒");
    }
}

通过运行上述三组查找性能测试代码,可以直观地看到在相同数据量下,HashSetLinkedHashSet的查找性能在平均情况下优于TreeSetHashSetLinkedHashSet利用哈希表的快速定位特性,能够更快地找到目标元素,而TreeSet由于基于树结构的查找方式,时间复杂度相对较高。

内存占用对比

  1. HashSet的内存占用 HashSet基于HashMap实现,其内存占用主要包括哈希表本身以及存储元素所占用的空间。哈希表是一个数组,数组的每个元素是一个链表(在哈希冲突的情况下)。哈希表的大小会根据元素的数量动态调整,当元素数量达到一定阈值(负载因子)时,会进行扩容操作。

此外,每个存储在HashSet中的元素都需要占用一定的内存空间。由于HashSet允许null元素,并且在处理哈希冲突时使用链表结构,所以在哈希冲突严重的情况下,链表会占用额外的内存空间。

  1. TreeSet的内存占用 TreeSet基于红黑树实现,其内存占用主要来自红黑树的节点。每个红黑树节点除了存储元素本身外,还需要存储指向左右子节点和父节点的引用,以及表示节点颜色的信息。相比哈希表结构,红黑树的节点结构相对复杂,因此在存储相同数量元素的情况下,TreeSet通常会占用更多的内存空间。

另外,由于TreeSet不允许null元素,在这方面与HashSet有所不同。

  1. LinkedHashSet的内存占用 LinkedHashSet继承自HashSet,在内存占用上除了包含HashSet的哈希表和元素本身占用的空间外,还需要额外维护双向链表。双向链表的每个节点需要存储前驱和后继节点的引用,这会增加一定的内存开销。因此,LinkedHashSet的内存占用通常会比HashSet略高,但比TreeSet在某些情况下可能会低,特别是当数据量较大且哈希冲突较少时。

线程安全与并发性能

  1. HashSet的线程安全与并发性能 HashSet本身是非线程安全的,在多线程环境下,如果多个线程同时对HashSet进行操作(如插入、删除或查找),可能会导致数据不一致或其他并发问题。为了在多线程环境中安全使用HashSet,可以使用Collections.synchronizedSet方法来创建一个线程安全的Set包装类。
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

public class SynchronizedHashSetExample {
    public static void main(String[] args) {
        Set<String> set = new HashSet<>();
        Set<String> synchronizedSet = Collections.synchronizedSet(set);
    }
}

然而,这种方式虽然保证了线程安全,但由于使用了同步锁,在高并发环境下会严重影响性能,因为所有对集合的操作都需要获取锁,导致线程之间的竞争加剧。

  1. TreeSet的线程安全与并发性能 TreeSet同样是非线程安全的,在多线程环境下使用时也需要采取额外的同步措施。与HashSet类似,可以使用Collections.synchronizedSet方法来创建线程安全的包装类。但同样,这种同步方式在高并发场景下性能较差。

  2. LinkedHashSet的线程安全与并发性能 LinkedHashSet也是非线程安全的,在多线程环境下使用时需要同步。使用Collections.synchronizedSet方法创建的线程安全包装类在高并发场景下会因为锁竞争而导致性能下降。

在Java并发包中,ConcurrentSkipListSet提供了一种线程安全且适用于高并发的Set实现。它基于跳表数据结构,在插入、删除和查找操作上具有较好的并发性能,相比使用同步锁的方式,能够在高并发环境下提供更高的吞吐量。

适用场景分析

  1. HashSet适用场景
    • 快速查找和插入删除:当需要快速进行元素的插入、删除和查找操作,并且对元素顺序没有要求时,HashSet是一个很好的选择。例如,在缓存系统中,需要快速判断某个数据是否已经在缓存中,或者在数据去重场景中,HashSet能够高效地完成任务。
    • 大量数据处理:由于其平均O(1)的操作时间复杂度,在处理大量数据时,HashSet能够保持较好的性能,只要哈希冲突能够得到合理控制。
  2. TreeSet适用场景
    • 有序遍历:当需要对集合中的元素进行有序遍历,无论是按照自然顺序还是自定义顺序,TreeSet是首选。例如,在排行榜系统中,需要按照分数对用户进行排序并展示,TreeSet可以满足这种需求。
    • 范围查询TreeSet提供了一些支持范围查询的方法,如subSetheadSettailSet,这在需要进行范围查找的场景下非常有用,例如在统计某个分数段的学生人数时。
  3. LinkedHashSet适用场景
    • 保持插入顺序:当需要在保持元素唯一性的同时,维护元素的插入顺序时,LinkedHashSet是最佳选择。例如,在记录用户操作历史时,既需要保证操作记录不重复,又要按照操作发生的顺序进行存储和展示,LinkedHashSet就能够很好地满足需求。
    • LRU缓存LinkedHashSet可以通过设置访问顺序来实现简单的LRU(最近最少使用)缓存。当访问一个元素时,该元素会被移动到链表尾部,当缓存满时,链表头部的元素(即最近最少使用的元素)可以被移除。

通过对HashSet与其他Set集合在插入、删除、查找操作性能、内存占用、线程安全以及适用场景等方面的对比分析,开发者可以根据具体的业务需求和性能要求,选择最合适的Set集合实现类,从而优化程序的性能和资源利用效率。在实际应用中,还需要结合具体的数据集规模、数据分布特点以及并发访问情况等因素进行综合考虑和测试,以确保选择的集合类型能够满足系统的性能和功能需求。