目录
一、HashMap定义
HashMap实现了Map接口,继承AbstractMap。其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map。
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8; (jdk1.8以后才有)
// 当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table;
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
int threshold;
// 负载因子
final float loadFactor;
}
注:
transient:java语言的关键字,变量修饰符,如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。换句话来说就是,用transient关键字标记的成员变量不参与序列化过程。
二、HashMap底层结构示意图
红黑树:
红黑树(Red Black Tree) 是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。
特点:
- 每个节点要么是红色,要么是黑色。
- 根节点必须是黑色
- 红色节点不能连续(也即是,红色节点的孩子和父亲都不能是红色)。
- 对于每个节点,从该点至null(树尾端)的任何路径,都含有相同个数的黑色节点。
在树的结构发生改变时(插入或者删除操作),往往会破坏上述条件3或条件4,需要通过调整使得查找树重新满足红黑树的条件。如果有删除或者插入节点,使用左旋和右旋;
三、HashMap底层代码解析
HashMap提供了4个构造函数:
HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空 HashMap。
HashMap(Map<? extends K, ? extends V> m):传入一个map以构造一个新的map,使用默认加载因子(0.75)。
在这里提到了两个参数:初始容量,加载因子。这两个参数是影响HashMap性能的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。
1.存值put(K key, V value)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
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是否为空,如果为空,则使用扩容函数进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果通过hash值取模得到的桶为空,则直接把新生成的节点放入该桶
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {//以下为该桶不为空的逻辑
Node<K,V> e; K k;
//判断桶的第一个元素的key值是否相同(hash值相同,且能equals)
//如果相同,则返回当前元素(函数末尾进行统一处理)
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
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);
//插入之后,检查是否达到了转成红黑树结构的标准
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;
p = e;
}
}
//处理相同元素的情况
if (e != null) { // existing mapping for key
V oldValue = e.value;
//如果onlyIfAbsent为ture,则在oldValue为空时才替换
//否则直接替换
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//修改次数+1
//map的size加1,然后判断是否达到了threshold,否则进行扩容
//threshold由Node[] table的长度及loadFactor控制
if (++size > threshold)
resize();
//执行回调函数
afterNodeInsertion(evict);
return null;
}
put过程总结
1、根据key生成hashcode
2、判断当前HashMap对象中的数组是否为空,如果为空则初始化该数组
3、1.7的时候会进行4次无符号右移,5个与运算,1.8会进行高16位和低16位进行逻辑与运算,算出hashcode基于当前数组对应的数组下标i
4.判断数组的第i个位置的元素(tab[i])是否为空
a. 如果为空,则将key,value封装为Node对象赋值给tab[i]
b. 如果不为空:
i. 如果put⽅法传⼊进来的key等于tab[i].key,那么证明存在相同的key
ii. 如果不等于tab[i].key,则:
1. 如果tab[i]的类型是TreeNode,则表示数组的第i位置上是⼀颗红⿊树,那么将key和value插⼊到红⿊树中,并且在插⼊之前会判断在红⿊树中是否存在相同的key
2. 如果tab[i]的类型不是TreeNode,则表示数组的第i位置上是⼀个链表,那么遍历链表寻找是否存在相同的key,并且在遍历的过程中会对链表中的结点数进⾏计数,当遍历到最后⼀个结点时,会将key,value封装为Node插⼊到链表的尾部,同时判断在插⼊新结点之前的链表结点个数是不是⼤于等于8,且数组的长度大于64,如果是,则将链表改为红⿊树。
iii. 如果上述步骤中发现存在相同的key,则根据onlyIfAbsent标记来判断是否需要更新value值,然后返回oldValue
5. modCount++
6. HashMap的元素个数size加1
7. 如果size⼤于扩容的阈值,则进⾏扩容
取值 get(Object key)
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//如果table不为空,则再进行查询操作
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//先检查第一个元素是否key相同
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//如果为红黑树结构,则走红黑树的查询逻辑
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {//否则遍历链表
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
get过程总结
1、根据key⽣成hashcode
2、如果数组为空,则直接返回空
3、如果数组不为空,则利⽤hashcode和数组⻓度通过逻辑与操作算出key所对应的数组下标i
4、如果数组的第i个位置上没有元素,则直接返回空
5、如果数组的第1个位上的元素的key等于get⽅法所传进来的key,则返回该元素,并获取该元素的value
6、如果不等于则判断该元素还有没有下⼀个元素,如果没有,返回空
7、如果有则判断该元素的类型是链表结点还是红⿊树结点 a. 如果是链表则遍历链表 b. 如果是红⿊树则遍历红⿊树
8、找到即返回元素,没找到的则返回空
四、HashMap 常见面试问题
1、1.7和1.8的不同点
1、1.8用了红黑树
2、1.7插入的时候用了头插法,1.8插入的时候用了尾插法
3、1.7会rehash,1.8没有这份代码逻辑
4、1.8的hash算法时高低16位做异或运算,1.7的时候会进行4次无符号右移,5个与运算==(具体原因:JDK7的Hash算法⽐JDK8中的更复杂,Hash算法越复杂,⽣成的hashcode则更散列,那么hashmap中的元素则更散列,更散列则hashmap的查询性能更好,JDK7中没有红⿊树,所以只能优化Hash算法使得元素更散列,⽽JDK8中增加了红⿊树,查询性能得到了保障,所以可以简化⼀下Hash算法,毕竟Hash算法越复杂就越消耗CPU)==
5. JDK8中扩容的条件和JDK7中不⼀样,除开判断size是否⼤于阈值之外,JDK7中还判断了tab[i]是否为空,不为空的时候才会进⾏扩容,⽽JDK8中则没有该条件了
6. JDK8中还多了⼀个API:putIfAbsent(key,value)
7. JDK7和JDK8扩容过程中转移元素的逻辑不⼀样,JDK7是每次转移⼀个元素,JDK8是先算出来当前位 置上哪些元素在新数组的低位上,哪些在新数组的⾼位上,然后在⼀次性转移
2、1.7和1.8的插入方式,1.8为什么是尾插
1.7是头插法,1.8是尾插法
因为1.7是头插法,在多线程情况下会产生循环链表,而1.8改为尾插就不会出现循环链表问题了,还有一点就是链表要转换为红黑树的时候需要用到链表长度
3、JDK8中的HashMap为什么要使⽤红⿊树?
当元素个数⼩于⼀个阈值时,链表整体的插⼊查询效率要⾼于红⿊树,当元素个数⼤于此阈值时,链表整体的插⼊查询效率要低于红⿊树。此阈值在HashMap中为8
4、JDK8中的HashMap什么时候将链表转化为红⿊树?
当发现链表中的元素个数⼤于8之后,还会判断⼀下当前数组的⻓度,如果数组⻓度⼩于64时,此时并不会转化为红⿊树,⽽是进⾏扩容。只有当链表中的元素个数⼤于8,并且数组的⻓度⼤于等于64时才会将链表转为红⿊树。
5、扩容机制(resize)
举个例子:当前的容量⼤⼩为16,当你存进第13个的时候,判断发现需要进⾏resize了,也就是插入数据后的容量>=HashMap当前⻓度(6)*负载因子0.75(float
ft = (float)newCap * loadFactor),那就进⾏扩容,具体的就是:
1、创建⼀个新的Entry空数组,⻓度是原数组的2倍。 2、ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。
6、为什么默认容量是16?
采用位运算,是因为位与运算⽐算数计算的效率⾼了很多。之所以选择16,是为了key映射到index的算法
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
7、HashMap怎么处理hash碰撞的?
链表法(HashMap),扩展一下还有个开放地址法
8、HashMap 的数据结构?
哈希表结构(链表散列:数组 + 链表)实现,结合数组和链表的优点。当链表长度超过 8 时,链表转换为红黑树。
9、你知道 hash 的实现吗?为什么要这样实现?
DK 1.8 中,是通过 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。
10、 为什么要用异或运算符?
保证了对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变。尽可能的减少碰撞。
11、数组扩容的过程?
创建一个新的数组,其容量为旧数组的两倍,并重新计算旧数组中结点的存储位置。结点在新数组中的位置只有两种,原下标位置或原下标 + 旧数组的大小。
12、拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?
之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。推荐:面试问红黑树,我脸都绿了。
而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持 “平衡” 是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于 8 的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。
13、为什么容量总是为2的n次幂
&运算速度快,至少比%取模运算块
(n - 1) & hash,当n为2次幂时,会满足一个公式:(n - 1) & hash = hash % n
不为2的n次幂,(n - 1) & hash出现hash碰撞概率提高