Java 核心知识总结 集合-List 和 Set

232 阅读11分钟

集合概述

🔥什么是集合?集合有哪些?

Java 集合(容器)主要由两大接口派生而来:一个是 Collection 接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。对于Collection 接口,下面又有三个主要的子接口:List、Set 和 Queue。

说说 List、Set、Map 三者的区别?

  • List:存储的元素是有序的、可重复的。
  • Set:存储的元素是无序的、不可重复的;Set 就是所有 key 都指向同一个 value 的 Map,这个 value 是一个 Object 对象。
  • Map:使用键值对(key-value)存储,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。

🔥List、Set、Map 底层数据结构是什么?

List:

  • ArrayList: Object[] 数组。
  • Vector:Object[] 数组。
  • LinkedList: 双向链表(JDK 1.6 之前为循环链表,JDK 1.7 取消了循环)。

Set:

  • HashSet(无序,唯一):基于 HashMap 实现,底层采用 HashMap 来保存元素。
  • LinkedHashSet:LinkedHashSet 是 HashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。
  • TreeSet(有序,唯一):红黑树(自平衡的排序二叉树)。

Map:

  • HashMap:
    • JDK 1.8 之前 HashMap 是由数组 + 链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。
    • JDK 1.8 以后是由数组 + 链表 + 红黑树组成的,在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间。执行转换之前如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。
  • LinkedHashMap: LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组 + 链表 + 红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
  • Hashtable: 数组 + 链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突而存在的。它是 HashMap 的古老实现类,与 HashMap 不同,它是线程安全的。
  • TreeMap: 红黑树(自平衡的排序二叉树)。

如何选用集合?

主要根据集合的特点来选用,比如我们需要根据键值获取到元素值时就选用 Map 接口下的集合,需要排序时选择 TreeMap,不需要排序时就选择 HashMap,需要保证线程安全就选用 ConcurrentHashMap

当我们只需要存放元素值时,就选择实现Collection 接口的集合,需要保证元素唯一时选择实现 Set 接口的集合比如 TreeSetHashSet,不需要就选择实现 List 接口的比如 ArrayListLinkedList,然后再根据实现这些接口的集合的特点来选用。

为什么要使用集合?

当我们需要保存一组类型相同的数据的时候,我们应该是用一个容器来保存,这个容器就是数组,但是,使用数组存储对象具有一定的弊端。

  1. 数组的缺点是一旦声明之后,长度就不可变了;
  2. 声明数组时的数据类型也决定了该数组存储的数据的类型;
  3. 数组存储的数据是有序的、可重复的。

集合提高了数据存储的灵活性,Java 集合不仅可以用来存储不同类型不同数量的对象,还可以保存具有映射关系的数据。

Collection 子接口之 List

ArrayList 和 Array(数组)的区别?

ArrayList 内部基于动态数组实现,比 Array(静态数组) 使用起来更加灵活:

  • ArrayList 会根据实际存储的元素动态地扩容或缩容,而 Array 被创建之后就不能改变它的长度了。
  • ArrayList 允许你使用泛型来确保类型安全,Array 则不可以。
  • ArrayList 中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。Array 可以直接存储基本类型数据,也可以存储对象。
  • ArrayList 支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如 add()、remove()等。Array 只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。
  • ArrayList 创建时不需要指定大小,而Array创建时必须指定大小。

ArrayList 和 Vector 的区别?

  • ArrayList 是 List 的主要实现类,底层使用 Object[]存储,适用于频繁的查找工作,线程不安全;
  • Vector 是 List 的古老实现类,底层使用Object[] 存储,线程安全。

Vector 和 Stack 的区别?

  • Vector 和 Stack 两者都是线程安全的,都是使用 synchronized 关键字进行同步处理
  • Stack 继承自 Vector,是一个后进先出的栈,而 Vector 是一个列表

随着 Java 并发编程的发展,Vector 和 Stack 已经被淘汰,推荐使用并发集合类(例如 ConcurrentHashMap、CopyOnWriteArrayList 等)或者手动实现线程安全的方法来提供安全的多线程操作支持

🔥ArrayList 与 LinkedList 区别?

  • 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,不保证线程安全;
  • 底层数据结构: ArrayList 底层使用的是 Object 数组;LinkedList 底层使用的是双向链表(JDK1.6 之前为双向循环链表,JDK1.7 取消了循环)
  • 插入和删除是否受元素位置的影响:
    • ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element))时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的 (n-i) 个元素都要执行向后位/向前移一位的操作。
    • LinkedList 采用链表存储,所以,如果是在头尾插入或者删除元素不受元素位置的影响(add(E e)、addFirst(E e)、addLast(E e)、removeFirst() 、 removeLast()),时间复杂度为 O(1),如果是要在指定位置 i 插入和删除元素的话(add(int index, E element)、remove(Object o)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入。
  • 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,查询时间复杂度为 O(n);而 ArrayList(实现了RandomAccess 接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法),查询时间复杂度为 O(1)。
  • 内存空间占用: ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放数据以及直接后继和直接前驱的位置)。

介绍一下 ArrayList

  1. ArrayList 的特点:
    1. 实现了 List 接口,存储有序的、可以重复的数据,可以存储任何类型的对象,包括 null 值
    2. 底层使用 Object[] 存储
    3. 线程不安全
  2. ArrayList 源码解析:
    1. jdk7 版本:

      // 如下代码的执行:底层会初始化数组,数组的长度为10。
      // Object[] elementData = new Object[10];
      ArrayList<String> list = new ArrayList<>();
      
      list.add("AA"); //elementData[0] = "AA";
      list.add("BB"); //elementData[1] = "BB";
      ...
      
      // 当要添加第11个元素的时候,底层的elementData数组已满,则需要扩容。
      // 默认扩容为原来长度的1.5倍。并将原有数组中的元素复制到新的数组中。
      
    2. jdk8 版本:

      // 如下代码的执行:底层会初始化数组,
      // 即:Object[] elementData = new Object[]{};
      ArrayList<String> list = new ArrayList<>();
      
      list.add("AA"); 
      //首次添加元素时,会初始化数组elementData = new Object[10];elementData[0] = "AA";
      list.add("BB");//elementData[1] = "BB";
      ...
      
      // 当要添加第11个元素的时候,底层的elementData数组已满,则需要扩容。
      // 默认扩容为原来长度的1.5倍。并将原有数组中的元素复制到新的数组中。
      
  3. 小结:
    1. jdk1.7 中,ArrayList 类似于饿汉式
    2. jdk1.8 中,ArrayList 类似于懒汉式

🔥ArrayList 是怎么扩容的?

ArrayList 底层是数组 elementData,用于存放插入的数据。jdk8 中,初始大小是0,当有数据插入时,默认大小 DEFAULT_CAPACITY = 10,在创建时也可以指定 initialCapacity 为初始大小。

扩容分为创建数组和复制两个步骤:

  1. 当添加元素时,如果底层的 elementData 数组已满就进行扩容,创建一个容量为原来 1.5 倍的新数组
  2. 将旧数组内容调用 Arrays.copyof() 复制到新数组中

🔥为什么开辟新的数组而不是在原有数组上扩展?

数组在创建之后长度是不可变的。

🔥为什么数组开辟出来的长度是不可变的?

数组是一种连续的内存结构,创建数组时需要一段连续的内存空间来存储元素。由于内存的分配是在编译时或者运行时确定的,所以数组的长度在创建后就已经固定了。Java 垃圾回收器管理的是一整块内存,如果一个数组的长度可变,那么将难以有效管理其中的空间,从而造成破碎的内存。

介绍一下 LinkedList

  1. LinkedList 的特点:

    1. 实现了 List 接口,存储有序的、可以重复的数据
    2. 底层使用双向链表存储
    3. 线程不安全
  2. LinkedList 在 jdk8 中的源码解析:

    LinkedList<String> list = new LinkedList<>(); //底层也没做啥
    list.add("AA"); 
    // 将"AA"封装到一个Node对象1中,list对象的属性first、last都指向此Node对象1
    list.add("BB"); 
    // 将"BB"封装到一个Node对象2中,对象1和对象2构成一个双向链表,同时last指向此Node对象2
    ...
    
    // 因为LinkedList使用的是双向链表,不需要考虑扩容问题。
    
    // LinkedList内部声明:
    // private static class Node<E> {
    //     E item;
    //     Node<E> next;
    //     Node<E> prev;
    // }
    

介绍一下 Vector

  1. Vector 的特点:

    1. 实现了 List 接口,存储有序的、可以重复的数据
    2. 底层使用 Object[] 数组存储
    3. 线程安全
  2. Vector源码解析:(以jdk1.8.0_271为例)

    Vector v = new Vector(); 
    // 底层初始化数组,长度为10. Object[] elementData = new Object[10];
    v.add("AA"); // elementData[0] = "AA";
    v.add("BB"); // elementData[1] = "BB";
    ...
    // 当添加第11个元素时,需要扩容,默认为原来的两倍
    

Collection 子接口之 Set

介绍一下自然排序和定制排序(compareTo 和 compare)

Comparable 和 Comparator 的区别:

  • Comparable 接口来自 java.lang 包,它的 compareTo(Object obj) 方法用来排序;
  • Comparator 接口来自 java.util 包,它的 compare(Object obj1, Object obj2) 方法用来排序。

自然排序和选择排序的区别(以 TreeSet 为例,默认情况下,TreeSet 采用自然排序)

  • 自然排序:TreeSet 会调用集合元素的 compareTo(Object obj) 方法来比较元素之间的大小关系,然后将集合元素按升序(默认情况)排列。
    • 如果试图把一个对象添加到 TreeSet 时,则该对象的类必须实现 Comparable 接口。
    • 实现 Comparable 的类必须实现 compareTo(Object obj) 方法。
  • 定制排序:如果元素所属的类没有实现 Comparable 接口,或不希望按照默认情况的方式排列元素或希望按照其它属性大小进行排序,则考虑使用定制排序。定制排序,通过 Comparator 接口来实现。需要重写 compare(T o1,T o2) 方法。
    • 利用 int compare(T o1,T o2) 方法,比较 o1 和 o2 的大小:如果方法返回正整数,则表示 o1 大于 o2;如果返回0,表示相等;返回负整数,表示 o1 小于 o2。
    • 要实现定制排序,需要将实现 Comparator 接口的实例作为形参传递给 TreeSet 的构造器。

比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同

  • HashSet、LinkedHashSet 和 TreeSet 都是 Set 接口的实现类,都能保证元素唯一,并且都不是线程安全的。
  • HashSet、LinkedHashSet 和 TreeSet 的主要区别在于底层数据结构不同。HashSet 的底层数据结构是哈希表(基于 HashMap 实现,包括数组、单向链表,JDK 8 还使用了红黑树)。LinkedHashSet 的底层数据结构是双向链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
  • 底层数据结构不同又导致这三者的应用场景不同。HashSet 用于不需要保证元素插入和取出顺序的场景,LinkedHashSet 用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet 用于支持对元素自定义排序规则的场景。

参考内容:

  1. Java 面试指南 | JavaGuide
  2. 《剑指 Java:核心原理与应用实践》

其它参考内容在文章中已有引用