HashMap经典面试题+源码分析
- 1、HashMap 经典面试题
-
-
- 谈一下HashMap的底层原理是什么?
- 谈一下HashMap中put是如何实现的?
- 谈一下HashMap中什么时候需要进行扩容,扩容resize()又是如何实现的?
- 谈一下HashMap中get是如何实现的?
- 为什么不直接将key作为哈希值而是与高16位做异或运算?
- 为什么是16?为什么必须是2的幂?如果输入值不是2的幂比如10会怎么样?
- 谈一下当两个对象的hashCode相等时会怎么样?
- 请解释一下HashMap的参数loadFactor,它的作用是什么?
- 如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
- 传统HashMap的缺点(为什么引入红黑树?):
- 平时在使用HashMap时一般使用什么类型的元素作为Key?
-
- 2、HashMap 源码分析
1、HashMap 经典面试题
谈一下HashMap的底层原理是什么?
基于hashing的原理,jdk8后采用数组+链表+红黑树的数据结构。我们通过put和get存储和获取对象。当我们给put()方法传递键和值时,先对键做一个hashCode()的计算来得到它在bucket数组中的位置来存储Entry对象。当获取对象时,通过get获取到bucket的位置,再通过键对象的equals()方法找到正确的键值对,然后在返回值对象。
谈一下HashMap中put是如何实现的?
1.计算关于key的hashcode值(与Key.hashCode的高16位做异或运算)
2.如果散列表为空时,调用resize()初始化散列表
3.如果没有发生碰撞,直接添加元素到散列表中去
4.如果发生了碰撞(hashCode值相同),进行三种判断
4.1:若key地址相同或者equals后内容相同,则替换旧值
4.2:如果是红黑树结构,就调用树的插入方法
4.3:链表结构,循环遍历直到链表中某个节点为空,尾插法进行插入,插入之后判断链表个数是否到达变成红黑树的阙值8;也可以遍历到有节点与插入元素的哈希值和内容相同,进行覆盖。
5.如果桶满了大于阀值,则resize进行扩容
谈一下HashMap中什么时候需要进行扩容,扩容resize()又是如何实现的?
调用场景:
1.初始化数组table
2.当数组table的size达到阙值时即++size > load factor * capacity 时,也是在putVal函数中
实现过程:(细讲)
1.通过判断旧数组的容量是否大于0来判断数组是否初始化过
否:进行初始化
判断是否调用无参构造器,
是:使用默认的大小和阙值
否:使用构造函数中初始化的容量,当然这个容量是经过tableSizefor计算后的2的次幂数
是,进行扩容,扩容成两倍(小于最大值的情况下),之后在进行将元素重新进行与运算复制到新的散列表中
概括的讲:扩容需要重新分配一个新数组,新数组是老数组的2倍长,然后遍历整个老结构,把所有的元素挨个重新hash分配到新结构中去。
PS:可见底层数据结构用到了数组,到最后会因为容量问题都需要进行扩容操作
谈一下HashMap中get是如何实现的?
对key的hashCode进行hashing,与运算计算下标获取bucket位置,如果在桶的首位上就可以找到就直接返回,否则在树中找或者链表中遍历找,如果有hash冲突,则利用equals方法去遍历链表查找节点。
为什么不直接将key作为哈希值而是与高16位做异或运算?
因为数组位置的确定用的是与运算,仅仅最后四位有效,设计者将key的哈希值与高16为做异或运算使得在做&运算确定数组的插入位置时,此时的低位实际是高位与低位的结合,增加了随机性,减少了哈希碰撞的次数。
为什么是16?为什么必须是2的幂?如果输入值不是2的幂比如10会怎么样?
HashMap默认初始化长度为16,并且每次自动扩展或者是手动初始化容量时,必须是2的幂。
1.为了数据的均匀分布,减少哈希碰撞。因为确定数组位置是用的位运算,若数据不是2的次幂则会增加哈希碰撞的次数和浪费数组空间。(PS:其实若不考虑效率,求余也可以就不用位运算了也不用长度必需为2的幂次)
2.输入数据若不是2的幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字
谈一下当两个对象的hashCode相等时会怎么样?
会产生哈希碰撞,若key值相同则替换旧值,不然链接到链表后面,链表长度超过阙值8就转为红黑树存储
请解释一下HashMap的参数loadFactor,它的作用是什么?
loadFactor表示HashMap的拥挤程度,影响hash操作到同一个数组位置的概率。默认loadFactor等于0.75,当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,在HashMap的构造器中可以定制loadFactor。
如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
超过阙值会进行扩容操作,概括的讲就是扩容后的数组大小是原数组的2倍,将原来的元素重新hashing放入到新的散列表中去。
传统HashMap的缺点(为什么引入红黑树?):
JDK 1.8 以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。针对这种情况,JDK 1.8 中引入了 红黑树(查找时间复杂度为 O(logn))来优化这个问题。
平时在使用HashMap时一般使用什么类型的元素作为Key?
择Integer,String这种不可变的类型,像对String的一切操作都是新建一个String对象,对新的对象进行拼接分割等,这些类已经很规范的覆写了hashCode()以及equals()方法。作为不可变类天生是线程安全的。
2、HashMap 源码分析
使用无参创建 HashMap(默认容量为16), loadFactor 为加载因子, 是扩容的一个重要参数, 此时并未真正创建创建数组, 而是在 put 第一次添加元素的时候初始化数组
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
创建 map 中有一个小细节, 也就是我们使用了 int 类型的构造器创建一个指定初始容量的 HashMap
// 创建了一个指定初始容量为 17 的 map
Map<String, Object> map = new HashMap<>(17);
通过 this 调用另外外一个构造函数传入初始容量和默认加载因子, 如果初始容量小于0, 则直接抛出 IllegalArgumentException 非法参数异常, 注意的是此时的是初始容量并不等于 17 , 而是 大于等于 2 的幂次方最小值, 也就是 32, 阈值也会基于 32 进行从新计算 32 * 0.75 等于 24, 也就是本次扩容的阈值为 24
// 通过唯一运算从新计算扩容阈值并返回
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= 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;
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
// 判断当前初始容量的合法性
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 判断指定初始化容量是否大于默认的最大容量, 如果大于就将最大容量设置为初始容量, 容量并不是无上限
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
// 从新计算出来的阈值
this.threshold = tableSizeFor(initialCapacity);
}
调用普通 put 方法添加元素, 值得注意的是:
public V put(K key, V value)
return putVal(hash(key), key, value, false, true);
}
- 调用了真正添加元素的方法 putVal , 下面仔细讲解
- putVal 第一个参数是 hash 方法 通过 key 的 hashCode 值 进行右移异或运算返回 int 类型 hash 值; 如果 key = null 则直接返回 0
- putVal 第四个参数主要用于 hash 碰撞时且 key 相同是否需替换之前的旧值, 默认是 false 即 key 相同时 替换旧值并返回旧值
- 如果调用 putIfAbsent 当发生 hash 碰撞时且 key 相同则既不会替换旧值也不会添加
@Override
public V putIfAbsent(K key, V value) {
return putVal(hash(key), key, value, true, true);
}
resize 初始化数组和扩容方法分析
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 如果老数组的容量大于0, 执行扩容
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 扩容之后基于新数组的长度设置
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 初始化数组操作, 新数组的容量(长度)采用默认长度 16, 数组的阈值使用默认加载因子(0.75) * 默认初始容量(16) 为 12
else {
// zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
// 数组扩容之后, 使用新数组的容量 * 0.75 计算出新数组的阈值
// 假如扩容之后新数组的长度为 32, 那么新数组的阈值: 32 * 0.75 = 24
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({
"rawtypes","unchecked"})
// 生成新的数组, newCap 是通过扩容计算出来的新长度
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;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 当前元素没有下一个节点也就意味着只有一个元素
if (e.next == null)
// 通过 hash 取模运算计算出新数组的索引位置, 直接存储到新数组
newTab[e.hash & (newCap - 1)] = e;
// 如果当前是一个红黑树
else if (e instanceof TreeNode)
// 就将红黑树上面的节点进行分割, 转移红黑树
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 此时只有一种情况了, 剩下的只能是一个链表, 下面进行拆分链表
else {
// preserve order
// 将当前链表拆分成一个高位链表和一个低位链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 将当前元素加入到低位链表
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 加入高位链表
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = 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;
}
再来看看 putVal 是怎么添加的元素, 由于 putVal 方法相对于要复杂一点, 分为两部分来分析 里面其实就干了两件事: 初始化数组以及添加替换元素
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//------------------初始化数组的大小-------------------------
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 判断 table 数组中是否有元素, 第一次添加的时候 table 等于 null, 这里调用 resize 的作用主要是 创建一个 Node 类型的数 // 组, Node 中一四个重要的属性: hash、key、value、next
// hash 就是经过 hash 运算返回的值, key 和 value 见名知意就不多解释了, next 就是链表中指向下一个节点的指针
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 通过 hash 计算出索引, 再判断 table[index] 是否有值, 如果没有值就直接创建一个 Node 节点将 key、value、hash设置进
// 去, 由于新创建的这个节点没有下一个节点, 因此 next 就赋值为 null
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//------------------后面为添加元素的逻辑, 此处省略,下面细讲-------------------------
}
putVal方法后面的主要添加逻辑是 基于链表或红黑树的方式添加
// 如果该下标位置存在元素
else {
Node<K,V> e; K k;
// 判断 put 进来元素的 key 是否等于已存在元素的 key, 如果等于且不等于 null, 再去判断两个 key 是否相等, 相等就替换之前的的旧值并返回替换之前的值; 如果 key 不相等执行后面的逻辑, 最终使用尾插法将元素追加到链表的末尾
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 判断当前元素是否是 treeNode 的实例, 是则使用红黑树的方式添加元素
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 走到这一步, 就只剩下一种情况了, 该位置是一个链表, 需要遍历整个链表判断有没有相同的 key,
// key 相同就替换, 不同就尾插
else {
// binCount 是判断是否走树化的一个关键
for (int binCount = 0; ; ++binCount) {
// 运行到这里, 此时 binCount = 0, 链表中有 1 个元素
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null); // 运行到这一步时, binCount = 0, 此时链表中有两个元素
// 这一步很关键, 当前循环次数 >= 阈值 - 1, 也就是大于等于 7 的时候, 改造为红黑树, 但是我们平时说的达到 阈值 8 的时候才树化, 为什么这里是等于 7 的时候转化成红黑树呢, 注意的是上面遍历是从 0 开始, 也就是 binCount 等于 0 时原始链表有 1 个节点, 此时 put 进来的元素还没有插入到链表中, 循环结束还没正式进入下一轮循环之前, put 进来的元素插入到了链表的最后, 也就是说 bincount = 0, 此时链表中有两个元素, 以此类推, 当 binCount 等于 7 时, 链表中有 9 个元素, 链表就会改造为红黑树的方法, 那么也不对呀, 不是链表长度为 8 的时候树化吗, 怎么这里 链表长度为 9 才树化, 这里有个小细节点, 它是拿每次 追加元素之前的原始链表长度作为的判断条件, 也就是原始长度为 8 改造为红黑树, 把新元素追加到链表中(中间有个执行时机), 转换城红黑树时真正有 9 个元素
if (binCount >= TREEIFY_THRESHOLD - 1)
// 具体的树化方法后面再讲, 只需要知道在这一步即将转换成红黑树
treeifyBin(tab, hash);
break;
}
// 遍历链表, 如果有相同的 key 则直接退出当前循环, 进行替换旧数据
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) {
// existing mapping for key
// 真正执行替换旧数据的操作, 并返回之前的数据
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
// 返回旧数据
return oldValue;
}
}
// 记录修改次数
++modCount;
// 判断当前的元素个数是否达到扩容条件
if (++size > threshold)
// 如果达到扩容条件, 调用 resize 方法进行扩容
resize();
afterNodeInsertion(evict);
// 如果在当前数组中没有相同的key, 也就没有替换操作, 就返回一个 null
return null;
转为红黑树的方法
// 转化成红黑树时散列表最小的树化容量
static final int MIN_TREEIFY_CAPACITY = 64;
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 判断当前的数组是否为空或当前散列表的长度是否小于 64
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// 当 table 数组为空时调用 resize 方法进行初始化数组
// 当 散列表的长度小于 树化容量 64, 此时链表的长度已经等于 8, 一旦链表长度大于 8 则链表的查询效率很低(0n), 为了解决这个问题, 就调用 resize 方法进行扩容, 将当前的链表截半转成一个高位链表和一个低位链表, 再通过 hash 运算计算出存放新数组的索引, 直接将, 老数组上面的元素迁移到新数组上面
resize();
// 真正转化成红黑树的逻辑
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 将链表改造成双向链表, 将原始的 Node 节点 转换成 TreeNode 节点
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
// 改造为红黑树
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}