以前对HashMap的认识仅限于使用——在遇到可以用key-value形式存储的数据并需要高效的查询效率时便使用java API中的HashMap类,对其底层也只是在阅读一些博客或观看一些视频时获得的片段性的了解。
毕竟,HashMap是一个秀外慧中的容器,(外:提供了高效的查询效率,内:众多数据结构的集合,包括哈希、顺序表、单向链表和红黑树),故在此通过尝试阅读源码望对其深入理解。
基础:HashMap数据结构
JDK1.7:顺序表+单向链表
JDK1.8:顺序表+单向链表+红黑树(Why?)
Why? 想必改变必有其道理。
红黑树的开销+查询的综合效率最高
红黑树中最长的路径不超过最短路径的1倍。
源码中:
数组:
transient Node<K,V>[] table
;`
链表:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next
红黑树:
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; TreeNode(int hash, K key, V val, Node<K,V> next) { super(hash, key, val, next); } /** * Returns root of tree containing this node. */ final TreeNode<K,V> root() { for (TreeNode<K,V> r = this, p;;) { if ((p = r.parent) == null) return r; r = p; } } ```
一,类前的注释
进入定义代码定位到类定义的头部的位置。看看其提供了什么信息:
- 它是有两个泛型的泛型类,猜测K、V各代表键和值的类型;
- 继承了AbstractMap<K,V>类,这个类提供了Map接口的主干实现,以最小化实现该接口所需的工作;
- 实现了Map<K,V>接口,符合Map继承结构图。
向上划拉,看看提供的大量的注释带来了什么讯息,第一段注释大概这么些内容要点吧:
- hash表允许空值和空健存入;
- 还与HashTable做了一个略微的对比:二者大致相似,但HashMap is unsynchronized,所以它不是线程安全的,同时允许空键值对;
- 所存储的键值对是无序的,并且插入删除的操作会改变其原来的顺序。
继续向下看注释,其实看到这里的时候一些内容不太明白为什么了:
- 如果hash函数能把元素合适的分配到各个桶中,该容器的get()和set()方法将具有常量级的效率。
- 迭代整个集合所需的时间与HashMap实例的“容量”(桶的数量)加上它的大小(键值映射的数量)成正比。——不明所以
- 根据2,如果需要保证迭代的性能,就不要将初始容量设置得太高(或负载因子过低)。
下面这一段,好像更有意思:
- 两个影响HashMap实例性能的参数:initial capacity()初始容量 and load factor(装填因子),其中这个容量指哈希表桶的个数(也即是顺序表的长度);
- 初始容量,是hash表创建的时候的顺序表的长度(桶的个数);
- 装填因子,是哈希表中的项数量超过装填因子和当前容量的乘积时,哈希表将扩容。
下面的注释是对默认装填因子为0.75的解释,以及与线程安全相关的问题,我稍后再看。
二,类内的注释—“Implementation notes”
在类内刚开始的地方还有好几段的注释,主要是说链表达到一定长度之后转化为了红黑树,并介绍了一些树的相关问题。(之后看)
三,代码中的参数
默认的初始容量为16。初始容量在前面的注释中解释过,就是创建哈希表实例时顺序表的长度(也叫桶的个数)。——这里注意,“必须是2的次幂”,目前不知道为啥,反正是有点意思。
在哈希表的散列函数的设计中的楚除留余数法的要求,m不应是2的整数幂,而一个不太接近2的整数次幂的素数是比较合适的选择。
后来可知,与1、hash算法和2、扩容这两个因素有关
1,2的整数次幂,方便计算hash和数组下标
2,2的整数次幂扩容的时候,同一个桶内的元素被重新分配时只可能在两个位置,一个在原来位置,另一个在原位置+原来的数组长度
好像是说顺序表的长度(桶的个数)不能超过2的30次方。——具体应该再通过程序分析。
默认的装载因子是0.75,通过注释也可以看出构造函数中可以指定装载因子。
装填因子 = 填入表中的元素个数 / hash表的长度
装填因子:是表示Hsah表中元素的填满的程度。由公式可知,装载因子越大,填满的元素越多,空间利用率高,但冲突的可能越大导致查找的困难增加。反之,装填因子越小,填满的元素越少,则冲突的可能减小了使查找更加快捷,但空间浪费多了。
装填因子 | 查找效率 | 空间利用率 |
---|---|---|
大 (元素占比多,冲突多) | 低 | 高 |
小(元素占比低,冲突少) | 高 | 低 |
插入:看到这里,该回头看看类前注释对装载因子的描述了:
这段话解释了装填因子为什么选择0.75:
- 直接英译汉吧:默认装填系数(0.75)在时间和空间成本之间提供了很好的权衡。
更高的值能减少空间开销,但会增加查找成本(反映在HashMap类的大多数操作中,包括get和put)。——据说,还有人称这个0.75为经验值。- 这段话后一部分给出了选择装填因子应考虑:尽量不使哈希表进行重新散列的操作。这时候应该考虑HashMap实例预期要放入键值对的个数,据此设置的load factor和initial capacity若能使初始容量大于最大键值对个数除以装填因子,该表则不会发生重哈希。
这里回到主线:
这个变量指定,当单向链表的长度大于等于8时会自动转换为红黑树(树化);注释中还提到这个值应该设置在2~8之间——说实话,不懂为什么?
这个值,限定红黑树恢复到单向链表(反树化)的阈值为6。
啊咧咧?
链表——>树,树化阈值为8,树——>链表,反树化阈值却为6?跟下一个参数有关
数化需要满足两个条件:
1,链表长度达到了吧,即超过了TREEIFY_THRESHOLD这个阈值
2,整个Hashmap的数组的长度达到了64,即达到了MIN_TREEIFY_CAPACITY这个阈值
大概是,HashMap的数组长度达到64时才可能发生树化。——因为还应满足链表长度达到8
//Hash的节点定义
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
//构造函数,传入hash值、key值、value值和节点的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; } //获取节点key值
public final V getValue() { return value; } //获取节点value值
public final String toString() { return key + "=" + value; } //以字符串的形式返回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;
}
//比较两个对象是否想等,key和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;
}
}
上面的代码是对该数据结构的基本节点的定义,正常的的set、get和equal。
四、hash算法
一大堆注释,好像是想说为啥这个样子计算hashCode——因为表长是2的次幂的关系,所以如果只用数据key的低位计算hashCode将经常发生冲突,所以将高位也带进入来降低冲突;位运算提高代码的执行效率。
这个中一行式代码看着挺晃眼哈,在此,我拆分一下:
static final int myHash(Object key) {
int h;
int temp;
//如果传入的key值为空,返回hashCode为0,即将该key为空的键值对存入0号位置的桶(在此,与前面的HashCode支持key为空相呼应)
if(key == null)
{
return 0;
}
//获得key的hashCode
h = key.hashCode();————这里的hashCode(),暂不知怎么个原理,32位
//将h无符号右移16位
temp = h >>> 16;
//将h与无符号右移动的h异或,得到hash码
return h ^ temp;
}
这一段代码深层含义挺多:
- key == null 时,返回hash值为0;一方面,照应了前面所说HashMap支持key为空;另一方面,key为空的数据被存放在0号桶;
- 正如注释所说,hash值不是仅通过key的低位计算得到,而是由key计算获得的hashCode的高16位和低16位异或计算得。——理由,因为表长是2的次幂的关系,所以如果只用数据key的低位计算hashCode将经常发生冲突,所以将高位也带进入来降低冲突;
!!!话说,为啥用异或?
从统计学的角度可知,“&”和“|”所得的结果的0、1的概率分别为为75%、25%和25%、75%,而“^”所得结果0、1出现的概率都为50%。
四,put()方法源码分析
put方法,顾名思义,将数据放入容器中;对于HashMap的put,即将key-value键值对放入该容器中。
对于该方法,传入key和value;应特别注意注释中提到的返回值的问题:如果要插入的key已经存在于容器则返回这个key原来对应的value,否则返回null。
这个put又调用了putVal()方法,并传入了五个参数,这里瞅一瞅它的真面目:
/**
* Implements Map.put and related methods.
*
* @param hash key的hash值
* @param key key
* @param value 要存的value
* @param onlyIfAbsent true的时候不改变原先的值
* @param evict false的时候表为创建模式
* @return 返回原先的value或null
*/
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是顺序表(数组) 定义:transient Node<K,V>[] table;
//程序含义:如果如果顺序表为空或表长为0,为tab(临时table)分配长度并将长度赋值于n
//变量含义:tab代表顺序表,n表示顺序表的长度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; //resize初始化或扩容两倍数组
//???
//程序含义:如果对应的桶内没有节点,就新建一个链表节点并将其保存到顺序表中
//变量含义:i为键值对该放入的数组的下标,p是对应的桶的数组中存放的节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); //null:节点的next域为null
//如果该桶之前已经有元素
else {
Node<K,V> e; K k;
//如果待插入节点和这个首节点的key相等或 key为不为空且值相等,(即新节点的key已经存在),保存原来对应的节点
//变量含义:k是原节点的key,e是与现待插入节点的key相等的节点
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//判断p(原节点)是不是tree的节点,若是则用插入树的方式插
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//遍历整个链表,目的:插查找是否有与待插入节点的key相同的节点,同时将指针移动到最后一个节点
for (int binCount = 0; ; ++binCount) {
//如果这个节点的next域为空,说明找到了最后一个节点
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相同
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;// 指针后移
}
}
//如果已经存在一个节点与待插入的 key相同
if (e != null) { // existing mapping for key
V oldValue = e.value; //保存原来key对应的值
//如果onlyIfAbsent为false(返回已有的值),或
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
第一个if:(数组为空)
n:数组的长度
tab:哈希表的数组
作用:如果hashMap实例的数组为空或长度为0,初始化数组
第二个if:(待插的数组位置没有元素)
(n-1) & hash :数组下标的计算
i:待插入的位置的表
p:下标为i的数组元素(也是一个Node节点)
作用:如果待插入的位置为空,直接插入至数组(也即,没有冲突的插入)
else:(待插的数组位置有元素)
-
第一个if:(与待插的数组的位置的元素冲突)
-
e:保存与当前待插入元素相同的key的节点
-
作用:在此put相同的key的值时,保存节点至e
-
从
-
之后代码进入
-
if (e != null) { // existing mapping for key
V oldValue = e.value; //保存原来key对应的值
//如果onlyIfAbsent为false(返回已有的值),或
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
} -
当onleyIfAbsen为false 或 原来的值为null时进行新value替换原value;不论是否替换均返回原value。
-
else if:(树节点)
-
树化(!!!!!!!等等在看)
-
else: (key与数组位置的元素不冲突,此时要插到链表位置了)
-
for循环:往下遍历整个链表
-
第一个if:找到最后一个节点,插入
-
第二个if:某个节点与待插入节点的发生hash和key相等的冲突时,保存旧节点,转 if (e != null)
本次先到这里结束吧,整个过程好似在英译汉和读代码,但对我来说对HashMap的底层(关于底层数据结构、hash算法和put方法,之后应该在看一看红黑树)有了更进一步的认识。
因为本文章旨在为我此次HashMap源码学习的一次梳理总结和未来回顾使用,所以整个语言表达也没有仔细斟酌(仅为我之后能看懂),没有良好的图进行解释,所以请对内容的逻辑的混乱和理解的艰难勿喷,但虚心接收交流和指导。
HahMap已经存在n多年,如今有很多的优质博客和视频可供大家学习,如需要更好的学习可移步其他资源,当然如果本文能为有缘人提供一定的帮助,我深感荣幸!