HashMap详解
一、HashMap源码 - 概述
1、继承关系
package java.util;
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
}
- 继承 AbstractMap 类:AbstractMap提供了Map接口的实现,以最大限度地减少实现此接口所需的工作。
- 实现 Cloneable 接口:表示可以克隆HashMap对象,创建并返回HashMap对象的一个副本。
- 实现 Serializable 接口:HashMap对象可以被序列化和反序列化。
这里已经继承了 AbstractMap,而 AbstractMap实现了Map接口,那为什么HashMap还要在实现Map接口呢?
- 据Java集合框架的创始人Josh Bloch描述,这样的写法是一个失误。JDK的维护者后来也懒得改了…
2、静态常量
package java.util;
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列化版本号
private static final long serialVersionUID = 362498820763181265L;
// Map 默认的容量是:16(必须是2的幂次方)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 集合 最大的容量是:2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;
// 负载因子(默认0.75)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// bucket桶上的结点数大于8时,链表会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// bucket桶上的结点数小于6时,红黑树会转回链表
static final int UNTREEIFY_THRESHOLD = 6;
// 链表转成红黑树时,对应的数组长度最小的值(数组没有达到该值,优先扩容数组)
static final int MIN_TREEIFY_CAPACITY = 64;
}
3、成员变量
package java.util;
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 存储元素的数组(容量必须是2的n次幂)
transient Node<K,V>[] table;
// 存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;
// 已经存放的元素个数
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 数组扩容的阈值(size > threshold,数组就要扩容为原来的2倍)
int threshold;
// 负载因子,table能够使用的比例,默认为0.75(threshold = capacity * loadFactor)
final float loadFactor;
}
4、静态内部类 - Node
// java.util.HashMap
// 实现了 Map 中的 Entry接口
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;
}
// 重写了 equals 和 hashCode 方法
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
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;
}
}
// java.util.Map
interface Entry<K,V> {
K getKey();
V getValue();
V setValue(V value);
boolean equals(Object o);
int hashCode();
// comparingByKey、comparingByValue....
}
二、HashMap源码 - 构造方法
1、空参构造
构造一个空的 HashMap
,默认初始容量(16)和 默认负载因子(0.75)
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
2、指定 初始容量
构造一个 HashMap
,指定初始容量(传参指定)和 默认负载因子(0.75)
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
3、指定 初始容量 和 负载因子
构造一个HashMap
,指定初始容量(传参指定)和 指定负载因子(传参指定)
public HashMap(int initialCapacity, float loadFactor) {
// 初始容量initialCapacity < 0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
// 初始容量initialCapacity > 集合的最大容量 MAXIMUM_CAPACITY(2的30次幂)
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 负载因子loadFactor <= 0 或 是一个非数值
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
// 指定负载因子的值
this.loadFactor = loadFactor;
// 返回比initialCapacity大的最小的2的幂次方(例如10,返回16)
this.threshold = tableSizeFor(initialCapacity);
}
tableSizeFor 方法
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;
}
位运算演示:
10000000 00000000 00000000 00000000 // n
01000000 00000000 00000000 00000000 // n >>> 1
------------------------------------ // n |= n >>> 1
11000000 00000000 00000000 00000000
00110000 00000000 00000000 00000000 // n >>> 2
------------------------------------ // n |= n >>> 2
11110000 00000000 00000000 00000000
00001111 00000000 00000000 00000000 // n >>> 4
------------------------------------ // n |= n >>> 4
11111111 00000000 00000000 00000000
00000000 11111111 00000000 00000000 // n >>> 8
------------------------------------ // n |= n >>> 8
11111111 11111111 00000000 00000000
00000000 00000000 11111111 11111111 // n >>> 16
------------------------------------ // n |= n >>> 16
11111111 11111111 11111111 11111111
为什么要对cap做减1操作。int n = cap - 1;
-
如果cap已经是2的幂,又没有执行减1操作,那么执行完 位运算 和 最后的+1操作之后,返回值将是这个cap的2倍。
例如:1000(8) —> 1111 —> 10000(16)
tableSizeFor
分析:
-
如果
cap = 0
,即(n = cap-1) == -1
执行完 位运算 操作后,n依然是-1,此时 n < 0,返回1
-
如果
cap = 1
,即(n = cap-1) == 0
执行完 位运算 操作,n依然是0,此时返回 n+1 = 1
-
如果
cap
不是 2 的整数次幂执行完 -1操作、位运算、+1操作,返回 cap 最高位1左移1位(即比cap大的最小的2的幂次方)
-
如果
cap
是 2 的整数次幂执行完 -1操作、位运算、+1操作,和原来的值一样。
4、基于Map传参
构造一个HashMap
,映射关系与指定 Map 相同,使用默认负载因子(0.75)
public HashMap(Map<? extends K, ? extends V> m) {
// 负载因子使用默认值0.75
this.loadFactor = DEFAULT_LOAD_FACTOR;
// copy指定Map的元素
putMapEntries(m, false);
}
putMapEntries 方法
这里看一下 putMapEntries
方法的实现:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
// 指定Map的size
int s = m.size();
if (s > 0) {
// table还未初始化
if (table == null) {
// 存放指定Map的元素后 不需要扩容的阈值
float ft = ((float)s / loadFactor) + 1.0F;
// 超过最大容量,则取最大容量的值
int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);
// t > 扩容阈值,则初始化阈值(threshold默认是0)
if (t > threshold)
threshold = tableSizeFor(t); // 大于t的最小的2的幂次方
}
// table已初始化 && 指定Map的元素个数大于阈值
else if (s > threshold)
resize(); // 扩容处理
// 将 指定Map的元素 添加至 当前的HashMap
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
问题:float ft = ((float)s / loadFactor) + 1.0F;
这一行代码中为什么要加1.0F
?
((float)s / loadFactor)
的结果是小数。- 加
1.0F
与下面的(int)ft
相当于是对小数做一个向上取整。 - 然后 调用
tableSizeFor(t)
方法,获取大于t的最小的2的幂次方。
所以 +1.0F
是为了将 小数ft
向上取整,获取更大的threshold
,减少数组扩容的概率。
举个栗子:假设参数指定Map的元素个数是6个,此时 ((float)s / loadFactor) = 8
- 如果没有加
1.0F
,则调用tableSizeFor(8)
,得到的阈值是8。- 此时阈值是8,已经有6个元素了,再存两个元素,就会导致扩容
- 如果加了
1.0F
,则调用tableSizeFor(9)
,得到的阈值是16(比9大的最小的2的幂次方)- 此时阈值是16,有6个元素,相对来说不容易导致扩容。
三、HashMap源码 - 成员方法
1、新增方法_put()
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
1)hash 方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
key == null
:hash值为0(这里可以看出,HashMap是支持key为null的)key != null
:先调用key的hashCode方法赋值给h,然后将 低16位 和 高16位 进行按位异或
得到最后的hash值。
为什么要 将 低16位 和 高16位 进行
按位异或
的计算呢?
// 在后续的`putVal`方法中,使用 计算出来的hash值 与 数组长度-1 进行按位与操作,确定哈希槽
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// ...
if ((p = tab[i = (n - 1) & hash]) == null) // 这里的n表示数组长度16
// ...
}
如果数组的长度很小,假设就是默认值16,那么n-1=15
,二进制就是1111
- 此时如果直接和
hashCode()
进行按位与运算,实际上只用到了hashCode()
的后4位。 - 如果
hashCode()
高位变化很大,低位变化很小,就很容易造成哈希冲突。
hashCode()值 1111 1111 1111 1111 1111 0000 1110 1010
n-1即16-1=15 0000 0000 0000 0000 0000 0000 0000 1111
------------------------------------------------------
直接进行按位与 0000 0000 0000 0000 0000 0000 0000 1010 // 如果只有高位变化,不管怎么变,哈希槽都是一样的
因此,将低16位与高16位进行 按位异或
运算
- 这样获取的hash值的低位其实是
hashCode()
值高位与低位的结合, - 将高低位都利用起来,增加了随机性,减少了哈希碰撞发生的几率。
2)putVal 方法
/**
* put方法的核心逻辑,putVal方法
*
* @param hash key的hash值
* @param key key
* @param value value
* @param onlyIfAbsent 如果为true,代表不更改现有的值
* @param evict 如果为false,表示table为创建状态
*/
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)
n = (tab = resize()).length;
// 要存入的桶中没有元素,直接创建新的节点放入桶中(没有哈希碰撞,直接存入)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 要存入的桶中已有元素(产生了哈希碰撞)
else {
Node<K,V> e; K k;
// 通过hash值和equals方法比较哈希冲突的两个key是否相同
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
// 相等 -> 用e来记录旧元素节点
e = p;
// 不相等 -> 判断p是否是红黑树节点
else if (p instanceof TreeNode)
// 放入红黑树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// p是链表节点
else {
// 循环遍历链表,遍历到最后节点插入(JDK1.8之后采用尾插法)
for (int binCount = 0; ; ++binCount) {
// 没有下一个元素 -> 到达了链表尾部 -> 创建新的节点插入链表尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 链表长度超过8,尝试将链表转为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 不是最后一个节点,比较链表节点的key和新增元素的key是否相同
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
// key相同,跳出循环,在if (e != null)中进行值的替换
break;
// 和e = p.next配合,遍历链表
p = e;
}
}
// 处理key相同的元素,用新值替换旧值,并返回旧值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 访问后回调(LinkedHashMap会用到)
afterNodeAccess(e);
return oldValue;
}
}
// 记录修改次数
++modCount;
// 判断实际大小是否大于threshold阈值,如果超过则扩容
if (++size > threshold)
resize();
// 插入后回调(LinkedHashMap会用到)
afterNodeInsertion(evict);
return null;
}
3)put 小结
put
方法的大致流程如下:
- 计算key的hash值
(h = key.hashCode()) ^ (h >>> 16)
(低16位 与 高16位 进行 按位异或) - 计算桶下标
hash & (length - 1)
- 如果数组table为空,调用
resize()
进行数组初始化。 - 如果没有发生哈希碰撞,直接添加元素到散列表中。
- 如果发生了哈希碰撞(hash值相同),进行三种判断
- key地址相同 或 equals比较相同,则替换旧值,返回旧值
- 如果是红黑树结构,调用树的插入方法
- 如果是链表结构,采用尾插法进行插入(JDK1.8之后采用尾插法)
- 如果容量size大于threshold阈值,调用
resize()
进行扩容
2、链表转红黑树_treeifyBin()
1)源码分析
/**
* 替换指定哈希表的索引处桶中的所有链接节点,除非表太小,否则将修改大小。
*
* @param tab 数组名
* @param hash 哈希值
*/
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)
resize();
// 满足树形化的条件 -> 将数组指定位置桶中的链表节点赋值给e(从第一个开始)
else if ((e = tab[index = (n - 1) & hash]) != null) {
// hd:红黑树的头结点 tl:红黑树的尾结点
TreeNode<K,V> hd = null, tl = null;
// 遍历链表,将链表的每个节点放入红黑树中
do {
// 新创建一个树节点p,内容和当前链表节点e一致
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
// 将链表的头节点e 转换为红黑树节点p 并赋值给红黑树的头节点
hd = p;
else {
// 不断向下遍历,将链表节点 转为 红黑树节点
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
// 让桶中的第一个元素指向新创建的树根节点,替换桶的链表内容为树形化内容
if ((tab[index] = hd) != null)
// 左旋、右旋、自平衡等一系列操作
hd.treeify(tab);
}
}
2)treeifyBin 小结
treeifyBin
方法的大致流程如下:
- 根据数组长度确定是扩容还是树形化。
- 如果是树形化,遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系。
- 然后让桶中的第一个元素指向新创建的树根节点,替换桶的链表内容为树形化内容。
3、扩容方法_resize()
扩容会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。因此在编写程序中,要尽量避免resize。
1)什么时候会扩容?
-
当 HashMap 中的数组还没有初始化时,会调用 resize 方法进行数组的初始化。
-
当 size (HashMap中的元素个数) > capicity (数组长度) * loadFactor (负载因子) 时,会进行数组的扩容。
-
当 HashMap 中一个链表的长度超过阈值8时
-
如果数组长度没有达到64,HashMap会优先扩容解决
-
如果数组长度达到了64,会将链表变成红黑树,节点类型由Node变成TreeNode类型。
如果映射关系被移除后,下次执行resize方法时,判断树的节点个数低于6,也会再把树转换为链表。
-
2)扩容的机制
HashMap在进行扩容时,使用的rehash方式非常巧妙。
- 因为每次扩容都是翻倍,与原来计算的
(n-1)&hash
的结果相比,只是多了一个bit位, - 所以节点要么就在原来的位置,要么就被分配到 “ 原位置+旧容量 " 这个位置。
怎么理解呢?假设我们从16扩容为32,具体的变化如下所示:
元素在重新计算hash之后,n变为2倍,那么n-1的标记范围在高位多1bit,因此新的index就会发生如下变化:
这样就验证了上述所描述的:扩容后的节点要么就在原来的位置,要么就被分配到 “ 原位置+旧容量 " 这个位置。
因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就可以了
- 如果是0,索引不变;如果是1,索引变成 “oldIndex+oldCap (原位置+旧容量)”。
下图为16扩充为32的resize示意图:
正是因为这样巧妙的rehash方式,既省去了重新计算hash值的时间,同时由于新增的1bit是0还是1是随机的,又在resize的过程中保证了rehash之后 每个桶上的节点数一定小于等于原来桶上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中了。
3)源码分析
final Node<K,V>[] resize() {
// 老数组
Node<K,V>[] oldTab = table;
// 老数组的长度(这里>=0)
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 老的阈值
// 1. 空参构造 --> threshold = 0
// 2. 有参构造 --> threshold = tableSizeFor(initialCapacity)
int oldThr = threshold;
int newCap, newThr = 0;
// 老数组长度大于0,开始计算扩容后的大小
if (oldCap > 0) {
// 超过最大值就不再扩充了,就只好随你碰撞去吧
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE; // 2^31 - 1
return oldTab;
}
// 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
// 原数组长度 >= 数组初始长度16
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 阈值也扩大一倍
}
// 老数组的长度=0 且 老的阈值>0(空参构造) --> 新的数组长度 = 老的阈值(大于容量的最小的2的整次幂)
else if (oldThr > 0)
newCap = oldThr;
// 老数组的长度=0 且 老的阈值=0(有参构造)--> 直接使用默认值
else {
newCap = DEFAULT_INITIAL_CAPACITY; // 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 0.75 * 16
}
// 计算新的resize最大上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 新的阈值 默认原来是12 乘以2之后变为24
threshold = newThr;
// newCap是新的数组长度 -> 32
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 判断旧数组是否等于空
if (oldTab != null) {
// 把每个bucket都移动到新的buckets中
// 遍历旧的哈希表的每个桶,重新计算桶里元素的新位置
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// help gc
oldTab[j] = null;
// 没有next,说明没有hash冲突,即只有一个元素,直接插入
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 有hash冲突
// 红黑树处理冲突
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 链表处理冲突
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// rehash(参考上面讲的扩容的机制)
do {
// 原索引
next = e.next;
// 如果等于true,e这个节点在resize之后不需要移动位置
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引 + oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
4)resize 小结
resize
方法的大致流程如下:
- 判断数组是否初始化过?
- 否,进行数组的初始化(默认大小16,加载因子0.75,扩容阈值12)
- 是,将数组扩容为原来的两倍,然后将元素进行rehash,复制到新的散列表中。
4、删除方法_remove()
1)源码分析
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;
}
/**
* remove方法的具体实现在removeNode方法中
*/
final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 判断要删除的key映射的桶是否为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 如果桶上的节点就是要找的key,则将node指向该节点
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
node = p;
// 继续找下一节点
else if ((e = p.next) != null) {
// p是红黑树节点 -> 说明是以红黑树来处理的hash冲突,则获取红黑树要删除的节点
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
// p是链表节点 -> 说明是以链表方式处理的hash冲突,则遍历链表来寻找要删除的节点
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 比较找到的key的value和要删除的是否匹配
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// node是红黑树节点 -> 调用红黑树的方法来删除节点
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
// 链表删除
tab[index] = node.next;
else
p.next = node.next;
// 记录修改次数
++modCount;
// 变动的数量
--size;
// 删除回调(LinkedHashMap会用)
afterNodeRemoval(node);
return node;
}
}
return null;
}
2)remove 小结
remove
方法的大致流程如下:
- 首先先找到元素的位置
- 如果是链表就遍历链表,找到元素之后删除
- 如果是红黑树就遍历树,找到元素之后删除(树小于6的时候要转链表)
5、查找方法_get()
1)源码分析 - get
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;
// 如果哈希表不为空 且 key对应的桶上不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 判断第一个元素是不是就是要找的,是就直接返回
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 如果不是第一个元素,判断是否有后续节点
if ((e = first.next) != null) {
// 红黑树 -> 调用红黑树中的getTreeNode方法获取节点
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 链表 -> 循环判断链表中是否存在该key
do {
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
2)源码分析 - getTreeNode
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p; // 找到之后直接返回
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
// 递归查找
else if ((q = pr.find(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
return null;
}
-
查找红黑树,由于之前添加时已经保证这个树是有序的了,因此查找时基本就是折半查找,效率更高。
-
这里和插入时一样,如果对比节点的哈希值和要查找的哈希值相等,就会判断key是否相等,相等直接返回。不相等就从子树中递归查找。
-
若为树,则在树中通过
key.equals(k)
查找,O(logn)
若为链表,则在链表中通过
key.equals(k)
查找,O(n)
3)get 小结
get
方法的实现:
- 通过key计算获取bucket中的下标位置
- 如果在桶的首位,则直接返回
- 如果不在桶的首位,则在树中(getTreeNode)或链表中(getNode)遍历找
- 如果有哈希冲突,则利用equals方法去遍历链表查找节点。
四、HashMap相关问题
哈希冲突
什么是哈希冲突?何时发生哈希冲突?如何解决哈希冲突?
1. 什么是哈希冲突?何时发生哈希冲突?
哈希冲突是指,两个元素通过hash计算后,放到同一个哈希槽,发生了冲突。
只要两个元素的key计算的hash值相同,就会发生哈希冲突。
2. 如何解决哈希冲突?
将哈希碰撞/哈希冲突的元素放到哈希桶中
jdk8之前使用 链表 解决哈希碰撞。
jdk8之后使用 链表+红黑树 解决哈希碰撞。
哈希值相同就是同一个对象?
哈希值相同 就 一定是同一个对象吗?
// 两个不同的对象,hashCode相同
System.out.println("重地".hashCode()); // 1179395
System.out.println("通话".hashCode()); // 1179395
- hashCode不同,两对象一定不同。
- hashCode相同,两对象也不一定相同。(需要通过equals方法进一步判断)
HashMap数据结构的变化
JDK1.8前后,HashMap的数据结构是不同的:
- JDK 1.8 之前 : 数组+链表
- JDK 1.8 之后 : 数组+链表 +红黑树
为什么要引入红黑树?
哈希函数取得再好,也很难达到百分百均匀分布。
当HashMap中有大量的元素都存放到同一个哈希桶中时,这个桶下就会有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是O(n)
,完全失去了它的优势。
此时引入红黑树,遍历的时间复杂度就是O(logn)
,解决了链表过长时的问题,提高了查询效率。
红黑树 是否可以取代 链表?
- 当链表长度很小的时候,即使遍历,速度也非常快,没有必要引入结构复杂的红黑树。
- 但是当链表长度不断变长,肯定会对查询性能有一定的影响,此时就需要引入红黑树来提升效率了。
因此,HashMap并非是用红黑树替换链表,而是两者结合使用,应对不同的场景。
链表与红黑树的转换
- 链表长度 > 阈值8
- 数组长度 < 64:优先进行数组的扩容(2倍扩容,rehash)
- 数组长度 > 64:链表 转为 红黑树,减少了查找时间,提高了查找效率。
- 链表长度 < 6
- 红黑树 转回 链表。
为什么链表转红黑树的阈值是8?
理想情况下,随机hashCode算法下所有bin中节点的分布频率会遵循 泊松分布(离散概率分布)
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。
所以,之所以选择8,不是随便决定的,而是根据概率统计决定的。
HashMap中的关键变量?
参数 | 含义 |
---|---|
capacity | 数组table的容量大小,初始容量为16。(必须保证是2n) |
size | HashMap中键值对的数量,每存入一组数据,就++。(不等于数组的长度) |
threshold | size的临界值,当size > threshold 时,数组就要进行扩容操作。(扩容为原来的两倍) |
loadFactor | 加载因子,table能够使用的比例,默认为0.75(threshold = capacity * loadFactor ) |
- 扩容通过
resize()
方法实现,扩容后,table数组的容量 capacity 为原来的两倍。 - 扩容操作 会把 oldTable 的所有键值对重新插入 newTable 中,并重新计算 hash值 / 桶下标。
为什么数组容量必须是2n ?
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
可以看到,注释中明确指出,数组的容量必须是 2n,为什么?
向HashMap中添加一个元素时,需要根据key的hash值,去确定其在数组中的具体位置。 HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据在数组中分配均匀,使得每个链表长度大致相同。
- 通过取模运算:
hash%length
,可以使数据在数组中均匀分布。 - 计算机中,取模运算的效率不如位运算,所以源码中使用位运算进行了优化:
hash&(length-1)
- 而
hash&(length-1)
等价于hash%length
的前提,就是 length 是 2n
2n-1 的 二进制,实际上就是 n 个 1,这样和 hash 值进行按位与运算,可以保证数据的均匀分布,减少哈希碰撞。
而且,在数组扩容的时候,也可以巧妙的进行rehash。
如果数组容量不是2n ?
- 空参构造:初始化Map的时候,
threshold = 0
- 有参构造:初始化Map的时候,
threshold = tableSizeFor(initialCapacity)
tableSizeFor
方法会返回 大于等于参数的最小的2的幂次方
然后,在第一次resize
的时候,会进行数组的初始化:
threshold = 0
:数组容量 = 默认值16threshold > 0
:数组容量 = threshold = 2的幂次方
由此可见,及时数组容量不是2n ,也会通过计算,找到最接近的2的幂次方作为数组容量。
HashMap的初始化问题
如果知道有多少键值对需要存储,那么在初始化HashMap时就应该指定它的容量,以防止HashMap自动扩容,影响使用效率。
- 如果没有设置初始容量大小,随着元素的不断增加,HashMap就有可能会发生多次扩容,非常影响性能。
那么当我们已知HashMap中即将存放的 K-V 的个数时,容量具体设置成多少比较好呢?
HashMap的构造方法是支持指定初始容量的,但是HashMap不会拿我们传入的initialCapacity
作为容量
- 通过之前的内容,我们知道,HashMap会根据
initialCapacity
计算找到最接近的一个2的幂当做初始容量。 - 如果我们设置的默认值是7,经过计算处理之后,会被设置成8,这扩容的几率是很高的,显然这么设置是有问题的。
关于这个值的设置,在《阿里巴巴Java开发手册》有以下建议:
initialCapacity = (expectedSize / 0.75F) + 1.0F
通过以上方式计算,7/0.75 + 1 = 10
,10经过计算处理之后,会被设置成16,这就大大的减少了扩容的几率。
什么是负载因子?
负载因子是用来衡量 HashMap 满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率。
- 默认是0.75,当HashMap存储的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容。
负载因子为什么默认0.75?
-
loadFactor越大,也就是趋近于1,数组中存放的数据(entry)也就越多,也就越密集,会让链表的长度增加。
-
loadFactor越小,也就是趋近于0,数组中存放的数据(entry)也就越少,也就越稀疏,浪费空间,浪费性能。
如果希望链表尽可能少些。要提前扩容,有的数组空间可能一直没有存储数据。加载因子尽可能小一些。
举例:
例如:加载因子是0.4。 那么16*0.4 ---> 6 如果数组中满6个空间就扩容,会造成数组利用率太低了。
加载因子是0.9。 那么16*0.9 ---> 14 那么这样就会导致链表有点多了,导致查找元素效率低。
所以既要保证数组利用率,又考虑链表不要太多,经过大量测试0.75是最佳方案。
哈希值的计算?
哈希值的计算:(h = key.hashCode()) ^ (h >>> 16)
- 将 hashCode 的 低16位 与 高16位 进行
按位异或
计算,得到哈希值。
这样hash值的低16位是hashCode
值中高位与低位的结合,增加了随机性,减少了哈希碰撞发生的几率。(只有高16位变化时)
哈希槽的计算
哈希槽的计算:hash & (length - 1)
- length 设定为 2n 后,
hash&(length-1)
等价于hash%length
取模运算。
取模保证了数据在数组中分配均匀,同时位运算具有更高的效率。
数组什么时候会扩容?
- 当 HashMap 中的数组还没有初始化时,会调用 resize 方法进行数组的初始化。
- 当 size (HashMap中的元素个数) > capicity (数组长度) * loadFactor (负载因子) 时,会进行数组的扩容。
- 当 HashMap 中一个链表的长度超过阈值8 且 数组长度没有达到64 时,HashMap会优先扩容解决。
扩容rehash的机制?
扩容操作 会把 oldTable 的所有键值对重新插入 newTable 中,并重新计算 hash值 / 桶下标。
HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n-1)&hash
的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到 “ 原位置+旧容量 " 这个位置。
HashMap的存储流程?
- 先通过hash值计算出key映射到哪个桶;
- 计算key的hash值
(h = key.hashCode()) ^ (h >>> 16)
(低16位 与 高16位 进行 按位异或) - 计算桶下标
hash & (length - 1)
- 计算key的hash值
- 如果数组table为空,调用
resize()
进行数组初始化。 - 如果没有发生哈希碰撞,直接添加元素到散列表中。
- 如果发生了哈希碰撞(hash值相同),进行三种判断
- key地址相同 或 equals比较相同,则替换旧值,返回旧值
- 如果是红黑树结构,调用树的插入方法。
- 如果是链表结构,采用尾插法进行插入(JDK1.8之后采用尾插法)
- 如果容量size大于threshold阈值,调用
resize()
进行扩容