面试常问的HashMap源码分析(jdk1.8)
目录
一、面试常见问题二、基本常量属性三、构造方法四、节点结构4.1 Node类4.2.TreeNode五、put方法5.1 key的hash方法5.2 resize() 扩容方法六、get方法
一、面试常见问题
Q1: HashMap底层数据结构是什么?
jdk1.7是数组+链表;jdk1.8 采用数组+ 链表+红黑树
红黑树的引入是为了提高查询效率,哈希冲突可以减少(元素key均匀散列程度和通过扩容),不可避免;
如果冲突元素较多,会导致链表过长,而链表查询效率是O(n), 当链表长度超过8后,且数组长度(length)超过64才转化为红黑树,
这点易忽略(后源码证明),不是链表长度一超高8个就将其树化为红黑树,此时扩容解决冲突效果更好。(注:红黑树的特性需要了解掌握)
Q2:为什么要引入红黑树,有什么优势,解决什么问题?
红黑树的引入是为了提高查询效率,哈希冲突可以减少(元素key均匀散列程度和通过扩容),不可避免,
如果冲突元素较多,会导致链表过长,而链表查询效率是O(n), 当链表长度超过8后,且数组长度(length)超过64才转化为红黑树,
这点易忽略(后源码证明),不是链表长度一超高8个就将其树化为红黑树,此时扩容解决冲突效果更好。(注:红黑树的特性需要了解掌握)
Q3: 在一条链表上新增节点,什么方式插入?
1.8 是尾插法,1.7是采用头插法,为什么1.7需要头插法?头插法效率高?
Q4:放入HashMap中元素需要什么特性?
需要重写hashCode和 equals()方法 ,不重写会有什么问题?
Q5: HashMap是线程安全的吗?你列举其他线程安全的,特性?
不是, 因为它的方法没有同步锁,HashTable 是线程安全的,每个方法有加synhronized关键字,线程安全,但也会导致效率变低;
一般多线程环境使用 ConcurrentHashMap,其原理是采用了分段锁机制,每一段(Segment)中是一个HashMap,每个Segment中操作是加锁的,
即保证线程操作某一个Segment是排他的,但不同线程在不同Segment是可以同时操作的,即保证了线程安全,又提高了并发效率。
(此处不详细展开ConcurrentHashMap,面试问题是环环相套的,好的面试官会步步引导,目的是为了全面考察面试者的技术水平)。
Q6: 1.7中 HashMap存在什么问题?
在多线程环境下,1.7中Map可能会导致cpu使用率过高,是因为存在环形链表了,HashMap中 链表是单链表结构,怎么会有环?
是链表中元素指向下一个元素的指针next,指向了前面的元素,导致了环,所以在遍历链表时,程序一直死循环无法结束。在多个线程放置元素时,resize()方法中导致(后源码证明)。
Q7: 1.8是先插入新值再判断是否扩容,还是先扩容在插入新值?
1.8是先插入元素,在判断容量是否超过阈值,扩容,1.7是先扩容再插入新值
Q8: HashMap是延迟初始化?
是的,创建的Map对象,如果有指定容量大小,会记录下来;没有指定会使用默认16,在第一次put元素时才初始化数组,1.7,1.8都是如此,可以节省内存空间。
Q9: HashMap允许放置空键(null),空值(null)吗?
允许放置,null 键是放置在数组第一个位置的,因此在判断某个key是否存在时,不能通过该get() 方法获取value为null判断,
这时键值对可能是 null:null,此时是存在Node对象的,可以通过containsKey(key)判断 ,key为null,Node对象不为null,如下图所示
Q10 :简要讲述一下HashMap放置元素过程,即put()方法。
put方法流程如下图所示
二、基本常量属性
//默认初始容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
*最大容量,如果在构造函数中指定大于该值,会使用该值作为容量大小,2^30
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认加载因子75%,好比地铁满载率,受疫情影响地铁满载率不超过30%
* 1,加载因子设置较大,随着数组中元素装的越多,发生的冲突的概率越大,即对应的链表
* 越长,影响查找效率。
* 2,加载因子设置较小,元素很容易达到设定的阈值,发生扩容操作,数组空间还有很大部分
* 没有利用上,造成空间浪费。
* 因此在时间与空间上的权衡考虑
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 树化阈值:在链表元素超过8个了,将链表转化为红黑数结果,提高查找效率
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 取消树化阈值:在红黑树中节点数量小于6个,将其转化为链表结构
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 最小树化容量,容易忽视
* 链表树化红黑树条件:
* 1,链表中元素数量超过(>)8个;
* 2, 满足map中元素个数(size)大于等于64否则会先扩容,扩容对解决hash冲突更有效。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
三、构造方法
//传有初始容量和加载因子
1.HashMap(int initialCapacity, float loadFactor)
//有初始容量,加载因子默认
2.HashMap(int initialCapacity)
//初始容量, 加载因子默认
3.HashMap()
//传递的一个Map子集
4.HashMap(Map<? extends K, ? extends V> m)
四、节点结构
1.数组和链表节点对象为HashMap内部类 Node.
2.红黑树节点 TreeNode,继承LinkedHashMap.Entry , 而 Entry继承Node,因此 TreeNode 实际是 Node孙子.
3.Node类,重写了hashCode和 equals方法,记录了当前key, value, key的hash值,以及指向后一个元素指针.
4.1 Node类
//实现Map集合的Entry类
static class Node<K,V> implements Map.Entry<K,V> {
//记录当前节点key的hash
final int hash;
//键
final K key;
//值
V value;
//用于链表时,指向后一个元素
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
//重写了hashcode
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
//重写了equals
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
4.2.TreeNode
什么是红黑树?特点?
性质1. 节点是红色或黑色.
性质2. 根节点是黑色.
性质3.所有叶子都是黑色.(叶子是NUIL节点)
性质4. 每个红色节点的两个子节点都是黑色.(从每个叶子到根的所有路径上不能有两个连续的红色节点)
性质5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点.
// 继承LinkedHashMap.Entry 继承>> HashMap.Node
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
//颜色是否为红色
boolean red;
五、put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
5.1 key的hash方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash(key)方法:
1.hashMap支持 key 为null,放在数组索引为0的位置
2.为了保证key的hash值分布均分、散列,减少冲突
(h = key.hashCode()) ^ (h 16)
等于 key的hash值 异或于 其hash值的低 16位
例:"水果"的 hashCode()
h = 11011000000111101000
h>>>16=00000000000000001101
两者异或,不同为1,相同为0
1101 10000001 11101000
0000 00000000 00001101
1101 10000001 11100101
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;
if ((tab = table) == null || (n = tab.length) == 0)
//1,数组为空,初始化操作,此处resize() 待解析1
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
/**2,通过key的计算出的hash值与数组长度-1与运算,算出在数组下标位置
* 若该位置为空则创建新节点封装元素值,放在该位置
*/
tab[i] = newNode(hash, key, value, null);
else {
// 若数组下标位置已经有值
Node<K,V> e; K k;
//3,首先判断数组位置两个key是否相同,e用来指向存在相同key的节点。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
//4,key不相同的情况下,判断数组该下标位置连接的是链表还是树节点
//若是树结构,就将该值放入树中(放入树中操作,也会遍历树判断是否存在相同的key的节点,若无相同会以新节点插入树中)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//数组该下标位置只有1个节点或者链表:这里统一为链表形式
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//5,没有找到相同key的元素,在链表后插入新节点
p.next = newNode(hash, key, value, null);
/**此处为什么要>=7,因为bincount从0开始递增,当bincount=7时,
*此时for循环执行了7次,加上新增加的节点,以及数组下标位置节点
*共7+1+1= 9了,即该数组下标位置链表长度大于8了,需要转化为红黑树
*/
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//遍历链表比较判断是否存在两个key相同元素
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//有上面 e= p.next,这里 p有重新被赋值为e,即继续遍历链表下一个元素
p = e;
}
}
// 存在相同的key的元素,可能在数组、链表、树上
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//6,覆盖旧值,返回旧值,也可设置仅仅当旧value存在,不为null情况才覆盖原值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//修改次数+1
++modCount;
//7,判断整个map中元素个数是否大于阈值,大于则扩容,由此可见是先插入元素再判断容量大小是否扩容,区别1.7先扩容再插入新值
if (++size > threshold)
resize();
//该方法为空,不用理会
afterNodeInsertion(evict);
return null;
}
5.2 resize() 扩容方法
该方法有个比较有意思的是将旧数组的元素移到扩大为原来容量2倍的新数组中时,原来数组中链表需要进行拆链,非常巧妙,下见。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//1,原来数组容量>0情况
if (oldCap > 0) {
//如果数组容量已经为最大值了 2^30,那就仅仅是将扩容阈值修改
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//1.1 如果原来数组容量>=16,且其2倍小于最大容量(oldCap<<1 等价于 oldCap*2^1)
// 阈值也扩容为原来2倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
//1.2 什么情况数组容量=0,阈值>0呢?
//创建HashMap,指定了数组初始容量cap,会将其转换为大于等于cap的最小的2的幂次方的数赋值给threshold,这里即将数组容量赋值
newCap = oldThr;
else { // zero initial threshold signifies using defaults
//1.3原来数组容量和阈值都为0,即常用的无参构造方式,使用默认容量大小
//阈值根据容量和负载因子算出
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//2,newThr = 0情况出现在1.1中,没满足条件扩大为原来2倍
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
//小于最大容量,则用容量和加载因子乘积,否则为 Integer最大值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//3,创建新数组对象
"rawtypes","unchecked"}) ({
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//4,原数组对象不为空,需要将原数组元素移到新数组中
if (oldTab != null) {
//遍历旧数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//数组下标位置有元素
if ((e = oldTab[j]) != null) {
//直接把该下标位置链表(或红黑树)取出来,1个元素也当做链表看待,原数组该位置置空,只要我们拿着链表头节点/树根节点(e)就行
oldTab[j] = null;
if (e.next == null)
//该位置只有1个元素,直接与新数组长度hash,确定位置放入新数组
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//该位置是一颗红黑树,迁移到新数组
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//5,重点解析:这里将原数组链表拆分为2条链表放入新数组,那它是怎么拆的呢?
// loHead,loTail(lo 理解为low缩写)分别记录放在新数组下标小的那一条链表的头节点和尾结点,
// 同理hiHead,hiTail(hi理解为 hight缩写)放在新数组下标大的位置
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// e.hash & oldCap 是什么目的呢?
// e.hash & (oldCap-1)求得是数组下标,e.hash & oldCap
// 是为了获取比oldCap-1更高的那位一是0还是1,是0的就留在原位,是1的话需要增加oldCap。这里不易理解,下面详解
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;
}
扩容拆链过程分析:
#1,扩容前位置
7 =
0001 0111
&
0000 1111
---------
0000 0111
#2,扩容后位置
23 =
0001 0111
&
0001 1111
---------
0001 0111
/**因为每次扩容都是old数组长度的2倍,那么在要计算在扩容后新数组位置,
而扩容后,15变为31,那么key的hash值影响计算结果位由4位变为5位,
e.hash & oldCap的结果就是获取新增的位置,000X 0000,即X的值,0或者1;
0运算结果不变,放在原位,1与运算结果会增加一个原数组长度。
*/
如下图所示
7 & 7=7
0000 0111
0000 0111
----------
23 & 7=7
0001 0111
0000 0111
----------
31 & 7=7
0001 1111
0000 0111
----------
15 & 7=7
0000 1111
0000 0111
#可见第4位(从右向左)为1的有15,31,扩容后位置:原有下标+原数组长度
拆链位运算实现的巧妙:
1,扩容迁移原有数组中的元素,不用再重复计算原有元素key的hash值,提高效率.
2,扩容后拆链,会将链表长度缩短,减少hash冲突,提高查找效率.
六、get方法
根据键值对中的键(key)获取值
public V get(Object key) {
Node<K,V> e;
//获取节点赋值给e,e!=null, 返回该键值对的value值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
下见方法:getNode(int hash, Object key)
final Node<K,V> getNode(int hash, Object key) {
//传入的hash值与put方法一样的方法,相同规则计算key的hash值
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//1,获取数组下标位置的第一个节点,first
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null &&
key.equals(k))))
//2,检查是否为相同的key,是直接返回该节点对象;不是则遍历下一个节点
return first;
if ((e = first.next) != null) {
//3,下一个节点存在,需判断是红黑树,还是链表
if (first instanceof TreeNode)
//3.1红黑树则遍历树获取是否存在与该key相同的节点
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//3.2 链表则依次遍历,查找相同的key,找到则返回
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
//数组为空,或者没有找到返回空
return null;
}