Java集合容器面试题


这阵子在准备找寒假实习(java后台开发),因为自己是转专业的,基础不是很牢固,所以打算通过复习java常用的面试题来巩固。 继昨天的Java基础后,今天主要看了Java集合容器的面试题,感觉大部分需要理解+背诵,参考链接是 [ Java基础知识面试题(2020最新版)](https://thinkwon.blog.csdn.net/article/details/104588551) ,感谢这位博主的贡献。 全文25129字,一半是自己跟着敲的,一半是复制的。 继续加油,希望寒假找个满意的实习。

文章目录

1. 集合容器概述

1.1 什么是集合

  • 集合框架:用于存储数据的容器。集合框架是为表示和操作集合而规定的一种统一的标准的体系结构。任何集合框架都包含三大块内容:对外的接口、接口的实现和对集合运算的算法。
  • 接口:表示集合的抽象数据类型。接口允许我们操作集合时不必关注具体实现,从而达到“多态”。在面向对象编程语言中,接口通常用来形成规范。
  • 实现:集合接口的具体实现,是重用性很高的数据结构。
  • 算法:在一个实现了某个集合框架中的接口的对象身上完成某种有用的计算的方法,例如查找、排序等。这些算法通常是多态的,因为相同的方法可以在同一个接口被多个类实现时有不同的表现。事实上,算法是可复用的函数。

1.2 集合的特点

  • 对象封装数据,对象多了也需要存储。集合用于存储对象
  • 对象的个数确定可以使用数组,不确定用集合,因为集合是可变长度的

1.3 集合和数组的区别

  • 数组是固定长度的,集合是可变长度的
  • 数组可以存储基本数据类型,也可以存储引用数据类型;集合只能存储引用数据类型
  • 数组存储的元素必须是同一个数据类型;集合存储的对象可以使不同数据类型

1.4 使用集合框架的好处

  1. 容量自增长
  2. 提供了高性能的数据结构和算法,编码简单,提高了程序速度和质量
  3. 运行不同API之间的互操作,API之间可以来回传递操作
  4. 可以方便扩展和改写集合,提高代码复用性和可操作性
  5. 通过使用JDK自带的集合类,可以降低代码维护和学习新API成本

1.5 常用的集合类有哪些?

Map接口和Collection接口是多有集合框架的父接口

  1. collection接口的子接口包括:Set接口和List接口
  2. Map接口的实现类有:HashMap,TreeMap,Hashtable,ConcurrentHashMap以及Properties等
  3. Set接口的实现类主要有:HashSet、TreeSet、LinkedHashSet等
  4. List接口的实现类主要有:ArrayList、LinkedList、Stack以及Vector等

1.6 List,Set,Map三者的区别?List、Set、Map 是否继承自 Collection 接口?List、Map、Set 三个接口存取元素时,各有什么特点?

在这里插入图片描述

  • Java容器分为Collection和Map两大类,Collection的子接口有Set,List,Queue三种
  • List:有序容器(元素存入集合的顺序和取出顺序一致),元素可以重复,可以插入多个null元素,元素都有索引。常用的实现类:ArrayList,LinkedList,Vector
  • Set:无序的容器(存入和取出顺序可能不一致),不可以存储重复元素,只允许存入一个null元素,必须保证元素的唯一性。常用实现类:HashSet,LinkedHashSet以及TreeSet
  • Map:一个键值对集合,存储键和值之间的映射。Key无序,唯一。value不要求有序,允许重复。Map没有继承于Collection接口,从Map集合中检索元素时,只要给出键对象,就会返回对应的值对象。Map 的常用实现类:HashMap、TreeMap、HashTable、LinkedHashMap、ConcurrentHashMap

1.7 集合框架底层数据结构

  • Collection:
  1. List:Arraylist: Object数组;Vector: Object数组;LinkedList: 双向循环链表
  2. Set:
    2.1 HashSet(无序,唯一):基于 HashMap 实现的,底层采用 HashMap 来保存元素
    2.2 LinkedHashSet: LinkedHashSet 继承与 HashSet,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的LinkedHashMap 其内部是基于 Hashmap 实现一样,不过还是有一点点区别的
    2.3 TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树。)
  • Map
  1. HashMap: JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突).JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间
  2. LinkedHashMap:LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑
  3. HashTable: 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
  4. TreeMap: 红黑树(自平衡的排序二叉树)

1.8 哪些集合类是线程安全的?

  • vector:比arraylist多了一个同步化机制,因为效率太低,不建议使用
  • stack:堆栈类,先进后出
  • hashtable:比hashmap多了线程安全
  • enumeration:枚举,相当于迭代器

1.9 Java集合的快速失败机制 “fail-fast”?

  • 定义:java集合是一种错误检测机制,当多个线程对集合进行结构上的修改时,有可能会产生fail-fast机制
  • 例子:假设存在两个线程(线程1、线程2),线程1通过Iterator在遍历集合A中的元素,在某个时候线程2修改了集合A的结构(是结构上面的修改,而不是简单的修改集合元素的内容),那么这个时候程序就会抛出 ConcurrentModificationException 异常,从而产生fail-fast机制
  • 分析:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个modCount变量。集合在遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hasNext()/next()遍历下一个元素之前,都会检测modCount变量是否是expectedmodCount,是的话就遍历;否则就抛出异常,终止遍历。
  • 解决方法:
  1. 遍历过程中,所有涉及到改变modCount值的地方全部加上synchronized
  2. 使用CopyOnWriteArryaList替换ArrayList

1.10 怎么确保一个集合不能被修改?

  • 可以使用Collection.unmodifiableCollection(Collection c)方法来创建一个只读集合,这样改变集合的任何操作都会抛出Java.lang.UnsupportedOperationException异常
  • 例子:
List<String> list = new ArrayList<>();
list. add("x");
Collection<String> clist = Collections. unmodifiableCollection(list);
clist. add("y"); // 运行时此行报错
System. out. println(list. size());

2. Collection接口

2.1 List接口

2.1.1 迭代器 Iterator 是什么?

  • 定义:Iterator接口提供遍历任何Collection的接口
  • 迭代器取代了Java结合框架中的Enumeration,迭代器允许调用者在迭代过程中移除元素

2.1.2 Iterator 怎么使用?有什么特点?

  • 使用
List<String> list = new ArrayList<>();
Iterator<String> it = list. iterator();
while(it. hasNext()){
  String obj = it. next();
  System. out. println(obj);
}
  • 特点:Iterator的特点是只能单向遍历,但是更加安全。因为它可以确保,在当前遍历的集合元素被修改的时候,就会抛出ConcurrentModificationException异常

2.1.3 如何边遍历边移除 Collection 中的元素?

  • 使用Iterator.remove()
  • 例子:
Iterator<Integer> it = list.iterator();
while(it.hasNext()){
   *// do something*
   it.remove();
}
  • 错误示范:
for(Integer i : list){
   list.remove(i)
}

以上代码会报ConcurrentModificationException 异常。因为使用for(Integer i : list)的时候,会自动生成一个迭代器来遍历list,同时list被Iterator.remove()。Java一般不允许一个线程在遍历 Collection 时另一个线程修改它。

2.1.4 Iterator 和 ListIterator 有什么区别?

  • Iterator可以遍历Set和List,而ListIterator只能遍历List
  • Iterator只能单向遍历,ListIterator可以双向遍历
  • ListIterator实现Iterator接口,然后添加了一些额外的功能,比如添加一个元素、替换一个元素、获取前面或者后面元素的索引位置

2.1.5 遍历一个 List 有哪些不同的方式?每种方法的实现原理是什么?Java 中 List 遍历的最佳实践是什么?

  • 遍历方法:
  1. for遍历,基于计数器。在集合外维护一个计数器,然后依次读取每一个位置的元素,当前读取到最后一个元素后停止。
  2. 迭代器遍历,Iterator。Iterator是面向对象的一个设计模式,目的是屏蔽不同数据集合的特点,统一遍历集合的接口。Java在Collections中支持了Iterator模式。
  3. foreach循环遍历:内部采用了Iterator的方式实现,使用时不需要显式声明Iterator或计数器。优点是代码简洁,不易出错;缺点是只能做简单的遍历,不能再遍历过程中操作数据结合,如删除和替换
  • 最佳实践:Java Collections 框架中提供了一个 RandomAccess 接口,用来标记 List 实现是否支持 Random Access
  1. 如果一个数据集合实现了该接口,就意味着它支持 Random Access,按位置读取元素的平均时间复杂度为 O(1),如ArrayList
  2. 如果没有实现该接口,表示不支持 Random Access,如LinkedList
  • 推荐的做法就是,支持 Random Access 的列表可用 for 循环遍历,否则建议用 Iterator 或 foreach 遍历

2.1.6 说一下ArrayList 的优缺点

  • 优点:底层以数组实现,是一种随机访问模式。实现了RandomAccess接口,查找速度块,在顺序添加一个元素很方便
  • 缺点:删除元素或者插入元素时,需要做一次元素复制操作,复制操作耗费性能
  • ArrayList 比较适合顺序添加、随机访问的场景

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

  • 数组转List:使用Arrays.asList(array)
  • List转数组:使用List自带的**toArray()**方法
  • 例子:
// list to array
List<String> list = new ArrayList<String>();
list.add("123");
list.add("456");
list.toArray();

// array to list
String[] array = new String[]{"123","456"};
Arrays.asList(array);

2.1.8 ArrayList 和 LinkedList 的区别是什么?

  • 数据结构实现:ArrayList 是动态数组的数据结构实现,而 LinkedList 是双向链表的数据结构实现
  • 随机访问效率:ArrayList 比 LinkedList 在随机访问的时候效率要高,因为 LinkedList 是线性的数据存储方式,所以需要移动指针从前往后依次查找
  • 增加和删除效率:在非首尾的增加和删除操作,LinkedList 要比 ArrayList 效率要高,因为 ArrayList 增删操作要影响数组内的其他数据的下标
  • 内存空间占用:LinkedList 比 ArrayList 更占内存,因为 LinkedList 的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素
  • 线程安全:都是不线程安全

2.1.9 ArrayList 和 Vector 的区别是什么?

  • 相同点:都实现了List接口,都是有序集合
  • 线程安全:Vector 使用了 Synchronized 来实现线程同步,是线程安全的,而 ArrayList 是非线程安全的
  • 性能:ArrayList 在性能方面要优于 Vector
  • 扩容:ArrayList 和 Vector 都会根据实际的需要动态的调整容量,只不过在 Vector 扩容每次会增加 1 倍,而 ArrayList 只会增加 50%

2.1.10 插入数据时,ArrayList、LinkedList、Vector谁速度较快?阐述 ArrayList、Vector、LinkedList 的存储性能和特性?

  • ArrayList底层的实现都是使用数组方式存储数据。数组元素数大于实际存储的数据以便增加和插入元素,它们都允许直接按序号索引元素,但是插入元素要涉及数组元素移动等内存操作,所以索引数据快而插入数据慢。
  • Vector 中的方法由于加了 synchronized 修饰,因此 Vector 是线程安全容器,但性能上较ArrayList差。
  • LinkedList 使用双向链表实现存储,按序号索引数据需要进行前向或后向遍历,但插入数据时只需要记录当前项的前后项即可,所以 LinkedList 插入速度较快

2.1.11 多线程场景下如何使用 ArrayList?

  • 使用synchronizedList方法将其转化为线程安全容器后使用
  • 例子:
List<String> synchronizedList = Collections.synchronizedList(list);
synchronizedList.add("aaa");
synchronizedList.add("bbb");

for (int i = 0; i < synchronizedList.size(); i++) {
    System.out.println(synchronizedList.get(i));
}

2.1.12 为什么 ArrayList 的 elementData 加上 transient 修饰?

  • ArrayList的数组定义:
private transient Object[] elementData;
  • ArrayList的定义:
public class ArrayList<E> extends AbstractList<E>
     implements List<E>, RandomAccess, Cloneable, java.io.Serializable
  • 可以看出ArrayList实现了serializable接口,所以ArrayList可以序列化。transient的作用是说不希望elementData数据被序列化,重写了writeObject实现:
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{
    *// Write out element count, and any hidden stuff*
        int expectedModCount = modCount;
    s.defaultWriteObject();
    *// Write out array length*
        s.writeInt(elementData.length);
    *// Write out all elements in the proper order.*
        for (int i=0; i<size; i++)
            s.writeObject(elementData[i]);
    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
}

每次序列化时,先调用 defaultWriteObject() 方法序列化 ArrayList 中的非 transient 元素,然后遍历 elementData,只序列化已存入的元素,这样既加快了序列化的速度,又减小了序列化之后的文件大小

2.1.13 List 和 Set 的区别

  • List:有序,元素可以重复,可以插入多个null,元素都有索引。
  • Set:无序,元素不能重复,只能插入一个null
  • 相同点:都继承自Collection接口
  • 不同点:Set检索元素效率低,删除和插入效率高,插入和删除不能引起元素位置改变;List检索效率高,动态增长,插入删除效率低,并且会引起其他元素位置变化

2.2 Set接口

2.2.1 说一下 HashSet 的实现原理?

  • HashSet 是基于 HashMap 实现的,HashSet的值存放于HashMap的key上,HashMap的value统一为PRESENT,因此 HashSet 的实现比较简单,相关 HashSet 的操作,基本上都是直接调用底层 HashMap 的相关方法来完成,HashSet 不允许重复的值

2.2.2 HashSet如何检查重复?HashSet是如何保证数据不可重复的?

  • add()元素时,不只需要比较hash值,还需要结合equals方法
  • HashSet的add方法会使用到HashMap的put方法
  • 源码:
private static final Object PRESENT = new Object();
private transient HashMap<E,Object> map;

public HashSet() {
    map = new HashMap<>();
}

public boolean add(E e) {
    // 调用HashMap的put方法,PRESENT是一个至始至终都相同的虚值
	return map.put(e, PRESENT)==null;
}

2.2.3 hashCode(),equals(),==

  • 两个对象相等,hashCode一定相同,使用equals得到true
  • 两个对象有相同的hashCode值,他们不一定相等
  • equals方法被覆盖过的话,hashCode也必须被覆盖
  • hashCode默认行为是对堆上的对象产生独特值,如果没有重写hashCode,则该class的两个对象无论如何都不会相等,即使这两个对象指向相同的数据
  • ==判断两个变量或者实例是不是指向同一个内存空间,equals是判断两个实例或者变量所指向的内存空间的值是否相同
  • ==是指对内存地址进行比较 equals()是对字符串的内容进行比较
  • ==指引用是否相同 equals()指的是值是否相同

2.2.4 HashSet与HashMap的区别

HashMapHashSet
实现了Map接口实现了Set接口
存储键值对存储对象
调用put添加元素调用add添加元素
使用Key计算HashCode使用成员对象来计算hashcode,对于两个对象来说hashcode可能相同,所以equals方法来判断对象的想等性,如果对象不相等,则返回false
速度更快,是唯一的键获取对象比较慢

2.3 Queue

2.3.1 BlockingQueue是什么?(感觉有点偏)

  • Java.util.concurrent.BlockingQueue是一个队列,在进行检索或移除一个元素的时候,它会等待队列变为非空;当在添加一个元素时,它会等待队列中的可用空间
  • BlockingQueue接口是Java集合框架的一部分,主要用于实现生产者-消费者模式。我们不需要担心等待生产者有可用的空间,或消费者有可用的对象,因为它都在BlockingQueue的实现类中被处理了
  • Java提供了集中BlockingQueue的实现,比如ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue,、SynchronousQueue等

2.3.2在 Queue 中 poll()和 remove()有什么区别?

  • 相同点:都是返回第一个元素,并在队列中删除返回的对象
  • 不同点:如果没有元素,poll返回null,remove抛出异常(NoSuchElementException)

3. Map接口

3.1 说一下 HashMap 的实现原理?

  • 概述:HashMap是基于哈希表的Map接口的非同步实现。此实现提供所有可选的映射操作,并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变
  • 数据结构:在Java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外。HashMap实际上是一个**“链表散列”**的数据结构,即数组和链表的结合体
  • HashMap基于Hash算法实现:
  1. 当我们往Hashmap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标
  2. 存储时,如果出现hash值相同的key,此时有两种情况。(1)如果key相同,则覆盖原始值;(2)如果key不同(出现冲突),则将当前的key-value放入链表中
  3. 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值
  4. 理解了以上过程就不难明白HashMap是如何解决hash冲突的问题,核心就是使用了数组的存储方式,然后将冲突的key的对象放入链表中,一旦发现冲突就在链表中做进一步的对比
  5. 需要注意Jdk 1.8中对HashMap的实现做了优化,当链表中的节点数据超过八个之后,该链表会转为红黑树来提高查询效率,从原来的O(n)到O(logn)

3.2 HashMap在JDK1.7和JDK1.8中有哪些不同?HashMap的底层实现

  • 在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突

3.2.1 JDK1.8之前

  • JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可
    在这里插入图片描述

3.2.2 JDK1.8之后

  • 相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间
    在这里插入图片描述

3.2.3 JDK1.7 VS JDK1.8 比较

  • JDK1.8解决和优化的问题:
  1. resize 扩容优化
  2. 引入了红黑树,目的是避免单条链表过长而影响查询效率
  3. 解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题
  • 不同:
不同JDK1.7JDK1.8
存储结构数组+链表数组+链表+红黑树
初始化方式单独函数:inflateTable()直接集成到了扩容函数resize()中
hash值计算方式扰动处理 = 9次扰动 = 4次位运算 + 5次异或运算扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算
存放数据的规定无冲突时,存放数组;冲突时,存放链表无冲突时,存放数组;冲突 & 链表长度 < 8:存放单链表;冲突 & 链表长度 > 8:树化并存放红黑树
插入数据方式头插法(先讲原位置的数据移到后1位,再插入数据到该位置)尾插法(直接插入到链表尾部/红黑树)
扩容后存储位置的计算方式全部安装原来方法进行计算:即hashCode ->> 扰动函数 ->> (h&length-1)按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧容量)

3.3 HashMap的put方法的具体流程?

  • 首先计算key的hash值,调用了hash()方法(hash方法实际是让key.hashCode()与key.hashCode()>>>16进行异或操作,高16bit补0,一个数和0异或不变,所以 hash 函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或,目的是减少碰撞)
  • 按照函数注释,因为bucket数组大小是2的幂,计算下标index = (table.length - 1) & hash,如果不做 hash 处理,相当于散列生效的只有几个低 bit 位,为了减少散列的碰撞,设计者综合考虑了速度、作用、质量之后,使用高16bit和低16bit异或来简单处理减少碰撞,而且JDK8中用了复杂度 O(logn)的树结构来提升碰撞下的性能
  • putVal方法执行流程图
    在这里插入图片描述
  • 代码示例:
public V put(K key, V value) {
    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);
}

//实现Map.put和相关方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 步骤①:tab为空则创建 
    // table未初始化或者长度为0,进行扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 步骤②:计算index,并对null做处理  
    // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 桶中已经存在元素
    else {
        Node<K,V> e; K k;
        // 步骤③:节点key存在,直接覆盖value 
        // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
                // 将第一个元素赋值给e,用e来记录
                e = p;
        // 步骤④:判断该链为红黑树 
        // hash值不相等,即key不相等;为红黑树结点
        // 如果当前元素类型为TreeNode,表示为红黑树,putTreeVal返回待存放的node, e可能为null
        else if (p instanceof TreeNode)
            // 放入树中
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 步骤⑤:该链为链表 
        // 为链表结点
        else {
            // 在链表最末插入结点
            for (int binCount = 0; ; ++binCount) {
                // 到达链表的尾部
                
                //判断该链表尾部指针是不是空的
                if ((e = p.next) == null) {
                    // 在尾部插入新结点
                    p.next = newNode(hash, key, value, null);
                    //判断链表的长度是否达到转化红黑树的临界值,临界值为8
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //链表结构转树形结构
                        treeifyBin(tab, hash);
                    // 跳出循环
                    break;
                }
                // 判断链表中结点的key值与插入的元素的key值是否相等
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 相等,跳出循环
                    break;
                // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                p = e;
            }
        }
        //判断当前的key已经存在的情况下,再来一个相同的hash值、key值时,返回新来的value这个值
        if (e != null) { 
            // 记录e的value
            V oldValue = e.value;
            // onlyIfAbsent为false或者旧值为null
            if (!onlyIfAbsent || oldValue == null)
                //用新值替换旧值
                e.value = value;
            // 访问后回调
            afterNodeAccess(e);
            // 返回旧值
            return oldValue;
        }
    }
    // 结构性修改
    ++modCount;
    // 步骤⑥:超过最大容量就扩容 
    // 实际大小大于阈值则扩容
    if (++size > threshold)
        resize();
    // 插入后回调
    afterNodeInsertion(evict);
    return null;
}
  • 过程:
  1. 判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容
  2. 根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③
  3. 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals
  4. 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤
  5. 遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可
  6. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容

3.4 HashMap的扩容操作是怎么实现的?

  1. 在JDK1.8中,resize方法是在hashmap中的键值对大于阈值时或者初始化时,就调用resize方法进行扩容
  2. 每次扩容,都扩展2倍
  3. 扩容之后,Node对象的位置要么在原位置,要么移动到原偏移量两倍的位置
  • 在putVal()中,我们看到在这个函数里面使用到了2次resize()方法,resize()方法表示的在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于其临界值值(第一次为12),这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发,这也是JDK1.8版本的一个优化的地方,在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发,但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
  • 代码:
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;//oldTab指向hash桶数组
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {//如果oldCap不为空的话,就是hash桶数组不为空
        if (oldCap >= MAXIMUM_CAPACITY) {//如果大于最大容量了,就赋值为整数最大的阀值
            threshold = Integer.MAX_VALUE;
            return oldTab;//返回
        }//如果当前hash桶数组的长度在扩容后仍然小于最大容量 并且oldCap大于默认值16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold 双倍扩容阀值threshold
    }
    // 旧的容量为0,但threshold大于零,代表有参构造有cap传入,threshold已经被初始化成最小2的n次幂
    // 直接将该值赋给新的容量
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 无参构造创建的map,给出默认容量和threshold 16, 16*0.75
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 新的threshold = 新的cap * 0.75
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 计算出新的数组长度后赋给当前成员变量table
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶数组
    table = newTab;//将新数组的值复制给旧的hash桶数组
    // 如果原先的数组没有初始化,那么resize的初始化工作到此结束,否则进入扩容元素重排逻辑,使其均匀的分散
    if (oldTab != null) {
        // 遍历新数组的所有桶下标
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // 旧数组的桶下标赋给临时变量e,并且解除旧数组中的引用,否则就数组无法被GC回收
                oldTab[j] = null;
                // 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树
                if (e.next == null)
                    // 用同样的hash映射算法把该元素加入新的数组
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // e是链表的头并且e.next!=null,那么处理链表中元素重排
                else { // preserve order
                    // loHead,loTail 代表扩容后不用变换下标,见注1
                    Node<K,V> loHead = null, loTail = null;
                    // hiHead,hiTail 代表扩容后变换下标,见注1
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍历链表
                    do {             
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                // 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead
                                // 代表下标保持不变的链表的头元素
                                loHead = e;
                            else                                
                                // loTail.next指向当前e
                                loTail.next = e;
                            // loTail指向当前的元素e
                            // 初始化后,loTail和loHead指向相同的内存,所以当loTail.next指向下一个元素时,
                            // 底层数组中的元素的next引用也相应发生变化,造成lowHead.next.next.....
                            // 跟随loTail同步,使得lowHead可以链接到所有属于该链表的元素。
                            loTail = e;                           
                        }
                        else {
                            if (hiTail == null)
                                // 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

3.5 HashMap是怎么解决哈希冲突的?

3.5.1 什么是哈希?

  • 定义:Hash,翻译为散列,就是把任意长度的输入通过散列算法,变成固定长度的输出,该输出就是散列值
  • 这种转换其实是一种压缩印刷,也就是,散列值的空间远小于输入的空间,不同输入可能会散列成先相同的输出,所以不可能从散列值来唯一确定输入值
  • 简单的说就是将任意长度的消息压缩到某一固定长度的消息摘要函数
  • Hash值不同,输入一定不同

3.5.2 什么是哈希冲突?

  • 当两个不同的输入,根据同一散列函数计算出相同的散列值的现象

3.5.3 HashMap的数据结构

  • 数组+链表
  • 将拥有相同hash值的对象组织成一个链表放在hash值所对应的bucket下
  • 但相比于hashCode返回的int类型,我们HashMap初始的容量大小DEFAULT_INITIAL_CAPACITY = 1 << 4(即2的四次方16)要远小于int类型的范围,所以我们如果只是单纯的用hashCode取余来获取对应的bucket这将会大大增加哈希碰撞的概率,并且最坏情况下还会将HashMap变成一个单链表,所以我们还需要对hashCode作一定的优化

3.5.4 hash()函数

  • 上面提到的问题,主要是因为如果使用hashCode取余,那么相当于参与运算的只有hashCode的低位,高位是没有起到任何作用的,所以我们的思路就是让hashCode取值出的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,在JDK 1.8中的hash()函数如下:
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 与自己右移16位进行异或运算(高低位异或)
}
  • 这比在JDK 1.7中,更为简洁,相比在1.7中的4次位运算,5次异或运算(9次扰动),在1.8中,只进行了1次位运算和1次异或运算(2次扰动)

3.5.5 JDK1.8新增红黑树

在这里插入图片描述

  • 通过上面的链地址法(使用散列表)和扰动函数我们成功让我们的数据分布更平均,哈希碰撞减少
  • 但是当我们的HashMap中存在大量数据时,加入我们某个bucket下对应的链表有n个元素,那么遍历时间复杂度就为O(n)
  • 为了针对这个问题,JDK1.8在HashMap中新增了红黑树的数据结构,进一步使得遍历复杂度降低至O(logn)

3.5.6 总结

  • HashMap使用了哪些方法解决哈希冲突?
  1. 使用链表地址法来链接拥有相同哈希值的数据
  2. 使用2次扰动函数来降低哈希冲突的概率,使得数据分布更加平均
  3. 引入红黑树进一步减低遍历的时间复杂度

3.6 能否使用任何类作为 Map 的 key?

  • 可以使用任何类作为Map的key,但需要注意一下几点
  1. 如果类重写了equals方法,也需要重写hashCode方法
  2. 类的所有实例都需要遵循equals与hashCode相关的规则
  3. 如果一个类没有使用equals,不应该在hashCode中使用它
  4. 用户自定义 Key 类最佳实践是使之为不可变的,这样 hashCode() 值可以被缓存起来,拥有更好的性能。不可变的类也可以确保 hashCode() 和 equals() 在未来不会改变,这样就会解决与可变相关的问题了

3.7 为什么HashMap中String、Integer这样的包装类适合作为K?

  • String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性能够有效的减少Hash碰撞的几率
  1. 都是final类型,即不可变性,保证key的不可更改性,不会存在获取hash值不同的情况
  2. 内部重写了equals()、hashCode()等方法,遵守了HashMap内部的规范(不清楚可以去上面看看putValue的过程),不容易出现Hash值计算错误的情况

3.8 如果使用Object作为HashMap的Key,应该怎么办呢?

  • 重写hashCode()和equals()方法
  1. 重写hashCode()是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞
  2. 重写equals()方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性

3.9 HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标?

  • hashCode()方法返回的是int整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1),约有40亿个映射空间,而HashMap的容量范围是在16(初始化默认值)~2 ^ 30,HashMap通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置
  • 解决方法:
  1. HashMap自己实现了自己的hash()方法,通过两次扰动使得它自己的哈希值高低位自行进行异或运算,降低哈希碰撞概率也使得数据分布更平均
  2. 在保证数组长度为2的幂次方的时候,使用 hash()运算之后的值与运算(&)(数组长度 - 1) 来获取数组下标的方式进行存储,这样一来是比取余操作更加有效率,二来也是因为只有当数组长度为2的幂次方时,h&(length-1)才等价于h%length,三来解决了“哈希值与数组大小范围不匹配”的问题

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

  • 为了让HashMap存储高效,尽量减少碰撞,需要尽量把数据分配均匀,每个链表/红黑树的长度大致相同,这个实现就是把数据存到哪个链表/红黑树中的算法
  • 算法设计方式:首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方
  • 为什么是二次扰动:这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少Hash冲突,两次就够了,已经达到了高位低位同时参与运算的目的

3.11 HashMap 与 HashTable 有什么区别?

  1. 线程安全:HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过 synchronized 修饰(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!)
  2. 效率:因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它
  3. 对Null key和Null value的支持:HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛NullPointerException
  4. 初始容量大小和每次扩容大小的不同:①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。也就是说 HashMap 总是使用2的幂作为哈希表的大小
  5. 底层数据结构:JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制
  6. 推荐使用:在 Hashtable 的类注释可以看到,Hashtable 是保留类不建议使用,推荐在单线程环境下使用 HashMap 替代,如果需要多线程使用则用 ConcurrentHashMap 替代

3.12 如何决定使用 HashMap 还是 TreeMap?

  • 对于在Map中插入、删除和定位元素这类操作,HashMap是最好的选择。然而,假如你需要对一个有序的key集合进行遍历,TreeMap是更好的选择基于你的collection的大小,也许向HashMap中添加元素会更快,将map换为TreeMap进行有序key的遍历

3.13 HashMap 和 ConcurrentHashMap 的区别

  • ConcurrentHashMap对整个桶数组进行了分割分段(Segment),然后在每一个分段上都用lock锁进行保护,相对于HashTable的synchronized锁的粒度更精细了一些,并发性能更好,而HashMap没有锁机制,不是线程安全的。(JDK1.8之后ConcurrentHashMap启用了一种全新的方式实现,利用CAS算法。)
  • HashMap的键值对允许有null,但是ConCurrentHashMap都不允许

3.14 ConcurrentHashMap 和 Hashtable 的区别?

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

  • JDK1.7的ConcurrentHashMap:
    在这里插入图片描述

  • JDK1.8的ConcurrentHashMap(TreeBin: 红黑二叉树节点 Node: 链表节点):
    在这里插入图片描述

  • ConcurrentHashMap 结合了 HashMap 和 HashTable 二者的优势。HashMap 没有考虑同步,HashTable 考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构。 ConcurrentHashMap 锁的方式是稍微细粒度的

3.15 ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?

  • 首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问
  • 在JDK1.7中,ConcurrentHashMap采用Segment + HashEntry的方式进行实现,结构如下:
  1. 一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁
  2. 该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对后者用来充当锁的角色
  3. Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁
    在这里插入图片描述
  • 在JDK1.8中,放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍
    在这里插入图片描述

  • 源码:
    插入元素过程(建议去看看源码):
    如果相应位置的Node还没有初始化,则调用CAS插入相应的数据;

else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
        break;                   // no lock when adding to empty bin
}

如果相应位置的Node不为空,且当前该节点不处于移动状态,则对该节点加synchronized锁,如果该节点的hash不小于0,则遍历链表更新节点或插入新节点;

if (fh >= 0) {
    binCount = 1;
    for (Node<K,V> e = f;; ++binCount) {
        K ek;
        if (e.hash == hash &&
            ((ek = e.key) == key ||
             (ek != null && key.equals(ek)))) {
            oldVal = e.val;
            if (!onlyIfAbsent)
                e.val = value;
            break;
        }
        Node<K,V> pred = e;
        if ((e = e.next) == null) {
            pred.next = new Node<K,V>(hash, key, value, null);
            break;
        }
    }
}
  1. 如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过putTreeVal方法往红黑树中插入节点;如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin方法转化为红黑树,如果oldVal不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值;
  2. 如果插入的是一个新节点,则执行addCount()方法尝试更新元素个数baseCount;

4. 辅助工具类

4.1 Array 和 ArrayList 有何区别?

  • Array可以存储基本数据类型和对象,ArrayList只能存储对象
  • 前者是固定大小,后者是自动扩展
  • 前者内置方法少,后者有addAll,removeAll,iteration等方法
  • 注意:对于基本数据类型,集合使用自动装箱来减少编码工作量,但是,当处理固定大小的基本数据类型时,这种方式比较慢

4.2 如何实现 Array 和 List 之间的转换?

  • Array转List:Arrays.asList(array)
  • List转Array:List的toArray()方法

4.3 comparable 和 comparator的区别?

  • comparable接口实际上是出自java.lang包,它有一个 compareTo(Object obj)方法用来排序
  • comparator接口实际上是出自 java.util 包,它有一个compare(Object obj1, Object obj2)方法用来排序
  • 一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo方法或compare方法,当我们需要对某一个集合实现两种排序方式,比如一个song对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写compareTo方法和使用自制的Comparator方法或者以两个Comparator来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的Collections.sort()

4.4 Collection 和 Collections 有什么区别?

  • java.util.Collection是一个集合接口(集合类的顶级接口),他提供了集合对象进行基本操作的接口和方法。
  • Collection接口在java类库中有很多具体的实现,它的意义是为各种具体的集合提供了最大化的统一操作方法,其直接继承的接口有Set与List
  • Collections是集合类的一个工具类/帮助类,其中提供了一系列静态方法,用于对集合元素进行排序,搜索以及线程安全等操作

4.5 TreeMap 和 TreeSet 在排序时如何比较元素?

  • TreeSet 要求存放的对象所属的类必须实现Comparable接口 ,该接口提供了比较元素的compareTo方法,当插入元素时就会回调该方法比较元素的大小
  • TreeMap 要求存放的键值对映射的键必须实现Comparable接口 ,从而根据键对元素进行排序

4.6 Collections 工具类中的 sort()方法如何比较元素?

  • 有两种重载的形式
  1. 第一种:要求传入的待排序容器存放的对象比较实现Comparable接口以实现元素的比较
  2. 第二种:不强制性要求容器的元素必须可比较,但要求传入的第二个参数,参数是Comparable接口的子类型,相当于一个临时定义的排序规则,其实就是通过接口注入比较元素大小的算法,也就是回调模式的引用
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值