CrabNote螃蟹笔记

Java集合常见面试题总结

[![JavaGuide官方知识星球](/content/images/javaguide/xingqiu/xingqiu.png)](https://javaguide.cn/about-the-au

前言 这是 JavaGuide 面试突击版本 ,只保留最常问的面试题,并对重点内容进行了 ⭐️ 标注。提供亮色和暗色两个主题,需要打印的朋友请选择亮色版本。 时间充裕的朋友,推荐使用 JavaGuide 网站系统学习,内容更全面深入。 如果你想要付费支持/面试辅导(比如简历优化、一对一提问、高频考点突击资料等)的话,欢迎了解我的知识星球。已经坚持维护六年,内容持续更新,虽白菜价(0.4元/天)但质量很高,主打一个良心! 面试突击最新版可在公众号回复「 PDF 」获取(知识星球会提前同步最新版)。 重要说明 本站所有面试题保持 年度系统性优化完善 ,严格同步 Java 技术生态与招聘市场的最新动态, 确保内容时效性与前瞻性 。 这部分内容摘自 JavaGuide 下面几篇文章中的重点: Java 集合常见面试题总结(上)(Java 集合基础、 、 、 、 、 、 等) Java 集合常见面试题总结(下)( 、 等) 基础概念 简单介绍一下 Java 集合 Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 接口,主要用于存放单一元素;另一个是 接口,主要用于存放键值对。对于 接口,下面又有三个主要的子接口: 、 、 。 Java 集合框架如下图所示: 注:图中只列举了主要的继承派生关系,并没有列举所有关系。比方省略了 , 等抽象类以及其他的一些辅助类,如想深入了解,可自行查看源码。 说说 List, Set, Queue, Map 四者的区别? (对付顺序的好帮手): 存储的元素是有序的、可重复的。 (注重独一无二的性质): 存储的元素不可重复的。 (实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。 (用 key 来搜索的专家): 使用键值对(key value)存储,类似于数学上的函数 y=f(x),"x" 代表 key,"y" 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。 List ⭐️ArrayList 和 Array(数组)的区别? 内部基于动态数组实现,比 (静态数组) 使用起来更加灵活: 会根据实际存储的元素动态地扩容或缩容,而 被创建之后就不能改变它的长度了。 允许你使用泛型来确保类型安全, 则不可以。 中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。 可以直接存储基本类型数据,也可以存储对象。 支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如 、 等。 只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。 创建时不需要指定大小,而 创建时必须指定大小。 下面是二者使用的简单对比: : : 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 是什么 关于 引用 中一篇文章关于 和 的说法: 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. 快速失败的思想即针对可能发生的异常进行提前表明故障并停止运行,通过尽早的发现和停止错误,降低故障系统级联的风险。 在 包下的大部分集合是不支持线程安全的,为了能够提前发现并发操作导致线程安全风险,提出通过维护一个 记录修改的次数,迭代期间通过比对预期修改次数 和 是否一致来判断是否存在并发操作,从而实现快速失败,由此保证在避免在异常时执行非必要的复杂代码。 对应的我们给出下面这样一段在示例,我们首先插入 个操作元素,一个线程迭代元素,一个线程删除元素,最终输出结果如愿抛出 : 我们在初始化时插入了 个元素,此时对应的修改 次数为 ,随后线程 2 在线程 1 迭代期间进行元素删除操作,此时对应的 就变为 。 线程 1 在随后 第 2 轮循环发现 为 ,与预期的 不等,判定为并发操作异常,于是便快速失败,抛出 : 对此我们也给出 循环底层迭代器获取下一个元素时的 方法,可以看到其内部的 具有针对修改次数比对的逻辑: 而 也就是安全失败的含义,它旨在即使面对意外情况也能恢复并继续运行,这使得它特别适用于不确定或者不稳定的环境: 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. 该思想常运用于并发容器,最经典的实现就是 的实现,通过写时复制的思想保证在进行修改操作时复制出一份快照,基于这份快照完成添加或者删除操作后,将 底层的数组引用指向这个新的数组空间,由此避免迭代时被并发修改所干扰所导致并发操作安全问题,当然这种做法也存在缺点,即进行遍历操作时无法获得实时结果: 对应我们也给出 实现 的核心代码,可以看到它的实现就是通过 获取数组引用然后通过 得到一个数组的快照,基于这个快照完成添加操作后,修改底层 变量指向的引用地址由此完成写时复制: Set Comparable 和 Comparator 的区别 接口和 接口都是 Java 中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用: 接口实际上是出自 包 它有一个 方法用来排序 接口实际上是出自 包它有一个 方法用来排序 一般我们需要对一个集合使用自定义排序时,我们就要重写 方法或 方法,当我们需要对某一个集合实现两种排序方式,比如一个 对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写 方法和使用自制的 方法或者以两个 来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的 . Comparator 定制排序 Output: 重写 compareTo 方法实现按年龄来排序 Output: 比较 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 大的数、带权图的遍历等,所以需要会熟练使用才行。 什么是 BlockingQueue? (阻塞队列)是一个接口,继承自 。 阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。 常用于生产者 消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。 BlockingQueue 的实现类有哪些? Java 中常用的阻塞队列实现类有以下几种: 1. :使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。 2. :使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为 。和 不同的是, 它仅支持非公平的锁访问机制。 3. :支持优先级排序的无界阻塞队列。元素必须实现 接口或者在构造函数中传入 对象,并且不能插入 null 元素。 4. :同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此, 通常用于线程之间的直接传递数据。 5. :延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。 6. …… 日常开发中,这些队列使用的其实都不多,了解即可。 ⭐️ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别? 和 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别: 底层实现: 基于数组实现,而 基于链表实现。 是否有界: 是有界队列,必须在创建时指定容量大小。 创建时可以不指定容量大小,默认是 ,也就是无界的。但也可以指定队列大小,从而成为有界的。 锁是否分离: 中的锁是没有分离的,即生产和消费用的是同一个锁; 中的锁是分离的,即生产用的是 ,消费是 ,这样可以防止生产者和消费者线程之间的锁争夺。 内存占用: 需要提前分配数组内存,而 则是动态分配链表节点内存。这意味着, 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而 则是根据元素的增加而逐渐占用内存空间。 Map(重要) ⭐️HashMap 和 Hashtable 的区别 线程是否安全: 是非线程安全的, 是线程安全的,因为 内部的方法基本都经过 修饰。(如果你要保证线程安全的话就使用 吧!); 效率: 因为线程安全的问题, 要比 效率高一点。另外, 基本被淘汰,不要在代码中使用它; 对 Null key 和 Null value 的支持: 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 。 初始容量大小和每次扩充容量大小的不同: ① 创建时如果不指定容量初始值, 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 会直接使用你给定的大小,而 会将其扩充为 2 的幂次方大小( 中的 方法保证,下面给出了源代码)。也就是说 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。 底层数据结构: JDK1.8 以后的 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。 没有这样的机制。 哈希函数的实现 : 对哈希值进行了高位和低位的混合扰动处理以减少冲突,而 直接使用键的 值。 中带有初始容量的构造函数: 下面这个方法保证了 总是使用 2 的幂作为哈希表的大小。 HashMap 和 HashSet 区别 如果你看过 源码的话就应该知道: 底层就是基于 实现的。( 的源码非常非常少,因为除了 、 、 是 自己不得不实现之外,其他方法都是直接调用 中的方法。 | | | | | | | 实现了 接口 | 实现 接口 | | 存储键值对 | 仅存储对象 | | 调用 向 map 中添加元素 | 调用 方法向 中添加元素 | | 使用键(Key)计算 | 使用成员对象来计算 值,对于两个对象来说 可能相同,所以 方法用来判断对象的相等性 | ⭐️HashMap 和 TreeMap 区别 和 都继承自 ,但是需要注意的是 它还实现了 接口和 接口。 实现 接口让 有了对集合内元素的搜索的能力。 接口提供了丰富的方法来探索和操作键值对: 1. 定向搜索 : , , 和 等方法可以用于定位大于等于、小于等于、严格大于、严格小于给定键的最接近的键值对。 2. 子集操作 : , 和 方法可以高效地创建原集合的子集视图,而无需复制整个集合。 3. 逆序视图 : 方法返回一个逆序的 视图,使得可以反向迭代整个 。 4. 边界操作 : , , 和 等方法可以方便地访问和移除元素。 这些方法都是基于红黑树数据结构的属性实现的,红黑树保持平衡状态,从而保证了搜索操作的时间复杂度为 O(log n),这让 成为了处理有序集合搜索问题的强大工具。 实现 接口让 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下: 输出: 可以看出, 中的元素已经是按照 的 age 字段的升序来排列了。 上面,我们是通过传入匿名内部类的方式实现的,你可以将代码替换成 Lambda 表达式实现的方式: 综上,相比于 来说, 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。 HashSet 如何检查重复? 以下内容摘自我的 Java 启蒙书《Head first java》第二版: 当你把对象加入 时, 会先计算对象的 值来判断对象加入的位置,同时也会与其他加入的对象的 值作比较,如果没有相符的 , 会假设对象没有重复出现。但是如果发现有相同 值的对象,这时会调用 方法来检查 相等的对象是否真的相同。如果两者相同, 就不会让加入操作成功。 在 JDK1.8 中, 的 方法只是简单的调用了 的 方法,并且判断了一下返回值以确保是否有重复元素。直接看一下 中的源码: 而在 的 方法中也能看到如下说明: 也就是说,在 JDK1.8 中,实际上无论 中是否已经存在了某元素, 都会直接插入,只是会在 方法的返回值处告诉我们插入前是否存在相同元素。 ⭐️HashMap 的底层实现 JDK1.8 之前 JDK1.8 之前 底层是 数组和链表 结合在一起使用也就是 链表散列 。HashMap 通过 key 的 经过扰动函数处理过后得到 hash 值,然后通过 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。 中的扰动函数( 方法)是用来优化哈希值的分布。通过对原始的 进行额外处理,扰动函数可以减小由于糟糕的 实现导致的碰撞,从而提高数据的分布均匀性。 JDK 1.8 HashMap 的 hash 方法源码: JDK 1.8 的 hash 方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。 对比一下 JDK1.7 的 HashMap 的 hash 方法源码. 相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。 所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。 JDK1.8 之后 相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树。 这样做的目的是减少搜索时间:链表的查询效率为 O(n)(n 是链表的长度),红黑树是一种自平衡二叉搜索树,其查询效率为 O(log n)。当链表较短时,O(n) 和 O(log n) 的性能差异不明显。但当链表变长时,查询性能会显著下降。 为什么优先扩容而非直接转为红黑树? 数组扩容能减少哈希冲突的发生概率(即将元素重新分散到新的、更大的数组中),这在多数情况下比直接转换为红黑树更高效。 红黑树需要保持自平衡,维护成本较高。并且,过早引入红黑树反而会增加复杂度。 为什么选择阈值 8 和 64? 1. 泊松分布表明,链表长度达到 8 的概率极低(小于千万分之一)。在绝大多数情况下,链表长度都不会超过 8。阈值设置为 8,可以保证性能和空间效率的平衡。 2. 数组长度阈值 64 同样是经过实践验证的经验值。在小数组中扩容成本低,优先扩容可以避免过早引入红黑树。数组大小达到 64 时,冲突概率较高,此时红黑树的性能优势开始显现。 TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。 我们来结合源码分析一下 链表到红黑树的转换。 1、 方法中执行链表转红黑树的判断逻辑。 链表的长度大于 8 的时候,就执行 (转换红黑树)的逻辑。 2、 方法中判断是否真的转换为红黑树。 将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。 ⭐️HashMap 的长度为什么是 2 的幂次方 为了让 存取高效并减少碰撞,我们需要确保数据尽量均匀分布。哈希值在 Java 中通常使用 表示,其范围是 前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但是,问题是一个 40 亿长度的数组,内存是放不下的。所以,这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。 这个算法应该如何设计呢? 我们首先可能会想到采用 % 取余的操作来实现。但是,重点来了:“ 取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作 (也就是说 的前提是 length 是 2 的 n 次方)。” 并且, 采用二进制位操作 & 相对于 % 能够提高运算效率 。 除了上面所说的位运算比取余效率高之外,我觉得更重要的一个原因是: 长度是 2 的幂次方,可以让 在扩容的时候更均匀 。例如: length = 8 时,length 1 = 7 的二进制位 length = 16 时,length 1 = 15 的二进制位 这时候原本存在 中的元素计算新的数组位置时 ,取决 hash 的第四个二进制位(从右数),会出现两种情况: 1. 第四个二进制位为 0,数组位置不变,也就是说当前元素在新数组和旧数组的位置相同。 2. 第四个二进制位为 1,数组位置在新数组扩容之后的那一部分。 这里列举一个例子: ⚠️注意:这里列举的场景看的是第四个二进制位,更准确点来说看的是高位(从右数),例如 时, ,二进制为 ,这里看的就是第五个二进制位。 也就是说扩容之后,在旧数组元素 hash 值比较均匀(至于 hash 值均不均匀,取决于前面讲的对象的 方法和扰动函数)的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。 这样也使得扩容机制变得简单和高效,扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。 最后,简单总结一下 的长度是 2 的幂次方的原因: 1. 位运算效率更高:位运算(&)比取余运算(%)更高效。当长度为 2 的幂次方时, 等价于 。 2. 可以更好地保证哈希值的均匀分布:扩容之后,在旧数组元素 hash 值比较均匀的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。 3. 扩容机制变得简单和高效:扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。 ⭐️HashMap 多线程操作导致死循环问题 JDK1.7 及之前版本的 在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。 为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 ,因为多线程下使用 还是会存在数据覆盖的问题。并发环境下,推荐使用 。 一般面试中这样介绍就差不多,不需要记各种细节,个人觉得也没必要记。如果想要详细了解 扩容导致死循环问题,可以看看耗子叔的这篇文章:Java HashMap 的死循环。 ⭐️HashMap 为什么线程不安全? JDK1.7 及之前版本,在多线程环境下, 扩容时会造成死循环和数据丢失的问题。 数据丢失这个在 JDK1.7 和 JDK 1.8 中都存在,这里以 JDK 1.8 为例进行介绍。 JDK 1.8 后,在 中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程对 的 操作会导致线程不安全,具体来说会有数据覆盖的风险。 举个例子: 两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。 不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。 还有一种情况是这两个线程同时 操作导致 的值不正确,进而导致数据覆盖的问题: 1. 线程 1 执行 判断时,假设获得 的值为 10,由于时间片耗尽挂起。 2. 线程 2 也执行 判断,获得 的值也为 10,并将元素插入到该桶位中,并将 的值更新为 11。 3. 随后,线程 1 获得时间片,它也将元素放入桶位中,并将 size 的值更新为 11。 4. 线程 1、2 都执行了一次 操作,但是 的值只增加了 1,也就导致实际上只有一个元素被添加到了 中。 HashMap 常见的遍历方式? HashMap 的 7 种遍历方式与性能分析! 🐛 修正(参见:issue 1411) : 这篇文章对于 parallelStream 遍历方式的性能分析有误,先说结论: 存在阻塞时 parallelStream 性能最高, 非阻塞时 parallelStream 性能最低 。 当遍历不存在阻塞时, parallelStream 的性能是最低的: 加入阻塞代码 后, parallelStream 的性能才是最高的: ⭐️ConcurrentHashMap 和 Hashtable 的区别 和 的区别主要体现在实现线程安全的方式上不同。 底层数据结构: JDK1.7 的 底层采用 分段的数组+链表 实现,在 JDK1.8 中采用的数据结构跟 的结构一样,数组+链表/红黑二叉树。 和 JDK1.8 之前的 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; 实现线程安全的方式(重要): 在 JDK1.7 的时候, 对整个桶数组进行了分割分段( ,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候, 已经摒弃了 的概念,而是直接用 数组+链表+红黑树的数据结构来实现,并发控制使用 和 CAS 来操作。(JDK1.6 以后 锁做了很多优化) 整个看起来就像是优化过且线程安全的 ,虽然在 JDK1.8 中还能看到 的数据结构,但是已经简化了属性,只是为了兼容旧版本; (同一把锁) :使用 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。 下面,我们再来看看两者底层数据结构的对比图。 Hashtable : https://www.cnblogs.com/chengxiao/p/6842045.html JDK1.7 的 ConcurrentHashMap : 是由 数组结构和 数组结构组成。 数组中的每个元素包含一个 数组,每个 数组属于链表结构。 JDK1.8 的 ConcurrentHashMap : JDK1.8 的 不再是 Segment 数组 + HashEntry 数组 + 链表 ,而是 Node 数组 + 链表 / 红黑树 。不过,Node 只能用于链表的情况,红黑树的情况需要使用 。当冲突链表达到一定长度时,链表会转换成红黑树。 是存储红黑树节点,被 包装。 通过 属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 中 通过 属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。 ⭐️ConcurrentHashMap 线程安全的具体实现方式/底层具体实现 JDK1.8 之前 首先将数据分为一段一段(这个“段”就是 )的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。 是由 数组结构和 数组结构组成 。 继承了 ,所以 是一种可重入锁,扮演锁的角色。 用于存储键值对数据。 一个 里包含一个 数组, 的个数一旦 初始化就不能改变 。 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。 的结构和 类似,是一种数组和链表结构,一个 包含一个 数组,每个 是一个链表结构的元素,每个 守护着一个 数组里的元素,当对 数组的数据进行修改时,必须首先获得对应的 的锁。也就是说,对同一 的并发写入会被阻塞,不同 的写入是可以并发执行的。 JDK1.8 之后 Java 8 几乎完全重写了 ,代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行。 取消了 分段锁,采用 来保证并发安全。数据结构跟 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。 Java 8 中,锁粒度更细, 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。 ⭐️JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同? 线程安全实现方式 :JDK 1.7 采用 分段锁来保证安全, 是继承自 。JDK1.8 放弃了 分段锁的设计,采用 保证线程安全,锁粒度更细, 只锁定当前链表或红黑二叉树的首节点。 Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。 并发度 :JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。 ConcurrentHashMap 为什么 key 和 value 不能为 null? 的 key 和 value 不能为 null 主要是为了避免二义性。null 是一个特殊的值,表示没有对象或没有引用。如果你用 null 作为键,那么你就无法区分这个键是否存在于 中,还是根本没有这个键。同样,如果你用 null 作为值,那么你就无法区分这个值是否是真正存储在 中的,还是因为找不到对应的键而返回的。 拿 get 方法取值来说,返回的结果为 null 存在两种情况: 值没有在集合中 ; 值本身就是 null。 这也就是二义性的由来。 具体可以参考 Concur