目录
本人的源码阅读主要聚焦于类的使用场景,一般只在java层面进行分析,没有深入到一些native方法的实现。并且由于知识储备不完整,很可能出现疏漏甚至是谬误,欢迎指出共同学习
本文基于corretto-17.0.9源码,参考本文时请打开相应的源码对照,否则你会不知道我在说什么
简介
HashMap是Map接口的一个实现,采用哈希表实现的键值对集合,键和值都允许为null。HashMap 的特殊存储结构使得在获取指定元素前需要经过哈希运算,得到目标元素在哈希表中的位置,然后再进行少量比较即可得到元素,这使得 HashMap 的查找效率贼高。由于是基于哈希的Map,元素之间没有特定的顺序。
模型
HashMap的源码里有一大段的 Implementation notes,下面根据个人理解进行解析。
HashMap是对哈希表的封装,底层数据结构是桶哈希表,什么是桶哈希表?首先从最简单的数组哈希表入手,每个下标只能容纳一个元素。每个元素通过其哈希值映射到不同的下标,但是难免会遇到哈希冲突,即不同的元素的哈希值映射到的下标相同,但是一山不容二虎,因此,把原来下标对应只能容纳一个元素的位置变成桶,每个桶可以容纳多个元素,这些元素都有一个共性,就是他们的哈希值映射到这个表的下标相同。其实这个用桶存储的方法,有一个更加常见的叫法:拉链法,为了方便和通用我还是称其为桶。
综上,在桶哈希表中根据key查找元素的时候,可行的步骤如下:得到key的哈希值hash(key) -> 获取桶的下标hash(key) % table.length -> 在该桶中查找对应的value。
桶是一个抽象概念,具体实现可以是数组、链表、树等。在Java8之后,桶哈希表是这样的:
哈希表是一个数组,而桶以链表或者红黑树的形式存在。当桶元素比较少的时候,用链表存储元素。否则,将其转换成红黑树存储,红黑树是一颗二叉查找数,在桶元素过多的时候可以以O(logn)的复杂度实现元素查找。相比链表来说维护红黑树更复杂,并且红黑树节点几乎是链表节点的两倍大小,但是查找效率在元素较多时更高。链表方式实现的桶显而易见,没什么好讲。下面主要讲一下红黑树。
红黑树是根据元素的排序性来查找,一般情况下用元素的hashCode来进行比较,当hashCode相同的情况下,进一步通过反射检查元素是否相同的Comparable类型并尝试采用compareTo进行比较。因此hashCode和compareTo的目的都是为了在查找元素的时候可以省去很多不必要的比较,后者作为前者的尝试性的兜底方案。
因此当hashCode的返回值分布很不均匀的话(来源于用户对hashCode的实现很烂),很容易出现hashCode冲突,并且如果元素也是Comparable类型的话,那么就会走反射这个流程,最终使用compareTo来比较元素。由于反射本身也很拖慢运行效率,因此在这种情况下还不如不走这个兜底方案,效率还更高一点。但由于我们(HashMap开发者)又无法知道用户定义的hashCode的分布是怎么样的,因此不可能去掉这个兜底方案,最终解铃还须系铃人,用户需要细致地实现hashCode,让其返回值区分度更高一些(分布更加均匀),否则HashMap的运行效率可能因此降低:
Thus, performance degrades gracefully under accidental or malicious usages in which hashCode() methods return values that are poorly distributed, as well as those in which many keys share a hashCode, so long as they are also Comparable. (If neither of these apply, we may waste about a factor of two in time and space compared to taking no precautions. But the only known cases stem from poor user programming practices that are already so slow that this makes little difference.)
之前讲过,由于红黑树节点几乎是链表节点的两倍大小,而且维护成本高,因此HashMap定义了一个阈值,当桶节点数低于阈值为链表,超过阈值则转换为红黑树(treeify);桶的大小会增加,自然也会因为删除元素或rehash而减小,当低于阈值的时候又会从红黑树转换回链表(untreeify)。当然,treeify的阈值与untreeify的阈值是不同的,前者比后者大,否则如果两者相等的话,用户如果重复插入又删除同一个元素的话,并且在这个阈值反复横跳,那么就会不断触发treeify和untreeify,因此不同的阈值可以在一定程度上避免这个现象,留个缓冲。
在hashCode返回值有着理想的分布下,每个桶的大小都小于阈值,红黑树几乎用不到。在最理想的情况下,桶的元素个数概率遵循参数为0.5的泊松分布(负载因子阈值0.75),了解即可,总之就是让我们hashCode返回值更加均匀分布些以提高HashMap的时间和空间效率。
代码分析
成员变量
// 默认哈希表大小
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 哈希表最大大小
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// treeify桶大小阈值
static final int TREEIFY_THRESHOLD = 8;
// untreeify桶大小阈值
static final int UNTREEIFY_THRESHOLD = 6;
// treeify哈希表大小阈值
static final int MIN_TREEIFY_CAPACITY = 64;
// 哈希表
transient HashMap.Node<K,V>[] table;
// entry集合,其实就是将哈希表所有键值对收集到集合里,提供给外部访问
transient Set<Map.Entry<K,V>> entrySet;
// 元素个数
transient int size;
// 期望的修改次数,用于检测并发修改
transient int modCount;
// 下一次扩容的阈值,即capacity * load factor
int threshold;
// 负载因子
final float loadFactor;
基于之前分析的模型,table就是哈希表数组,HashMap规定其大小一定为2的幂,因为相比对数组长度取余得到哈希值对应的数组下标(hash % table.length),如果数组长度为2的次方,可以用效率更高的位运算得到结果(hash & (table.length-1))。元素类型为Node,Node可以是链表的节点,通过多态也可以存储Node的子类TreeNode,TreeNode为红黑树的节点,简单看一下Node:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
Node继承于Map.Entry,表示一个键值对,hash存储key的哈希值避免重复计算,next为链表指向下一个节点的指针。
TreeNode继承于Node,也简单看一下:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
// 指向父节点
TreeNode<K,V> parent;
// 指向左子节点
TreeNode<K,V> left;
// 指向右子节点
TreeNode<K,V> right;
//
TreeNode<K,V> prev;
// 红或黑
boolean red;
}
TreeNode虽然表示树的其中一个节点,但其内部封装了对节点所在的树的所有操作,包括查找、插入、删除、树分裂、treeify、untreeify,对应的方法为:
final TreeNode<K,V> getTreeNode(int h, Object k);
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab, int h, K k, V v);
final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab, boolean movable);
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit);
final void treeify(Node<K,V>[] tab);
// untreeify是在树大小改变的时候(比如删除节点、树分裂)自动完成的
目前知道方法的用法就行,红黑树这个数据结构有时间会再出一篇来专门介绍。
方法
首先看构造函数,初始化HashMap可以指定table的大小initialCapacity,由于table大小必须为2的次方,因此将该用户指定的大小调整为“刚好大于initialCapacity的为2的幂次的数”,比如initialCapacity=100,则最终table初始化大小为128,实现调整大小的函数为:
static final int tableSizeFor(int cap) {
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
里面的Integer.numberOfLeadingZeros实现也挺有意思,使用了二分的思想来获取前导0的个数。
HashMap遵循Map接口的规范,支持用另一个Map构造自己。
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) {
// 初始化threshold
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
} else {
// 提前扩容
while (s > threshold && table.length < MAXIMUM_CAPACITY)
resize();
}
// 遍历m并加入当前表
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);
}
}
}
下面零碎地介绍一下各个方法,从get开始
// 获取key对应的value
public V get(Object key) {
HashMap.Node<K,V> e;
return (e = getNode(key)) == null ? null : e.value;
}
// get的具体实现
final HashMap.Node<K,V> getNode(Object key) {
HashMap.Node<K,V>[] tab; HashMap.Node<K,V> first, e; int n, hash; K k;
// 检查key对应的桶是否为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & (hash = hash(key))]) != null) {
// 检查桶的第一个节点是否为目的返回值
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 否则根据第一个节点为TreeNode或者链表Node,接着在该桶查找
if ((e = first.next) != null) {
// 红黑树
if (first instanceof HashMap.TreeNode)
return ((HashMap.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比较简单,继续看put:
// 将key对应的value存入
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// put的具体实现
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
HashMap.Node<K,V>[] tab; HashMap.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 {
// e:如果key已经存在,e保存对应节点,否则为null
HashMap.Node<K,V> e; K k;
// 如果桶第一个节点的key就是我们要put的key,直接将该节点赋值给e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果桶是红黑树,调用红黑树版本的put,将其插入树中,即TreeNode.putTreeVal
else if (p instanceof HashMap.TreeNode)
e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 否则桶是链表,遍历链表,如果key不在链表中则插入链表,插完之后如果达到treeify阈值则treeify
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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果key在put之前就存在,则在此覆盖旧值,并返回旧值
// 注意此时不算是结构性修改,因为只是修改了节点本身的属性,没有改变节点之间的结构
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// LinkedHashMap的钩子函数,在此可以先忽略
afterNodeAccess(e);
return oldValue;
}
}
// 否则是新添加的key,为结构性修改
++modCount;
// 扩容
if (++size > threshold)
resize();
// LinkedHashMap的钩子函数,在此可以先忽略
afterNodeInsertion(evict);
return null;
}
put看着是长了点,不过整个流程也比较直观,不懂的话看注释一步步分析即可。
作为容器,增删改查还有删除没分析(put已经包含了增和改),下面来看看:
// 删除key对应的键值对
public V remove(Object key) {
HashMap.Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
// remove的具体实现
final HashMap.Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, index;
// 如果桶不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
HashMap.Node<K,V> node = null, e; K k; V v;
// 如果桶第一个节点的key就是要remove的key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
// 否则,根据桶是红黑树还是链表分别处理
else if ((e = p.next) != null) {
// 桶是红黑树,直接用getTreeNode查找要被删除的节点
if (p instanceof HashMap.TreeNode)
node = ((HashMap.TreeNode<K,V>)p).getTreeNode(hash, key);
// 否则桶是链表,遍历链表查找要被删除的节点
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);
}
}
// 如果找到了要删除的节点
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 桶是红黑树,使用removeTreeNode从树中删除节点
if (node instanceof HashMap.TreeNode)
((HashMap.TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
// 否则是链表,并且如果是第一个节点,手动将桶的第一个节点修改为该节点的下一个节点
else if (node == p)
tab[index] = node.next;
// 否则不是第一个节点,此时p保存着被删除节点的前驱节点,执行链表经典删除操作,将前驱的next指向node的next即可
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
put如果是新增键值对的话,可能还会涉及到扩容,下面看一下扩容方法resize。而且这个方法不光需要扩容,因为哈希表长度变了,还需要rehash元素到新的哈希表,具体实现是遍历所有桶,并将元素逐个rehash到新桶:
final HashMap.Node<K,V>[] resize() {
HashMap.Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
/********** 设置newCap和newThr **********/
// 如果oldCap大于0
if (oldCap > 0) {
// 如果oldCap已经达到最大容量,无法再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 如果oldCap大于默认容量并且oldCap*2不超过最大容量,newCap设为oldCap*2,newThr设为oldThr*2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
// 否则oldCap为0,如果oldThr大于0,则将newCap设为oldThr
else if (oldThr > 0)
newCap = oldThr;
// 否则oldCap和oldThr都为0,则newCap和newThr都设为默认值
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 根据newCap统一设置newThr
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
/********** 将oldTab元素rehash(重新哈希)到newTab **********/
HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
HashMap.Node<K,V> e;
// 如果桶不为空
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 如果桶只有一个节点
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果桶是红黑树,调用split将该树的的元素rehash
else if (e instanceof HashMap.TreeNode)
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 否则桶是链表,手动将其rehash,过程如下
else {
// 由于扩容一倍,那么元素要么rehash到原来的下标,要么rehash到原来的下标+oldCap(最高位拓展了一位,要么是0要么是1)
// lo是在旧桶下标的桶,hi是旧桶下标+oldCap的桶,head为桶的第一个元素,tail为最后一个元素
HashMap.Node<K,V> loHead = null, loTail = null;
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
do {
next = e.next;
// 如果rehash到lo
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 如果rehash到hi
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;
}
resize看着长其实也不难,主要在理解rehash这个过程,为什么要rehash,首先key的哈希值Node.hash肯定是不变的,然后通过Node.hash % table.length来得到该Node在哈希表中存储的位置,哈希表长度为2的次方,可以使用更高效率的位运算:Node.hash & (table.length - 1)。由于扩容,table.length发生了变化,那么Node映射到新表的下标肯定也要重新计算,因此Node所在桶的下标可能会发生变化,造成元素重新分布,即rehash。
作为扩展,笔者之前看过一下golang的map的扩容机制,大致思想就是,golang在rehash机制上采用了更加保守的渐进式扩容,所谓“渐进式”也就是不同于HashMap.resize的一次性把oldTab的元素rehash到newTab,而是保留了oldTab,每次查找key的时候先从oldTab找,找到的话把它rehash到newTab,直到oldTab.length为0,此时就可以释放oldTab,完全用newTab来完成将来的查找。
下面再来看看treeify,即当链表桶的节点超过一定阈值的时候,将其“树化”成红黑树,提高将来的查找效率:
final void treeifyBin(HashMap.Node<K,V>[] tab, int hash) {
int n, index; HashMap.Node<K,V> e;
// 如果表容量小的情况下,可以考虑resize,效率可能比树化更好
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 进行树化
else if ((e = tab[index = (n - 1) & hash]) != null) {
HashMap.TreeNode<K,V> hd = null, tl = null;
do {
// 将Node转换成TreeNode
HashMap.TreeNode<K,V> p = replacementTreeNode(e, null);
// 将单链表重新链成双链表
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
// 调用TreeNode.treeify真正将其树化
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
TODO:HashMap原理相关的代码基本就是这些,剩下的以后再看(暂时不想看了
参考链接
「CSDN」Map - HashSet & HashMap 源码解析
「Java全栈知识体系」Map - HashSet & HashMap 源码解析
标签:Node,hash,HashMap,HashSet,哈希,源码,key,null From: https://www.cnblogs.com/nosae/p/17969984