一文吃透Java集合:从基础到实战,带你玩转数据容器(上)

一文吃透Java集合:从基础到实战,带你玩转数据容器(上)

下一篇一文吃透Java集合:从基础到实战,带你玩转数据容器(下)

一、Java 集合初相识

在这里插入图片描述

在 Java 的编程世界里,集合(Collections)可是一个超级重要的角色,就像是我们日常生活中的收纳盒,只不过这个 “收纳盒” 更加智能和强大,能帮我们管理各种数据。不管是开发一个小型的命令行工具,还是构建大型的企业级应用,集合都无处不在,是 Java 程序员必不可少的工具之一。

想象一下,你要管理一个班级学生的成绩,如果没有集合,你可能得用数组来存储成绩,但是数组的大小是固定的,一旦学生人数有变动,那可就麻烦了。而集合就不一样啦,它可以动态地调整大小,轻松应对各种变化。这只是集合的一个小小应用场景,实际上,在数据处理、算法实现、Web 开发等众多领域,集合都发挥着关键作用。

Java 集合框架提供了一套丰富的接口和类,让我们可以方便地操作和管理数据。它就像是一个大家族,包含了各种不同类型的 “成员”,每个 “成员” 都有自己独特的功能和用途。接下来,就让我们一起深入这个大家族,看看都有哪些重要的 “成员” 吧!

二、集合与数组的爱恨情仇

在 Java 的世界里,数组和集合就像是两个性格迥异的小伙伴,虽然都能用来存储数据,但在很多方面都有着明显的不同。

2.1 定义与声明

  • 数组:数组是一种固定长度的数据结构,在声明时就需要指定其大小。比如,我们要创建一个存储整数的数组,可以这样写:int[] numbers = new int[5];,这里就明确了数组numbers的长度是 5,之后这个长度就不能再改变了。如果我们想要存储不同类型的数据,就需要使用对象数组,比如Object[] objects = new Object[3];,但这样会失去类型安全的优势。

  • 集合:集合则是可变长度的数据结构,它的大小可以根据需要动态调整。以ArrayList为例,我们可以这样创建一个存储整数的集合:ArrayList<Integer> list = new ArrayList<>();,这里并没有指定集合的初始大小,它会在添加元素时自动扩容。

2.2 元素类型

  • 数组:数组只能存储相同类型的数据,这是它的一个限制。如果我们需要存储不同类型的数据,就需要使用Object数组,但这样会失去类型安全的保障,在使用时需要进行类型转换,容易出错。

  • 集合:集合可以存储不同类型的对象,通过泛型的支持,我们可以在编译时确保类型安全。比如,ArrayList<String> stringList = new ArrayList<>();,这个集合就只能存储String类型的对象,如果尝试添加其他类型的对象,编译器会报错。

2.3 内存分配

  • 数组:数组在内存中是连续分配的,这使得它在访问元素时非常高效,因为可以通过索引直接计算出元素的内存地址。例如,int[] arr = {1, 2, 3, 4, 5};,数组arr的元素在内存中是紧密排列的,访问arr[2]时,通过数组的基地址加上偏移量就能快速找到元素 3。但是,这种连续内存分配也带来了一些问题,比如在插入或删除元素时,可能需要移动大量的元素,效率较低。

  • 集合:集合中的元素在内存中不一定是连续存储的。以ArrayList为例,它底层是通过动态数组实现的,在内存不足时会进行扩容,重新分配内存空间,将原来的元素复制到新的内存中。而LinkedList则是基于链表实现,每个节点包含元素和指向下一个节点的引用,节点在内存中是分散存储的。这种内存分配方式使得集合在插入和删除元素时更加灵活,但在随机访问元素时效率相对较低。

2.4 操作方法

  • 数组:数组提供的操作方法相对较少,主要是通过索引来获取和设置元素。例如,arr[0] = 10; 可以设置数组arr的第一个元素为 10,int value = arr[1]; 可以获取数组arr的第二个元素的值。如果要进行排序、查找等操作,需要使用Arrays类提供的静态方法,如Arrays.sort(arr); 对数组arr进行排序。

  • 集合:集合框架提供了丰富的操作方法,比如添加元素(add)、删除元素(remove)、查找元素(contains)、排序(Collections.sort)等。以ArrayList为例,list.add(1); 可以向集合list中添加一个元素 1,list.remove(0); 可以删除集合list中的第一个元素,boolean exists = list.contains(2); 可以判断集合list中是否包含元素 2。这些方法使得对集合的操作更加方便和灵活。

2.5 使用场景

  • 数组:当数据量固定,并且需要频繁进行随机访问操作时,数组是更好的选择。例如,存储一周的天气数据,由于一周的天数是固定的,并且我们可能经常需要根据索引获取某一天的天气情况,这时使用数组就非常合适。此外,数组在存储基本数据类型时,比集合更节省内存空间,因为集合只能存储对象,需要额外的内存开销来存储对象的元数据。

  • 集合:当数据量不确定,或者需要频繁进行插入、删除操作时,集合更为适合。比如,处理一个用户输入的动态数据列表,数据量在运行时是未知的且可能频繁变动,使用集合就能轻松应对。而且,集合提供的丰富操作方法,使得在进行数据处理、算法实现等场景下,比数组更具优势。例如,在实现一个简单的购物车功能时,使用集合可以方便地添加、删除商品,计算总价等。

总的来说,数组和集合各有优缺点,在实际开发中,我们需要根据具体的需求来选择使用哪种数据结构。就像选择工具一样,合适的才是最好的。

三、Java 集合家族大揭秘

3.1 家族体系总览

Java 集合框架就像是一个庞大的家族,而CollectionMap就是这个家族中最核心的两大接口,它们各自带领着一群 “子子孙孙”,共同构成了丰富多彩的集合世界。

先来看Collection接口,它是所有单列集合的根接口,就像是家族中的大家长,定义了一些通用的操作方法,比如添加元素、删除元素、判断集合是否包含某个元素等。Collection接口有三个重要的子接口:ListSetQueue

List接口代表一个有序、可重复的集合,就像一个有序的清单,每个元素都有其对应的顺序索引。常见的实现类有ArrayListLinkedListArrayList基于动态数组实现,查询效率高,但增删元素时可能需要移动大量元素;LinkedList基于双向链表实现,增删元素效率高,但随机访问元素时需要遍历链表,效率较低。

Set接口代表一个无序、不可重复的集合,就像一个独特物品的收纳盒,里面的元素不能重复。常见的实现类有HashSetLinkedHashSetTreeSetHashSet基于哈希表实现,插入、删除和查找操作的时间复杂度通常为 O (1),但不保证元素的遍历顺序;LinkedHashSet在哈希表的基础上维护了一个双向链表,因此可以保证元素的插入顺序;TreeSet基于红黑树实现,元素会按照自然顺序或自定义顺序排序。

Queue接口用于模拟队列这种数据结构,遵循先进先出(FIFO)的原则,就像排队买票一样,先到的先处理。常见的实现类有PriorityQueueLinkedListPriorityQueue基于堆结构实现,可以根据元素的优先级进行排序;LinkedList既可以作为链表使用,也可以作为队列使用。

再看Map接口,它和Collection接口不同,它存储的是键值对(Key - Value)形式的数据,就像一本字典,通过键可以快速查找对应的值。Map接口的常见实现类有HashMapLinkedHashMapTreeMapHashtableHashMap基于哈希表实现,查询效率高,允许一个null键和多个null值,但不保证元素的顺序;LinkedHashMap继承自HashMap,通过维护一个双向链表来保持元素的插入顺序或访问顺序;TreeMap基于红黑树实现,键会按照自然顺序或自定义顺序排序;Hashtable是一个古老的实现类,线程安全,但不允许null键和null值,性能相对较低。

下面是 Java 集合体系的类图,通过这个图可以更直观地了解它们之间的关系:

在这里插入图片描述

3.2 Collection 接口详解

Collection接口是 Java 集合框架中所有单列集合的根接口,它定义了一系列操作集合的方法,这些方法是所有单列集合都必须实现的。可以把Collection接口想象成一个模板,它规定了集合应该具备的基本功能。

常用方法
  • 添加元素

    • boolean add(E e):向集合中添加一个元素,如果添加成功则返回true,否则返回false。比如:
Collection<String> collection = new ArrayList<>();
boolean added = collection.add("apple");
System.out.println(added);  // 输出 true
  • boolean addAll(Collection<? extends E> c):将指定集合中的所有元素添加到当前集合中,如果当前集合因为这个操作而发生了改变,则返回true。例如:
Collection<String> collection1 = new ArrayList<>();
collection1.add("apple");
collection1.add("banana");

Collection<String> collection2 = new ArrayList<>();
collection2.add("cherry");

boolean addedAll = collection1.addAll(collection2);
System.out.println(addedAll);  // 输出 true
System.out.println(collection1);  // 输出 [apple, banana, cherry]
  • 删除元素

    • boolean remove(Object o):从集合中移除指定的元素,如果移除成功则返回true,否则返回false。比如:
Collection<String> collection = new ArrayList<>();
collection.add("apple");
boolean removed = collection.remove("apple");
System.out.println(removed);  // 输出 true
  • boolean removeAll(Collection<?> c):从当前集合中移除指定集合中包含的所有元素,如果当前集合因为这个操作而发生了改变,则返回true。例如:
Collection<String> collection1 = new ArrayList<>();
collection1.add("apple");
collection1.add("banana");
collection1.add("cherry");

Collection<String> collection2 = new ArrayList<>();
collection2.add("banana");

boolean removedAll = collection1.removeAll(collection2);
System.out.println(removedAll);  // 输出 true
System.out.println(collection1);  // 输出 [apple, cherry]
  • 查找元素

    • boolean contains(Object o):判断集合中是否包含指定的元素,如果包含则返回true,否则返回false。比如:
Collection<String> collection = new ArrayList<>();
collection.add("apple");
boolean contains = collection.contains("apple");
System.out.println(contains);  // 输出 true
  • boolean containsAll(Collection<?> c):判断当前集合是否包含指定集合中的所有元素,如果包含则返回true,否则返回false。例如:
Collection<String> collection1 = new ArrayList<>();
collection1.add("apple");
collection1.add("banana");

Collection<String> collection2 = new ArrayList<>();
collection2.add("apple");

boolean containsAll = collection1.containsAll(collection2);
System.out.println(containsAll);  // 输出 true
  • 其他方法

    • int size():返回集合中元素的个数。

    • boolean isEmpty():判断集合是否为空,如果集合中没有元素则返回true,否则返回false

    • void clear():清空集合中的所有元素。

    • Object[] toArray():将集合转换为一个数组。

    • <T> T[] toArray(T[] a):将集合转换为指定类型的数组。

3.3 Map 接口详解

Map接口是 Java 集合框架中用于存储键值对(Key - Value)数据的接口,它提供了一种通过键来快速访问对应值的数据结构,就像我们日常生活中的字典,通过单词(键)可以查到对应的释义(值)。

常用方法
  • 添加键值对

    • V put(K key, V value):将指定的键值对添加到Map中,如果Map中已经存在该键,则会用新的值替换旧的值,并返回旧值;如果Map中不存在该键,则直接添加键值对,并返回null。例如:
Map<String, Integer> map = new HashMap<>();
Integer oldValue = map.put("apple", 1);
System.out.println(oldValue);  // 输出 null

oldValue = map.put("apple", 2);
System.out.println(oldValue);  // 输出 1
  • 获取值

    • V get(Object key):根据指定的键获取对应的值,如果Map中不存在该键,则返回null。比如:
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
Integer value = map.get("apple");
System.out.println(value);  // 输出 1

value = map.get("banana");
System.out.println(value);  // 输出 null
  • 删除键值对

    • V remove(Object key):从Map中移除指定键对应的键值对,并返回被移除的值,如果Map中不存在该键,则返回null。例如:
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
Integer removedValue = map.remove("apple");
System.out.println(removedValue);  // 输出 1

removedValue = map.remove("banana");
System.out.println(removedValue);  // 输出 null
  • 判断是否包含键或值

    • boolean containsKey(Object key):判断Map中是否包含指定的键,如果包含则返回true,否则返回false

    • boolean containsValue(Object value):判断Map中是否包含指定的值,如果包含则返回true,否则返回false

Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);

boolean containsKey = map.containsKey("apple");
System.out.println(containsKey);  // 输出 true

boolean containsValue = map.containsValue(1);
System.out.println(containsValue);  // 输出 true
  • 获取所有键或值的集合

    • Set<K> keySet():返回Map中所有键的集合,类型为Set,因为键是唯一的。

    • Collection<V> values():返回Map中所有值的集合,类型为Collection,值可以重复。

    • Set<Map.Entry<K, V>> entrySet():返回Map中所有键值对的集合,每个键值对被封装成一个Entry对象,类型为Set

Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);

Set<String> keySet = map.keySet();
System.out.println(keySet);  // 输出 [apple, banana]

Collection<Integer> values = map.values();
System.out.println(values);  // 输出 [1, 2]

Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
for (Map.Entry<String, Integer> entry : entrySet) {
    System.out.println(entry.getKey() + " : " + entry.getValue());
}
// 输出 apple : 1
//      banana : 2

Collection接口和Map接口是 Java 集合框架的核心,它们定义了丰富的方法来操作集合和键值对数据,熟练掌握这些接口及其方法,是成为 Java 集合高手的关键一步。

四、List 集合 —— 有序可重复的宝藏库

在 Java 集合家族中,List集合就像是一个有序的宝藏库,它允许元素重复,并且每个元素都有其对应的顺序索引,就像电影院里的座位,每个座位都有编号,而且不同的人可以坐在相同编号的不同场次座位上。List集合提供了丰富的方法来操作这些元素,让我们可以方便地管理和访问数据。下面,我们就来深入了解一下List集合的几个重要实现类。

4.1 ArrayList

ArrayListList接口的一个常用实现类,它基于动态数组实现,就像是一个可以自动调整大小的数组。当我们向ArrayList中添加元素时,如果当前数组的容量不足,它会自动扩容,创建一个更大的数组,并将原来数组中的元素复制到新数组中。

底层实现原理

ArrayList的底层是一个Object类型的数组elementData,用来存储元素。在创建ArrayList对象时,如果使用无参构造函数,会初始化一个空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA,当第一次添加元素时,会将数组扩容为默认容量 10;如果使用带初始容量参数的构造函数,则会创建一个指定大小的数组。

// ArrayList的部分源码
private static final int DEFAULT_CAPACITY = 10;
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
transient Object[] elementData;
private int size;

public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
    }
}
扩容机制

当向ArrayList中添加元素时,如果当前数组的容量不足以容纳新元素,就会触发扩容机制。扩容时,新的容量会变为原来容量的 1.5 倍(int newCapacity = oldCapacity + (oldCapacity >> 1);) ,然后使用Arrays.copyOf方法将原来数组中的元素复制到新数组中。

private void grow(int minCapacity) {
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    elementData = Arrays.copyOf(elementData, newCapacity);
}
优缺点和适用场景
  • 优点

    • 随机访问效率高:由于底层是数组,通过索引可以直接访问元素,时间复杂度为 O (1),就像通过座位号可以快速找到对应的座位。

    • 遍历方便:可以使用普通for循环或者增强for循环进行遍历,代码简洁明了。

    • 适用于数据量相对稳定的场景:如果数据的规模相对稳定,不会频繁进行大量的插入和删除操作,尤其是在中间位置的插入和删除,那么ArrayList是比较合适的选择。

  • 缺点

    • 插入和删除效率较低:在数组中间插入或删除元素时,需要移动大量元素,时间复杂度为 O (n),就像在电影院中间的座位插入或删除一个人,需要让后面的人都移动位置。

    • 扩容时性能开销大:扩容时需要创建新数组并复制元素,当数据量较大时,这个过程会比较耗时。

    • 线程不安全ArrayList不是线程安全的,如果多个线程同时操作一个ArrayList,可能会出现数据不一致的问题。

  • 适用场景

    • 需要频繁随机访问元素的场景:比如实现一个学生成绩管理系统,需要频繁根据学生的序号获取成绩,这时使用ArrayList就非常合适。

    • 数据量变化不大且以查询为主的场景:例如存储一个城市的固定公交线路站点信息,线路站点一般不会频繁变动,而经常需要查询某个站点的位置,ArrayList能够很好地满足这种需求。

代码示例
import java.util.ArrayList;
import java.util.List;

public class ArrayListExample {
    public static void main(String[] args) {
        // 创建一个ArrayList对象
        List<String> list = new ArrayList<>();

        // 添加元素
        list.add("apple");
        list.add("banana");
        list.add("cherry");

        // 打印集合
        System.out.println("List: " + list);

        // 获取指定位置的元素
        String element = list.get(1);
        System.out.println("Element at index 1: " + element);

        // 修改指定位置的元素
        list.set(1, "orange");
        System.out.println("List after modification: " + list);

        // 删除指定位置的元素
        String removedElement = list.remove(2);
        System.out.println("Removed element: " + removedElement);
        System.out.println("List after removal: " + list);

        // 遍历集合
        System.out.println("Traversing the list:");
        for (String s : list) {
            System.out.println(s);
        }
    }
}

4.2 LinkedList

LinkedList也是List接口的一个重要实现类,它的底层是基于双向链表实现的,每个节点包含元素值、指向前一个节点的引用和指向后一个节点的引用,就像一条链子,每个环节都连接着前后两个环节。

底层实现原理

LinkedList内部定义了一个Node静态内部类来表示链表节点,每个节点包含三个部分:元素值item、指向前一个节点的引用prev和指向后一个节点的引用nextLinkedList还维护了两个重要的引用firstlast,分别指向链表的第一个节点和最后一个节点。

// LinkedList的部分源码
private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;

    Node(Node<E> prev, E element, Node<E> next) {
        this.item = element;
        this.next = next;
        this.prev = prev;
    }
}

transient int size = 0;
transient Node<E> first;
transient Node<E> last;
增删改查的时间复杂度
  • 添加元素:在链表末尾添加元素的时间复杂度为 O (1),因为只需要修改几个指针的指向;在链表中间插入元素时,虽然插入操作本身的时间复杂度为 O (1),但需要先找到插入位置,这个查找过程的时间复杂度为 O (n),所以总的时间复杂度为 O (n)。

  • 删除元素:删除链表中的某个元素时,同样需要先找到该元素,查找时间复杂度为 O (n),找到后删除操作本身的时间复杂度为 O (1),因此总的时间复杂度为 O (n)。

  • 修改元素:修改指定位置的元素时,首先要找到该位置的节点,查找时间复杂度为 O (n),找到后修改元素值的时间复杂度为 O (1),总体时间复杂度为 O (n)。

  • 查找元素:由于链表不支持随机访问,查找指定位置的元素需要从链表头或链表尾开始遍历,时间复杂度为 O (n)。

优缺点和适用场景
  • 优点

    • 插入和删除效率高:在链表的任意位置插入或删除元素,只需修改几个指针的指向,时间复杂度为 O (1)(不考虑查找位置的时间),非常适合需要频繁进行插入和删除操作的场景。

    • 内存利用率高:链表的内存分配是动态的,不需要预先分配大量连续的内存空间,当链表中的元素数量变化时,不会造成内存的浪费。

    • 适合动态大小调整:不需要像ArrayList那样进行数组扩容或缩容操作,因此在不确定集合大小或需要频繁调整大小时更为灵活。

  • 缺点

    • 随机访问效率低:查找指定位置的元素需要遍历链表,时间复杂度为 O (n),相比ArrayList的随机访问效率低很多。

    • 遍历效率低:由于不能像数组那样通过索引直接访问元素,遍历链表时需要依次访问每个节点,效率相对较低。

    • 内存开销大:每个节点除了存储元素值外,还需要额外存储两个指针,用于指向前一个节点和后一个节点,因此内存占用比ArrayList大。

  • 适用场景

    • 需要频繁插入和删除元素的场景:比如实现一个聊天软件的消息列表,用户可能会频繁地发送和撤回消息,使用LinkedList可以高效地处理这些操作。

    • 数据量不确定且变化频繁的场景:例如实现一个任务队列,任务的数量和顺序可能随时发生变化,LinkedList能够很好地适应这种动态变化。

代码示例
import java.util.LinkedList;
import java.util.List;

public class LinkedListExample {
    public static void main(String[] args) {
        // 创建一个LinkedList对象
        List<String> list = new LinkedList<>();

        // 添加元素
        list.add("apple");
        list.add("banana");
        list.add("cherry");

        // 打印集合
        System.out.println("List: " + list);

        // 获取指定位置的元素
        String element = list.get(1);
        System.out.println("Element at index 1: " + element);

        // 修改指定位置的元素
        list.set(1, "orange");
        System.out.println("List after modification: " + list);

        // 删除指定位置的元素
        String removedElement = list.remove(2);
        System.out.println("Removed element: " + removedElement);
        System.out.println("List after removal: " + list);

        // 遍历集合
        System.out.println("Traversing the list:");
        for (String s : list) {
            System.out.println(s);
        }
    }
}

4.3 Vector

Vector同样是List接口的一个实现类,它的底层也是基于动态数组实现的,和ArrayList有很多相似之处,但Vector是线程安全的,就像一个有专人维护秩序的宝藏库,多个线程同时访问时也不会出现混乱。

底层实现原理

Vector的底层也是一个Object类型的数组elementData,用于存储元素。它也有一个容量和当前元素个数的属性,当元素个数超过容量时,会进行扩容操作。

// Vector的部分源码
protected Object[] elementData;
protected int elementCount;
protected int capacityIncrement;

public Vector() {
    this(10);
}

public Vector(int initialCapacity) {
    this(initialCapacity, 0);
}

public Vector(int initialCapacity, int capacityIncrement) {
    super();
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
    this.elementData = new Object[initialCapacity];
    this.capacityIncrement = capacityIncrement;
}
与 ArrayList 的区别(线程安全)

VectorArrayList最大的区别在于线程安全性。Vector的大部分方法都使用synchronized关键字进行同步,保证在多线程环境下的安全访问;而ArrayList没有进行同步处理,在多线程环境下可能会出现数据不一致的问题。例如,当一个线程在读取ArrayList中的元素时,另一个线程可能正在修改这个ArrayList,导致读取到的数据不准确。

// Vector的add方法,使用了synchronized关键字
public synchronized boolean add(E e) {
    modCount++;
    ensureCapacityHelper(elementCount + 1);
    elementData[elementCount++] = e;
    return true;
}

// ArrayList的add方法,没有使用同步
public boolean add(E e) {
    ensureCapacityInternal(size + 1);
    elementData[size++] = e;
    return true;
}
优缺点和适用场景
  • 优点

    • 线程安全:在多线程环境下可以安全地使用,不需要额外的同步措施,这使得Vector在多线程并发访问的场景中非常有用。

    • 功能丰富:提供了和ArrayList类似的丰富方法,如添加、删除、查找元素等,能够满足大多数集合操作的需求。

  • 缺点

    • 性能较低:由于使用了同步机制,在单线程环境下,Vector的性能比ArrayList低,因为同步操作会带来额外的开销。

    • 扩容策略不同Vector的扩容策略和ArrayList有所不同,默认情况下,Vector扩容时会将容量增加一倍,如果指定了容量增量capacityIncrement,则每次扩容时会增加capacityIncrement个容量;而ArrayList扩容时是增加原来容量的一半。这种扩容策略可能会导致Vector在某些情况下占用更多的内存。

  • 适用场景

    • 多线程环境下需要线程安全的集合:比如在一个多线程的服务器应用中,多个线程可能同时访问和修改一个共享的集合,这时使用Vector可以确保数据的一致性和线程安全。

    • 对线程安全有严格要求且数据量不是特别大的场景:由于Vector的性能在多线程环境下相对较低,当数据量非常大时,可能会影响系统的整体性能,因此更适合数据量不是特别大但对线程安全要求较高的场景。

代码示例
import java.util.Vector;

public class VectorExample {
    public static void main(String[] args) {
        // 创建一个Vector对象
        Vector<String> vector = new Vector<>();

        // 添加元素
        vector.add("apple");
        vector.add("banana");
        vector.add("cherry");

        // 打印集合
        System.out.println("Vector: " + vector);

        // 获取指定位置的元素
        String element = vector.get(1);
        System.out.println("Element at index 1: " + element);

        // 修改指定位置的元素
        vector.set(1, "orange");
        System.out.println("Vector after modification: " + vector);

        // 删除指定位置的元素
        String removedElement = vector.remove(2);
        System.out.println("Removed element: " + removedElement);
        System.out.println("Vector after removal: " + vector);

        // 遍历集合
        System.out.println("Traversing the vector:");
        for (String s : vector) {
            System.out.println(s);
        }
    }
}

ArrayListLinkedListVector虽然都实现了List接口,但它们在底层实现、性能特点和适用场景上都有所不同。在实际开发中,我们需要根据具体的需求来选择合适的集合类,就像选择工具一样,只有选择了最合适的工具,才能高效地完成任务。

五、Set 集合 —— 无序不可重复的神秘花园

在 Java 集合的世界里,Set集合就像是一个神秘的花园,它里面的元素无序且不可重复,每一个元素都是独一无二的存在。Set集合主要有三个常用的实现类:HashSetTreeSetLinkedHashSet,它们各自有着独特的特点和用途。接下来,就让我们一起走进这个神秘的花园,探索它们的奥秘吧!

5.1 HashSet

HashSetSet接口的一个常用实现类,它的底层是基于HashMap实现的,就像是在HashMap的基础上搭建了一个独特的 “花园”。

底层实现原理

HashSet内部维护了一个HashMap对象,当我们向HashSet中添加元素时,实际上是将元素作为HashMap的键(Key)存储进去,而HashMap的值(Value)统一为一个固定的常量PRESENT ,这个常量只是一个占位符,没有实际的意义。

// HashSet的部分源码
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();

public HashSet() {
    map = new HashMap<>();
}

public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}
元素唯一性的实现机制

HashSet利用HashMap的键唯一性来保证元素的唯一性。当添加元素时,HashSet会先计算元素的哈希值(通过元素的hashCode()方法),然后根据哈希值确定该元素在HashMap中的存储位置(桶索引)。如果该位置已经存在元素,就会调用元素的equals()方法与已存在的元素进行比较,如果两个元素的equals()方法返回true,则认为这两个元素是重复的,不会进行添加操作;如果equals()方法返回false,则会将新元素添加到HashMap中。

例如,我们自定义一个类Person,并向HashSet中添加Person对象:

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

class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 重写hashCode方法
    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + name.hashCode();
        result = 31 * result + age;
        return result;
    }

    // 重写equals方法
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return age == person.age && name.equals(person.name);
    }

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

public class HashSetExample {
    public static void main(String[] args) {
        Set<Person> set = new HashSet<>();
        set.add(new Person("Alice", 20));
        set.add(new Person("Bob", 25));
        set.add(new Person("Alice", 20)); // 重复元素,不会添加成功

        for (Person person : set) {
            System.out.println(person);
        }
    }
}

在这个例子中,Person类重写了hashCode()equals()方法,以确保HashSet能够正确判断元素的唯一性。运行结果只会输出两个不同的Person对象,说明重复的元素没有被添加到HashSet中。

优缺点和适用场景
  • 优点

    • 插入、删除和查找效率高:在理想情况下,HashSet的这些操作的平均时间复杂度为 O (1),因为它是基于哈希表实现的,通过哈希值可以快速定位元素的存储位置。

    • 去重功能强大:能够自动去除重复元素,非常适合需要保证元素唯一性的场景。

  • 缺点

    • 不保证元素的顺序HashSet中的元素是无序的,遍历HashSet时,元素的顺序可能与添加的顺序不同。

    • 依赖哈希值和equals()方法:如果自定义类没有正确重写hashCode()equals()方法,可能会导致HashSet无法正确判断元素的唯一性。

  • 适用场景

    • 需要快速查找和去重的场景:比如统计一篇文章中出现的单词,使用HashSet可以快速判断某个单词是否已经出现过,并且能高效地存储所有不同的单词。

    • 对元素顺序没有要求的场景:例如实现一个简单的用户权限管理系统,只需要知道用户是否拥有某个权限,而不需要关心权限的顺序,这时HashSet是一个不错的选择。

5.2 TreeSet

TreeSetSet接口的另一个重要实现类,它的底层是基于红黑树(一种自平衡的二叉搜索树)实现的,就像是在红黑树的基础上精心打造的一个有序花园。

底层实现原理

TreeSet内部维护了一个TreeMap对象,当向TreeSet中添加元素时,元素会作为TreeMap的键存储,而TreeMap的值同样是一个固定的常量PRESENT。红黑树的特性保证了TreeSet中的元素是有序的,并且插入、删除和查找操作的时间复杂度都为 O (log n)。

// TreeSet的部分源码
private transient NavigableMap<E,Object> m;
private static final Object PRESENT = new Object();

public TreeSet() {
    this(new TreeMap<>());
}

public boolean add(E e) {
    return m.put(e, PRESENT)==null;
}
元素排序的实现机制

TreeSet支持两种排序方式:自然排序和自定义排序。

  • 自然排序:元素必须实现Comparable接口,通过compareTo()方法定义排序规则。例如,Integer类和String类都实现了Comparable接口,所以可以直接使用自然排序。
import java.util.TreeSet;

public class TreeSetNaturalOrderExample {
    public static void main(String[] args) {
        TreeSet<Integer> set = new TreeSet<>();
        set.add(3);
        set.add(1);
        set.add(2);

        for (Integer num : set) {
            System.out.println(num);
        }
    }
}

运行结果会按照升序输出 1、2、3,因为Integer类实现了Comparable接口,其compareTo()方法定义了升序的比较规则。

  • 自定义排序:通过构造方法TreeSet(Comparator<? super E> comparator)传入一个自定义的比较器(Comparator),覆盖自然排序规则。例如,我们可以定义一个按照字符串长度排序的比较器:
import java.util.Comparator;
import java.util.TreeSet;

public class TreeSetCustomOrderExample {
    public static void main(String[] args) {
        TreeSet<String> set = new TreeSet<>(Comparator.comparingInt(String::length));
        set.add("apple");
        set.add("banana");
        set.add("cherry");

        for (String str : set) {
            System.out.println(str);
        }
    }
}

运行结果会按照字符串长度升序输出applecherrybanana,因为我们传入的比较器定义了按照字符串长度排序的规则。

优缺点和适用场景
  • 优点

    • 元素有序TreeSet中的元素会按照自然顺序或自定义顺序排序,非常适合需要有序存储元素的场景。

    • 高效的范围查询:提供了一些方法,如subSet(E fromElement, E toElement)headSet(E toElement)tailSet(E fromElement),可以高效地进行范围查询。

  • 缺点

    • 插入、删除和查找操作的时间复杂度相对较高:由于是基于红黑树实现,时间复杂度为 O (log n),相比HashSet的 O (1) 要慢一些。

    • 元素必须实现Comparable接口或提供自定义比较器:如果元素不满足这个条件,会抛出ClassCastException异常。

  • 适用场景

    • 需要对元素进行排序的场景:比如存储学生的成绩,需要按照成绩从高到低或从低到高进行排序,使用TreeSet可以轻松实现。

    • 需要进行范围查询的场景:例如在一个存储员工年龄的集合中,查找年龄在某个范围内的员工,TreeSet的范围查询方法可以高效地完成这个任务。

5.3 LinkedHashSet

LinkedHashSetHashSet的一个子类,它的底层是基于LinkedHashMap实现的,就像是在HashSet的花园里又添加了一条有序的小径,既保证了元素的唯一性,又维护了元素的插入顺序。

底层实现原理

LinkedHashSet内部维护了一个LinkedHashMap对象,LinkedHashMap继承自HashMap,它在HashMap的基础上增加了一个双向链表,用于维护元素的插入顺序或访问顺序(默认是插入顺序)。当向LinkedHashSet中添加元素时,元素会作为LinkedHashMap的键存储,值同样是PRESENT

// LinkedHashSet的部分源码
public class LinkedHashSet<E>
    extends HashSet<E>
    implements Set<E>, Cloneable, java.io.Serializable {

    public LinkedHashSet() {
        super(16, .75f, true);
    }

    // 其他构造方法省略
}

// HashSet中为LinkedHashSet提供的构造方法
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
    map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
元素有序性和唯一性的实现机制
  • 元素唯一性:和HashSet一样,利用LinkedHashMap的键唯一性来保证元素的唯一性,通过计算元素的哈希值和调用equals()方法来判断元素是否重复。

  • 元素有序性:通过LinkedHashMap维护的双向链表来保证元素的插入顺序。在添加元素时,新元素会被添加到双向链表的末尾;在遍历LinkedHashSet时,会按照双向链表的顺序依次访问元素,从而保证了遍历顺序与插入顺序一致。

优缺点和适用场景
  • 优点

    • 保证元素的插入顺序:在需要保持元素插入顺序的场景中非常有用,比如记录用户的操作历史,使用LinkedHashSet可以按照操作的先后顺序存储操作记录。

    • 具有HashSet的高效性:由于底层基于HashMap,插入、删除和查找操作的平均时间复杂度仍然为 O (1),同时又保证了元素的唯一性。

  • 缺点

    • 额外的内存开销:因为需要维护双向链表,相比HashSet会占用更多的内存空间。
  • 适用场景

    • 需要保持元素插入顺序且去重的场景:比如实现一个简单的浏览器历史记录功能,使用LinkedHashSet可以存储用户访问过的网址,并且按照访问顺序显示,同时去除重复的网址。

    • 对内存空间要求不是特别严格的场景:由于会占用额外的内存,所以在内存资源紧张的情况下,需要谨慎使用。

HashSetTreeSetLinkedHashSet虽然都实现了Set接口,但它们在底层实现、元素特性和适用场景上都有所不同。在实际开发中,我们需要根据具体的需求来选择合适的Set实现类,让这个神秘的花园绽放出最绚丽的光彩。

六、Queue 集合 —— 按特定规则排队的队伍

在 Java 集合的大家庭中,Queue集合就像是一条按特定规则排队的队伍,它遵循先进先出(FIFO,First In First Out)的原则,就像我们日常生活中排队买票、排队上车一样,先到的先处理。Queue集合在很多场景中都有着重要的应用,比如任务调度、消息传递等。接下来,我们就来深入了解一下Queue集合的两个重要实现类:LinkedListPriorityQueue

6.1 队列的基本概念

队列是一种特殊的线性数据结构,它的特点是只允许在一端进行插入操作,而在另一端进行删除操作。这就好比一个单行道,车辆只能从一端进入,从另一端驶出。在队列中,进行插入操作的一端称为队尾(Rear),进行删除操作的一端称为队首(Front)。

队列的常见操作有以下几种:

  • 入队(Enqueue):将元素添加到队尾的操作,就像在队伍的末尾加入一个人。例如,在 Java 的Queue接口中,add(E e)offer(E e)方法都可以用于入队操作。add方法在添加元素成功时返回true,如果队列已满(对于有容量限制的队列),则会抛出IllegalStateException异常;而offer方法添加成功时返回true,添加失败(队列已满)时返回false

  • 出队(Dequeue):从队首移除并返回元素的操作,就像队伍中第一个人完成事务后离开队伍。在Queue接口中,remove()poll()方法用于出队操作。remove方法移除并返回队首元素,如果队列为空,会抛出NoSuchElementException异常;poll方法同样移除并返回队首元素,但队列为空时返回null

  • 查看队首元素(Peek):获取队首元素,但不将其从队列中移除,就像看看队伍的第一个人是谁,但他还在队伍里。element()peek()方法可以实现这个功能。element方法返回队首元素,如果队列为空,抛出NoSuchElementException异常;peek方法返回队首元素,队列为空时返回null

6.2 LinkedList 作为队列

LinkedList不仅是List接口的实现类,还实现了Queue接口,这使得它可以方便地作为队列来使用。由于LinkedList基于双向链表实现,在队列的两端进行插入和删除操作的时间复杂度都为 O (1),非常高效。

实现队列功能的方式

LinkedList作为队列使用时,我们可以使用Queue接口中定义的方法:

  • 入队操作:使用offer(E e)方法将元素添加到队列的末尾,例如:
Queue<String> queue = new LinkedList<>();
queue.offer("apple");
queue.offer("banana");
  • 出队操作:使用poll()方法从队列的头部移除并返回元素,例如:
String element = queue.poll();
System.out.println(element);  // 输出 apple
  • 查看队首元素:使用peek()方法获取队列头部的元素,但不移除它,例如:
String peekElement = queue.peek();
System.out.println(peekElement);  // 输出 banana
优缺点和适用场景
  • 优点

    • 高效的插入和删除操作:在队列的两端进行插入和删除操作的时间复杂度为 O (1),非常适合需要频繁进行插入和删除操作的场景,比如实现一个消息队列,消息可以快速地入队和出队。

    • 灵活的使用场景LinkedList既可以作为队列使用,也可以作为双端队列(Deque)使用,还可以作为普通的列表使用,具有很高的灵活性。

    • 允许空队列:与某些数组实现的队列不同,LinkedList作为队列时允许空队列存在,不会因为空数组而抛出异常。

  • 缺点

    • 随机访问性能差:由于基于链表实现,随机访问元素的时间复杂度为 O (n),如果需要频繁进行随机访问操作,LinkedList不是一个好的选择。

    • 内存开销大:相比于数组实现的队列,LinkedList需要额外的内存来存储节点的指针,每个节点除了存储元素本身,还需要存储指向前一个节点和后一个节点的引用。

  • 适用场景

    • 需要快速插入和删除元素的场景:如实现一个打印任务队列,打印任务可以快速地添加到队列中,并且按照添加的顺序依次被处理。

    • 作为队列或双端队列使用:特别是在需要从两端进行元素添加或移除的操作中,LinkedList能够很好地满足需求。例如,在实现一个简单的浏览器历史记录功能时,可以使用LinkedList作为双端队列,用户可以向前和向后浏览历史记录,新的访问记录可以添加到队列的一端,而最早的记录可以从另一端移除。

6.3 PriorityQueue

PriorityQueue是一个基于优先级的队列,它的出队顺序与元素的优先级有关,而不是简单的先进先出。这就好比在一个医院的急诊室里,病情严重的患者会优先得到治疗,而不是按照到达的先后顺序。

底层实现原理(基于堆)

PriorityQueue的底层是基于堆(一种特殊的完全二叉树)实现的。堆有两个关键特性:结构性和堆序性。结构性指除了最后一层外,其他层都是完全填满的,最后一层从左到右填充;堆序性指每个节点都大于等于(大顶堆)或小于等于(小顶堆)其子节点。PriorityQueue默认实现小顶堆,即根节点是最小元素。

在 Java 中,堆是通过数组来实现的,通过索引可以计算父子关系:对于索引i的节点,父节点索引为(i - 1) >>> 1(无符号右移,等同于(i - 1) / 2但性能更优),左子节点索引为2 * i + 1,右子节点索引为2 * i + 2

元素优先级的实现机制

PriorityQueue中元素的优先级可以通过两种方式确定:

  • 自然顺序:元素实现Comparable接口,通过compareTo()方法定义元素之间的比较规则,从而确定优先级。例如,Integer类实现了Comparable接口,所以PriorityQueue<Integer>中的元素会按照数值大小进行排序,数值小的优先级高。
PriorityQueue<Integer> queue = new PriorityQueue<>();
queue.add(3);
queue.add(1);
queue.add(2);
while (!queue.isEmpty()) {
    System.out.println(queue.poll());
}
// 输出 1
//      2
//      3
  • 自定义比较器:在创建PriorityQueue时传入一个Comparator接口的实现类,通过compare(T o1, T o2)方法来定义元素的比较规则。例如,我们可以定义一个按照字符串长度从大到小排序的比较器:
PriorityQueue<String> queue = new PriorityQueue<>(Comparator.comparingInt(String::length).reversed());
queue.add("apple");
queue.add("banana");
queue.add("cherry");
while (!queue.isEmpty()) {
    System.out.println(queue.poll());
}
// 输出 banana
//      cherry
//      apple
优缺点和适用场景
  • 优点

    • 高效的元素获取:获取优先级最高(或最低)的元素的时间复杂度为 O (1),因为堆顶元素就是优先级最高(或最低)的元素。

    • 自动排序:根据元素的优先级自动对元素进行排序,不需要手动进行排序操作。

  • 缺点

    • 不保证同优先级元素的顺序:对于优先级相同的元素,PriorityQueue不保证它们的出队顺序与入队顺序一致。

    • 不允许存储null元素PriorityQueue中不允许存储null元素,否则会抛出NullPointerException异常。

  • 适用场景

    • 需要按照优先级处理元素的场景:如实现一个任务调度系统,任务可以根据优先级被优先执行,高优先级的任务会先被从队列中取出并执行。

    • 在海量数据中获取前k个最大(或最小)元素:利用PriorityQueue可以高效地实现这个功能,例如在一个包含大量学生成绩的集合中,获取成绩最高的前 10 名学生。

Queue集合的LinkedListPriorityQueue实现类在不同的场景中都有着重要的应用。LinkedList适合需要频繁插入和删除元素的场景,而PriorityQueue则适用于需要按照优先级处理元素的场景。在实际开发中,我们要根据具体的需求选择合适的队列实现类,让程序更加高效和灵活。

下一篇一文吃透Java集合:从基础到实战,带你玩转数据容器(下)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值