1.数据结构

1.7

数组+链表,键值对是以Entry内部类的数组存放的。键计算得到哈希值是该数组的下标。又称桶数组当存在哈希冲突时,会通过Entry类内部的成员变量 Entry<k,v> next; 形成一个链表,哈希值相同的元素会以头插法添加到链表中,即拉链法。

1.7有两个主要弊端

  • 头插法在多并发情况下,扩容使会导致两个线程中出现元素的互相指向而形成循环链表,在执行 get() 时会触发死循环而消耗CPU资源
  • 链表的搜索时间复杂度时O(n),不太好。

1.8

  • 数组+链表+红黑树,具体是当链表节点数的大于8(树化阈值),且数组的长度大于最小树化容量64时,链表就会树化成红黑树,将查询的时间复杂度维持在O(logN)级别;
  • 采用尾插法,避免了并发扩容后面get产生的死循环;
  • 使用继承了Entry的Node来作为键值对的存储内部类

2.HashMap(1.8)详解

2.1 继承/实现

public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {}

2.2 静态变量

/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
  • 默认桶数组初始容量:16(1 << 4,必须是 \(2^{n}\) ) ,最大 \(2^{30}\)
static final float DEFAULT_LOAD_FACTOR = 0.75f; //扩容加载因子
static final int TREEIFY_THRESHOLD = 8; //树化阈值
static final int UNTREEIFY_THRESHOLD = 6; //链表化阈值
static final int MIN_TREEIFY_CAPACITY = 64; //树化容量阈值

2.2 数据存储结构

static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}
transient Node<K,V>[] table;

内部静态类 Node 继承了 Entry,保存了键值对,hash值,下一个节点的指针。然后组成了 table数组。

2.3 哈希值计算

static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 位与运算
i = (n-1) & hash // 更快的取余

HashMap 可以存储Null,强制将其放在索引为0的桶内。当key不是null时,首先正常调用 hashCode 计算得到int类型(4个字节,32位),然后将 位右移运算 >> 高位32移到低位16,再和自己做 ^ 位异或运算,得到 hash码,再和 (n-1) 做 位与 运算得到最终的 数组下标位置。

& 与运算:两个位都为1时,结果才为1;

^ 或运算:两个位相同为0,相异为1;

上述处理被称为 扰动函数,目的是使得计算的哈希值更加均匀,增加随机性,降低哈希冲突的概率。具体解释:如果直接用32位的 hashcode来与 15=16-1 做位于运算,那么Hash值在高位就全部被舍弃了,全部为0,哈希冲突就会很严重。但是如上述 扰动函数处理后,通过 位右移,将32位高位半区和低位半区做异或,混合后低位掺杂了高位的差异特征,相当于保留了高位的差异信息,由此增加了低位的随机性。使得后面计算得到 i下标更加均匀。用另一句话来总结就是

因为有些数据计算出的哈希值差异主要在高位,而HashMap里的哈希寻址时忽略容量以上高位,扰动处理就可以有效避免类似情况下的哈希碰撞

这里还隐藏一个问题,那就是为什么n必须是 2的幂,因为 \(2^{n}-1\) 用二进制表示位数从第一个非空的位数往后都是1,没有0位,做与运算每一位就有两种可能,如果是其他的数据,那么就会有很多0位,与运算后只有一种可能,大大限制了索引i的范围。因此目的还是让桶数组中的桶分布的更加均匀一些。

2.4 初始化-构造函数

public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}

如果没有指定初始容量和加载因子,默认是加载因子 0.75,容量=16,此时并没有对 存储容器table的初始化,只有第一次 put 值的时候才会,因此是 Lazy_load,这里对table的初始化使用的是 resize() 方法初始化长16的数组。如果指定了 容量,那么最终实际的容量会调用 tableSizefor() 将容量设置位大于设置值最小的 \(2^{n}\)。

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;
}

2.5 Resize 扩容

Resize兼初始化和扩容两个功能(本质上是一个,初始化也是扩容,默认的话是从0扩容到16)。主要参数是加载因子,如果当前桶数组元素已经超过 capacity * loadFactor ,就会扩容两倍

扩容之后,将原数组的元素计算其新的 索引并放入,新的索引可能是原来的索引 oldIndex ,也可能是 oldIndex+ oldCapacity(原数组容量),这里可以参考 赵小发的解释

进入到第 39 行代码,也是这篇文章最重要的链表的 rehash 方法。在分析之前,我们来思考一下,为什么原先在 index 为 2 位置上的元素要么停留在原位,要么跑到 index 为 18 的位置上呢?我们来举个例子:

假设张三的 hash 的后 5 位是 00010(在这种情况下,只有后 5 位比较重要,如果不知道为什么的同学先看看我之前的 hash 优化算法的那篇文章)。

0 0010(张三的hash)

0 1111(n - 1 = 16 -1 = 15)

0 0010

这是在扩容前(初始容量16),张三算出来的 index 是 2 。如果此时扩容,新容量为 32 ,那么 rehash 的结果为

0 0010(张三的hash)

1 1111(n - 1 = 32 -1 = 31)

0 0010

rehash 的结果,index 还是 2,位置不变。

那如果张三的 hash 的后 5 位是 10010 呢?

1 0010(张三的hash)

0 1111(n - 1 = 16 -1 = 15)

0 0010

即 10010 & 15 = 2 ,那此时扩容 rehash 后呢?

1 0010 (张三的hash)

1 1111(n - 1 = 16 -1 = 15)

1 0010

1 0010 的十进制是 18 。我们思考一个问题,18 有什么特殊的含义呢?

18 = 2 + 16 = 2 + oldCap 。我们找到了一个规律,在 rehash 之后,要么停留在原位置,要么移到原 index + 原数组长度的位置上。其实很容易理解,初始化和 (16 - 1)做与运算时,只有低 4 位的 hash 是有意义的,但是扩容的时候,和 (32 -1 )做与运算时,低 5 位也参与了运算,所以低 5 位的值决定了 rehash 后新的 index 的值。如果低 5 位为 0 ,index 值不变,如果低 5 位为1,则 index 改变,并且在原先基础上加了 2 的 4 次方,即 16 。

依次类推,如果从 32 扩容到 64 ,则低 6 位决定了 index 是否改变,如果改变,在原先基础上加 2 的 5 次方,即 32 。

综上所述:在 resize 方法中,数组 rehash 时,要么停留在原位,要么移到 oldIndex + oldCap 的位置上。

2.7 Put方法

  1. 检查是否初始化,若未初始化,则先调用 resize() 初始化;
  2. 对key求 Hashcode,再计算得到索引;
  3. 如果无哈希冲突,则加入桶中,如果冲突,则尾插入链表中;
  4. 如果链表长度超过树化阈值8,且容量超过64,就将链表转换为红黑树,如果链表长度低于6,则将红黑树转回链表,;
  5. 如果节点的key已经存在,则更新value;
  6. 如果容量没有超过64,容量超过总容量的0.75,就要扩容。

2.8 Get方法

参考jackMan-HashMap之get方法详解

public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
} /**
* Implements Map.get and related methods.
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
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;
}

根据key使用前面 的扰动函数 hash 计算哈希值,然后调用 getNode获取node,如果node为null,返回null,否则返回node.val。

在getNode()中,(n-1)*hash获取索引,即桶位置,然后判断首节点是否为空以及key是否等。如果首节点不是,则使用 遍历链表的用 equals 判断 key是否相等。如果是红黑树,则调用 getTreeNode去搜索。

如果使用对象作为key,最好覆写keyhashcodeequals方法

补充:

虽然HashMap 1.8使用尾插法避免了死循环,但是HashMap线程不安全,并发下应该使用ConcurrentHashMap 或者 HashTable。

如有错误,欢迎讨论指正。

参考

  1. 赵小发的解释
  2. jackMan-HashMap之get方法详解
  3. 阿进写字台-HashMap是如何工作的
  4. Sam哥哥-HashMap的put和get方法原理

最新文章

  1. android ADT 无法查看第三方jar源代码
  2. Atitit wsdl的原理attilax总结
  3. (转)浅析Java中的访问权限控制
  4. 基于Spring Boot/Spring Session/Redis的分布式Session共享解决方案
  5. 从 Microsoft SQL Server 迁移到 Oracle
  6. 大熊君大话NodeJS之------Stream模块
  7. mysql安装及卸载
  8. 深度学习笔记(四)VGG14
  9. codeforces 442B B. Andrey and Problem(贪心)
  10. mybatis中的resultMap
  11. Kafka学习笔记(二):Partition分发策略
  12. Codeforces Round #192 (Div. 2) B. Road Construction
  13. 【译】在Asp.Net中操作PDF - iTextSharp - 使用字体
  14. Python3 的函数(2)
  15. $cordovaCamera 插件 上传头像 图片功能
  16. selenium测试(Java)-- 显式等待(九)
  17. 微信公众号开发C#系列-9、多公众号集中管理
  18. 简单介绍python的双向队列
  19. gcc 的参数 -Wall -O2 -ansi
  20. Spring方法级别的验证

热门文章

  1. 230th Weekly Leetcode Contest
  2. SQL反模式读书笔记思维导图
  3. 深入浅出图神经网络 GCN代码实战
  4. ESP32高分辨率计时器笔记
  5. Linux | Shell脚本的编写
  6. SpringBoot缓存管理(三) 自定义Redis缓存序列化机制
  7. python + flask轻量级框架
  8. C++:数据类型
  9. springMVC-9-异常处理器和拦截器
  10. 微信小程序云开发-数据库表创建和操作