Java集合常见面试题总结(上)
[集合概述](集合概述) [Java 集合概览](java-集合概览) Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 `Collection` 接口,主要用于存放单一元素;另一个
集合概述 Java 集合概览 Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 接口,主要用于存放单一元素;另一个是 接口,主要用于存放键值对。对于 接口,下面又有三个主要的子接口: 、 、 。 Java 集合框架如下图所示: 注:图中只列举了主要的继承派生关系,并没有列举所有关系。比方省略了 , 等抽象类以及其他的一些辅助类,如想深入了解,可自行查看源码。 ⭐️ 说说 List, Set, Queue, Map 四者的区别? (对付顺序的好帮手): 存储的元素是有序的、可重复的。 (注重独一无二的性质): 存储的元素不可重复的。 (实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。 (用 key 来搜索的专家): 使用键值对(key value)存储,类似于数学上的函数 y=f(x),"x" 代表 key,"y" 代表 value,key 无序、不可重复,value 无序、可重复,每个键最多映射到一个值。注意,这里的“无序”指的是 这类实现——键值对之间没有显式的关联顺序。 和 等实现则是有序的,它们通过额外的数据结构(双向链表或红黑树)来维护键值对的顺序。 集合框架底层数据结构总结 先来看一下 接口下面的集合。 List : 数组。详细可以查看:ArrayList 源码分析。 : 数组。 :双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)。详细可以查看:LinkedList 源码分析。 Set (无序,唯一): 基于 实现的,底层采用 来保存元素。 : 是 的子类,并且其内部是通过 来实现的。 (有序,唯一): 红黑树(自平衡的排序二叉树)。 Queue : 数组来实现小顶堆。详细可以查看:PriorityQueue 源码分析。 : 。详细可以查看:DelayQueue 源码分析。 : 可扩容动态双向数组。 再来看看 接口下面的集合。 Map :JDK1.8 之前 由数组+链表组成的,数组是 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。详细可以查看:HashMap 源码分析,基础概念可以先看 哈希表面试题总结。 : 继承自 ,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外, 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看:LinkedHashMap 源码分析,LRU 手写题可以看 LRU 缓存面试题总结。 :数组+链表组成的,数组是 的主体,链表则是主要为了解决哈希冲突而存在的。 :红黑树(自平衡的排序二叉树)。 如何选用集合? 我们主要根据集合的特点来选择合适的集合。比如: 我们需要根据键值获取到元素值时就选用 接口下的集合,需要排序时选择 ,不需要排序时就选择 ,需要保证线程安全就选用 。 我们只需要存放元素值时,就选择实现 接口的集合,需要保证元素唯一时选择实现 接口的集合比如 或 ,不需要就选择实现 接口的比如 或 ,然后再根据实现这些接口的集合的特点来选用。 为什么要使用集合? 当我们需要存储一组类型相同的数据时,数组是最常用且最基本的容器之一。但是,使用数组存储对象存在一些不足之处,因为在实际开发中,存储的数据类型多种多样且数量不确定。这时,Java 集合就派上用场了。与数组相比,Java 集合提供了更灵活、更有效的方法来存储多个数据对象。Java 集合框架中的各种集合类和接口可以存储不同类型和数量的对象,同时还具有多样化的操作方式。相较于数组,Java 集合的优势在于它们的大小可变、支持泛型、具有内建算法等。总的来说,Java 集合提高了数据的存储和处理灵活性,可以更好地适应现代软件开发中多样化的数据需求,并支持高质量的代码编写。 List ⭐️ ArrayList 和 Array(数组)的区别? 内部基于动态数组实现,比 (静态数组) 使用起来更加灵活: 会根据实际存储的元素动态地扩容或缩容,而 被创建之后就不能改变它的长度了。 允许你使用泛型来确保类型安全, 则不可以。 中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。 可以直接存储基本类型数据,也可以存储对象。 支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如 、 等。 只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。 创建时不需要指定大小,而 创建时必须指定大小。 下面是二者使用的简单对比: : : ArrayList 和 Vector 的区别?(了解即可) 是 的主要实现类,底层使用 存储,适用于频繁的查找工作,线程不安全。 是 的古老实现类,底层使用 存储,线程安全。 Vector 和 Stack 的区别?(了解即可) 和 两者都是线程安全的,都是使用 关键字进行同步处理。 继承自 ,是一个后进先出的栈,而 是一个列表。 随着 Java 并发编程的发展, 和 已经被淘汰,推荐使用并发集合类(例如 、 等)或者手动实现线程安全的方法来提供安全的多线程操作支持。 ArrayList 可以添加 null 值吗? 中可以存储任何类型的对象,包括 值。不过,不建议向 中添加 值, 值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。 示例代码: 输出: ⭐️ ArrayList 插入和删除元素的时间复杂度? 对于插入: 头部插入:由于需要将所有元素都依次向后移动一个位置,因此时间复杂度是 O(n)。 尾部插入:当 的容量未达到极限时,往列表末尾插入元素的时间复杂度是 O(1),因为它只需要在数组末尾添加一个元素即可;当容量已达到极限并且需要扩容时,则需要执行一次 O(n) 的操作将原数组复制到新的更大的数组中,然后再执行 O(1) 的操作添加元素。 指定位置插入:需要将目标位置之后的所有元素都向后移动一个位置,然后再把新元素放入指定位置。这个过程需要移动平均 n/2 个元素,因此时间复杂度为 O(n)。 对于删除: 头部删除:由于需要将所有元素依次向前移动一个位置,因此时间复杂度是 O(n)。 尾部删除:当删除的元素位于列表末尾时,时间复杂度为 O(1)。 指定位置删除:需要将目标元素之后的所有元素向前移动一个位置以填补被删除的空白位置,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。 这里简单列举一个例子: ⭐️ LinkedList 插入和删除元素的时间复杂度? 头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。 尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,不过由于有头尾指针,可以从较近的指针出发,因此需要遍历平均 n/4 个元素,时间复杂度为 O(n)。 这里简单列举一个例子:假如我们要删除节点 9 的话,需要先遍历链表找到该节点。然后,再执行相应节点指针指向的更改,具体的源码可以参考:LinkedList 源码分析。 LinkedList 为什么不能实现 RandomAccess 接口? 是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 接口。 ⭐️ ArrayList 与 LinkedList 区别? 是否保证线程安全: 和 都是不同步的,也就是不保证线程安全; 底层数据结构: 底层使用的是 数组 ; 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!) 插入和删除是否受元素位置的影响: 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行 方法的时候, 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话( ),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n i)个元素都要执行向后位/向前移一位的操作。 采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响( 、 、 、 、 ),时间复杂度为 O(1),如果是要在指定位置 插入和删除元素的话( , , ), 时间复杂度为 O(n),因为需要先移动到指定位置再插入和删除。 是否支持快速随机访问: 不支持高效的随机元素访问,而 (实现了 接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于 方法)。 内存空间占用: 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。 我们在项目中一般是不会使用到 的,需要用到 的场景几乎都可以使用 来代替,并且,性能通常会更好!就连 的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 。 另外,不要下意识地认为 作为链表就最适合元素增删的场景。我在上面也说了, 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的平均时间复杂度都是 O(n)。 补充内容: 双向链表和双向循环链表 双向链表: 包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。 双向循环链表: 最后一个节点的 next 指向 head,而 head 的 prev 指向最后一个节点,构成一个环。 补充内容:RandomAccess 接口 查看源码我们发现实际上 接口中什么都没有定义。所以,在我看来 接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能。 在 方法中,它要判断传入的 list 是否 的实例,如果是,调用 方法,如果不是,那么调用 方法 实现了 接口, 而 没有实现。为什么呢?我觉得还是和底层数据结构有关! 底层是数组,而 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。 实现了 接口,就表明了他具有快速随机访问功能。 接口只是标识,并不是说 实现 接口才具有快速随机访问功能的! ⭐️ 说一说 ArrayList 的扩容机制吧 详见笔主的这篇文章: ArrayList 扩容机制分析。 ⭐️ 集合中的 fail fast 和 fail safe 是什么? (快速失败)和 (安全失败)是 Java 集合框架在处理并发修改问题时,两种截然不同的设计哲学和容错策略。 关于 引用 中一篇文章关于 和 的说法: Fail fast systems are designed to immediately stop functioning upon encountering an unexpected condition. This immediate failure helps to catch errors early, making debugging more straightforward. 快速失败的思想即针对可能发生的异常进行提前表明故障并停止运行,通过尽早的发现和停止错误,降低故障系统级联的风险。 在 包下的大部分集合(如 , )是不支持线程安全的,为了能够提前发现并发操作导致线程安全风险,提出通过维护一个 记录修改的次数,迭代期间通过比对预期修改次数 和 是否一致来判断是否存在并发操作,从而实现快速失败,由此保证在避免在异常时执行非必要的复杂代码。 ArrayList (fail fast) 示例: 输出: 程序在线程 t2 修改列表后,线程 t1 的下一次迭代操作立刻就抛出了 。这是因为 ArrayList 的迭代器在每次 调用时,都会检查 是否被改变。一旦发现集合在迭代器不知情的情况下被修改,它会立即“快速失败”,以防止在不一致的数据上继续操作导致不可预期的后果。 对此我们也给出 循环底层迭代器获取下一个元素时的 方法,可以看到其内部的 具有针对修改次数比对的逻辑: 而 也就是安全失败的含义,它旨在即使面对意外情况也能恢复并继续运行,这使得它特别适用于不确定或者不稳定的环境: Fail safe systems take a different approach, aiming to recover and continue even in the face of unexpected conditions. This makes them particularly suited for uncertain or volatile environments. 该思想常运用于并发容器,最经典的实现就是 的实现,通过写时复制(Copy On Write)的思想保证在进行修改操作时复制出一份快照,基于这份快照完成添加或者删除操作后,将 底层的数组引用指向这个新的数组空间,由此避免迭代时被并发修改所干扰所导致并发操作安全问题,当然这种做法也存在缺点,即进行遍历操作时无法获得实时结果: 对应我们也给出 实现 的核心代码,可以看到它的实现就是通过 获取数组引用然后通过 得到一个数组的快照,基于这个快照完成添加操作后,修改底层 变量指向的引用地址由此完成写时复制: Set Comparable 和 Comparator 的区别 接口和 接口都是 Java 中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用: 接口实际上是出自 包 它有一个 方法用来排序 接口实际上是出自 包它有一个 方法用来排序 一般我们需要对一个集合使用自定义排序时,我们就要重写 方法或 方法,当我们需要对某一个集合实现两种排序方式,比如一个 对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写 方法和使用自制的 方法或者以两个 来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的 . Comparator 定制排序 Output: 重写 compareTo 方法实现按年龄来排序 Output: 无序性和不可重复性的含义是什么 无序性不等于随机性,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加,而是根据数据的哈希值决定的。 不可重复性是指添加的元素按照 判断时,返回 false,需要同时重写 方法和 方法。 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同 、 和 都是 接口的实现类,都能保证元素唯一,并且都不是线程安全的。 、 和 的主要区别在于底层数据结构不同。 的底层数据结构是哈希表(基于 实现)。 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。 底层数据结构不同又导致这三者的应用场景不同。 用于不需要保证元素插入和取出顺序的场景, 用于保证元素的插入和取出顺序满足 FIFO 的场景, 用于支持对元素自定义排序规则的场景。 Queue Queue 与 Deque 的区别 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。 扩展了 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。 | 接口 | 抛出异常 | 返回特殊值 | | | | | | 插入队尾 | add(E e) | offer(E e) | | 删除队首 | remove() | poll() | | 查询队首元素 | element() | peek() | 是双端队列,在队列的两端均可以插入或删除元素。 扩展了 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类: | 接口 | 抛出异常 | 返回特殊值 | | | | | | 插入队首 | addFirst(E e) | offerFirst(E e) | | 插入队尾 | addLast(E e) | offerLast(E e) | | 删除队首 | removeFirst() | pollFirst() | | 删除队尾 | removeLast() | pollLast() | | 查询队首元素 | getFirst() | peekFirst() | | 查询队尾元素 | getLast() | peekLast() | 事实上, 还提供有 和 等其他方法,可用于模拟栈。 ArrayDeque 与 LinkedList 的区别 和 都实现了 接口,两者都具有队列的功能,但两者有什么区别呢? 是基于可变长的数组和双指针来实现,而 则通过链表来实现。 不支持存储 数据,但 支持。 是在 JDK1.6 才被引入的,而 早在 JDK1.2 时就已经存在。 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。 从性能的角度上,选用 来实现队列要比 更好。此外, 也可以用于实现栈。 说一说 PriorityQueue 是在 JDK1.5 中被引入的, 其与 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。 这里列举其相关的一些要点: 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。 是非线程安全的,且不支持存储 和 的对象。 默认是小顶堆,但可以接收一个 作为构造参数,从而来自定义元素优先级的先后。 在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第 K 大的数、带权图的遍历等,所以需要会熟练使用才行。 如果想先补堆和 Top K 的算法模板,可以看 堆详解 和 Top K 问题面试题总结。 什么是 BlockingQueue? (阻塞队列)是一个接口,继承自 。 阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。 常用于生产者 消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。 BlockingQueue 的实现类有哪些? Java 中常用的阻塞队列实现类有以下几种: 1. :使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。 2. :使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为 。和 不同的是, 它仅支持非公平的锁访问机制。 3. :支持优先级排序的无界阻塞队列。元素必须实现 接口或者在构造函数中传入 对象,并且不能插入 null 元素。 4. :同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此, 通常用于线程之间的直接传递数据。 5. :延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。 6. …… 日常开发中,这些队列使用的其实都不多,了解即可。 ⭐️ ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别? 和 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别: 底层实现: 基于数组实现,而 基于链表实现。 是否有界: 是有界队列,必须在创建时指定容量大小。 创建时可以不指定容量大小,默认是 ,也就是无界的。但也可以指定队列大小,从而成为有界的。 锁是否分离: 中的锁是没有分离的,即生产和消费用的是同一个锁; 中的锁是分离的,即生产用的是 ,消费是 ,这样可以防止生产者和消费者线程之间的锁争夺。 内存占用: 需要提前分配数组内存,而 则是动态分配链表节点内存。这意味着, 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而 则是根据元素的增加而逐渐占用内存空间。 数据结构延伸阅读 Java 集合面试经常会追到底层数据结构。建议结合下面几篇一起复习: 线性数据结构详解:理解数组、链表、栈、队列和 、 、 的关系。 哈希表面试题总结:理解哈希冲突、扩容和 的底层思想。 红黑树详解:理解 、 以及 树化链表时涉及的红黑树。 堆详解:理解 的底层结构和 Top K 题型。 写在最后 如果内容对你有帮助的话,欢迎顺手给 JavaGuide 点一个免费的 Star 支持一下:GitHub | Gitee。 JavaGuide 已持续维护近七年,累计 6100+ 次提交,来自 620+ 位贡献者共同完善。你的 Star、反馈和 PR,都是这个项目继续更新的动力。 如果你正在准备后端/AI 应用开发面试,也可以了解一下我的知识星球,里面包括后端和 AI 实战项目、简历优化、一对一提问和高频考点资料,已经持续维护六年。