1, 存储结构
HashMap中的数据结构是数组+单链表+红黑树的组合, 它存储的内容是键值对(key-value)映射;
在jdk1.8的更新里,HashMap底层数据结构里引入了红黑树,来解决在链表过长时查询效率的问题,后面会有详细说明。
我们先分析下HashMap的数组和链表:
数组: 存储区间是连续的,占用内存严重,空间复杂度大。但数组的二分查找时间复杂度小,为O(1);
链表:链表存储区间离散,占用内存比较宽松,空间复杂度很小,但时间复杂度很大,为O(N);
由此得出结论
数组特点:查询快,增删慢;
链表特点:查询慢,增删快;
那么有没有既寻址快又增删快的数据结构呢?
HashMap此时作为解决方案出现了。它基于数组和链表来实现的,采用 Hash 算法来决定集合中元素的存储位置。
当系统开始初始化 HashMap 时,会创建一个长度为 capacity 的 Entry 数组,数组内可存储元素的位置被称为“桶(bucket)”,每个桶(bucket)都有其指定索引,系统可以根据其索引快速访问该桶里存储的元素。
2, 构造函数
HashMap有四个构造函数:
public HashMap()
public HashMap(int initialCapacity)
public HashMap(int initialCapacity, float loadFactor)
public HashMap(Map<? extends K, ? extends V> m)
初始化过程中,有两个重要的参数:容量(Capacity)和负载因子(Load factor)
容量(Capacity)指的是桶(bucket)数量, 默认初始值为16。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
负载因子(Load factor)默认值为0.75,如果哈希表内的元素数到达了容量的75%, 就会执行再哈希(rehashing)。
再哈希(rehashing)会创建新的哈希表,容量翻倍并将将哈希表内的元素导入, 在删除原有哈希表
static final float DEFAULT_LOAD_FACTOR = 0.75f;
无参构造函数只指定了负载因子 loadFactor,常量DEFAULT_LOAD_FACTOR默认为 0.75,初始化容量为默认容量16:
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
指定初始容量的构造函数,内部调用了两个参数的构造函数,loadFactor默认为0.75 ,初始化容量为传入的数值:
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
两参的构造函数,负载因子是直接传入的参数,初始容量经过了tableSizeFor函数的处理
public HashMap(int initialCapacity, float loadFactor) {
//..省略条件约束代码
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
tableSizeFor函数的功能是传入任意数, 都能找到距离它最近的2的次幂。也就是说,通过tableSizeFor函数,HashMap的容量始终都是2的次幂。
至于为什么要2的次幂,后面会详细说明。
3, tableSizeFor函数
先看下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;
}
初看会有些懵,我们忽略第一行代码,模拟运行一下。
假设参数cap为 010010:
对010010 右移1位: 001001 再位或:011011
对011011 右移2位: 000110 再位或:011111
...
对比输入输出:
010010
011111
高位数1后全变为1,这时如果再+1,就会变为2的次幂。
为了便于理解, 我们举一个小例子
1 + 1 = 2 //2的1次幂
11 + 1 = 4 //2的2次幂
111 + 1 = 8 //2的3次幂
1111 + 1 = 16 //2的4次幂
此时已经达到了目的
传入 tableSizeFor 函数任意数, 都能找到距离它最近的2的次幂
回过头来, 看下第一行代码
int n = cap - 1;
如果cap已经是2的次幂,不做-1操作得到的值则是cap的2倍。
例如二进制数 10000,十进制为 8,如果不减1,将得到11111 + 1,即16。减1得到1111+1 = 10000 即 8;
4, 为什么是2的次幂?
由于HashMap的结构原因(数组 + 链表,每个桶都指向一个链表 ), 我们希望的是元素存放的更均匀, 最理想的状态是每个桶内只存放一个元素,这样既不用遍历链表,也不用equals key。
那如何计算才会分布最均匀呢?
我们看下源码(jdk 1.7源码, 在jdk1.8中已经省略, 但作用依旧):
static int indexFor(int h, int length) {
return h & (length-1);
}
2的n次幂转换成二进制后,会呈现第1位数为1其余n位为0的形式,如:
2 -> 10
4 -> 100
8 -> 1000
16 -> 10000
2的n次幂 - 1转换成二进制后,会呈现第1位数为0其余n位为1的形式, 如:
1 -> 01
3 -> 011
7 -> 0111
15 ->01111
如果length不为2的幂,比如15。那么length-1的2进制就会变成1110。在h为随机数的情况下,和1110做&操作尾数永远为0。那么0001、1001、1101等尾数为1的位置就永远不可能被entry占用。这会造成浪费、不随机等问题。
所以,length参数二进制下为1的位数越多,分布就会约平均。在参数length是2的次幂时,是最优解。
5, put(K key, V value)操作
put(K key, V value) 源码:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
先看下 hash(key) 函数对key值的处理:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里做了几件事:
- key == null时, 返回 0
- key 的hashCode赋值该变量h
- h 于 h高16位 进行 异或 运算
此时的 h 就是在indexFor函数中于(length-1)做&运算的 h
为了让hash更加的散列随机, 这里将hashCode的前16位参与运算, 保留了hashCode的前16位的特征。
至于为什么使用^而不是&或|,是因为&或|的运算结果都有偏向0或1,所以最适合的是^运算。
总结一下hash(Object key)函数的作用:
- 使hashCode更加随机
- 使hashCode所有位都参与运算
继续看putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)函数
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)
// 如果table为空或table的长度为0 则创建一个默认16长度的哈希表
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;
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
// 如果当前节点哈希值相同并且key相同 替换掉key下的value
e = p;
else if (p instanceof TreeNode)
// 如果key不相同,是红黑树类型的节点, 创建红黑树类型的节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 不是红黑树节点, 此时就是链表节点
// 遍历该节点
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// p节点的下一个节点为空,说明到达节点尾端
// 创建节点并插入
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
// 当前链表是否超过长度限制, 超过需要树化
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 将p更新为下一个节点
p = e;
}
}
if (e != null) {
// 如果e不为空, 说明链表某个节点和我们要添加的节点的key和hash都相同
// 需要进行替换操作
// 取出的value值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
// 要替换的值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 增加数量
++modCount;
if (++size > threshold)
// 如果当前元素的数量大于总容量, 则扩容
resize();
afterNodeInsertion(evict);
return null;
}
putVal函数比较复杂, 关键点已经添加了注释, 简单归纳:
由于掘金不支持格式,暂使用图片
这里涉及了两个重要的概念,扩容resize()和树化treeifyBin(tab, hash)
6,扩容 resize()
1, 扩容什么时候触发?
回顾一下putVal() 函数:
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 (++size > threshold)
resize();
}
resize()函数在这里出现了两次:
- tab数组为空时,使用resize()构建数组
- size大于threshold时,使用resize()扩容。其中threshold等于 table.length(数组长度) * loadFactor(负载因子);
当元素数量超过阈值时,便会触发扩容操作,每次扩容是扩容前的两倍大小。
2, 扩容的实现过程?
源码过长,暂无法粘贴,这里只做简单归纳:
由于掘金不支持格式,暂使用图片
7,树化 treeifyBin(tab, hash)
先看具体源码:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize(); // hash表不为空或小于 64长度
else if ((e = tab[index = (n - 1) & hash]) != null) {
//hd 首节点 tl尾节点
TreeNode<K,V> hd = null, tl = null;
do {
//将链表节点转换为树节点
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); // 轮询链表
//到这里仅仅将单项链表处理成双向链表
if ((tab[index] = hd) != null)
hd.treeify(tab); // 转换树的方法
}
}
该函数在进行条件判断后,将链表转换成了双向链表,而具体的把双向链表树化的实现在TreeNode类的treeify函数中。
阅读到这里,已经进行问题的回答了,再深入就是具体的链表转红黑树的操作。
1,什么是树化?
将链表结构转换为红黑树结构,这个过程称之为树化。
2,为什么要树化?
HashMap里key的冲突越多,链表的长度就会越长,这会严重影响到map的查询性能。
链表的查询时间复杂度是O(n),转换为树后,时间复杂度降低到了log(n)。这样就解决了链表过程带来的查询性能问题。
而付出的代价,是相较于链表两倍的空间占用,属于典型的用空间换时间。
3,树化有什么条件?
具体到源码细节
执行树化函数treeifyBin()的前置条件,在 putVal()函数里:binCount >= TREEIFY_THRESHOLD - 1要求链表的实际长度大于8。
treeifyBin()函数内部,同样做了条件判断: if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) 条件下会执行resize()操作, 而不会进行树化,相反且tab不等于null的情况则进行树化,也就是数组长度大于等于 MIN_TREEIFY_CAPACITY 即64的情况。
树化条件总结:
- 链表节点大于8
- 数组长度大于64
4,为什么会有条件限制?
找到条件不是目的,我们来分析下原因。
假设链表节点数为8,O(n)平均查询长度为4,log(n)的平均查询长度为3,这里得出结论,当数量大于8时,树的优势会更加明显。
对于过小的数组,链表节点数已经到达需要树化的标准,说明hash碰撞已经很严重了,此时树化并不能直接解决问题。扩容重新计算hash分布,使链表长度变短才是解决问题的关键。
8,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;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
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;
}
代码比较简单,归纳一下:
由于掘金不支持格式,暂使用图片