java集合简单分类

在Java中,集合分为单列集合和双列集合。很多人会下意识地把单列集合和List画等号,把双列集合和Map画等号,还有人说集合分为:列表(List)、集(Set)、映射(Map),等等,这些观点也不能否定,每个人在使用的过程中都会产生自己独特的见地。

集合的顶层父接口

Conllection和Map

        集合有很多种分类,我们可以从继承关系开始来研究。所有集合都在java.util包下面,在Java的集合框架中,Collection 和 Map 是两个并列的顶层接口,翻看源码我们可以看到Map和Conllection接口没有继承其他集合接口了。

Iterable接口

        我们发现这个Collection接口还继承了Iterable接口,但是这个Iterable接口不是java.util包下的,而是java.lang包下的,它并不是集合接口。

        当一个类实现了Iterable接口后,它的对象就可以直接在for-each循环中使用,而无需显式地创建迭代器。for-each循环会自动调用iterator()方法来获取迭代器,并使用迭代器来遍历集合中的元素。

所以实现了Conllection接口的子类的对象可以享受以上便利。

        有人认为Conllection接口和Map接口都继承了Iterable接口,但是从刚刚的源码截图可以看出,Map并没有继承Iterable接口。

  Map接口的设计初衷是存储键值对(key-value pairs),而不是像Collection接口那样存储单个元素。虽然Map接口本身没有继承Iterable接口,但Java为Map接口提供了三个返回不同视图(key set, value collection, entry set)的方法,这些视图都是Iterable的。

所以Map的子类接口的实现类的对象也是可以被迭代的。

集合的实现类

         Conllection接口是所有单列集合的顶层父接口,Map接口是所有双列集合(存储键值对)的顶层父接口。(下图是根据jdk1.8进行分类的)

单列集合(Conllection)

Collection 接口的子接口包括:

1.List(列表):有序集合,允许重复元素。
   
2. Set(集):无序集合,不允许重复元素。
   
3. Queue(队列):一种特殊的集合,按照特定规则进行元素的插入和删除。

List

        List接口下的常见实现类有四个:ArrayList、LinkedList、Vector、Stack。

        其中Stack类直接继承于Vector类,而不直接实现于List接口,LinkedList不仅实现了List接口,还实现了Deque(双端队列)接口,所以LinkedList既有List集合的特性,又有Queue集合的特性,因为它同时实现了两个父接口的抽象方法。

ArrayList

        如果不想看原理,只需要各个实现类的特点总结,可以直接拉到文末。

        ArrayList的底层是采用数组这种数据结构来实现的,因此ArrayList元素查询速度快、增删相对较慢。我们在开发过程中,数据一般都是"读多写少",因此ArrayList非常常用。

        集合的所有实现类基本上都是基于八大数据结构的思想去实现的,不同的数据结构有不同的特性,主要体现在增删和查询的速度和性能的开销上,八大数据结构各有优缺点,如果不了解什么是八大数据结构,可以参考:八大基本数据结构-CSDN博客

        言归正传,说ArrayList的底层是数组可能有些抽象,具体来说,ArrayList类中有很多方法,它操作管理的对象是数组。众所周知,数组是一个基本单元,一旦创建,大小便不可变,比如我们创建了一个大小为10空间的数组,我们可以往里面存储最多10个元素,但突然我们发现需要存第11个元素,这个数组已经存不下了,怎么办呢?你可能会说,创建一个新的数组不就好了吗,很对,我们只需要创建一个新的,比之前更大的数组,比如15大小的数组,把之间那10个元素复制过来,再把之前那个数组删掉,这样这个新的数组还剩下五个空间,就可以装下第11个元素了,也不会改变原来那10个元素的顺序。而这,也正是ArrayList里面的方法做的事情,这也是他的自动扩容原理,当然它不只这一个方法,它还提供了其他方法来管理数组,方便你对该数组进行删除元素、遍历等操作,具体情况自己用过了就知道。有人可能会想到一个问题:扩容时新创建一个数组时删掉并复制原来的数组会消耗性能,且拖慢执行速度,为什么不直接写一个方法,管理多个数组的内存地址值呢,这样就不需要每次1.5倍扩容且复制并删除原来的数组了,在数组长度高达百万以上级别的时候可以大大提高执行效率。这个想法很有道理,但是我们需要注意的是,对于数组而言,增删本就不是强项,它主打的就是一个查询快,ArrayList也一样,主打的特色就是查询快,所以必须保证只管理一个数组的内存地址值,不能强求增删的速度。其他的集合也是一样,在增删和查询的效率之间做取舍。

扩容原理:ArrayList默认初始化的数组大小容量为10,当存储的元素超出数组大小时,按照1.5倍进行数组扩容。

        需要注意的是,在创建ArrayList对象的时候,并没有指定数组的大小,只有在添加第一个元素的时候,它才会创建一个大小为10的数组。

LinkedList

        与ArrayList查询快,增删慢恰恰相反,LinkedList查询较慢,增删较快。因为ArrayList做增删可能需要重构数组,而LinkedList做增删只需要改变某个节点对下一个节点的指针即可。感兴趣可以看一下:为什么数组查询比链表要快_数组查找效率为什么高-CSDN博客

         LinkedList是对链表这种数据结构的一种实现和增强。我们知道,LinkedList既实现了List接口,又实现了Deque(双端队列)接口,因此LinkedList除了具备List体系的方法外,还提供有Queue集合体系的方法。LinkedList底层实现原理如下:

  1. 节点(Node)

    • LinkedList 由一系列节点组成,每个节点至少包含两个信息:数据(存储实际值)和指向下一个节点的引用(在双向链表中,还有指向前一个节点的引用)。
    • 节点通常是一个内部类,这样 LinkedList 可以直接访问其内部字段。
  2. 头(Head)和尾(Tail)

    • LinkedList 通常维护一个对第一个节点(头)和最后一个节点(尾)的引用。这使得在链表的开头和结尾插入或删除节点变得非常快。
  3. 插入(Insertion)

    • 在链表的开头插入节点:创建一个新节点,将其数据字段设置为要插入的值,将其“下一个”字段设置为当前的头节点,然后将头引用更新为新节点。
    • 在链表的结尾插入节点:与在开头插入类似,但需要将新节点的“下一个”字段设置为 null(在单向链表中),并更新尾引用为新节点。在双向链表中,还需要处理“前一个”字段。
    • 在链表的任意位置插入节点:需要遍历链表直到找到要插入位置的前一个节点,然后调整链接以包含新节点。
  4. 删除(Deletion)

    • 删除头节点:将头引用更新为当前头节点的下一个节点(如果有的话)。
    • 删除尾节点:遍历链表找到倒数第二个节点,并将其“下一个”字段设置为 null。在双向链表中,还需要处理尾节点的“前一个”字段。
    • 删除任意节点:找到要删除的节点的前一个节点,并调整链接以跳过要删除的节点。
  5. 遍历(Traversal)

    • 从头节点开始,沿着“下一个”引用(在双向链表中,也可以使用“前一个”引用)遍历链表,直到到达 null 引用(表示链表的结尾)。
  6. 查找(Search)

    • 通常,链表不支持高效的按索引查找,因为需要从头节点开始遍历链表直到找到目标节点或到达链表结尾。然而,一些 LinkedList 实现可能提供了缓存或其他优化来提高查找性能。
  7. 内存管理

    • 由于链表节点是动态分配的,因此在使用链表时需要注意内存管理。在不再需要节点时,应确保释放其占用的内存以避免内存泄漏。
  8. 双向链表(Doubly Linked List)

    • 除了包含“下一个”引用外,双向链表的每个节点还包含一个“前一个”引用,这使得在链表的任何位置进行插入和删除操作都更加高效。同时,它还支持从尾部开始的遍历。
  9. 循环链表(Circular Linked List)

    • 在循环链表中,尾节点的“下一个”引用指向头节点,形成一个闭环。这种结构在某些应用中(如环形缓冲区)可能很有用。

扩容原理: 它并没有类似ArrayList的扩容原理,因为他的底层不是数组(并不是说存储的数据不是数组,而是不像ArrayList那样所有的数据都存在一个数组里面,增删时需要考虑到容器的大小),后面也是一样,基本上只有底层靠数组实现的集合才有扩容原理(包括初始大小,扩容倍数,负载因子等)。

Vector

        Vector集合很少用,因为他和HashTable一样属于一代集合,已经被弃用,凡是被弃用的类,几乎都是因为线程安全问题。Vector是线程安全的,这意味着它的所有公共方法都使用了synchronized关键字,每次都无脑加锁,自然会带来更多的性能开销,拖慢执行效率,所以在jdk1.2以后,引入了ArrayList来取代他,ArrayList是线程不安全的,在使用过程中,是否要加锁,在哪个位置加锁交给开发者自己决定,相对而言这样可以更加灵活地减少部分性能开销。

        但是由于其是一代集合,某些老旧代码库中可能已经使用了Vector,所以在维护这些老旧代码时,继续使用Vector会比较方便。

        Vector相对来说很陌生,简单理解它就相当于ArrayList,它是老一辈的ArrayList,它被ArrayList取代了,他们的功能基本相似。但是它们之间仍然有一些差别:Vector的默认扩容机制是将容量增加一倍;ArrayList的默认扩容机制是当前容量的 1.5 倍。

       这个集合为什么以Vector命名呢,有些人会觉得很别扭。 Vector翻译为中文是矢量的意思,即向量,在数学中,矢量可以看作是一列有序的数,这些数可以表示方向和大小。类似地,编程中的向量是一种动态数组,可以按顺序存储元素,并且可以动态调整大小。这种动态性与数学中的矢量有一定的对应关系。矢量具有顺序性,可以按照索引访问每一个元素,这类似于数学中的矢量成分。同样,向量数据结构允许按索引高效访问元素,这使得它具备了随机访问的特点。

Stack

        Vector类提供了一些Stack所需的方法,比如push()pop()peek()等,这些方法在Stack类中只需要进行简单的封装即可使用,可以简化Stack类的设计和实现,所以Stack继承自Vector。

        因为Stack继承于Vector,所以Vector也是线程安全的,也是一代集合,也是被弃用的。

        Stack为栈,管理数据的特点为:先进后出,后进先出。与队列相对。

        其被弃用,替代集合类为ArrayDeque。

CopyOnWriteArrayList

        这个集合类可能不常见,但是非常常用。CopyOnWriteArrayList 是 Java 中并发编程中的一个重要工具类,它提供了一种线程安全的动态数组实现。它的设计思想是在读操作远远多于写操作的情况下优化性能,通过牺牲一定的实时性来换取并发读的高效率。

        特本特点和原理:

  1. 线程安全性

    CopyOnWriteArrayList 是线程安全的,可以在多线程环境下使用而无需额外的同步措施。这是通过在写操作(添加、修改、删除元素)时对底层数组进行一次复制来实现的。
  2. 写操作的复制策略

    当对 CopyOnWriteArrayList 进行写操作时(例如添加、修改、删除元素),它会先复制当前的数组,并在副本上执行写操作。因此,写操作不会影响到正在进行的读操作,保证了读写的并发性。
  3. 读操作的效率

    由于读操作并不加锁,并且读取的是一个一致性的快照(即写操作时复制的数组),所以读操作非常高效,不会受到写操作的影响。
  4. 适用场景

    CopyOnWriteArrayList 适合于读操作远远多于写操作的场景,比如配置信息的读取和更新、事件监听器列表等。它不适合频繁的写操作,因为每次写操作都会复制整个数组,消耗较大。
  5. 弱一致性保证

    由于 CopyOnWriteArrayList 在写操作时会创建一个新的数组副本,并且读取的是旧的数组副本,所以它提供了一种弱一致性的保证。这意味着在写操作完成后,可能有一段时间读操作仍然会看到旧的数据,直到新数组副本被完全替换。

Queue

        栈和队列是相对的两种数据结构,也是相对的两个集合(数据结构是一种思想,集合是接口或者实现类),大部分集合沿用了其相同名字的数据结构的思想,继承其特征,栈先进后出,队列先进先出。我们知道,在jdk1.2以后,栈集合(java.util.Stack)已经被弃用,用(java.util.ArrayDeque)替代。

ArrayDeque

        ArrayDeque是栈(java.util.Stack)的替代类,由于栈的底层为数组,所以ArrayDeque的底层也是数组,然而ArrayDeque并不止是栈,其实现了Deque(双端队列)接口,所以ArrayDeque有双向队列的特性,类似于双向链表,只不过双向链表的底层不是数组(只管理一个数组),ArrayDeque的底层为数组。双端,顾名思义,可以对数组的头和尾进行操作,相比于传统的栈操作(只能操作栈顶,压栈和弹栈都是在数组的尾部进行修改),多了一些灵活性,它既可以操作栈顶又可以操作栈底,那这不就是队列特性了吗,所以ArrayDeque既可以作为栈使用,又可以作为队列使用,二者之间可以相互转换,但是一般作为栈来使用的时候不会对栈底进行操作。

        爱思考的人就有疑惑了,ArrayDeque既然底层是数组,那么它如何能既操作尾部,又操作头部呢?回想我们的ArrayList,作为一个底层为数组的集合,所有元素不仅要在逻辑上连续,还必须在物理上连续,为保证这个特性,每次扩容时都需要占用一块新的、更大的、连续的空间来新建一个数组,然后把原来的元素复制过来,以ArrayList为例,初始大小为10,存储的元素个数达到负载因子0.75后开始扩容,始终保证数组的尾部有多余的空间,但是它无法保证头部的前面有空间,大多数情况下,在物理内存上,数组第一个元素的位置的前一位一般都是有其他数据的,所以我们没办法轻松的向数组头部插入数据,唯一的办法就只能继续扩容,但是我们每一次扩容后,可以允许向数组尾部多次进行插入元素操作不用继续扩容,而向头部插入每个元素都必须进行扩容。ArrayDeque巧妙地解决了这个问题,先说一说它的扩容原理:ArrayDeque管理的数组的初始大小为16,扩容倍数为两倍。它如何解决这个问题的呢,它有四个重要的指针,一个指向数组的内存地址值,一个指向第一个元素的内存地址值,一个指向数组的尾部的内存地址值,一个指向最后一个元素的内存地址值。一般情况下,数组的内存地址值即是第一个元素的内存地址值,也无需特意去管理数组尾部的内存地址值,但是ArrayDeque的第一个元素并不是放在数组的第一位,比如你创建了一个大小为16的ArrayDeque,根据它这个类的规则,它可能指定下标为4的位置为第一个元素,前面四个位置为null或0,然后下标为5的位置为第二个元素,以此类推,然后有特殊需求需要向头部插入元素时,就可以在下标为3的位置插入元素,并将第一个元素的指针向前移一位,比如说之前标记下标为4的元素为第一个元素,现在标记的是下表为3的元素是第一个元素,这样在需要大量向数组头部插入数据时,就不需要频繁地扩容了。那有人就懵了,我怎么知道它到底标记哪个元素是第一个元素,我怎么去拿到他,是不是还要做运算。这个类是它写的,他想怎么定义规则都是他说了算,但是我们也不需要去管他的具体运算过程,它这个类提供了方法给我们去拿他的第一个元素,我们根本无需关注他实际上是该数组的第几个元素,以及这个元素的前面有几个空元素,我们只需要知道他的特性并善加利用即可。

        ArrayDeque也并不是说优于ArrayList,而是在不同的应用场景下性能较好,选择哪种集合需要自己去判断,比如说业务需求是需要频繁地往集合头部和尾部插入数据(既对头又对尾操作),很少向中间插入元素的情况下,必然要选择ArrayDeque,因为它可以避免频繁扩容带来的性能开销;然而除了这种情况,一般都选择ArrayList,因为毕竟ArrayDeque多管理了好几个指针,也会带来一定的性能开销。

ConcurrentLinkedQueue

        关于ConcurrentLinkedQueue(并发链接队列)有以下特征:

1、数据结构基于链表,每个节点(Node)包含一个元素和指向下一个节点的引用。

2、是线程安全的。但并未被弃用,要看具体情况使用。

3、ConcurrentLinkedQueue 不支持在队列中间插入或者移除元素。它只支持在队列的头部和尾部进行插入和移除操作。

适用场景:当多个线程需要访问一个共享的队列时,ConcurrentLinkedQueue 是一个很好的选择,因为它提供了高效的并发访问能力,避免了传统同步队列(如使用 synchronized 或 ReentrantLock)可能带来的性能开销和复杂性。

ConcurrentLinkedQueue提供的方法:

offer(E e):向队列尾部添加一个元素。

poll():从队列头部移除并返回一个元素,如果队列为空则返回 null。

peek():返回队列头部的元素,但不移除它。

size():返回队列中元素的数量,这个操作的代价比较大,因为需要遍历整个队列。

PriorityQueue

        PriorityQueue(优先队列)是 Java 中的一个队列实现,它根据元素的优先级来确定元素的顺序。具体来说,优先级队列保证当你从队列中取出元素时,总是取出优先级最高(或最低,取决于比较器的定义)的元素。以下是PriorityQueue集合的特征:

1、PriorityQueue 是基于优先级堆(binary heap)实现的,这是一种完全二叉树结构。在最小堆(默认情况下)中,父节点的值小于或等于任何一个子节点的值。

2、插入元素(add 或 offer)的时间复杂度为 O(log n),移除元素(remove 或 poll)的时间复杂度也为 O(log n),其中 n 是队列的大小。这是由于堆操作的特性决定的。

适用场景:PriorityQueue 主要用于实现基于优先级的任务调度、事件处理等场景,其中需要快速访问和处理具有不同优先级的元素。

提供的方法:

add(E e) 或 offer(E e):将元素插入队列。

remove() 或 poll():移除并返回队列中的最高优先级元素。

peek():返回但不移除队列中的最高优先级元素。

size():返回队列中的元素数量。

isEmpty():检查队列是否为空。

clear():清空队列中的所有元素。

        怎么理解优先级?

        优先级最高的元素是根据你所提供的比较器或者元素自身的自然顺序来决定的。简单来说,就是元素的优先级规则是由你自己定义的,你自己不定义它就默认按照元素的自然顺序来比较优先级,以下是对元素类型为整型和字符类型的PriorityQueue 集合的使用示例。

PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.add(5);
pq.add(10);
pq.add(3);

// 输出最高优先级元素(最小的元素)
System.out.println(pq.peek()); // 输出 3
PriorityQueue<String> pq = new PriorityQueue<>(Comparator.reverseOrder());
pq.add("apple");
pq.add("banana");
pq.add("orange");

// 输出最高优先级元素(Comparator.reverseOrder() 会逆序,即最大的元素)
System.out.println(pq.peek()); // 输出 "orange"

         对于基本类型和其对应的包装类,优先级队列默认会按照元素的自然顺序(从小到大)来排列。例如,PriorityQueue<Integer> 将会按照整数的大小顺序进行排序。对于 String 类型,优先级队列默认会按照字符串的自然顺序(按字典顺序)进行排序,即按照 Unicode 码点的顺序。

         但只有常见的类型Integer、String等提供了默认方法,其他不常见的类型必须自定义比较规则,自定义规则需要实现了Comparable 接口并重写 compareTo 方法,如:

class Person implements Comparable<Person> {
    private String name;
    private int age;
    
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    public String getName() {
        return name;
    }
    
    public int getAge() {
        return age;
    }
    
    @Override
    public int compareTo(Person other) {
        // 按年龄从小到大排序
        return Integer.compare(this.age, other.age);
    }
}

PriorityQueue<Person> pq = new PriorityQueue<>();
pq.add(new Person("Alice", 25));
pq.add(new Person("Bob", 30));
pq.add(new Person("Charlie", 20));

// 输出最高优先级元素(年龄最小的人)
System.out.println(pq.peek().getName()); // 输出 "Charlie"
BlockingQueue接口的实现类

        BlockingQueue 是 Java 并发包中的一种接口,它扩展自 Queue 接口,提供了线程安全的队列操作,并支持在队列为空或者满时进行阻塞的特性。主要用于多线程间的数据交换和协调。

        实现了BlockingQueue接口的实现类都是阻塞队列,其实现类有:ArrayBlockQueue、LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue、DelayQueue等。

特性和用途:

  1. 线程安全性

    BlockingQueue 是线程安全的,支持在多线程环境下进行并发操作,而无需额外的同步手段。它通过内部的锁或其他同步机制来确保多个线程可以安全地访问和修改队列。
  2. 阻塞操作

    BlockingQueue 提供了阻塞方法,这些方法在队列满或空时会阻塞线程,直到条件满足或超时。阻塞插入方法:如 put(E e) 方法,在队列满时会阻塞直到有空间可以插入新元素。阻塞移除方法:如 take() 方法,在队列空时会阻塞直到有元素可用。
  3. 支持有界和无界队列

    BlockingQueue 可以是有界或无界的,具体取决于具体的实现类。有界队列在达到容量限制时会阻塞插入操作,而无界队列则可以无限地添加元素。
  4. 生产者-消费者模式的实现

    BlockingQueue 经常用于实现生产者-消费者模式,其中生产者将数据放入队列,而消费者则从队列中取出数据进行处理。由于其阻塞特性,能够有效地协调和控制生产者和消费者的速度,避免了资源竞争和过度轮询的问题。

常见实现类:

        Java 提供了多种 BlockingQueue 的实现类,每种实现类在特定场景下有不同的优势:

  1. ArrayBlockingQueue:基于数组的有界阻塞队列,插入和移除操作都是 O(1) 的时间复杂度。
  2. LinkedBlockingQueue:基于链表的有界或无界阻塞队列,可以指定容量,默认为无界。
  3. PriorityBlockingQueue:优先级阻塞队列,元素按照优先级顺序出队列。
  4. DelayQueue:延时队列,其中的元素只有在延迟期满时才能够取出。
  5. SynchronousQueue:同步队列,每个插入操作必须等待另一个线程进行移除操作。

示例用法:

BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);

// 生产者线程
Thread producer = new Thread(() -> {
    try {
        queue.put(1); // 阻塞插入元素
        System.out.println("Produced 1");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});

// 消费者线程
Thread consumer = new Thread(() -> {
    try {
        int value = queue.take(); // 阻塞获取元素
        System.out.println("Consumed " + value);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});

producer.start();
consumer.start();

Set

HashSet

        Set集合和List集合是两个相对立且互补的集合,List集合是有序集合,允许元素重复,而Set集合不保证元素的有序性,但保证元素的唯一性。

        HashSet实现了Set接口,自然也是无序集合,元素唯一(允许有null值,但也只有一个)。

        HashSet内部通过哈希表(实际上是 HashMap 的实例)来存储元素,因此具有快速的插入、删除和查找操作。每个元素都存储在哈希表的一个特定位置,根据元素的哈希码决定存储的位置,这使得元素的添加和查找操作的平均时间复杂度为 O(1)。

适用场景:HashSet 适合于需要高效查找、插入和删除元素的场景,不需要维护元素的顺序,并且不需要处理重复元素的情况。

哈希表是如何查找元素的?

        哈希表(即散列表)的底层可以看做一个数组,但管理方式不是直接的使用下标,在存储元素时,使用哈希函数计算出该元素的HashCode,然后将该值映射成一个整数值,通常是非负整数,这个整数被作为索引使用,这个索引也类似于数组的下标,索引的值是通过取哈希码对哈希表大小的取模运算来确定的,即 index = hash(key) % table_size(table_size表示数组大小)。这个数组的每个元素通常称为一个桶(Bucket)或者槽(Slot),每个桶可以存储一个元素或者多个元素,具体取决于哈希表的实现方式。比如我当前的容量为10,我要插入的元素的HashCode映射的整数为8,那么index=8%10=2,我的这个元素就插入在数组的第二个下标位。知道了存储原理,查找也是一样,也是根据这种方法去计算出他的哈希值的映射索引。

引伸出的问题:哈希冲突和扩容原理

哈希冲突:

        哈希表当前管理的数组大小为10,我要插入的元素通过哈希函数计算得到的HashCode所映射得到的整数在10以内就可以直接存储,但是有时候可能大于10,比如11对10(数大小) 取模为1,1对10取模也为1,这样两个元素谁存到下标为1的位置吗?(哈希函数不一定能够保证每个元素都映射到唯一的位置。因此,可能会出现多个元素被映射到相同的位置,这种情况称为哈希冲突)

        对于哈希冲突,是有一套解决方案的:

  • 开放寻址法:当发生冲突时,线性地探测下一个空槽或者根据其他序列来寻找下一个空槽。
  • 链表法:每个桶存储一个链表,所有哈希到同一位置的元素都存储在这个链表中。
  • 其他方法:还有一些其他的冲突解决方法,如二次探测、双重哈希等,具体方法选择取决于实际情况和性能需求。

        HashSet采用的是链表法(使用链表或红黑树),简而言之,原本的数组一个下标只能存储一个元素,现在在每一个下标的位置再套一个链表或者数组,这样原本数组一个下标就可以存储多个元素了。

扩容原理:

        既然底层为数组,那一般都有扩容机制,HashSet 在创建时可以指定初始容量和负载因子。初始容量指的是 HashSet 最初能够容纳的元素数量;负载因子是一个表示 HashSet 在容量自动增加之前可以达到多满的一个浮点数。默认负载因子是 0.75;当 HashSet 中的元素数量达到了当前容量乘以负载因子(即负载因子 * 初始容量),HashSet 就会自动进行扩容操作;

        扩容会创建一个新的更大的哈希表,通常是当前容量的两倍。所有的元素会根据它们的哈希码重新计算并插入到新的哈希表中。这个过程涉及到重新计算哈希码和重新分配元素到新的桶中,因此需要一定的时间来完成。(根据公式index = hash(key) % table_size来判断,因为扩容会导致数组长度变化即table_size变化,这会导致每一个元素的index变化,都需要重新计算,所以会导致性能开销,扩容虽然会带来一定的性能开销,但它保证了哈希表的负载因子在一个合理的范围内,从而保持了较好的查询、插入和删除操作的性能)。

LinkedHashSet

        LinkedHashSet 是 HashSet 的一个子类,实现了 Set 接口。与 HashSet 不同的是,LinkedHashSet 除了具备 HashSet 的快速查找特性外,还保留了元素插入顺序。LinkedHashSet 内部使用了一个哈希表和一个双向链表来维护元素顺序。哈希表保证了快速的插入、删除和查找操作,而双向链表则维护了元素的插入顺序。LinkedHashSet 的性能与 HashSet 相似,插入、删除和查找操作的平均时间复杂度为 O(1)。迭代集合的性能也非常好,因为它使用了链表来维护插入顺序。当你需要一个既能快速查找元素,又能维护元素插入顺序的集合时,LinkedHashSet 是一个很好的选择,特别是在需要按照插入顺序迭代元素的场景下,使用 LinkedHashSet 可以避免维护额外的数据结构来记录顺序。

        简而言之,LinkedHashSet就是对HashSet的增强,增强点就是将遍历时无序变为有序。

        增强的方式就是在LinkedHashSet内部多维护了一个双向链表,在插入元素时,该双向链表不存储该元素的数据,只存储该元素的指针,即该元素在哈希表中的具体位置,这样遍历的时候就可以根据这个双向链表按照使用者插入元素的顺序原样取出。

        这样的就融入了一些List集合的特性(有序性),但LinkedHashSet是无法取代List集合中的任何一个集合的,因为它有以下缺点:

1、管理了一个哈希表加一个双向链表,必然是会比普通集合类占用更多的空间。

2、由于遍历时需要依靠双向链表来获取元素顺序,遍历的速度必然是很慢的。

3、元素唯一,无法存储两个相同的元素。

        所以它只能在特定的应用场景下才能发挥优势,然而一般都不会直接用这个类,而是用他的扩展类,比如用它存储键值对,通过计算key的HashCode的映射快速找到key所在的桶从而快速找到它对应的value值;当然在一些要求存储的元素不能重复的场景下LinkedHashSet也有得天独厚的优势。

TreeSet

        TreeSet实现了 Set 接口,并且基于红黑树(Red-Black tree)实现,它是有序集合,它根据元素的自然顺序(Comparable 接口)或者自定义的比较器(Comparator 接口)来维护元素的顺序。这使得 TreeSet 中的元素可以按照升序或者降序排列。

        同样的,TreeSet实现了Set接口,自然也保证了唯一性,当向TreeSet中插入重复元素时,插入操作不会成功,它还具有 Set 接口定义的所有方法,如添加元素、删除元素、清空集合、判断元素是否存在等。此外,它也实现了 NavigableSet 接口,提供了一些额外的导航方法,比如获取最小元素、最大元素、查找指定元素的前驱和后继等。

        TreeSet 内部通过红黑树来实现元素的存储和管理。红黑树是一种自平衡的二叉搜索树,它保证了基本的插入、删除和查找操作的时间复杂度为 O(log n)。

双列集合 (Map)

        我们知道单列集合主要用于存储单个元素的集合,每个元素可以独立存在,彼此之间没有直接的关系,而双列集合主要用于存储键值对形式的元素,每个元素包含一个唯一的键和对应的值,键和值之间存在一定的映射关系。

        Map简单来理解就是映射,双列集合存储的数据为键值对,什么是键值对,就是key-value,根据key去找value,你提供一个key,它计算出key的哈希值从而确定该元素(键值对)的位置,从而找到value,前面理解了HashSet就不难理解这一点。

        为什么要根据key去找value呢?我们知道集合是一个容器,一个工具,一般来说,先有需求,才会诞生工具。从应用场景去理解工具的特性会事半功倍,还是以我们熟悉的ArrayList为例作比较,我们如何去拿 ArrayList中的元素数据呢?首先我们得知道该元素在数组中的位置,然后根据下标去拿到数据,前提是你得知道你想要的数据是数组中的第五个元素,你就可以根据arr[4]去拿到元素数据。但是有些场景是,我们不知道元素的第几个。那我们该如何拿到它呢?那你如果想在不遍历的情况下精准的拿到一个元素数据,不知道元素下标,就必须知道其他关键信息。

        比如说你想找一个人,你知道它的准确家庭住址最好,你可以马上找到他,如果没有,那你至少得知道他的名字,或者他的电话号码等一些关键信息,你才能找得到他(根据名字取询问别人,或者打电话问他在哪)。而键值对就可以应对这种情况,你可以不知道元素的下标,但是你如果知道它的名字(即key)的话,也是可以直接拿到它的值的。

        前面HashSet中讲过,它的底层是散列表,即数组中的每个元素是链表(或树),在存储元素时使用哈希函数计算出该元素的HashCode,然后将该值映射成一个整数值(index = hash(key) % table_size),从而将元素存储在该整数对应的桶中的链表的某个节点里,节点里面存储该元素的关键信息:key的指针、value的指针、下一个节点的指针等。所以根据key找value的原理就是,根据index = hash(key) % table_size计算出key所在桶的位置,即value所在桶的位置(因为key和value放在同一个节点),然后遍历该桶(链表),找到key的准确位置,然后取出value。注意,在没有哈希冲突的情况下(最理想状态),链表里只有一个节点,查找操作的时间复杂度为 O(1),在哈希冲突较多的情况下,查找操作的时间复杂度为 O(log n),所以在存储元素时应尽量避免哈希冲突。

HashMap

        HashMap是双列集合中十分常用的一个集合,下是 HashMap 的主要特点和适用场景:

存储结构:

HashMap 内部通过数组和链表(或红黑树,Java 8 引入)实现。数组用来存储元素,每个元素称为桶(bucket),每个桶可能存放一个或多个键值对。当多个键值对散列到同一个桶时,它们会以链表形式存储。Java 8 引入了桶中元素个数达到一定阈值时,链表会转换为红黑树,以提高检索效率。

键值对存取:HashMap 允许 null 键和 null 值的存在(只允许一个 null 键)。通过键(key)可以快速地检索到对应的值(value),时间复杂度接近 O(1),在没有哈希冲突的理想情况下,是常数时间复杂度。

非线程安全:HashMap 不是线程安全的,即多个线程同时操作 HashMap 可能导致数据不一致或抛出 ConcurrentModificationException 异常。如果需要在多线程环境下使用,可以通过 Collections.synchronizedMap 方法创建一个同步的 HashMap 或者使用 ConcurrentHashMap。

适用场景:HashMap 适合在单线程环境或者不要求线程安全的环境中使用,特别是在需要高效进行键值对的存储和检索、无需保证顺序的情况下。例如,缓存、索引等场景都可以使用 HashMap 来存储数据。

迭代顺序:HashMap 的迭代顺序是不确定的,即不保证按照插入顺序或者其他顺序进行遍历。这是因为 HashMap 内部使用哈希算法确定存储位置,桶的顺序与元素插入顺序无关。

扩容:HashMap 允许设置初始容量和负载因子。初始容量表示 HashMap 初始的桶的数量,默认为 16;负载因子表示当桶中的元素个数达到总容量的多少时,进行扩容操作,默认为 0.75。调整这两个参数可以影响 HashMap 的性能和空间利用率。

HashTable

        HashTable顾名思义,即哈希表本表,但是在集合中,它是Java中的一种旧的实现方式,在现代Java中已经不推荐使用,它已经被ConcurrentHashMap替代。它有以下特征:

1、线程安全性:HashTable 是线程安全的,所有的操作方法都是同步的(使用synchronized关键字),因此多个线程可以安全地访问一个 HashTable 实例。

2、继承和接口:HashTable 继承自 Dictionary 类,并实现了 Map 接口。

3、键值非空:HashTable 不允许空键或空值。如果试图插入空键或空值,会抛出 NullPointerException。

4、性能:由于所有方法都是同步的,HashTable 的性能通常比较低下。在大多数情况下,推荐使用 HashMap 代替 HashTable,后者在性能上更优。

ConcurrentHashMap

        ConcurrentHashMap是java5中引入的线程安全的哈希表实现类,并作为对 Hashtable 和 synchronizedMap 的替代。

        我们知道Hashtable是线程安全的,但是由于效率较低,已经被弃用,而ConcurrentHashMap也是线程安全的,它凭什么能替代Hashtable呢,原因在于ConcurrentHashMap 使用了分段锁,默认情况下将哈希表分成 16 个段(Segment)。每个段相当于一个小的 HashMap,不同的段可以由不同的线程同时访问,从而提高了并发访问的效率。只有在修改操作时,才需要锁定对应的段,而不会锁定整个数据结构。ConcurrentHashMap 在并发读取的情况下可以显著提高性能,因为不同的线程可以同时读取不同的段,而不会发生阻塞。在大多数情况下,读取操作的性能几乎和普通的 HashMap 一样快。

        总之,ConcurrentHashMap 适用于需要高并发性能、线程安全且需要支持大规模数据的场景。它是处理多线程并发访问的优秀选择,能够有效地提高系统的吞吐量和响应性能。

LinkedHashMap

        LinkedHashMapHashMap 的一个具体实现,它继承自 HashMap 并实现了 Map 接口。与 HashMap 不同的是,LinkedHashMap 维护了一个双向链表,该链表按照元素插入的顺序来维护元素的顺序。LinkedHashMap是对HashMap的遍历做了增强,与LinkedHashSet对HashSet做增强的原理类似。

TreeMap

        TreeMap 是 Java 中实现了 NavigableMap 接口的一个有序映射。它基于红黑树(Red-Black Tree)实现,具有以下主要特点和用途:

1、有序性:TreeMap 中的键值对是按照键的自然顺序或者通过提供的 Comparator 排序的。如果使用默认的构造函数创建 TreeMap,则键需要实现 Comparable 接口,按照自然顺序进行排序。也可以通过带有 Comparator 参数的构造函数,提供自定义的比较器来排序键。

2、性能:TreeMap 是基于红黑树实现的,因此它的各种操作(插入、删除、查找)的时间复杂度为O(log n),其中 n 是 TreeMap 中的元素个数。这使得 TreeMap 在元素数量较大时仍能保持较高的性能。

3、功能:TreeMap 提供了一些基于顺序的操作,如获取第一个和最后一个键、获取小于或大于某个键的最接近的键等。它还实现了 NavigableMap 接口,提供了各种导航方法,如 lowerKeyfloorKeyhigherKeyceilingKey 等,能方便地进行范围查找和子映射操作。

4、线程不安全:TreeMap 不是线程安全的,如果多个线程同时访问一个 TreeMap 并且至少有一个线程修改了映射,它必须在外部进行同步。

5、典型应用场景:

  • 字典和词典:存储单词及其对应的定义,按照字母顺序进行检索和展示。
  • 日程安排:按照时间顺序存储和管理预定的会议或事件。
  • 排行榜:按照分数或其他指标对参与者或对象进行排序和排名。
  • 数据检索:在需要快速查找和遍历数据的应用中,如数据库索引的简单模拟。

Properties

        Properties 是 Java 中一个经典的工具类,用于处理配置文件。它继承自 Hashtable,因此具备了 Hashtable 的特性,同时也实现了 Map 接口。主要用途是简化读写配置信息,特别是键值对形式的配置文件。

        其主要特点和用途如下:

  1. 存储配置信息Properties 主要用于存储键值对形式的配置信息,这些信息通常以文本文件的形式存在,如 .properties 文件。

  2. 简化配置文件的读写:提供了读取和写入配置文件的便捷方法,支持将配置信息从文件加载到 Properties 对象中,也支持将 Properties 对象的内容保存回文件。

  3. 基于文本的存储格式:配置文件通常采用简单的文本格式,每一行表示一个键值对,格式为 key=value,其中键和值都是字符串类型。

  4. 易于处理Properties 提供了许多方便的方法来访问和操作配置信息,如获取指定键的值、设置键值对、列出所有键等。

  5. 默认值支持:可以指定默认值,如果在配置文件中找不到特定的键,则可以返回预设的默认值,这样有助于处理缺失的配置项。

  6. 典型应用场景:

  • 应用配置:存储应用程序的配置项,如数据库连接信息、服务器地址、日志级别等。
  • 国际化和本地化:用于存储不同语言或地区的文本信息,便于国际化和本地化处理。
  • 动态加载模块:在需要动态加载和配置模块的应用中,可以使用 Properties 来管理模块的配置信息。

总结

List 接口的实现类

  1. ArrayList

    • 读效率: O(1),通过索引直接访问元素。
    • 写效率: 平均情况下为 O(n),插入和删除元素可能需要移动其他元素。
    • 适用场景: 需要随机访问元素且尾部插入删除频繁的场景。
  2. LinkedList

    • 读效率: O(n),需要从头开始遍历链表查找元素。
    • 写效率: O(1),在任意位置插入或删除元素效率高。
    • 适用场景: 需要频繁在中间插入或删除元素的场景。
  3. Vector

    • 读效率: O(1),通过索引直接访问元素。
    • 写效率: 平均情况下为 O(n),插入和删除元素可能需要移动其他元素。
    • 适用场景: 多线程环境下使用,或需要动态增长数组的情况。
  4. Stack

    • 继承自Vector,读写效率与Vector相似。
    • 适用场景: 栈数据结构,后进先出(LIFO)的需求场景。
  5. CopyOnWriteArrayList

    • 读效率: O(1),不加锁直接读取。
    • 写效率: O(n),写操作时复制整个数组。
    • 适用场景: 读多写少的场景,如事件监听器、配置列表等。

Queue 接口的实现类

  1. ArrayDeque

    • 读效率: O(1),对头部和尾部操作效率高。
    • 写效率: O(1),插入和删除操作效率高。
    • 适用场景: 双端队列,需要高效的头尾操作场景。
  2. ConcurrentLinkedQueue

    • 读效率: O(1),并发读取效率高。
    • 写效率: O(1),插入和删除元素效率高。
    • 适用场景: 并发环境下需要高效的队列操作场景。
  3. PriorityQueue

    • 读效率: O(log n),根据优先级获取元素效率高。
    • 写效率: O(log n),插入和删除元素按照优先级排序。
    • 适用场景: 需要按照优先级处理元素的场景。
  4. BlockingQueue (接口)

    • 具体实现类包括: LinkedBlockingQueue、ArrayBlockingQueue 等。
    • 适用场景: 多线程下需要阻塞式队列操作的场景。

Set 接口的实现类

  1. HashSet

    • 读效率: 平均 O(1),通过哈希表实现。
    • 写效率: 平均 O(1),插入和删除元素效率高。
    • 适用场景: 需要存储唯一元素且不需要保持顺序的场景。
  2. LinkedHashSet

    • 读效率: O(1),插入顺序遍历。
    • 写效率: 平均 O(1),插入和删除元素效率高。
    • 适用场景: 需要保持插入顺序并且存储唯一元素的场景。
  3. TreeSet

    • 读效率: O(log n),基于红黑树实现,元素有序。
    • 写效率: O(log n),插入和删除元素效率高。
    • 适用场景: 需要有序存储元素,并且支持高效的插入、删除、查找操作的场景。

Map 接口的实现类

  1. HashMap

    • 读效率: 平均 O(1),通过哈希表实现。
    • 写效率: 平均 O(1),插入和删除键值对效率高。
    • 适用场景: 存储键值对且不需要保持顺序的场景。
  2. Hashtable

    • 读效率: 平均 O(1),通过哈希表实现,线程安全。
    • 写效率: 平均 O(1),插入和删除键值对效率高。
    • 适用场景: 多线程环境下使用,需要线程安全的键值对存储。
  3. ConcurrentHashMap

    • 读效率: 平均 O(1),并发读取效率高。
    • 写效率: 平均 O(1),对不同段(Segment)进行锁定,提高并发写入的效率。
    • 适用场景: 高并发读写的场景,如缓存、并发任务处理等。
  4. LinkedHashMap

    • 读效率: O(1),插入顺序遍历。
    • 写效率: 平均 O(1),插入和删除键值对效率高。
    • 适用场景: 需要保持插入顺序存储键值对的场景。
  5. TreeMap

    • 读效率: O(log n),基于红黑树实现,键有序。
    • 写效率: O(log n),插入和删除键值对效率高。
    • 适用场景: 需要有序存储键值对,并且支持高效的插入、删除、查找操作的场景。
  6. Properties

    • 继承自Hashtable,读写效率与Hashtable相似。
    • 适用场景: 加载和保存配置文件的键值对数据。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值