HashMap源码
前情提要
1.什么是哈希?
Hash也叫散列、哈希,对应的英文是Hash;
基本原理就是把任意长度的输入,通过Hash算法变成固定长度的输出;
这个映射的规则就是对应的Hash算法,而原始数据映射后的二进制串就是哈希值。
2.哈希的特点
(1)由于hash值不可以反推导出原始的数据;
(2)输入数据的微小变化会得到完全不同的hash值,相同的数据会得到相同的值;
(3)哈希算法的执行效率需要高效,因为使用很平凡,并且长的文本也能快速的计算出哈希值;
(4)hash算法的冲突概率要小:
由于hash的原理是将输入空间的值映射到hash空间内,而hash值的空间远小于输入的空间,根据抽屉原则,一定会存在不同的输入被映射成相同输出的情况;
抽屉原理:有10个苹果放在九个抽屉中,无论怎么放,最后会发现至少有一个抽屉会放不少于两个苹果。
一.HashMap原理分析
1.HashMap的继承体系是什么?
2.Node数据结构分析
源码:
static class Node<K,V> implements Map.Entry<K,V> {
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; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
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;
}
}
分析:
(1)Map每当put一个值的时候都会创建一个Node<K,V>并存入到散列表中;
(2)final int hash:key.hashCode()得到的hash值通过一个扰动函数算出的新的hash值存放在该hash中;
(3)final K key:map.put的时候的key值;
(4)V value:map.put的时候的value值;
(4)Node<K,V> next:出现碰撞的时候通过链表的形式进行存储。
3.底层存储结构分析
散列表的详细存储如下:
分析:
(1)数组的默认初始化长度为16;
(2)未发生冲突的时候是单个存储,发生冲突后会形成一个链表;
(3)当链表的长度超过8(即达到9),并且当前哈希表中的桶的总数达到64个的时候,该链表会树化成红黑树。
4.put数据原理分析
map.put具体流程图如下:
分析:
(16 -1) & 1122 => B0000 0000 1111 & B0000 0000 0010 => B0010 =>2 =>存入index为2的位置
(16 -1) & ***** => B0000 0000 1111 & B**** **** 0010 => B0010 =>2 =>也存入index为2的位置
(1)当其他数据进来,由于该数据的二进制的后四位是0010,通过寻址运算出来的结果也是2,所有同样存入index为2的位置,此时就发生hash碰撞;
(2)hash碰撞后通过链表的形式进行存储,及发生链化;
(3)当map.get的时候,如果获取到的是一个链表,需要将传入的key和链表总每个node的key进行比较,相同则取出其value值。
5.JDK1.8为什么引入红黑树?
为了解决链化很长的问题而提出的红黑树,可以提高查找效率,红黑树是一个平衡二叉树(二叉查找树)。
6.HashMap的扩容原理
分析:
(1)当链表和树化过于严重的时候,此时就变为一个线性查询了,查询的效率会很低,所以需要进行扩容;
(2)扩容就是为了提高查询效率,以空间换时间。
二.源码分析
1.核心属性分析
(1)常量分析:
/**
* 默认的初始容量(数组大小)
*
* 必须是二的次方,默认16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/**
* 数组最大长度
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认负载因子大小
*
* 当构造函数没有指定加载因子的时候的默认值的时候使用
* 别轻易修改,0.75为大量科学计算出来的
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 树化阈值
*
* 数组的查询是O(1),形成链表后查询是O(n),
* 当链表长度达到8以后,该链表有可能升级成为一个树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 树降级成为链表的阈值
*
* 如果一个树删除部分元素后,如果还是树的话,可能不太合理
* 此时需要降成一个链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 树化阈值的另一个参数
*
* 哈希表的桶的个数达到64时,并且在某个链表的长度达到8的时候,该链表才会允许树化
* 并不是说某个链表的长度超过8时就马上树化
*/
static final int MIN_TREEIFY_CAPACITY = 64;
(2)核心属性分析:
/**
* 哈希表
*
*/
transient Node<K,V>[] table;
/**
* 当前哈希表中元素个数
*
* key-value的个数
*/
transient int size;
/**
* 哈希表结构修改次数
*
* 向哈希表中插入或者删除元素这种就叫修改
* 如果来了一个相同的key,进行元素替换,此时不叫修改
* 每次操作如果导致哈希表结构有变化的时候,modCount +1
*/
transient int modCount;
/**
* 扩容阈值
*
* 当你的哈希表中的元素超过阈值时,触发扩容
* 数据多了以后,如果不扩容,查找的性能就会变的很差,因为在所有的位置上,链化很严重了,O(1)会升级成O(n)
* 扩容后,会大大的缓解该问题
*/
int threshold;
/**
* 负载因子
*
* 一般不会改,使用默认即可
* threshold = capacity(数组的大小) * loadFactor
*/
final float loadFactor;
2.构造方法分析
(1)Hashmap构造方法分析:
/**
* 带初始化因子和负载因子的构造方法
*
* @param initialCapacity 初始化大小
* @param loadFactor 负载因子
* @throws IllegalArgumentException if the initial capacity is negative or the load factor is nonpositive
*/
public HashMap(int initialCapacity, float loadFactor) {
// 1.参数判断是否合理(做效验)
// 1.1数组初始化大小不能小于0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
// 1.2数组初始化大小大于最大值时,将其设为默认最大值
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 1.3负载因子不能为负数,也不能为一个非数字的数
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
// 2.赋值
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
为什么不直接将initialCapacity赋值给threshold?
因为传入的initialCapacity的数可以为任意数,但是数组的初始化是有要求的,必须是2的次方数,可以为2 4 8 16 32...等,但是不能为1 3 7...,所有通过tableSizeFor这个小算法进行处理。
(2)tableSizeFor算法分析:
/**
* 返回一个大于等于当前cap的一个数字,并且这个数字一定要2的次方数
*
* 例子:
* 假如 cap 为 10
* n = 10 -1 = 9 = 0b1001(0b代表位二进制)
* 0b1001 | 0b0100 = 0b1101
* 0b1101 | 0b0011 = 0b1111
* 0b1111 | 0b0000 = 0b1111
* 0b1111 | 0b0000 = 0b1111
* 0b1111 | 0b0000 = 0b1111
* n = 0b1111 = 15
* n > 0 ====> (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1
* n < MAXIMUM_CAPACITY ====> n = 15 + 1 = 16
*
* 假如 cap 为 16
* n = 16 -1 = 15 = 0b1111
* 0b1111 | 0b0111 = 0b1111
* 0b1111 | 0b0001 = 0b1111
* 0b1111 | 0b0000 = 0b1111
* 0b1111 | 0b0000 = 0b1111
* 0b1111 | 0b0000 = 0b1111
* n = 0b1111 = 15
* n > 0 ====> (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1
* n < MAXIMUM_CAPACITY ====> n = 15 + 1 = 16
*
* 为什么n = cap - 1?
* cap必须要减1,如果不减,并且如果传入的cap为16,那么算出来的值为32
* 1.例子:
* 假如 cap 为 10
* n = 10 = 0b1010
* 0b1010 | 0b0101 = 0b1111
* 0b1111 | 0b0001 = 0b1111
* 0b1111 | 0b0000 = 0b1111
* 0b1111 | 0b0000 = 0b1111
* 0b1111 | 0b0000 = 0b1111
* n = 0b1111 = 15
* n > 0 ====> (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1
* n < MAXIMUM_CAPACITY ====> n = 15 + 1 = 16
* 此时,当cap为10的时候,n为cap或者cap-1算出的最后结果都为16
*
* 假如 cap 为 16
* n = 16 = 0b10000
* 0b10000 | 0b01000 = 0b11000
* 0b11000 | 0b00110 = 0b11110
* 0b11110 | 0b00001 = 0b11111
* 0b11111 | 0b00000 = 0b11111
* 0b11111 | 0b00000 = 0b11111
* n = 0b11111 = 31
* n > 0 ====> (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1
* n < MAXIMUM_CAPACITY ====> n = 31 + 1 = 32
* 此时,当cap为16的时候,n为cap或者cap-1算出的最后结果一个是32,一个是16
*
* 2.分析:
* 这里的cap必须要减1,如果不减,并且如果传入的cap为16,那么算出来的值为32,容量大了一倍,不合理
*
* 3.该方法的目的:
* 为了把最高位1的后面都变为1,然后该数字再加1,这样就可以保证该数字一定是2的n次方
* 0001 1101 1100 =>
* 0001 1111 1111 =>
* +1
* => 0010 0000 0000
*/
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;
}
(3)位运算介绍:
1)&:按位与 ====>两个操作数都为1,结果才为1,否则为0;
2)|:按位或 ====>两个操作数有一个为1,结果就位1;
3)~按位非 ====>如果位为0,结果是1,如果位为1,结果为0;
4)^按位异或 ====>两个操作数不相同,结果为1,两个操作数相同,结果为0;
5)<<左移 ====>向左移n位,相当于乘以2^n;
6)>>右移 ====>向右移n位,相当于除以2^n;
7)>>>无符号右移 ====>左边加n个0,右边减n个数。
3.put方法分析(putVal方法)
(1)put方法分析:
/**
* put方法
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* 实际插入值的方法
*
* @param hash 哈希值
* @param key 键
* @param value 值
* @param onlyIfAbsent 如果散列表中存在改值,值不插入,默认false,不用管是否有值,当无值则插入,有值则替换
* @param evict 没有使用到
* @return 如果当前key的位置有值则返回被替换前的值;如果没有值,则为null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// tab:引用当前hashMap的散列表
Node<K,V>[] tab;
// p:表示散列表数组的元素
Node<K,V> p;
// n:表示散列表数组的长度
// i:表示路由寻址结果
int n, i;
// 判断散列表是否有值,如果没值,第一次调用putval()方法的时候才进行初始化hashmap中最耗内存的talbe
// 防止创建hashMap后不使用导致的占用内存(延迟初始化)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 1.最简单的一种情况,寻址找到的桶位,刚好是null,这个时候直接将 key-value 构建成 Node节点放进去即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 2.寻址找到的桶位,已经有数据了,则需要添加成链表或者树的形式,或者进行value的替换
else {
// e:node为临时变量,如果key不为null,并且找到了当前要插入的 key 一致的 node 元素,就保存在e中
Node<K,V> e;
// k:表示一个临时变量的key
K k;
// 2.1表示当前桶位第一个元素与你当前插入的node元素的key一致,表示后续需要进行替换操作(此时可以理解为只有一个元素)
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 2.2表示当前桶位已经树化了
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 2.3表示当前捅位是一个链表
else {
// 遍历链表
for (int binCount = 0; ; ++binCount) {
// 2.3.1 e先被赋值成当前元素的下一个元素的值,判断该值是否为空
// 如果e为空,则表示已经找到了链表的最后还没找到一个与需要插入的key相同的node
if ((e = p.next) == null) {
// 直接将需要插入的 key-value 添加到链表的末尾
p.next = newNode(hash, key, value, null);
// 如果达到树化的阈值的条件,将链表进行树化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 2.3.2 如果e不为空时,表示当前p的下一个值有数据,此时需要判断e的key是否和需要插入的值的key相同
// 如果找到了与需要插入的key相同的node元素,则跳出循环
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
// 2.3.3 否则p的指针后移到下一个node,继续进行遍历
p = e;
}
}
// e不等于null,则找到了与要插入的key一致的node元素,那么进行替换
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// modCount表示散列表table结构的修改次数,替换Node元素的value不算
++modCount;
// 插入新元素后,size加1,当数组的长度大于扩容阈值时,进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
(2)扰动函数分析:
/**
* 扰动函数
*
* 介绍:
* 在往 haspmap 中插入一个元素的时候,由元素的 hashcode 经过一个扰动函数之后再与数组的长度进行与运算才找到插入位置
*
* 作用:
* 当数组的长度过小时,让 key 的 hashCode 值的高16位参与运算, hash() 方法返回的值的 低十六位 是有 hashCode 的高低16位共同的特征的,减少碰撞
*
* 例子:
* 假如 h = key.hashCode = 0b 0010 0101 1010 1100 0011 1111 0010 1110
* 0b 0010 0101 1010 1100 0011 1111 0010 1110
* ^
* 0b 0000 0000 0000 0000 0010 0101 1010 1100
* => 0010 0101 1010 1100 0001 1010 1000 0010
*
*/
static final int hash(Object key) {
int h;
// key为null的时候,放在数组index为0的位置
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); // ^异或运算
}
1)为什么要将key.hashCode()右移16位?
右移16位正好为32bit的一半,自己的高半区和低半区做异或,是为了混合原始哈希码的高位和低位,来加大低位的随机性;
而且混合后的低位掺杂了高位的部分特征,使高位的信息也被保留下来;
通过混合高位和低位来加大随机性,可以减少hash冲突。
2)为什么要用扰动函数?
若不使用扰动函数,则直接将key.hashCode()与数组的长度进行与运算,当数组的长度比较小的时候,则只有低位进行运算,高位不会参与位运算,碰撞就会很严重;
比如当数组的长度 l 等于16 32 64时,(l - 1) & h 得到的结果相同的情况就会很多,加剧碰撞,通过扰动函数,可以减少碰撞的发生。
4.resize方法分析(核心)
(1)源码:
/**
* 扩容方法
*
* @return the table
*/
final Node<K,V>[] resize() {
// oldTab:引用扩容前的哈希表
Node<K,V>[] oldTab = table;
// oldCap:表示扩容之前table数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// oldThr:表示扩容之前的扩容阈值(触发本次扩容的阈值)
int oldThr = threshold;
// newCap:表示扩容之后的table大小
// newThr:表示扩容之后,下次再次触发的扩容的条件
int newCap, newThr = 0;
// =================== 给newCap和newThr赋值start =============================
// 1.oldCap != 0,条件如果成立说明,hashmap中的散列表已经初始化过了,这是一次正常扩容
if (oldCap > 0) {
// 1.1 扩容之前的table数组大小已经最大值了,不能再进行扩容了,且设置扩容条件为int最大值,返回当前数组即可(非常非常的少数情况能满足此条件)
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 1.2 oldCap左移一位实现数值翻倍(2左移一位变为4,4左移一位变为8),并且赋值给newCap, newCap 小于数组最大值限制 且 扩容之前的oldCap是长度 >= 16,则扩容
// (newCap = oldCap << 1) < MAXIMUM_CAPACITY 基本都是成立的
// oldCap >= DEFAULT_INITIAL_CAPACITY 不一定都成立
// 假如新new一个hashmap,指定初始化大小为8,则oldCap为8 < DEFAULT_INITIAL_CAPACITY,那么此条件不成立newThr将不会赋值
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
// 这种情况下,下一次扩容的阈值 等于当前阈值翻倍
newThr = oldThr << 1;
}
// 2.oldCap == 0,说明hashMap中的散列表是null,以下三种情况都可以满足
// 2.1.1 new HashMap(initCap, loadFactor);
// 2.1.2 new HashMap(initCap);
// 2.1.3 new HashMap(map); 并且这个map有数据
else if (oldThr > 0)
newCap = oldThr;
// 2.2 new HashMap(),仅此一种方法满足
else {
newCap = DEFAULT_INITIAL_CAPACITY; // 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 0.75 * 16 = 12
}
// oldCap >= DEFAULT_INITIAL_CAPACITY)不成立的时候 或者 oldThr > 0 的时候,这两个情况只有newCap被赋值了,此时的newThr == 0
if (newThr == 0) {
// 通过newCap和loadFactor计算出一个newThr
float ft = (float)newCap * loadFactor;
// 将flaot转为int类型
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
}
// 将新的阈值 赋给 扩容阈值,下次扩容就用threshold就可以了
threshold = newThr;
// =================== 给newCap和newThr赋值end =============================
// =================== 扩容start =============================
@SuppressWarnings({"rawtypes","unchecked"})
// 创建出一个更长更大的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 说明 hashMap 本次扩容之前,table不为null
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
// 当前node节点
Node<K,V> e;
// 头结点不为空,说明当前桶位中有数据,但是数据具体是 单个数据,还是链表 还是 红黑树 并不知道
if ((e = oldTab[j]) != null) {
// 将对应的桶位指向null,方便JVM GC时回收内存
oldTab[j] = null;
// 1.第一种情况,只有一个节点
// 当前桶位只有一个元素,从未发生过碰撞,这情况 直接计算出当前元素应存放在 新数组中的位置,然后扔进去就可以了
if (e.next == null)
// 寻址算法:index = hash & (length - 1)
newTab[e.hash & (newCap - 1)] = e;
// 2.第二种情况,当前节点已经树化
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 3.第三种情况,桶位已经形成链表
else {
// 低位链表:存放在扩容之后的数组的下标位置,与当前数组的下标位置一致
Node<K,V> loHead = null, loTail = null;
// 高位链表:存放在扩容之后的数组的下表位置为 当前数组下标位置 + 扩容之前数组的长度
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// hash值只能为两种可能 1 1111 或者 0 1111
// e.hash-> .... 1 1111
// e.hash-> .... 0 1111
// oldCap-> 0b 10000
// &上后为 .... 1 0000(32) 或者 .... 0 0000(0)
// 1.存入低位链表
if ((e.hash & oldCap) == 0) {
// 第一次进入,该节点没数据,所有第一个数据设置为头节点
if (loTail == null)
loHead = e;
// 该节点有数据后,该数据为该节点的下一个节点数据
else
loTail.next = e;
// 将判断完后的节点,也叫变量节点设置为刚存入的值,下次再进行判断的时候,就可以使用最新存入的数据进行判断
loTail = e;
}
// 2.存入高位链表
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 如果低位链表有数据
if (loTail != null) {
// 低位链表的下一位肯定是高位链表,并且该高位链表会添加到新的位置中
// loTail.next本来应该为null,而实际不是,所以需要置空,如果不置空,连接就会出现问题
loTail.next = null;
newTab[j] = loHead;
}
// 如果高位链表有数据
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// =================== 扩容end =============================
return newTab;
}
(2)数组长度为16的哈希表扩容为长度为32的哈希表的流程:
为什么长度为16的数组[15]的链表的数据一部分会添加到32的数组的[15],部分会添加到32的数组的[31]中?
长度16:hash & (16 - 1) => 1111(hash值的前面不管,但是后四位必为1111) & 1111 => 1111 => 15;
此时的hash值前面不管,但是倒数第五位可能是1也可能是0,即hash可能为11111或者01111。
长度32:hash & (16 - 1) => 11111 & 11111 => 11111 => 31;
hash & (16 - 1) => 01111 & 11111 => 01111 => 15。
所以长度16数组[15]的值,会交替添加到长度32的[15]和[31]中。
(3)为什么哈希表需要扩容?
为了解决hash冲突导致的链化影响查询效率的问题,扩容会缓解该问题(数据量大的时候,将O(1)变为O(n));
如果原来是长度为16的数组,每个桶链化都是8,如果扩容为32,则相当于把16*8变为了32*4,减少了链化长度,提升了查询效率。
5.get方法分析
/**
* get方法
*/
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value; // 存的时候rehash了一下 取的时候也需要再hash一下
}
/**
* get方法的具体实现
*
* @param hash key的hash值
* @param key 元素的key
* @return 当前节点 或者 null
*/
final Node<K,V> getNode(int hash, Object key) {
// tab:引用当前hashMap的散列表
Node<K,V>[] tab;
// first:桶位中的头元素,e:临时node元素
Node<K,V> first, e;
// n:table数组长度
int n;
// k:一个元素里面的key
K k;
// 如果哈希表为空,或key对应的桶为空,返回null
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
// 1.第一种情况:定位出来的桶位元素即为要get的数据,即这个桶的头元素就是想要找的,返回该数据即可
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 说明当前桶位不止一个元素,可能 是链表 也可能是 红黑树
if ((e = first.next) != null) {
// 2.第二种情况:桶位升级成了 红黑树
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 3.第三种情况:桶位形成链表
// 循环逐一进行比对
do {
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
6.remove方法分析
/**
* remove方法
*
* @param key map中需要移除的key值
* @return map中的value值
*/
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
/**
* remove方法的具体实现
*
* @param hash key的hash值
* @param key key值
* @param value value值,需要 matchValue 时才用到
* @param matchValue 如果设置为true,则需要 key 和 value 都匹配上才能删除
* @param movable 如果为false,则在删除时不要移动其他节点(可以不管)
* @return 当前节点或者null
*/
final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
// tab:引用当前hashMap中的散列表
Node<K,V>[] tab;
// p:当前node元素
Node<K,V> p;
// n:表示散列表数组长度
// index:表示寻址结果
int n, index;
// 1.如果数组table为空或key映射到的桶为空,返回null。
if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) {
// 进入,则说明路由的桶位是有数据的,需要进行查找操作,并且删除
// node:查找到的结果
// e:当前Node的下一个元素
Node<K,V> node = null, e; K k; V v;
// 1.1第一种情况:当前桶位中的元素就是要删除的元素,即桶位的头元素就是我们要找的
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
node = p;
// 说明,当前桶位 要么是 链表 要么 是红黑树
else if ((e = p.next) != null) {
// 1.2第二种情况,当前桶位是否升级为,红黑树,进行红黑树查找操作
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
// 1.3第三种情况,链表的情况
else {
do {
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
node = e; // 将e赋值给查找到结果
break;
}
p = e; // 将e赋值给当前元素
} while ((e = e.next) != null);
}
}
// 2.如果node不为null,说明按照key查找到想要删除的数据了
// matchValue为false,直接进如if
// matchValue为true,则需要判断value的值是否相等
if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) {
// 2.1第一种情况:node是树节点,说明需要进行树节点移除操作
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
// 2.2第二种情况:删除的是 桶的第一个元素,则将该元素的下一个元素放至桶位中
else if (node == p)
tab[index] = node.next;
// 2.3第三种情况:不是第一个元素,将当前元素p的下一个元素 设置成 要删除元素的 下一个元素
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
7.replcae方法分析
@Override
public boolean replace(K key, V oldValue, V newValue) {
Node<K,V> e; V v;
// 通过key找到对应的node,并判断该node的value和传入的oldValue进行比较,如果相等则进行替换
if ((e = getNode(hash(key), key)) != null &&
((v = e.value) == oldValue || (v != null && v.equals(oldValue)))) {
e.value = newValue;
afterNodeAccess(e);
return true;
}
return false;
}
@Override
public V replace(K key, V value) {
Node<K,V> e;
// 通过key找到对应的node,将node的value直接替换即可
if ((e = getNode(hash(key), key)) != null) {
V oldValue = e.value;
e.value = value;
afterNodeAccess(e);
return oldValue;
}
return null;
}
三.常见问题
1.当key为null时,该次put操作,数据将被放入哪个桶位?为什么?
会存在数组index为0的位置;
因为HashMap计算索引的位置是通过传入的key的hashcode经过扰动函数后,再通过路由寻址公式[(table.length-1) & hash]找到的;
扰动函数的判断是如果传入的key为null,返回的hash值直接为0,然后通过路由寻址[(table.length-1) & 0],由于0&任何数都是为0,所有计算出的index为0。
2.为什么HashMap内部的散列表数组的长度一定是2的次方数?
(1)能利用 & 操作代替 % 操作,提升性能;
(2)数组扩容时,仅仅关注 “特殊位” 就可以重新定位元素(原数组在index为15位置的数,扩容后只会出现在新数组index为15和31的位置);
(3)当数组长度为2的N次方时,不同的key算出的index相同的几率小,数据在数组上分配均匀,hash碰撞的几率小,提升查询效率,从大O(N)提升至O(1)。
3.HashMap内部的散列表结构,什么时候初始化?以及初始化大小分别有哪几种情况?
(1)在第一次进行put的时候才进行初始化;
(2)初始化大小分为4种:
第一种:没有任何传参的时候,使用默认的方法进行初始化,即数组长度为默认16,加载因子为默认0.75,扩容阈值为12;
第二种:传入初始化大小和加载因子,此时数组长度为传入的值通过位运算算法求得的2的次方的数,加载因子为传入的加载因子,扩容阈值为数组长度*加载因子求的;
第三种:只传入初始化大小,会套用第二种的构造方法进行计算,此时数组长度为传入的值通过位运算算法求得的2的次方的数,加载因子为默认0.75,扩容阈值为数组长度*加载因子求的;
第四种:传入的是一个map,会根据传入的map进行初始化计算,加载因子为默认0.75,数组大小取决与传入的map的大小。
4.HashMap为什么需要扩容?谈谈你的理解!
为了提升查询效率;
因为如果哈希冲突导致的链化严重后(数据量大的时候,将O(1)变为O(n)),查询效率会降低,扩容会缓解该问题;
扩容会扩容为之前的两倍大小,这样扩容之后,可以大幅提高查找效率。
5:HashMap扩容算法,请你描述一下!
扩容分为两种情况,一种是第一次初始化,另一个是已经初始化过;
然后扩容分两步,第一步就是通过扩容前的数组大小和扩容阈值计算出新的数组大小和扩容阈值,第二步再进行扩容。
(1)第一次初始化:
根据传入的值对HashMap进行初始化,初始化好之后,会把表返回。
(2)已经初始化:
首先根据传入当前表的值进行判断,如果扩容之后的大小超过了最大限制,那么HashMap将不会再扩容了,而是把扩容阈值设置为Int的最大值,然后返回旧表;
如果扩容之后的大小没有超过最大限制,并且大于默认的初始化容量,那么会扩容为之前HashMap的两倍;
扩容完成之后,HashMap会把旧表中的数据填充到新表中,数据的填充有三种情况:
1)桶位只有一个元素,从未发生过碰撞,这情况 直接计算出当前元素应存放在 新数组中的位置,然后存进去即可;
2)桶位已经形成链表,通过逐一遍历链表,把一个链表分成高位链和低位链,这样分配后插入数据;
2)桶位已经形成红黑树,会把数据往新表存,新表会不会有红黑树取决于数据放进去后会不会树化,和链表差不多,也会先分成两个链,高位链和低位链,然后把链放到新表中。
6.HashMap内部散列表中存放的node元素中的hash属性值,与你插入的key的hashcode是否一致,为什么?
不一致;
node元素的hash值,是通过key的hashcode经过异或运算得来的,本来就是两个hash值;
如果直接使用key的hashcode作为hash值来计算,当数组的长度比较小的时候,则只有低位进行运算,高位不会参与位运算,此时的碰撞就会很严重;
所有需要通过扰动函数运算得到新的hash值,会让数据更加的散列,减少碰撞,从而提高存储效率。
7:JDK1.8中HashMap中为什么引入红黑树?谈谈你的理解!
当HashMap中的数量足够多的时候,此时的链化就会很严重,查找效率也会很低,可能退化到O(n),从而引入红黑树,就是为了提高查找效率;
因为红黑树是一个平衡二叉树,这是解决查询效率低下的一种方法。