Java集合相关面试题

本文为原创内容,转载请注明出处并附带原文链接。感谢您的尊重与支持!

你必须非常努力,才能看起来毫不费劲。


1 Java常见的集合类

面试官:说一说Java提供的常见集合?(高频)

候选人

在java中提供了量大类的集合框架,主要分为两类:

第一个是Collection 属于单列集合,第二个是Map 属于双列集合

  • 在Collection中有三个子接口List、Set和Queue。在我们平常开发的过程中用的比较多像list接口中的实现类ArrarList和LinkedList。 在Set接口中有实现类HashSet和TreeSet。在Queue接口中有实现类PriorityQueue等。
  • 在map接口中有很多的实现类,平时比较常见的是HashMap、TreeMap,还有一个线程安全的ConcurrentHashMap

image-20250303094632756

面试官:说说List,Set,Map三者的区别?

候选人

  • List (对付顺序的好帮⼿): 存储的元素是有序的可重复的。

  • Set (注重独⼀⽆⼆的性质): 存储的元素是**⽆序的**、不可重复的。

  • Map (⽤ Key 来搜索的专家): 使⽤键值对(kye-value)存储,类似于数学上的函数y=f(x),“x”代表 key,”y”代表 value,Key 是⽆序的、不可重复的value 是⽆序的、可重复的,每个键最多映射到⼀个值。

面试官: 常见集合的时间复杂度分析

候选人:以下是各种常见数据结构的基本操作时间复杂度的简要概述:

1. 数组(Array)

  • 访问:O(1),因为可以直接通过索引访问。
  • 插入/删除:平均 O(n),因为在中间位置插入或删除元素需要移动后续元素。
  • 搜索:O(n),除非数组有序且使用二分查找,否则需要遍历整个数组。

2. 单向链表(Singly Linked List)

  • 访问:O(n),因为需要从头节点开始遍历。
  • 插入/删除:O(1),如果已知前驱节点的话;否则需要 O(n) 来找到前驱节点。
  • 搜索:O(n),因为需要遍历链表直到找到目标元素。

3. 双向链表(Doubly Linked List)

  • 访问:O(n),因为也需要从头节点或尾节点开始遍历。
  • 插入/删除:O(1),如果已知前驱或后继节点的话;否则需要 O(n) 来找到前驱或后继节点。
  • 搜索:O(n),因为需要遍历链表直到找到目标元素。

4. 二叉搜索树(Binary Search Tree)

  • 最佳情况(平衡)
    • 访问/搜索:O(log n),如果树是平衡的。
    • 插入/删除:O(log n),如果树是平衡的。
  • 最坏情况(不平衡)
    • 访问/搜索/插入/删除:O(n),如果树退化成链式结构。

5. 红黑树(Red-Black Tree)

  • 访问/搜索/插入/删除:O(log n),因为红黑树是一种自平衡的二叉搜索树,能够保持树的高度较低。

6. 散列表(Hash Table)

  • 理想情况(均匀分布)
    • 访问/搜索/插入/删除:平均 O(1),理想情况下,哈希函数将键均匀分布在哈希表中,减少了冲突。
  • 最坏情况(严重冲突)
    • 访问/搜索/插入/删除:O(n),如果哈希函数导致大量冲突,性能退化。

2 List

面试官:ArrayList底层是如何实现的?(jdk1.8源码解析)

候选人

  1. 类定义与成员变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ArrayList<E>
extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, Serializable {

// 内部使用的数组,用于存储元素
transient Object[] elementData; // non-private to simplify nested class access

// 列表的实际大小
private int size;

// 初识容量为10
private static final int DEFAULT_CAPACITY = 10;

// 默认的空数组,用于初始化空的 ArrayList
private static final Object[] EMPTY_ELEMENTDATA = {};

// 空闲数组,用于优化 ArrayList 的行为
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
}
  1. 构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 默认构造函数,初始化为空数组
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);
}
}
// 从 Collection 初始化 ArrayList
public ArrayList(Collection<? extends E> c) {
Object[] a = c.toArray();
if ((size = a.length) != 0) {
if (c.getClass() == ArrayList.class) {
elementData = a;
} else {
elementData = Arrays.copyOf(a, size, Object[].class);
}
} else {
// replace with empty array.
elementData = EMPTY_ELEMENTDATA;
}
}
  1. 添加元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public boolean add(E e) {
// 第一步:确保数组已使用长度(size)加1之后足够存下下一个数据
ensureCapacityInternal(size + 1); // Increments modCount!!

// 第三步:确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上
elementData[size++] = e;

// 第四步:返回添加成功布尔值
return true;
}

private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}

private void ensureExplicitCapacity(int minCapacity) {
modCount++;

// 第二步:计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}

private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
  1. 获取元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public E get(int index) {
// 检查索引是否合法。
RangeCheck(index);

return elementData(index);
}

private void RangeCheck(int index) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

E elementData(int index) {
return (E) elementData[index];
}

面试官:ArrayList list=new ArrayList(10)中的list扩容几次

候选人

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

面试官:说⼀说 ArrayList 的扩容机制吧?

候选人: 在添加元素时会检查当前数组的容量是否足够存放新的元素。如果不足够,则会触发扩容操作。扩容机制主要包括以下几个步骤:

  1. 检测容量不足:当尝试添加新元素时,ArrayList 会先检查当前的容量是否足以容纳新的元素。

  2. 触发扩容:如果当前容量不足,则会调用 ensureCapacityInternal 方法来确保有足够的空间。

  3. 计算新容量:通常情况下,新容量为当前容量的 1.5 倍。

  4. 执行扩容:使用 Arrays.copyOf 方法来创建一个新的数组,并将旧数组的内容拷贝到新数组中。

源码分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public boolean add(E e) {
// 确保有足够的容量来存放新的元素
ensureCapacityInternal(size + 1); // Increments modCount!!

// 将新元素放置在当前 size 的位置,并将 size 增加 1
elementData[size++] = e;

// 返回添加成功的标志
return true;
}

private void ensureCapacityInternal(int minCapacity) {
// 如果所需的最小容量大于当前数组的长度,则调用 grow 方法
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}

private void grow(int minCapacity) {
// 获取当前数组的长度
int oldCapacity = elementData.length;

// 计算新的容量,默认情况下是旧容量的 1.5 倍
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);
}

面试官: 为什么数组索引从0开始?

候选人:

在根据数据索引获取元素的时候,会用索引和寻址公式来计算内存中所对应的元素数据。

数组索引从0开始,寻址公式可以简化为:

arr[i] = baseAddress + i * dataTypeSize
地址=基地址+(索引×元素大小)

如果数组的索引从1开始,寻址公式中就会增加一次减法操作,对CPU来说就多了一条指令,性能不高。

如果索引从1开始,则寻址公式变为:

arr[i] = baseAddress + (i - 1) dataTypeSize*
地址=基地址+((索引−1)×元素大小)

面试官:如何实现数组和List之间的转换

候选人

  1. 数组转List:可以使用 Arrays.asList(T... a) 方法将数组转换为 List。该方法返回一个基于原数组的 List,所以它是一个固定大小的列表,不能进行添加或删除操作,但可以修改元素。

    示例:

    1
    2
    String[] array = {"A", "B", "C"};
    List<String> list = Arrays.asList(array);
  2. List 转数组:可以使用 ListtoArray() 方法将 List 转换为数组。

    • toArray():返回一个包含所有 List 元素的数组,但该数组类型是 Object[],需要类型转换。

    示例:

    1
    2
    List<String> list = Arrays.asList("A", "B", "C");
    String[] array = list.toArray(new String[0]);

    注意,toArray(new String[0]) 中的 new String[0] 是为了确保返回的数组类型正确,而不是使用 new String[list.size()],这样可以避免在一些情况下的性能问题。

面试官:用Arrays.asList转List后,如果修改了数组内容,list受影响吗?List用toArray转数组后,如果修改了List内容,数组受影响吗

候选人

  • Arrays.asList 转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
1
2
3
4
5
6
7
8
9
10
11
12
class ArrayListExample {
public static void main(String[] args) {
String[] array = {"A", "B", "C"};
List<String> list = Arrays.asList(array);

// 修改数组的元素
array[0] = "X";

System.out.println("List: " + list); // 输出 List: [X, B, C]
System.out.println("Array: " + Arrays.toString(array)); // 输出 Array: [X, B, C]
}
}
  • list用了 toArray 转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ArrayListExample {
public static void main(String[] args) {
List<String> list = Arrays.asList("A", "B", "C");

// 使用 toArray 方法转换为数组
String[] array = list.toArray(new String[0]);

// 修改 List
list.set(0, "X");

System.out.println("List: " + list); // 输出 List: [X, B, C]
System.out.println("Array: " + Arrays.toString(array)); // 输出 Array: [A, B, C]
}
}

面试官:ArrayList 和 LinkedList 的区别是什么?(高频)

候选人

  1. 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;

  2. 底层数据结构: Arraylist 底层使⽤的是 Object 数组; LinkedList 底层使⽤的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别)

  3. 插⼊和删除是否受元素位置的影响:ArrayList 采⽤数组存储,所以插⼊和删除元素的时间复杂度受元素位置的影响。 ⽐如:执⾏ add(E e) ⽅法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置插⼊和删除元素的话时间复杂度就为 O(n)。②LinkedList 采⽤链表存储,所以对于 add(E e) ⽅法的插⼊,删除元素时间复杂度不受元素位置的影响,近似O(1),如果是要在指定位置插⼊和删除元素的话( (add(int index, Eelement) ) 时间复杂度近似为o(n)。

  4. 是否⽀持快速随机访问: LinkedList 不⽀持⾼效的随机元素访问,⽽ ArrayList ⽀持。(数组天然⽀持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不⽀持快速随机访问。)

  5. 内存空间占⽤: ArrayList 底层是数组,内存连续,节省内存;LinkedList 是双向链表需要存储数据和两个指针,更占用内存。

  6. 应用场景: 访问频繁选数组,插删频繁选链表。

补充:双向链表和双向循环链表

双向链表: 包含两个指针,⼀个 prev 指向前⼀个节点,⼀个 next 指向后⼀个节点。

image-20250218112654847

双向循环链表: 最后⼀个节点的 next 指向 head,⽽ head 的 prev 指向最后⼀个节点,构成⼀个环。

image-20250218112704475

补充:RandomAccess接⼝

1
2
3
public interface RandomAccess {

}

查看源码我们发现实际上 RandomAccess 接⼝中什么都没有定义。所以,在我看来RandomAccess 接⼝不过是⼀个标识罢了。标识什么? 标识实现这个接⼝的类具有随机访问功能。ArrayList 实现了 RandomAccess 接⼝,就表明了他具有快速随机访问功能。 RandomAccess 接⼝只是标识,并不是说 ArrayList实现 RandomAccess 接⼝才具有快速随机访问功能的!

面试官: ArrayList 与 Vector 区别?

候选人:

线程安全性: Vector是线程安全的,ArrayList不是线程安全的。

扩容策略: ArrayList在底层数组不够用时在原来的基础上扩展0.5倍,Vector是扩展1倍。

ArrayList和Vector都支持动态扩容,都属于动态数组。

面试官:刚才你说了ArrayList 和 LinkedList 不是线程安全的,你们在项目中是如何解决这个的线程安全问题的?

候选人

主要有两种解决方案:

第一:我们使用这个集合,优先在方法内使用,定义为局部变量,且不能逃离方法的作用范围。这样的话,就不会出现线程安全问题。

第二:如果非要在成员变量中使用的话,可以使用线程安全的集合来替代

  • ArrayList可以通过Collections 的 synchronizedList 方法将 ArrayList 转换成线程安全的容器后再使用。

  • LinkedList 换成ConcurrentLinkedQueue来使用

3 HashMap

前提知识:红黑树和二叉搜索树(Binary Search Tree,BST)

二叉搜索树

二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型。

二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。

image-20250218112825681

红黑树(高频)

红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree)。

image-20250218112839568

(2)红黑树的特质

性质1:节点要么是红色,要么是黑色

性质2:根节点是黑色

性质3:叶子节点都是黑色的空节点

性质4:红黑树中红色节点的子节点都是黑色

性质5:从任一节点到叶子节点的所有路径都包含相同数目的黑色节点

在添加或删除节点的时候,如果不符合这些性质会发生旋转,以达到所有的性质,保证红黑树的平衡

面试官:说一下HashMap的实现原理?

候选人

1,底层使用hash表数据结构,即数组 + 链表 / 红黑树

2,添加数据时,计算key的值确定元素在数组中的下标。

  • key相同则替换

  • 不同则存入链表或红黑树中

3,获取数据通过key的hash计算数组下标获取元素。

红黑树比链表的优点?
红黑树是一种自平衡的二叉搜索树,而链表是线性数据结构。红黑树的查找复杂度是 O(log n),查找效率更高;红黑树在插入和删除操作的时间复杂度为 O(log n);红黑树节点只需要存储少量额外的颜色信息,不会像链表那样需要存储大量的指针。
为什么不一开始就用红黑树?
虽然红黑树的查询效率更高,但它并不适合所有场景。如果数据量非常小,比如只有几个节点,链表的线性查找效率并不会比红黑树差多少。红黑树需要额外的内存来存储指向左右子节点的指针和颜色信息。链表仅需要存储一个指针(单链表)或两个指针(双向链表);如果数据变化频繁,红黑树的频繁平衡操作可能带来额外的计算成本。

面试官:HashMap的jdk1.7和jdk1.8有什么区别(高频)

候选人

  • JDK1.7采用的是拉链法,数组+链表。在扩容时使用 “头插法” 插入新元素。这种方式会导致链表顺序反转,在多线程环境下,可能引发环形链表问题,导致程序死循环。扩容是 “先扩容后插入”,无论是否发生哈希冲突,都会执行扩容操作,可能导致无效扩容。

  • JDK1.8之后采用数组+链表+红黑树,链表长度大于8且数组长度大于64则会从链表转化为红黑树。改为 “尾插法” 插入,保证链表顺序一致,避免了扩容中的死循环问题。扩容变为 “先插入再扩容”,只有当插入后超过阈值或发生哈希冲突时,才会触发扩容,减少了不必要的操作。

补充:什么是拉链法?

所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建⼀个链表数组,数组中每⼀格就是⼀个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

jdk1.8 中将链表改造红黑树还有一个非常重要的原因,可以防止DDos攻击。

DDos 攻击:分布式拒绝服务攻击(英文意思是Distributed Denial of Service,简称DDoS)

指处于不同位置的多个攻击者同时向一个或数个目标发动攻击,或者一个攻击者控制了位于不同位置的多台机器并利用这些机器对受害者同时实施攻击。由于攻击的发出点是分布在不同地方的,这类攻击称为分布式拒绝服务攻击,其中的攻击者可以有多个。

面试官: 如何解决hash冲突?

候选人: 在 Java 中,解决哈希冲突的常用方法有链地址法、红黑树、开放地址法和再哈希法

链地址法通过在每个桶中存储一个链表,将冲突的元素串联起来。它实现简单,但在链表过长时性能会下降。

为了解决链表性能问题,Java 8 引入了红黑树。当链表长度超过 8 且数组长度达到 64 时,链表会转换为红黑树,将查找效率提升至 O(log n)。

开放地址法则通过在哈希表中寻找下一个空闲位置存储数据,常用的方式包括线性探测(依次向后查找)和二次探测(采用平方检测)。它节省空间,但在负载因子较高时性能会下降。

再哈希法通过使用另一个哈希函数重新计算哈希值来解决冲突,避免链表或树结构,但计算成本较高。

在 Java 中,链地址法和红黑树的组合是最常用的解决方案,兼顾了性能和内存使用。

面试官:知道hashmap在1.7情况下的多线程死循环问题吗?

候选人

Java 1.7 中的死循环问题

在Java 1.7中,HashMap采用的是“头插法”进行扩容。这意味着在扩容时,旧数组中的元素会根据新的索引位置被插入到新数组对应的链表头部。当两个或更多的线程几乎同时进行扩容操作时,可能会导致如下情形:

  1. 线程一读取并开始扩容:假设线程一开始读取HashMap并准备进行扩容。此时,它读取到了某个链表,该链表包含节点A和节点B(顺序为A -> B)。
  2. 线程二介入并完成扩容:在线程一还没有完成扩容之前,线程二也开始了扩容操作。线程二按照头插法将节点A和节点B重新插入新数组中的对应位置,但是顺序变成了B -> A(因为A先插入,然后B插入到A的前面)。
  3. 线程一继续执行:当线程一恢复执行时,它尝试将节点A插入新数组中的对应位置。按照头插法,它会将A插入到链表的头部。然而,此时节点B已经在链表的头部,并且B的next已经指向了A。因此,当线程一将A插入时,A的next又指向了B,这就形成了一个循环链表(A -> B -> A)。

解决方法:Java 1.8 的尾插法

Java 1.8中的HashMap采用了“尾插法”,而不是头插法来进行扩容。这意味着在扩容时,元素会插入到链表的末尾,而不是头部。这样做的好处在于,即使多个线程几乎同时进行扩容,也不会改变链表原有的顺序,从而避免了循环链表的产生。

面试官:说下HashMap中put方法的具体流程(高频)

候选人

image-20250218112948771

  • 首先判断键值对数组table是否为空,如果为空则执行resize()进行扩容(初始化长度为16的数组)
  • 如果不为空则根据key计算hash值,得到数组的索引
  • 判断该索引位置是否为空,如果为空直接插入即可,否则进行后续操作
  • 判断当前索引位置上的key是否与要插入元素的key相同(是否存在),存在相同的话直接覆盖value就可以了
  • 不相同的话判断该位置是否为红黑树,如果是红黑树,则直接在树中插入键值对即可
  • 如果不是的话就要遍历链表,判断该位置的key是否存在,如果存在相同的直接覆盖value即可
  • 如果不相同的话就使用尾插法,并判断链表长度是否大于8,大于8的话把链表转换为红黑树,走红黑树插入的逻辑
  • 最后一步,就是在以上所有涉及到插入的操作中,判断实际存在的键值对数量size(也就是++size)是否超出了最大容量threshold,如果超过,就进行扩容。

添加元素的时候至少考虑三种情况:

  1. 数组位置为null
  2. 数组位置不为null,键重复,元素覆盖
  3. 数组位置不为null,键不重复,挂在下面形成链表或者红黑树

源码分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
public V put(K key, V value) {
//参数一:键
//参数二:值

//返回值:被覆盖元素的值,如果没有覆盖,返回null
return putVal(hash(key), key, value, false, true);
}


//利用键计算出对应的哈希值,再把哈希值进行一些额外的处理
//简单理解:返回值就是返回键的哈希值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}


final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
//定义一个局部变量,用来记录哈希表中数组的地址值。
Node<K, V>[] tab;
//临时的第三方变量,用来记录键值对对象的地址值
Node<K, V> p;
//表示当前数组的长度
int n;
//表示索引
int i;
//把哈希表中数组的地址值,赋值给局部变量tab
tab = table;
//判断数组是否未初始化
if (tab == null || (n = tab.length) == 0) {
//1.如果当前是第一次添加数据,底层会创建一个默认长度为16,加载因子为0.75的数组
//2.如果不是第一次添加数据,会看数组中的元素是否达到了扩容的条件
//如果没有达到扩容条件,底层不会做任何操作
//如果达到了扩容条件,底层会把数组扩容为原先的两倍,并把数据全部转移到新的哈希表中
tab = resize();
//表示把当前数组的长度赋值给n
n = tab.length;
}
//拿着数组的长度跟键的哈希值进行计算,计算出当前键值对对象,在数组中应存入的位置
i = (n - 1) & hash; //index
//获取数组中对应元素的数据
p = tab[i];
//判断该下标位置是否有数据
if (p == null) {
//如果没有,直接将数据放在该下标位置
tab[i] = newNode(hash, key, value, null);
} else {
Node<K, V> e;
K k;
//等号的左边:数组中键值对的哈希值
//等号的右边:当前要添加键值对的哈希值
//如果键不一样,此时返回false
//如果键一样,返回true
boolean b1 = p.hash == hash;
//判断该位置数据的key和新来的数据是否一样
if (b1 && ((k = p.key) == key || (key != null && key.equals(k)))) {
//如果一样,证明为修改操作,该节点的数据赋值给e,后边会用到
e = p;
} else if (p instanceof TreeNode) {
//判断数组中获取出来的键值对是不是红黑树中的节点
//如果是,则调用方法putTreeVal,把当前的节点按照红黑树的规则添加到树当中。
e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
} else {
//如果从数组中获取出来的键值对不是红黑树中的节点
//表示此时下面挂的是链表
for (int binCount = 0; ; ++binCount) {
//判断next节点,如果为空的话,证明遍历到链表尾部了
if ((e = p.next) == null) {
//把新值放入链表尾部
p.next = newNode(hash, key, value, null);
//判断当前链表长度是否超过8,如果超过8,就会调用方法treeifyBin
//treeifyBin方法的底层还会继续判断:判断数组的长度是否大于等于64
//如果同时满足这两个条件,就会把这个链表转成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//如果是,进行转换红黑树操作
treeifyBin(tab, hash);
break;
}
//判断链表当中有数据相同的值,如果一样,证明为覆盖操作
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//把下一个节点赋值为当前节点
p = e;
}
}
//判断e是否为空(e值为修改操作存放原数据的变量)
if (e != null) {
//不为空的话证明是修改操作,取出旧值
V oldValue = e.value;
//一定会执行 onlyIfAbsent传进来的是false
if (!onlyIfAbsent || oldValue == null) {
//将新值赋值当前节点
e.value = value;
}
afterNodeAccess(e);
//返回老值
return oldValue;
}
}
//计数器,计算当前节点的修改次数
++modCount;
//threshold:记录的就是数组的长度 * 0.75,哈希表的扩容时机 16 * 0.75 = 12
if (++size > threshold)
//进行扩容操作
resize();
//空方法
afterNodeInsertion(evict);
//表示当前没有覆盖任何元素,返回null
return null;
}

面试官:讲一讲HashMap的扩容机制(高频)

候选人

请添加图片描述

  • 在添加元素或初始化的时候需要调用resize()方法进行扩容,第一次添加数据初始化数组长度为16,以后每次扩容都是达到了扩容阈值(数组长度 * 0.75)

扩容阈值 = 数组容量 * 加载因子(默认为0.75)

负载因子太低会导致大量的空桶浪费空间,负载因子太高会导致大量的碰撞,降低性能。0.75 的负载因子在这两个因素之间取得了良好的平衡。

  • 每次扩容的时候,都是扩容之前容量的2倍;

  • 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中

  • 没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置

  • 有冲突节点的话,如果是红黑树,就走红黑树的添加

  • 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上

源码分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
//扩容、初始化数组
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//如果当前数组为null的时候,把oldCap老数组容量设置为0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//老的扩容阈值
int oldThr = threshold;
int newCap, newThr = 0;
//判断数组容量是否大于0,大于0说明数组已经初始化
if (oldCap > 0) {
//判断当前数组长度是否大于最大数组长度
if (oldCap >= MAXIMUM_CAPACITY) {
//如果是,将扩容阈值直接设置为int类型的最大数值并直接返回
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果在最大长度范围内,则需要扩容 OldCap << 1等价于oldCap*2
//运算过后判断是不是最大值并且oldCap需要大于16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold 等价于oldThr*2
}
//如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在, 如果是首次初始化,它的临界值则为0
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//数组未初始化的情况,将阈值和扩容因子都设置为默认值
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//初始化容量小于16的时候,扩容阈值是没有赋值的
if (newThr == 0) {
//创建阈值
float ft = (float)newCap * loadFactor;
//判断新容量和新阈值是否大于最大容量
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//计算出来的阈值赋值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//根据上边计算得出的容量 创建新的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//赋值
table = newTab;
//扩容操作,判断不为空证明不是初始化数组
if (oldTab != null) {
//遍历数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//判断当前下标为j的数组如果不为空的话赋值个e,进行下一步操作
if ((e = oldTab[j]) != null) {
//将数组位置置空
oldTab[j] = null;
//判断是否有下个节点
if (e.next == null)
//如果没有,就重新计算在新数组中的下标并放进去
newTab[e.hash & (newCap - 1)] = e;
//有下个节点的情况,并且判断是否已经树化
else if (e instanceof TreeNode)
//进行红黑树的操作
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//有下个节点的情况,并且没有树化(链表形式)
else {
//比如老数组容量是16,那下标就为0-15
//扩容操作*2,容量就变为32,下标为0-31
//低位:0-15,高位16-31
//定义了四个变量
// 低位头 低位尾
Node<K,V> loHead = null, loTail = null;
// 高位头 高位尾
Node<K,V> hiHead = null, hiTail = null;
//下个节点
Node<K,V> next;
//循环遍历
do {
//取出next节点
next = e.next;
//通过 与操作 计算得出结果为0
if ((e.hash & oldCap) == 0) {
//如果低位尾为null,证明当前数组位置为空,没有任何数据
if (loTail == null)
//将e值放入低位头
loHead = e;
//低位尾不为null,证明已经有数据了
else
//将数据放入next节点
loTail.next = e;
//记录低位尾数据
loTail = e;
}
//通过 与操作 计算得出结果不为0
else {
//如果高位尾为null,证明当前数组位置为空,没有任何数据
if (hiTail == null)
//将e值放入高位头
hiHead = e;
//高位尾不为null,证明已经有数据了
else
//将数据放入next节点
hiTail.next = e;
//记录高位尾数据
hiTail = e;
}

}
//如果e不为空,证明没有到链表尾部,继续执行循环
while ((e = next) != null);
//低位尾如果记录的有数据,是链表
if (loTail != null) {
//将下一个元素置空
loTail.next = null;
//将低位头放入新数组的原下标位置
newTab[j] = loHead;
}
//高位尾如果记录的有数据,是链表
if (hiTail != null) {
//将下一个元素置空
hiTail.next = null;
//将高位头放入新数组的(原下标+原数组容量)位置
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//返回新的数组对象
return newTab;
}

面试官:了解hashMap的寻址算法吗?为何HashMap的数组长度一定是2的次幂?

候选人:JDK 1.8 的 hash ⽅法

1
2
3
4
5
6
7
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或
// >>>:⽆符号右移,忽略符号位,空位都以0补⻬
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

二次哈希:首先计算出key的hashCode值,然后通过这个hash值右移16位后的二进制进行按位异或运算得到最后的hash值。

在putValue的方法中,计算数组下标的时候使用hash值与数组长度取模得到存储数据下标的位置,hashmap为了性能更好,并没有直接采用取模的方式,而是使用了数组长度-1 得到一个值,用这个值按位与运算hash值,最终得到数组的位置。(也就是说 hash%length==hash&(length-1) 的前提是 length 是2的 n次⽅) 并且采⽤⼆进制位操作 &,相对于 % 能够提⾼运算效率,这就解释了HashMap的⻓度为什么是2的幂次⽅。

面试官:好的,hashmap是线程安全的吗?

候选人:不是线程安全的

面试官:那我们想要使用线程安全的map该怎么做呢?

候选人:我们可以采用ConcurrentHashMap进行使用,它是一个线程安全的HashMap

面试官:那你能聊一下ConcurrentHashMap的原理吗?(高频)

《Java并发编程的艺术》 6.1.1 为什么要使用 ConcurrentHashMap?

在并发编程中使用 HashMap 可能导致程序死循环。而使用线程安全的 HashTable 效率又非常低下,基于以上两个原因,便有了ConcurrentHashMap 的登场机会。

(1)线程不安全的HashMap

在多线程环境下,使用 HashMap 进行 put 操作会引起死循环,导致 CPU 利用率接近100%,所以在并发情况下不能使用 HashMap。HashMap 在并发执行 put 操作时会引起死循环,是因为多线程会导致 HashMap 的 Entry 链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取 Entry。

(2)效率低下的HashTable(一锁就锁全表)

HashTable 容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable 的效率非常低下。因为当一个线程访问HashTable 的同步方法,其他线程也访问HashTable 的同步方法时,会进入阻塞或轮询状态。如线程1使用 put 进行元素添加,线程2 不但不能使用 put 方法添加元素,也不能使用 get 方法来获取元素,所以竞争越激烈效率越低。

(3)ConcurrentHashMap 的锁分段技术可有效提升并发访问率

HashTable 容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问 HashTable的线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap 所使用的锁分段技术。

候选人:ConcurrentHashMap 是一种线程安全的高效Map集合,jdk1.7和1.8也做了很多调整。

  • JDK1.7的底层采用是分段的数组+链表 实现
  • JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树

在jdk1.7中ConcurrentHashMap 是由Segment 数组结构和HashEntry数组结构组成。Segment 是一种可重入锁(类似ReentrantLock),扮演锁的⻆⾊。HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个Segment数组。一个Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组,当对 HashEntry 数组的数据进行修改时,必须首先获得与它对应的Segment锁。

image-20250218113808758

在jdk1.8中ConcurrentHashMap 取消了 Segment 分段锁,采⽤ CAS 和 synchronized 来保证并发安全。虽然 CAS 能保证单个变量的原子更新,但在 多个变量关联修改 时,就会产生ABA 问题自旋失败问题,因此 JDK 1.8 ConcurrentHashMap 仍然结合 synchronized 来保证更复杂场景的安全。

ABA 问题是 CAS(Compare-And-Swap) 机制中的一种特殊并发问题,指的是:
某个变量的值从 A 变为 B,然后又变回 A,CAS 机制无法察觉这个变化,从而导致错误的结果。

高并发环境 下,如果线程在 CAS 操作时仅仅判断值是否相等,而不检查是否发生过中间修改,就可能出现 ABA 问题。

面试官:说说ConcurrentHashMap的get、put、size操作

候选人:(源自《Java并发编程的艺术》 6.1.5节)

get操作:

先经过一次散列,然后使用这个散列值通过散列运算定位到 Segment,再通过散列算法定位到元素,代码如下。

1
2
3
4
public v get(object key) {
int hash = hash(key.hashcode());
return segmentFor(hash).get(key, hash);
}

get 操作的高效之处在于整个 get 过程不需要加锁,除非读到的值是空才会加锁重读。我们知道 HashTable 容器的 get 方法是需要加锁的,那么 ConcurrentHashMap 的 get 操作是如何做到不加锁的呢?原因是它的 get方法里将要使用的共享变量都定义成 volatile 类型。定义成 volatile 的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写共享变量 count 和 value,所以可以不用加锁。之所以不会读到过期的值,是因为根据 Java 内存模型的 happens-before 原则,对 volatile 字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile 变量,get操作也能拿到最新的值,这是用 volatile替换锁的经典应用场景。

put操作:

由于 put 方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须加锁。put 方法首先定位到 Segment,然后在 Segment 里进行插入操作。插入操作需要经历两个步骤,第一步断是否需要对 Segment 里的 HashEntry 数组进行扩容,第二步定位添加元素的位置,然后将其放在 HashEnty 数组里。

1)是否需要扩容

在插入元素前会先判断Segment 里的 HashEnty数组是否超过容量(threshold),如果超过阈值,则对数组进行扩容。值得一提的是,Segment的扩容判断比 HashMap 更恰当,因为 HashMap 是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,可能扩容之后没有新元素插入,这时 HashMap 就进行了一次无效的扩容。

2)如何扩容

在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只对某个 Segment 进行扩容。

size 操作:

如果要统计整个 ConcurrentHashMap 里元素的大小,就必须统计所有 Segment 里元素的大小后求和。Segment里的全局变量 count 是一个 volatile 变量,那么在多线程场景下,是不是直接把所有 Segment 的 count 相加就可以得到整个 ConcurrentHashMap 大小了呢? 不是的,虽然相加时可以获取每个 Segment 的 count 的最新值,但是可能累加前使用的 count 发生了变化,那么统计结果就不准了。所以,最安全的做法是在统计size的时候把所有Segment 的put、remove 和 clean 方法全部锁住,但是这种做法显然非常低效。

因为在累加 count 操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap 的做法是尝试2次:通过不锁住Segment的方式来统计各个Segment大小;如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment 的大小。

那么 ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount 变量,在 put、remove 和 clean 方法里操作元素前都会将变量 modCount 进行加1.那么在统计 size 前后比较 modCount 是否发生变化,从而得知容器的大小是否发生变化。

面试官:ConcurrentHashMap用了悲观锁还是乐观锁?

候选人:悲观锁和乐观锁都有用到。

添加元素时首先会判断容器是否为空:

• 如果为空则使用 volatile + CAS (乐观锁) 来初始化。

• 如果容器不为空,则根据存储的元素计算该位置是否为空。

•如果根据存储的元素计算结果为空,则利用CAS(乐观锁) 设置该节点;

• 如果根据存储的元素计算结果不为空,则使用 synchronized(悲观锁),然后遍历桶中的数据并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。

面试官:HashSet与HashMap的区别?

候选人:HashSet 底层就是基于 HashMap 实现的。

image-20250218113836389

面试官:HashTable与HashMap的区别

候选人

区别 HashTable HashMap
数据结构 数组+链表 数组+链表+红黑树
是否可以为null Key和value都不能为null 可以为null
hash算法 key的hashCode() 二次hash
扩容方式 当前容量翻倍 +1(2n + 1) 当前容量翻倍(2n)
线程安全 同步(synchronized)的,线程安全 非线程安全

在实际开发中不建议使用HashTable,在多线程环境下可以使用ConcurrentHashMap类。