Java 基础面试题——集合
创始人
2024-05-26 00:39:23
0

目录

  • 1.概述
    • 1.1.Java 有哪些常用集合(容器)?
    • 1.2.分别简单介绍一下 Collection 接口和 Map 接口下的主要集合。
    • 1.3.Collection 和 Collections 有什么区别?
    • 1.4.List、Set、Queue、Map之间的区别是什么?
    • 1.5.Java 中为什么要使用集合?如何选用集合?
  • 2.Collection
    • 2.1.ArrayList 和 LinkedList 有什么区别?
    • 2.2.ArrayList 和 Vector 有什联系和区别?
    • 2.3.谈一谈 ArrayList 的扩容机制。
      • 2.3.1.ArrayList 的 3 种创建方式
      • 2.3.2.扩容相关的源代码分析
      • 2.3.3.扩容测试
      • 2.3.4.为什么按大约 1.5 倍来扩容?
    • 2.4.Queue 和 Deque 有什么区别?
    • 2.5.ArrayDeque 和 LinkedList 有什么区别?
    • 2.6.谈一谈 PriorityQueue。
  • 3.Map
    • 3.1.HashMap 和 Hashtable 有什么区别?
    • 3.2.HashMap 和 HashSet 有什么联系与区别?
    • 3.3.HashMap 与 TreeMap 有什么区别?
    • 3.4.HashMap 的底层实现是什么样的?
      • 3.4.1.JDK1.8 之前
      • 3.4.1.JDK1.8 及之后
    • 3.3.HashMap 的长度为什么是 2 的 n 次方?源码中是如何保证的?
    • 3.4.HashTable 与 ConcurrentHashMap 有什么区别?
    • 3.5.HashMap 有哪些遍历方式?
    • 3.6.HashSet 如何检查重复的元素?
    • 3.7.集合中的 poll() 和 remove() 有什么异同?

参考文章:Java集合常见面试题总结

1.概述

1.1.Java 有哪些常用集合(容器)?

请添加图片描述

从上图可以知道,Java 集合框架主要包括两种类型的容器
① Collection,用于存放单一元素;
② Map,用于存储键/值对映射。

注意:上图中只列举了主要的继承派生关系,并没有列举完全,如果想要深入了解,可以自行去查看源码。

1.2.分别简单介绍一下 Collection 接口和 Map 接口下的主要集合。

(1)Collection 接口下面的集合:

  • List
    ① ArrayList:底层实现为 Object[] 数组;
    ② Vector:底层实现为 Object[] 数组;
    ③ LinkedList:底层实现为双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环);

  • Queue
    ① PriorityQueue: 元素出队顺序与优先级相关,即总是优先级最高的元素先出队,其底层是通过 Object[] 数组实现的二叉堆来完成的;
    ② ArrayQueue: 底层通过 Object[] 数组 + 双指针来实现;

  • Set
    ① HashSet:基于 HashMap 实现的,底层采用 HashMap 来保存元素,元素无序且唯一
    ② LinkedHashSet:HashSet 的子类,其内部是通过 LinkedHashMap 来实现的;
    ③ TreeSet:有序集合,可以以任意顺序将元素插入到集合中。在对集合进行遍历时,每个值将自动地按照排序后的顺序呈现,其中排序是通过红黑树来完成的;

(2)Map 接口下面的集合:

  • HashMap: JDK1.8 之前 HashMap 由数组 + 链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(通过“拉链法”解决冲突)。而 JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8,并且将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间;
  • LinkedHashMap:继承自 HashMap,所以它的底层仍然是基于拉链式散列结构,即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑;
  • Hashtable:数组+链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突而存在的,并且是线程安全的;
  • TreeMap:实现了 SortedMap 接口,保证了有序性。默认的排序是根据 key 值进行升序排序,也可以重写 comparator 方法来根据 value 进行排序,其中排序是通过红黑树来完成的;

1.3.Collection 和 Collections 有什么区别?

(1)Collection 是 JDK 中集合层次结构中的最根本的接口,定义了集合类的基本方法。Collection 接口在 Java 类库中有很多具体的实现,其意义是为各种具体的集合提供了最大化的统一操作方式。
(2)Collections 是一个包装类,是 Collection 集合框架的工具类。它包含有各种有关集合操作的静态多态方法,不能实例化,比如排序方法: Collections.sort(list)。

1.4.List、Set、Queue、Map之间的区别是什么?

(1)List:存储的元素是有序的、可重复的
(2)Set:存储的元素是无序的、不可重复的,LinkedHashSet 按照插入排序,SortedSet 可排序,HashSet 无序。
(3)Queue:按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。
(4)Map:存储键值对 (key-value),其中要求 key 无序且唯一,而 value 则不要求有序,允许重复。

1.5.Java 中为什么要使用集合?如何选用集合?

(1)当我们需要保存一组类型相同的数据时,可以选用数组,但是数组存在着一定的弊端,例如当数组声明后,其长度以及存储的数据类型也就固定了,并且存储的数据的特点单一。因此为了提高数据存储的灵活性、数据特点的多样性,Java 中使用了集合。

(2)在选用集合时,我们应主要根据需求和集合特点来选用:

  • 当我们只需要存储元素时,就可以选择 Collection 接口下的集合,例如:
    • 如果需要保证元素唯一,可以选择 HashSet、TreeSet;
    • 如果需要保证元素有序且唯一,可以选择 TreeSet;
    • 不需要保证元素唯一,可以选择 ArrayList、LinkedList 等;
  • 当我们需要根据键值获取元素值时,就可以选择 Map 接口下的集合,例如:
    • 需要排序时选择 TreeMap,不需要排序时就选择 HashMap;
    • 需要保证线程安全,选择 ConcurrentHashMap;

2.Collection

2.1.ArrayList 和 LinkedList 有什么区别?

(1)底层数据结构
① ArrayList 底层使用的是Object 数组,在一片连续的内存空间中存储数据。
② LinkedList 底层使用的是双向链表(JDK1.6 之前为双向循环链表,JDK1.7 取消了循环),数据可以存储在分散的内存空间中。

(2)扩容
① ArrayList 底层使用的是 Object 数组,在无参构造函数中默认初始化长度为 10,当需要扩容时会将原数组中的元素重新拷贝到长度为原数组的 1.5 倍的新数组中,扩容代价比较高;
② LinkedList 通过链表实现,新增元素根据要求插入到链表中即可。

有关 ArrayList 扩容的具体细节,可查看本节的 2.3。

(3)是否支持快速随机访问
① ArrayList 实现了 RandomAccess 接口且底层是通过 Object 数组实现的,故支持快速随机访问
② LinkedList 的底层是通过链表实现的,故不支持快速随机访问,查找某一元素的时间复杂度为 O(n)。

快速随机访问就是通过元素的下标快速获取元素,即对应于 ArrayList 中的 get(int index) 方法,其时间复杂度为 O(1)。

(4)插入和删除操作
① ArrayList 底层采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响,同时可能需要进行元素移动甚至扩容操作。 比如:执行 add(E e) 方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话 (add(int index, E element))时间复杂度就为 O(n - i)。因为在进行上述操作的时候集合中第 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) ,因为需要先移动到指定位置再插入。

(3)遍历操作
ArrayList 和 LinkedList 常见的遍历方式有 3 种:for(结合 get(int index) 方法)、foreach、iterator。
① 在数据量比较小时,不同遍历方式的性能差别不大。
② 但是在数据量比较大时:
1)对于 ArrayList 来说,3 种遍历方式差距不是很大,其中 for 循环的效率最高,因为使用的是快速随机访问方式。
2)对于 LinkedList 来说,迭代器效率最高,因为其相当于维护一个当前状态指针,遍历只需要扫描一遍双向链表即可,而 for 效率最低,因为需要扫描 n 遍链表。

① 在 List 遍历方式中,foreach 的底层就是由迭代器实现的,只不过为了方便书写,做了简单的封装。
② 在遍历 LinkedList 时,尽量不要使用 get(int index) 方法,因为每次都要从链表头或者表尾去遍历,时间复杂度较高。

具体细节可参考 ArrayList 和 LinkedList 的三种遍历方式 这篇文章。

(4)内存空间占用
① ArrayList 除了存储数据所占用的内存空间外,列表结尾往往会预留一定的容量空间,这可能会造成一定的内存空间的浪费。
② LinkedList 的内存空间占用则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接前驱直接后继以及数据)。

注意:在项目中一般不会使用 LinkedList,因为大部分需用到 LinkedList 的场景都可以使用 ArrayList 来代替,并且性能通常会更好!

2.2.ArrayList 和 Vector 有什联系和区别?

(1)联系
① Vector 是早期 JDK 版本提供,ArrayList 是新版本中用来替代 Vector 的;
② 底层都是通过数组实现的;
③ 功能相同,实现增删改查等操作的方法相似;

(2)区别
① Vector 是线程安全的,而 ArrayList 是非线程安全的;
② Vector 类中的方法很多有 synchronized 进行修饰,以保证线程安全,这样就导致了 Vector 在效率上无法与 ArrayList 相比;
③ 默认初始化容量都是10,但扩容时 Vector 默认会翻倍,也可指定扩容的大小,而 ArrayList 的大小则是扩容为原来的 1.5 倍;

2.3.谈一谈 ArrayList 的扩容机制。

2.3.1.ArrayList 的 3 种创建方式

先来看 ArrayList 的 3 种创建方式,即对应 3 个构造函数:

//默认初始容量大小
private static final int DEFAULT_CAPACITY = 10;private static final Object[] 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);}
}//无参构造函数,使用初始容量 10 来构造一个空列表
public ArrayList() {this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}//构造包含指定 collection 元素的列表,这些元素利用该集合的迭代器按顺序返回
public ArrayList(Collection c) {elementData = c.toArray();if ((size = elementData.length) != 0) {// c.toArray might (incorrectly) not return Object[] (see 6260652)if (elementData.getClass() != Object[].class)elementData = Arrays.copyOf(elementData, size, Object[].class);} else {// replace with empty array.this.elementData = EMPTY_ELEMENTDATA;}
}

注意:
① 以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。
② JDK6 new 无参构造的 ArrayList 对象时,直接创建了长度是 10 的 Object[] 数组 elementData。

2.3.2.扩容相关的源代码分析

(1)先来看 add(E e) 方法

//向列表末尾添加元素,添加成功时返回 true
public boolean add(E e) {//调用了 ensureCapacityInternal 方法,确保数组下标不越界ensureCapacityInternal(size + 1);  // Increments modCount!!//添加元素elementData[size++] = e;return true;
}

(2)再来看看 ensureCapacityInternal() 方法

private void ensureCapacityInternal(int minCapacity) {ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}private static int calculateCapacity(Object[] elementData, int minCapacity) {if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {//返回默认的容量和传入参数的较大值return Math.max(DEFAULT_CAPACITY, minCapacity);}return minCapacity;
}

当要向列表中添加第 1 个元素时,minCapacity 为 1,在 Math.max()方法比较后,minCapacity 变为 10。

(3)ensureExplicitCapacity() 方法如下:

//判断是否需要扩容
private void ensureExplicitCapacity(int minCapacity) {modCount++;// overflow-conscious codeif (minCapacity - elementData.length > 0)//调用 grow 方法进行扩容grow(minCapacity);
}

具体分析如下:

  • 当添加第 1 个元素时,elementData.length 为 0,因为执行了 ensureCapacityInternal() 方法 ,所以 minCapacity 此时为 10。此时,minCapacity - elementData.length > 0成立,所以会进入 grow(minCapacity) 方法;
  • 当添加第 2 个元素时,minCapacity = 2,此时 elementData.length 在添加第一个元素后扩容成 10 了。此时,minCapacity - elementData.length > 0 不成立,所以不会执行 grow(minCapacity) 方法,既不会进行扩容。添加第 3、4、···直到第 10 个元素时,依然不会执行 grow 方法,数组容量都为 10;
  • 当添加第 11 个元素时,minCapacity = 11,此时 minCapacity - elementData.length = 11 - 10 > 0 成立,因此便进行扩容操作;

(4)grow(int minCapacity) 方法是 ArrayList 扩容时的核心方法,其代码如下:

//要分配的最大数组大小
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;//扩容的核心代码
private void grow(int minCapacity) {// oldCapacity 为旧容量,newCapacity 为新容量int oldCapacity = elementData.length;//将 oldCapacity 右移一位,即相当于 oldCapacity / 2,整句运算式的结果就是将新容量更新为旧容量的 1.5 倍int newCapacity = oldCapacity + (oldCapacity >> 1);//检查新容量 newCapacity 是否大于最小需要容量 minCapacity,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量if (newCapacity - minCapacity < 0)newCapacity = minCapacity;//如果 minCapacity 大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 Integer.MAX_VALUE - 8。if (newCapacity - MAX_ARRAY_SIZE > 0)newCapacity = hugeCapacity(minCapacity);// minCapacity is usually close to size, so this is a win:/*(1) Arrays.copyOf()方法返回的数组是新的数组对象,原数组对象仍是原数组对象不变;(2) 该拷贝不会影响原来的数组, copyOf()的第二个自变量指定要建立的新数组长度,如果新数组的长度超过原数组的长度,则保留数组默认值;*/elementData = Arrays.copyOf(elementData, newCapacity);
}

(5)hugeCapacity(int minCapacity) 方法如下:

private static int hugeCapacity(int minCapacity) {if (minCapacity < 0) // overflowthrow new OutOfMemoryError();return (minCapacity > MAX_ARRAY_SIZE) ?Integer.MAX_VALUE :MAX_ARRAY_SIZE;
}

2.3.3.扩容测试

class Test {//通过反射获取 list 的容量,即 elementData 数组的长度public static Integer getCapacity(ArrayList list) {Integer length = null;Class clazz = list.getClass();Field field;try {field = clazz.getDeclaredField("elementData");field.setAccessible(true);Object[] object = (Object[]) field.get(list);length = object.length;return length;} catch (Exception e) {e.printStackTrace();}return length;}public static void main(String[] args) {ArrayList list = new ArrayList<>();//记录 list 的容量,初始值为 0int preCap = 0;for (int i = 0; i < 100; i++) {list.add(i);int curCap = getCapacity(list);// curCap > preCap 说明上次添加元素过程中发生了扩容if (curCap > preCap) {System.out.println("capacity: " + curCap + ", size: " + list.size());preCap = getCapacity(list);}}System.out.println("添加 100 个元素后,capacity: " + getCapacity(list) + ", size: " + list.size());}
}

输出结果如下:

capacity: 10, size: 1
capacity: 15, size: 11
capacity: 22, size: 16
capacity: 33, size: 23
capacity: 49, size: 34
capacity: 73, size: 50
capacity: 109, size: 74
添加 100 个元素后,capacity: 109, size: 100

2.3.4.为什么按大约 1.5 倍来扩容?

(1)扩容因子的大小选择,需要考虑如下情况:
① 扩容容量不能太小,防止频繁扩容,频繁申请内存空间 + 数组频繁复制;
② 扩容容量不能太大,需要充分利用空间,避免浪费过多空间;

(2)为了能充分使用之前分配的内存空间,最好把增长因子设为 1< k < 2,当 k = 1.5 时,就能充分利用前面已经释放的空间。如果 k >= 2,新容量刚刚好永远大于过去所有废弃的数组容量。除此之外,并且充分利用移位操作(右移一位,在不溢出的情况下相当于除以 2),减少了浮点数运算,提高了效率。

2.4.Queue 和 Deque 有什么区别?

(1)Queue 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循先进先出 (FIFO) 规则。Queue 扩展了 Collection 的接口,根据失败后处理方式的不同可以分为以下两类方法:

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

(2)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() 等其他方法,可用于模拟栈

2.5.ArrayDeque 和 LinkedList 有什么区别?

ArrayDeque 和 LinkedList 均实现了 Deque 接口,即都具有队列的功能,但两者具有以下这些区别:

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

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

2.6.谈一谈 PriorityQueue。

PriorityQueue 中元素出队顺序与优先级相关,即总是优先级最高的元素先出队。这里列举其相关的一些要点:

  • PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据;
  • PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(log2n) 的时间复杂度内插入元素和删除堆顶元素;
  • PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象;
  • PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后;
  • PriorityQueue 经常与面试中的典型算法题有关,例如堆排序、求第 K 大的数、带权图的遍历等。

3.Map

3.1.HashMap 和 Hashtable 有什么区别?

(1)线程是否安全
HashMap 是非线程安全的,Hashtable 是线程安全的(Hashtable 中的方法基本都使用 synchronized 修饰,同时在效率上不如 HashMap)。如果要保证线程安全,可以使用 ConcurrentHashMap,因为目前 Hashtable 使用的频率较低。

(2)对 null 键和 null 值的支持

HashMapHashtable
null 键支持,但只允许有一个支持,允许有多个
null 值不支持不支持

注意:如果 HashTable 中存在 null 键或 null 值,会抛出 NullPointerException。

(3)初始容量和扩容操作

HashMapHashtable
初始容量16,也可自己指定11,也可自己指定
扩容操作每次扩充容量变为原来的 2 倍每次扩充容量变为 2n+1,n 为上一次的容量

(4)计算 hash 值的方式
① HashMap 计算 hash 值的方式为先调用 hashCode() 计算出来一个 hash 值,再将 hash 与 hash 右移 16 位后的值进行异或操作,从而得到最终的 hash 值,具体的代码实现如下:

static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

② Hashtable 则通过计算 key 的 hashCode() 来得到最终的 hash 值。

(5)解决 hash 冲突的机制
① HashMap:在 JDK 1.8 之前,HashMap 底层由数组 + 链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(通过“拉链法”解决冲突)。而 JDK 1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8,并且将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间;
② HashTable:底层也是由数组 + 链表组成的,但没有像 HashMap 那样的机制。

具体细节可参考这篇文章。

3.2.HashMap 和 HashSet 有什么联系与区别?

(1)HashSet 底层就是基于 HashMap 实现的,具体见下面 HashSet 的几个构造函数:

public HashSet() {map = new HashMap<>();
}public HashSet(Collection c) {map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));addAll(c);
}
public HashSet(int initialCapacity, float loadFactor) {map = new HashMap<>(initialCapacity, loadFactor);
}public HashSet(int initialCapacity) {map = new HashMap<>(initialCapacity);
}

(2)但 HashMap 和 HashSet 之间也存在着一些区别:

HashMapHashSet
实现接口实现了 Map 接口实现了 Set 接口
添加方式调用 put() 向 map 中添加键值对调用 add() 向 set 中添加元素
计算 hashcode通过键 (key) 来计算使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以 equals() 方法用来判断对象的相等性

3.3.HashMap 与 TreeMap 有什么区别?

(1)TreeMap 和HashMap 都继承自 AbstractMap ,但是 TreeMap 还实现了 NavigableMap 接口和 SortedMap 接口

  • 实现 NavigableMap 接口让 TreeMap 有了对集合中的元素进行搜索的能力;
  • 实现 SortedMap 接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:
public class Person {private Integer age;public Person(Integer age) {this.age = age;}public Integer getAge() {return age;}public static void main(String[] args) {//重写排序规则TreeMap treeMap = new TreeMap<>((person1, person2) -> {int num = person1.getAge() - person2.getAge();return Integer.compare(num, 0);});treeMap.put(new Person(3), "person1");treeMap.put(new Person(18), "person2");treeMap.put(new Person(35), "person3");treeMap.put(new Person(16), "person4");treeMap.forEach((key, value) -> System.out.println(value));}
}

输出结果如下:

person1
person4
person2
person3

(2)总之,相比于 HashMap 来说,TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素进行搜索的能力

在这里插入图片描述

在这里插入图片描述

3.4.HashMap 的底层实现是什么样的?

3.4.1.JDK1.8 之前

(1)JDK1.8 之前 HashMap 底层是数组 + 链表结合在一起使用,也就是链表散列。HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法,换句话说使用扰动函数之后可以减少 hash 冲突,提高效率

(2)拉链法指将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。如果遇到哈希冲突,则将冲突的值加添加到链表中即可,如下图所示:

在这里插入图片描述

3.4.1.JDK1.8 及之后

相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

在这里插入图片描述

TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构

3.3.HashMap 的长度为什么是 2 的 n 次方?源码中是如何保证的?

(1)为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。

(2)我们首先可能会想到通过取模操作来实现。但是与 (&) 的运算速度比取余 (%) 取模运算快,并且在取余 (%) 操作中如果除数是 2 的幂次,则等价于与其除数减一的与 (&) 操作,即 hash % length == hash & (length - 1) 的前提是 length 是 2 的 n 次方。所以 HashMap 的长度为 2 的 n 次方。

注:HashMap 的初始默认长度是 16。

(3)此外,如果 HashMap 的长度为 2 的 n 次方,那么在扩容迁移时不需要再重新定位新的位置了,因为扩容后元素新的位置,要么在原下标位置,要么在原下标 + 扩容长度的位置

(4)HashMap 源码中的 tableSizeFor(int cap) 可以保证其长度永远是是 2 的 n 次方。

/*** Returns a power of two size for the given target capacity.*/
//返回大于且最靠近 cap 的 2^n,例如 cap = 17 时,返回 32
static final int tableSizeFor(int cap) {// n = cap - 1 使得 n 的二进制表示的最后一位和 cap 的最后一位一定不一样int n = cap - 1;//无符号右移,在移动期间,使用 | 运算保证低位全部是 1n |= n >>> 1;n |= n >>> 2;n |= n >>> 4;n |= n >>> 8;n |= n >>> 16;return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

无符号右移 >>>:忽略符号位,空位均用 0 补齐,最终结果必为非负数。

具体分析可参考这篇文章。

3.4.HashTable 与 ConcurrentHashMap 有什么区别?

ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。

  • 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用分段的数组+链表来实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用数组+链表的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
  • 实现线程安全的方式
    • 在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
    • 到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本
    • Hashtable(同一把锁):使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 方法添加元素,也不能使用 get 方法来获取元素,竞争会越来越激烈效率越低。

具体细节可参考这篇文章

3.5.HashMap 有哪些遍历方式?

参考文章:HashMap 的 7 种遍历方式与性能分析!「修正篇」

HashMap 遍历从大的方向来说,可分为以下 4 类:

  • 迭代器 (Iterator) 方式遍历;
  • foreach 方式遍历;
  • Lambda 表达式遍历 (JDK 1.8+);
  • Streams API 遍历 (JDK 1.8+)。

但每种类型下又有不同的实现方式,因此具体的遍历方式又可以分为以下 7 种:

  • 使用迭代器(Iterator)EntrySet 的方式进行遍历;
  • 使用迭代器(Iterator)KeySet 的方式进行遍历;
  • 使用 foreach EntrySet 的方式进行遍历;
  • 使用 foreach KeySet 的方式进行遍历;
  • 使用 Lambda 表达式的方式进行遍历;
  • 使用 Streams API 单线程的方式进行遍历;
  • 使用 Streams API 多线程的方式进行遍历。

(1)使用迭代器 (Iterator) EntrySet 的方式进行遍历:

class HashMapTest {public static void main(String[] args) {Map hashMap = new HashMap<>();hashMap.put(1, "Java");hashMap.put(2, "C++");hashMap.put(3, "Python");// Iterator entrySetIterator> iterator = hashMap.entrySet().iterator();while (iterator.hasNext()) {Map.Entry entry = iterator.next();System.out.println("(" + entry.getKey() + ", " + entry.getValue() + ")");}}
}

输出结果如下,下面几种遍历方式的结果于下面的一样,因此将不再展示。

(1, Java)
(2, C++)
(3, Python)

(2)使用迭代器 (Iterator) KeySet 的方式进行遍历:

class HashMapTest {public static void main(String[] args) {Map hashMap = new HashMap<>();hashMap.put(1, "Java");hashMap.put(2, "C++");hashMap.put(3, "Python");// Iterator keySetIterator iterator = hashMap.keySet().iterator();while (iterator.hasNext()) {Integer key = iterator.next();System.out.println("(" + key + ", " + hashMap.get(key) + ")");}}
}

(3)使用 foreach EntrySet 的方式进行遍历:

class HashMapTest {public static void main(String[] args) {Map hashMap = new HashMap<>();hashMap.put(1, "Java");hashMap.put(2, "C++");hashMap.put(3, "Python");// foreach entrySetfor (Map.Entry entry : hashMap.entrySet()) {System.out.println("(" + entry.getKey() + ", " + entry.getValue() + ")");}}
}

(4)使用 foreach KeySet 的方式进行遍历:

class HashMapTest {public static void main(String[] args) {Map hashMap = new HashMap<>();hashMap.put(1, "Java");hashMap.put(2, "C++");hashMap.put(3, "Python");// foreach keySetfor (Integer key : hashMap.keySet()) {System.out.println("(" + key + ", " + hashMap.get(key) + ")");}}
}

(5)使用 Lambda 表达式的方式进行遍历:

class HashMapTest {public static void main(String[] args) {Map hashMap = new HashMap<>();hashMap.put(1, "Java");hashMap.put(2, "C++");hashMap.put(3, "Python");// LambdahashMap.forEach((key, value) -> {System.out.println("(" + key + ", " + value + ")");});}
}

(6)使用 Streams API 单线程的方式进行遍历:

class HashMapTest {public static void main(String[] args) {Map hashMap = new HashMap<>();hashMap.put(1, "Java");hashMap.put(2, "C++");hashMap.put(3, "Python");// Streams API 单线程hashMap.entrySet().stream().forEach((entry) -> {System.out.println("(" + entry.getKey() + ", " + entry.getValue() + ")");});}
}

(7)使用 Streams API 多线程的方式进行遍历:

class HashMapTest {public static void main(String[] args) {Map hashMap = new HashMap<>();hashMap.put(1, "Java");hashMap.put(2, "C++");hashMap.put(3, "Python");// Streams API 多线程hashMap.entrySet().parallelStream().forEach((entry) -> {System.out.println("(" + entry.getKey() + ", " + entry.getValue() + ")");});}
}

3.6.HashSet 如何检查重复的元素?

(1)当把对象加入 HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用 equals() 方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。

(2)在 JDK1.8 中,HashSet 的 add() 方法只是简单的调用了 HashMap 的 put() 方法,并且判断了一下返回值以确保是否有重复元素。直接看一下 HashSet 中的源码:

//@return true if this set did not already contain the specified element
public boolean add(E e) {return map.put(e, PRESENT)==null;
}

而在 HashMap 的 putVal() 方法中也能看到如下说明:

// Returns : previous value, or null if none
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {...
}

也就是说,在 JDK1.8 中,实际上无论 HashSet 中是否已经存在了某元素,HashSet都会直接插入,只是会在 add() 方法的返回值处告诉我们插入前是否存在相同元素。

3.7.集合中的 poll() 和 remove() 有什么异同?

(1)相同点:poll() 和 remove() 都是从队列中取出一个元素
(2)不同点:poll() 在获取元素失败的时候会返回空,但 remove() 失败的时候会抛出异常

相关内容

热门资讯

【NI Multisim 14...   目录 序言 一、工具栏 🍊1.“标准”工具栏 🍊 2.视图工具...
银河麒麟V10SP1高级服务器... 银河麒麟高级服务器操作系统简介: 银河麒麟高级服务器操作系统V10是针对企业级关键业务...
不能访问光猫的的管理页面 光猫是现代家庭宽带网络的重要组成部分,它可以提供高速稳定的网络连接。但是,有时候我们会遇到不能访问光...
AWSECS:访问外部网络时出... 如果您在AWS ECS中部署了应用程序,并且该应用程序需要访问外部网络,但是无法正常访问,可能是因为...
Android|无法访问或保存... 这个问题可能是由于权限设置不正确导致的。您需要在应用程序清单文件中添加以下代码来请求适当的权限:此外...
北信源内网安全管理卸载 北信源内网安全管理是一款网络安全管理软件,主要用于保护内网安全。在日常使用过程中,卸载该软件是一种常...
AWSElasticBeans... 在Dockerfile中手动配置nginx反向代理。例如,在Dockerfile中添加以下代码:FR...
AsusVivobook无法开... 首先,我们可以尝试重置BIOS(Basic Input/Output System)来解决这个问题。...
ASM贪吃蛇游戏-解决错误的问... 要解决ASM贪吃蛇游戏中的错误问题,你可以按照以下步骤进行:首先,确定错误的具体表现和问题所在。在贪...
月入8000+的steam搬砖... 大家好,我是阿阳 今天要给大家介绍的是 steam 游戏搬砖项目,目前...