最近跟两个正在找工作的同学聊天,说起集合,都是面试的重灾区,必问的选项,而且在实际的面试中并不会单独提问某一个问题,而是围绕核心知识连环炮提问。所以背面试题治标不治本,还是得读一读源码。谁让这是个面试造火箭,工作拧螺丝的市场氛围,就连CSDN的首页第二张轮播图都在蹭这个热点:
本文主要包括两部分:
-
HashMap面试必问(总结了一些常见面试题)
-
JDK1.7 & JDK1.8 关于HashMap原理分析
这部分主要是通过断点debug来分析HashMap中常见操作的过程,但由于步骤繁多,只记录了关键步骤,建议读者也在自己电脑上debug一遍,了解详细流程。(计算机是一门实践性很强的学科,看的再多也不如自己亲自操作一遍,当然理论也同样重要)
长文警告!!!
1,HashMap面试必问
这是笔者在一篇博客中找出来的,很有代表性,实际的面试提问中不会按部就班的问,而是千变万化,所以除了把面试题背住之外,一定要花点时间看看源码具体实现,虽然不会360度无死角,但对源码总体有个大概的把握,回答起来就知道哪些知道哪些不知道,一来方便查漏补缺,二来也能更加灵活的回答问题。
示例性提问(真实场景下):
-
你看过JDK的源码吗?
看过。
-
HashMap是如何通过put添加元素的?
根据key计算hash值,再将hash值转换为数组下标。
-
底层数组默认的长度为多少?
默认为16。
-
什么时候会触发扩容机制?
元素个数超过阈值就会触发扩容机制,并且是在新增元素发生hash冲突的情况下。
-
扩容时,直接将数据从原数组平移到新数组可以吗?
不行,需要重新计算hash值(更正,是重新计算index值,而不是重新计算hash值,hash值只与key相关,index与table.length相关)
-
为什么需要重新计算hash值?
因为数组扩容了,从hash值转换为数组下标这个过程就发生了变化,同时,获取value这个过程也会发生变化。所以必须重新计算,不然之前保存的元素就无法访问。
一般性问题(建议背住,而后融会贯通):
-
什么是HashMap?
HashMap是基于Map接口的实现,主要用于存储键值对(1.7通过Entry对象封装键值对,1.8通过Node封装键值对)
-
HashMap采用了什么数据结构?
1.7:数组+链表
1.8:数组+链表+红黑树
-
HashMap是如何解决hash冲突的问题的?
链表。
-
hash冲突和index冲突的关系?
hash冲突就会导致index冲突,indexFor方法的两个参数一个是hash值,另外一个是table.length。
-
HashMap的put方法是如何实现的?
先通过key计算hash值,再通过indexFor方法转换为数组下标。
-
HashMap的扩容机制是什么样的?
HashMap默认初始容量为16,加载因子为0.75,实际存储大小为12。hashMap容量达到12并且当前加入的元素产生hash冲突时时,进行初始容量的2倍扩容
-
为什么初始容量为16?
HashMap重写的hash采用的是位运算,目的是使key到index的映射分布更加均匀
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } 也解释了为什么hash允许空值,实际上当key为null时,自动转换为0
-
-
为什么链表使用头插法?
HashMap的发明者认为,后插入的Entry被查找的可能性更大。
-
hashMap中的链表是单链表还是双链表?
单链表
final int hash; final K key; V value; Node<K,V> next;
-
扩容阈值threshold被赋值了几次?
- 调用构造函数被赋值,初始化容量大小(默认为16)
- 数组为空,初始化数组时,被赋值为初始化容量*加载因子(默认为12)
-
hash冲突插入链表的方式?
1.7:采用头插法:作者认为,后插入的会被优先访问
1.8:采用尾插法:避免链表死循环
-
hashMap允许key为null值吗?
允许一个key为null,会转换为数组下标0。当出现第二个key为null,其value会自动覆盖第一个null的值。
-
hashMap中链表过长会导致什么问题?
查询效率降低。时间复杂度为O(n)【需要遍历链表】
-
jdk7中的HashMap存在哪些问题?
-
链表过长导致查询效率降低
-
扩容导致的死循环
-
线程不安全(个人认为这不是问题,而是在设计上就没有考虑这个,线程安全就会导致效率降低,本质上是效率和安全之间的取舍)
-
-
jdk7和jdk8处理hash冲突的区别?为什么?
jdk7计算hash值的运算是非常复杂的,因为如果产生了hash冲突是用链表来进行存储的,效率比较慢,所以在设计上要尽可能避免冲突。
jdk8计算hash值的方法相对简单,因为采用了红黑树的结构,即使发生了hash冲突,也可以通过转换为红黑树来提高效率。
-
为什么加载因子是0.75而不是其他值?
因为加载因子参与indexFor数组下标的计算,return h & (length-1);
其数值会影响index是否发生冲突,同时也会影响空间利用率,默认情况下table长度为16,但只能存12个值。
所以这个加载因子是在index冲突和空间利用率之间寻求的一个平衡点。
-
HashMap是否可以存放自定义对象?
可以,因为HashMap使用了泛型。
-
为什么JDK8引入红黑树?
由于hash冲突导致链表查询非常慢,时间复杂度为O(n),引入红黑树后链表长度为8时会自动转换为红黑树,以提高查询效率O(logn)。
-
Java集合中ArrayList,LinkedList,HashMap的时间复杂度分别为多少?
ArrayList基于数组实现,基于下标查询的话时间复杂度为O(1),如果基于内容查找需要遍历的话,时间复杂度为O(n)。
LinkedList基于链表实现,查询效率为O(n)
HashMap在不考虑Hash冲突没有形成链表的情况下时间复杂度为O(1),形成链表后时间复杂度为O(n)
2,Debug源码的心得体会
【关注核心步骤,选择性忽略】
JDK是一个相当庞大的系统,把所有的类和原理全部弄清楚是相当有难度的,所以在debug源码的时候,如果遇见了不相关的类,忽略就是了。
然而单看HashMap源码(2300行)也是一个较为庞大的代码量,所以对其中不重要或者不常用的方法,最好先选择性忽略。比如计算hash值的各种位运算,研究起来还是得废一些功夫的,这个可以在把握了HashMap的大致框架后再做精细化的研究。
总的来说,先重点关注核心步骤,选择性忽略更加具体的实现,逐个击破,从而提高阅读效率。
ps:建议把1.7和1.8的jdk都装上,切换着分析。
3,JDK 1.7
3.1 用debug分析一个元素是如何加入到HashMap中的【jdk1.7】
创建一个Main.java类
HashMap<String,String> hashMap = new HashMap<>(16);
hashMap.put("x","x");
hashMap.put("y","y");
在创建HashMap对象上打上断点:
debug运行,强制进入方法内部(Alt+Shift+F7):
调用构造函数:
this方法,初始值判空异常(初始值不能小于0大于最大值),加载因子判空异常,
threshold被初始化容量赋值(threshold为扩容阈值)
在插入第一个元素上打上断点:
debug运行,强制进入方法内部(Alt+Shift+F7):
public V put(K key, V value) {
//判断数组是否为空,如果为空进行初始化,inflateTable初始化方法见下文①
//threshold:扩容的阈值(当前元素个数超过这个数值就会进行扩容)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//判断key是否为空
if (key == null)
//hashMap处理空值的方法②
return putForNullKey(value);
//计算key的hash值(主要是各种位运算)
int hash = hash(key);
//i就是将key的hash值再进行一次转换得出的数组下标
int i = indexFor(hash, table.length);
//同样是个处理hash冲突的头插算法
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//添加元素③
addEntry(hash, key, value, i);
return null;
}
①inflateTable初始化容量方法:
private void inflateTable(int toSize) {
//向上舍入为2的幂
int capacity = roundUpToPowerOf2(toSize);
//重点:threshold在初始化构造函数时默认为16,在初始化数组时,乘以加载因子被二次赋值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//初始化数组容量
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
②hashMap处理空值的方法
private V putForNullKey(V value) {
//处理key为null值的hash冲突,采用头插法(null会自动转为0)
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, v