Java集合常见面试题总结

主要参考:JavaGuide小林coding,同时加上网上搜索整理和个人理解总结

1 集合概述

1.1 Java 集合概览🔥

Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 Collection 接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。对于 Collection 接口,下面又有三个主要的子接口:ListSetQueue

Java 集合框架如下图所示:

Java 集合框架概览

  • List :存储的元素是有序的、可重复的。常用的实现List的类有LinkedList,ArrayList
  • Set :存储的元素不可重复的。常用的实现有HashSet,LinkedHashSet和TreeSet。
  • Queue :按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。常用的实现有 PriorityQueue.
  • Map :使用键值对(key-value)存储,key 是无序的、不可重复的,value 是无序的、可重复的。主要实现有TreeMap、HashMap、LinkedHashMap、ConcurrentHashMap。

1.2 数组与集合区别

  • 数组是固定长度的数据结构,一旦创建长度就无法改变,而集合是动态长度的数据结构,可以根据需要动态增加或减少元素。
  • 数组可以包含基本数据类型和对象,而集合只能包含对象。
  • 数组可以直接访问元素,而集合需要通过迭代器或其他方法访问元素。

2 List

2.1 ArrayList底层实现原理🔥

  • 底层数据结构:ArrayList底层是用动态的数组实现的
  • 初始容量:ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10
  • 扩容逻辑:ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
  • 添加逻辑
    • 确保数组已使用长度(size)加1之后足够存下下一个数据
    • 计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
    • 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。
    • 返回添加成功布尔值。

添加数据的流程如下

image-20230427192644244

2.2 如何实现数组和List之间的转换

  • 数组转List ,使用JDK中java.util.Arrays工具类的asList方法
  • List转数组,使用List的toArray方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组
List<String> myList = Arrays.asList(myArray); // 数组转list
str=myList.toArray(new String[0]); // list转数组

继续追问:用Arrays.asList转List后,如果修改了数组内容,list受影响吗?List用toArray转数组后,如果修改了List内容,数组受影响吗?

  • Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
  • list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响

2.3 ArrayList 与 LinkedList 区别?🔥

视频讲解:常见集合篇-08-ArrayList和LinkedList的区别是什么?_哔哩哔哩_bilibili

  • 底层数据结构: ArrayList 底层使用的是动态数组;LinkedList 底层使用的是 双向链表
  • 插入和删除效率不同: ArrayList在尾部的插入和删除操作效率较高,但在中间或开头的插入和删除操作效率较低,需要移动元素。LinkedList在任意位置的插入和删除操作效率都比较高,因为只需要调整节点之间的指针。
  • 随机访问的效率不同:ArrayList支持通过索引进行快速随机访问,时间复杂度为O(1)。LinkedList需要从头或尾开始遍历链表,时间复杂度为O(n)。
  • 空间占用: ArrayList 在创建时需要分配一段连续的内存空间,列表的结尾会预留一定的容量空间。而 LinkedList 的每个节点不光需要存储元素还要存储指针。
  • 使用场景: ArrayList适用于频繁随机访问和尾部的插入删除操作,而LinkedList适用于频繁的中间插入删除操作和不需要随机访问的场景。
  • 是否保证线程安全: ArrayListLinkedList 都不是线程安全的;

2.4 把ArrayList变成线程安全有哪些方法?

  • 使用Collections类的synchronizedList方法将ArrayList包装成线程安全的List:
  • 使用CopyOnWriteArrayList类代替ArrayList,它是一个线程安全的List实现:
  • 使用Vector类代替ArrayList,Vector是线程安全的List实现:

2.5 ArrayList list=new ArrayList(10)中的 list 扩容几次

在 ArrayList 的源码中提供了一个带参数的构造方法,这个参数就是指定的集合初始长度,所以给了一个10的参数,就是指定了集合的初始长度是10,这里面并没有扩容。

2.6 ArrayList 可以添加 null 值吗?

ArrayList 中可以存储任何类型的对象,包括 null 值。不过,不建议向 ArrayList 中添加 null 值, null 值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。

2.7 LinkedList 为什么不能实现 RandomAccess 接口?

RandomAccess 是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 LinkedList 底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 RandomAccess 接口。

3 Set

3.1 Comparable 和 Comparator 的区别

Comparable 接口和 Comparator 接口都是 Java 中用于排序的接口

  • Comparator 接口出自 java.util 包,它有一个 compare(Object obj1, Object obj2) 方法用来排序
// void sort(List list),按自然排序的升序排序
Collections.sort(arrayList);
// 定制排序的用法
Collections.sort(arrayList, new Comparator<Integer>() {
    @Override
    public int compare(Integer o1, Integer o2) {
        return o2.compareTo(o1);
    }
});
  • Comparable 接口出自 java.lang 包,它有一个 compareTo(Object obj) 方法用来排序
// person对象没有实现Comparable接口,所以必须实现
public class Person implements Comparable<Person> {
	// 省略属性定义部分
    @Override
    public int compareTo(Person o) {
        if (this.age > o.getAge()) {
            return 1;
        }
        if (this.age < o.getAge()) {
            return -1;
        }
        return 0;
    }
}

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

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

4 Queue

4.1 Queue 与 Deque 的区别

Queue 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。

Queue 扩展了 Collection 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。

Queue 接口抛出异常返回特殊值
插入队尾add(E e)offer(E e)
删除队首remove()poll()
查询队首元素element()peek()

Deque 是双端队列,在队列的两端均可以插入或删除元素。

Deque 扩展了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:

Deque 接口抛出异常返回特殊值
插入队首addFirst(E e)offerFirst(E e)
插入队尾addLast(E e)offerLast(E e)
删除队首removeFirst()pollFirst()
删除队尾removeLast()pollLast()
查询队首元素getFirst()peekFirst()
查询队尾元素getLast()peekLast()

事实上,Deque 还提供有 push()pop() 等其他方法,可用于模拟栈。

4.2 ArrayDeque 与 LinkedList 的区别

ArrayDequeLinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?

  • ArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现。
  • ArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持。
  • ArrayDeque 是在 JDK1.6 才被引入的,而LinkedList 早在 JDK1.2 时就已经存在。
  • ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。

从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈。

4.3 说一说 PriorityQueue

PriorityQueue 是在 JDK1.5 中被引入的,其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。

  • PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
  • PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
  • PriorityQueue 是非线程安全的,且不支持存储 NULLnon-comparable 的对象。
  • PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。

4.4 什么是 BlockingQueue?

BlockingQueue (阻塞队列)是一个接口,继承自 QueueBlockingQueue阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。

BlockingQueue 常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。

BlockingQueue

4.5 BlockingQueue 的实现类有哪些?

Java 中常用的阻塞队列实现类有以下几种:

  1. ArrayBlockingQueue:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。
  2. LinkedBlockingQueue:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE。和ArrayBlockingQueue不同的是, 它仅支持非公平的锁访问机制。
  3. PriorityBlockingQueue:支持优先级排序的无界阻塞队列。元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入 null 元素。
  4. SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue通常用于线程之间的直接传递数据。
  5. DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。
  6. ……

日常开发中,这些队列使用的其实都不多,了解即可。

4.6 ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?🔥

ArrayBlockingQueueLinkedBlockingQueue 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:

  • 底层实现:ArrayBlockingQueue 基于数组实现,而 LinkedBlockingQueue 基于链表实现。
  • 是否有界:ArrayBlockingQueue 是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue 创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。
  • 锁是否分离: ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。
  • 内存占用:ArrayBlockingQueue 需要提前分配数组内存,而 LinkedBlockingQueue 则是动态分配链表节点内存。这意味着,ArrayBlockingQueue 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue 则是根据元素的增加而逐渐占用内存空间。

5 Map

5.1 HashMap 和 Hashtable 的区别

  • 底层数据结构: Hashtable 是数组+链表。JDK1.8 以后的 HashMap 当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间。
  • 线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过 synchronized 修饰。
  • 效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它;
  • 对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值。
  • 初始容量大小和每次扩充容量大小的不同: Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。

5.2 HashMap 的底层实现🔥

JDK1.8 之前 HashMap 底层是 数组和链表 。HashMap 先得到 key 的 hashCode 值,然后和这个 hashCode 值右移16位后的二进制进行按位异或运算得到最终的 hash 值。然后通过 (n - 1) & hash 计算出当前元素存放的位置(这里的 n 指的是数组的长度)。如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

JDK1.8 在解决哈希冲突时有了较大的变化,当链表长度大于阈值8并且数组长度大于等于64时,将链表转化为红黑树,以减少搜索时间。并且在数量小于6时,会将红黑树转换回链表。如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。每次扩容的时候,都是扩容为之前容量的2倍。

5.3 哈希冲突解决方法有哪些?🔥

  • 链接法:使用链表或其他数据结构来存储冲突的键值对,将它们链接在同一个哈希桶中。
  • 开放寻址法:在哈希表中找到另一个可用的位置来存储冲突的键值对,而不是存储在链表中。常见的开放寻址方法包括线性探测、二次探测和双重散列。
  • 再哈希法(Rehashing):当发生冲突时,使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对。
  • 哈希桶扩容:当哈希冲突过多时,可以动态地扩大哈希桶的数量,重新分配键值对,以减少冲突的概率。

5.4 HashMap 的 put 方法的具体流程🔥

  1. 判断键值对数组是否为空或为 null,是的话执行 resize() 初始化容量为 16
  2. 根据键值 key 计算 hash 值,得到数组索引
  3. 检查该位置是否为空,若为空则直接插入
  4. 如果该位置不为空
    1. 判断首个元素的 key是否和待插入的 key 一样,如果相同直接覆盖
    2. 判断插入的是否是红黑树节点,如果是,则直接在树中插入键值对
    3. 遍历链表,遍历过程中若发现存在相同的 key ,则直接覆盖。否则在链表的尾部插入数据。
    4. 然后判断链表长度是否大于8,数组长度是否大于等于64,是的话则把链表转换为红黑树。
  5. 插入成功后,判断实际存在的键值对数量 size 是否超过了最大容量 threshold(数组长度*0.75),如果超过,进行扩容。

image-20230428210624847

5.5 HashMap 的扩容机制🔥

  • 在添加元素或初始化的时候需要调用 resize 方法进行扩容,第一次添加数据初始化数组长度为 16,以后每次都是达到了扩容阈值(数组长度 * 0.75)进行扩容
  • 每次扩容的时候,都是扩容之前容量的 2 倍;
  • 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
    • 没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置
    • 如果是红黑树,走红黑树的添加
    • 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为 0,是的话停留在原始位置,否则移动到原始位置+旧数组大小的位置上

image-20230428211031968

5.6 HashMap 的长度为什么是 2 的幂次方🔥

  • 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算 (n - 1) & hash 代替直接取模
  • 如果 n 为 2 次幂,那么 n-1 的低位就全是 1,哈希值进行与操作时可以保证低位的值不变,从而保证分布均匀

5.7 HashMap 为什么线程不安全?🔥

JDK1.7 及之前版本,在多线程环境下,HashMap 扩容时会造成死循环和数据丢失的问题。这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。

JDK1.8 版本的 HashMap 采用了尾插法而不是头插法,使得插入的节点永远都是放在链表的末尾,解决了 JDK1.7 扩容操作的死循环和数据丢失问题。但多个线程对 HashMapput 操作仍会存在数据覆盖的风险。

  • 两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突。
  • 当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
  • 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。

还有一种情况是这两个线程同时 put 操作导致 size 的值不正确,进而导致数据覆盖的问题。

5.8 HashMap一般用什么做Key?为啥String适合做Key呢?

用 string 做 key,因为 String对象是不可变的,一旦创建就不能被修改,这确保了Key的稳定性。如果Key是可变的,可能会导致hashCode和equals方法的不一致,进而影响HashMap的正确性。

5.9 为什么HashMap要用红黑树而不是平衡二叉树?

  • 平衡二叉树追求的是一种 “完全平衡” 状态:任何结点的左右子树的高度差不会超过 1,优势是树的结点是很平均分配的。这个要求实在是太严了,导致每次进行插入/删除节点的时候,几乎都会破坏平衡树的第二个规则,进而我们都需要通过左旋右旋来进行调整,使之再次成为一颗符合要求的平衡树。
  • 红黑树不追求这种完全平衡状态,而是追求一种 “弱平衡” 状态:整个树最长路径不会超过最短路径的 2 倍。优势是虽然牺牲了一部分查找的性能效率,但是能够换取一部分维持树平衡状态的成本。与平衡树不同的是,红黑树在插入、删除等操作,不会像平衡树那样,频繁着破坏红黑树的规则,所以不需要频繁着调整,这也是我们为什么大多数情况下使用红黑树的原因。

5.10 ConcurrentHashMap 和 Hashtable 的区别

  • 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树;Hashtable 采用 数组+链表 的形式,数组是主体,链表则是主要为了解决哈希冲突而存在的;
  • 实现线程安全的方式(重要):
    • 在 JDK1.7 的时候,ConcurrentHashMap 对整个数组分成了多个 Segment,每一把锁只锁一个 Segment,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
    • 到了 JDK1.8 的时候,ConcurrentHashMap 摒弃了 Segment 的概念,而是直接用数组+链表+红黑树的数据结构,并发控制使用 synchronizedCAS 操作。
    • Hashtable (同一把锁) :所有的方法都加了锁来保证线程安全,但是效率非常的低下,当一个线程访问同步方法,另一个线程也访问的时候,就会陷入阻塞或者轮询的状态。

5.11 ConcurrentHashMap 线程安全的底层具体实现🔥

在 jdk1.7中, ConcurrentHashMap 底层使用的是分段的数组+链表。ConcurrentHashMap 里包含一个 Segment 数组, Segment 的个数一旦初始化就不能改变Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。Segment 是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。Segment 是一种可重入的锁,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。

JDK1.8 采用的数据结构跟 HashMap1.8的结构一样,数组+链表/红黑树。在 jdk1.8中的 ConcurrentHashMap 做了较大的优化,放弃了 Segment 臃肿的设计,取而代之的是采用 Node + CAS + Synchronized 来保证并发安全,synchronized 只锁定当前链表或红黑树的首节点,锁粒度更细,并发度更大,性能更好。

5.12 ConcurrentHashMap已经用了synchronized,为什么还要用CAS呢?🔥

ConcurrentHashMap使用这两种手段来保证线程安全主要是一种权衡的考虑,在某些操作中使用synchronized(悲观锁),还是使用CAS(乐观锁),主要是根据锁竞争程度来判断的。

比如:在putVal中,如果计算出来的hash槽没有存放元素,那么就可以直接使用CAS来进行设置值,这是因为在设置元素的时候,因为hash值经过了扰动处理后,造成hash碰撞的几率较低,那么我们可以预测使用较少的自旋来完成具体的hash落槽操作。

发生了hash碰撞的时候说明容量不够用了或者已经有大量线程访问了,因此这时候使用synchronized来处理hash碰撞比CAS效率要高,因为发生了hash碰撞大概率来说是线程竞争比较强烈。

5.13 ConcurrentHashMap 为什么 key 和 value 不能为 null?

多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。

5.14 HashSet 与 HashMap 的区别?

HashSet 底层其实是用 HashMap 实现存储的,HashSet 封装了一系列 HashMap 的方法。依靠 HashMap 的 key 存储元素,而 value 值默认为 Object 对象。所以 HashSet 不允许出现重复值,判断标准和 HashMap 判断标准相同,两个元素的 hashCode 相等并且通过 equals()方法返回 true。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值