文章目录
一、数据结构
HashMap是高效的查询容器,底层的数据结构是数组 + 链表 + 红黑树(java8)。查询可以计算hash结果基于数组下标快速访问,链表用来解决hash冲突的问题,红黑树用于解决频繁的hash冲突导致查询效率低的问题。
数据结构
- 数组:基于下标随机访问,保证了查询速度。
- 链表:高效的插入和删除,但在hashMap中主要是为了解决Hash冲突。
- 红黑树:logn(n指的是链上的数量)级别的查询效率,解决频繁的hash冲突导致链表过长,查询效率低下的问题。
HashMap 1.7 导致的Tomcat的DoS问题
http://mail-archives.apache.org/mod_mbox/www-announce/201112.mbox/%3C4EFB9800.5010106@apache.org%3E
- 来自Tomcat邮件组的讨论。Tomcat参数是用HashMap存储的,故如果参数有5W个,并且有心人构造了hash冲突比较严重的参数,此时链表的长度很长,查询参数就占用了CPU很多资源,就可能出现Dos问题(DoS时CPU100%)。
5W长度的链表遍历1300多毫秒,5W长度的数组遍历3毫秒。
HashMap 1.8 链表树化为什么是红黑树
AVL平衡二叉查找树,左右子树高度差值不能大于1. 大于1需要通过旋转回溯,使得二叉查找树平衡。为了维护这样一颗平衡二叉查找树旋转次数很多,插入小号比较大。
红黑树
通过红黑节点性质来维护树的结构,通过节点的颜色变换,简单的旋转赋值保证树不会出现查找树最坏情况(链表),shu相对平衡。
max <= 2*min
红黑树的最长路径不会超过最短路径的两倍,此时全黑10 + 黑10红9.不会超过两倍。
HashMap的缺点
在HashMap中数组的空间的利用率较低,空间利用率和负载因子有关,默认的负载因子是0.75,实际空间利用率要低于0.75。我们都知道数组是一段连续的存储空间,数据量越大浪费的空间就越多。
二、哈希寻址
寻址流程
- 根据key的hashCode,调用hash算法求hash值。
- 根据返回的hash值与表长减一的值进行“与”运算。(hash & (n-1))和(hash % n)效果是一样的,但前提是n为2的n次方,2的N次方的值位上都是1,与操作后就是取模值。
- 把取模的结果当作目标下标进行相应的操作。
hash算法
hash算法的目的是为了让散列的数据更加分散,防止大链表或大树的产生。
// 源码的方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 拆解的hash方法
static final int hashRedev(Object key) {
if(key == null) {
return 0;
}
// 原始Hash值
int h = key.hashCode();
// 高16位移至低16位
int temp = h >>> 16;
// 做异或混淆操作
int newHash = h ^ temp;
return newHash;
}
// key 为空返回0,不为空执行高低位特征混淆求Hash值。目的是为了让Hash的结果更分散。
算法描述
key的哈希值异或哈希值的高16位,因为我们的Map实际上并没有存特别巨大数量的值,可以理解为低16位和高16位做异或操作。
算法目的
正常我们都会重写key的HashCode方法,尽可能的保证key的哈希值是均匀分布的以减少哈希冲突。但是在HashMap中,寻址是基于hash结果和当前的tableSize - 1进行与运算,得到目标位置。tableSize为2的幂次,tableSize - 1的结果为连续的0和连续的1,一般我们使用HashMap大小不会很大,低位的1比较少,和这样的结果进行与操作低位特征不够随机的话,冲突几率很大。
问题是,无论我们的hash算法结果再怎么均匀,实际上最后依旧只用后 logn 位的特征去进行与运算,碰撞依旧是比较严重。如果hash不均匀恰好计算得到的hash值的低位呈现规律性的重复,Hash碰撞就会极其严重,此时就体现出了“扰动函数”的价值。
为什么异或更加分散?
如上图可知与操作和或操作对位上0和1的概率是不均衡的,而异或是均衡的,这样会使得hash值更分散。
Hash值右移16位,正好是32位的一半,高16位和低16位异或,混合了原始hash值的高低位的特征,以此来加大低位的随机性。
三、HashMap的存取操作
1.链表数据结构
哈希表元素的数据结构
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数据类型。
2.红黑树数据结构
哈希表树结点的数据结构
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;
// ... ...
}
描述
HashMap在1.8之后引入了红黑树的概念,红黑树是二叉搜索树的一种,常规的二叉搜索树只需要满足结点的值大于做孩子的值小于右孩子的值即可。但是红黑树不仅仅是这样,红黑树需要满足以下的性质。
红黑树的性质:(来自算法导论第三版 p174)
- 每个结点或是红色的,或是黑色的。
- 根结点是黑色的。
- 每个叶结点(NIL)是黑色的。
- 如果一个结点是红色的,则它的两个子结点都是黑色的。
- 对每个结点,从该结点到其所有后代叶结点的简单路径上,均包含相同数目的黑色结点。
传统二叉搜索树的缺点
传统的二叉搜索树最坏的情况下树的高度等于结点的数目,以至于查询数据达到了O(n),最坏的查询情况和链表没啥区别。
AVL树
平衡二叉树是一种平衡了树高的结构,使得任意一个结点的左右子树高差值不超过1。可以解决上述的二叉搜索树的缺点,AVL树可以弥补传统的二叉搜索树的缺点,但是它又引来了新的问题,当数据量大了的时候维护AVL树性质的自旋操作会很影响插入性能。
数据结构小结
红黑树的是许多“平衡搜索树”中的一种,它是一种似平衡的状态,它可以保证在最坏的情况下基本动态集合的操作时间复杂度为O(logn)。
3.结点存入流程
put方法会先进行hash,然后调用putVal方法进行实际处理。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
putVal描述
- 如果当前表为空或长度为0,重新构建一个默认容量的容器。
- 如果哈希寻址到的下标的元素为空,创建一个Node放入。
- 非以上情况
i. 如果当前key等于待插入元素的key,hash值也相同,替换原值。
ii. 如果当前结点是treeNode,使用红黑树的putTreeVal方法插入结点。
iii. 否则就遍历当前链表依次比较key和hash,如果相同替换原值,不同就继续遍历,直到下一个结点是空的就插入。 插入会判断当前是否是第八个结点,是的话且表长等于64,链表就树化。 - 返回该key的原值,若没有则返回为空。
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;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 键相同值替换,后面判断替换值
e = p;
else if (p instanceof TreeNode)
// 树结点,使用红黑树的插入方法
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
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;
}
}
// 替换原值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// ... ...
4.取值流程
get方法会先hash然后getNode寻找值
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
getNode描述
- 根据hash与运算求得数组下标位置,并判断是否有值。
- 有值的话,则判断查询的key,是否等于first结点的key,等于则返回。
- 头结点不等于的话,判断是否是红黑树的结点,是红黑树的结点就走红黑树的查询逻辑。
- 不是红黑树的结点就遍历单链表查询。
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 &&
// 找hash值对应到数组中的元素下标,判断下标位置是否为空
(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) {
// 判断是否是树结点
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;
}
5.put和putIfAbsent()
put方法onlyIfAbsent传的是false
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)
putIfAbsent方法onlyIfAbsent传的是true
public V putIfAbsent(K key, V value) {
return putVal(hash(key), key, value, true, true);
}
putVal的onlyIfAbsent参数是干嘛的
// 键相同替换原值的逻辑
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 如果onlyIfAbsent是false则是替换原值的
// onlyIfAbsent是true则是不会替换原值的
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
onlyIfAbsent参数为false,则新值会替换原值并且返回原值。
onlyIfAbsent参数为true,则原值不变并且返回原值。
测试代码
public static void main(String[] args) {
Map<String,Object> map = new HashMap<>();
map.put("haha",123);
System.out.println(map.put("haha",456));
System.out.println(map.putIfAbsent("haha",789));
}
执行结果
123
456
Process finished with exit code 0
6.澄清HashMap链表的树化条件
条件1:当前待插入的结点是第8个结点。
条件1:putVal方法
当前count值要大于等于 TREEIFY_THRESHOLD(8) - 1,计数器从0开始计数,到7刚好是8个结点。所以和网上的一些博客描述的都一样,链表结点插入后,链表结点满足8个就要进行树化,但树化并非仅仅是这一个条件。
else {
// 遍历单链表,判断相同,直到找到空值
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 待插入结点已插入
p.next = newNode(hash, key, value, null);
// 树化条件1: 当前count值大于等于 8 - 1,当前待插入结点如果是第八个结点就树化。
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
条件2:表的长度大于或等于64
treeifyBin(tab, hash)方法
表的长度小于MIN_TREEIFY_CAPACITY(64),就不进行树化操作,resize扩容即可。反之,表的长度大于或等于64,才可以进行链表树化的操作。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 表长度小于 MIN_TREEIFY_CAPACITY 64,此时扩容即可不需要进行树化操作。
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 表长大于等于64,才需要进行树化操作
else if ((e = tab[index = (n - 1) & hash]) != null) {
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);
}
}
7.为什么要重写hashCode和equals方法?
equals方法是比较两个对象是否相同的方法,Object基础实现是比较hashCode,也就是对象的地址。
我们自定义类型如果想当作HashMap的键是需要重写equals方法的,否则两个对象的属性值相同,但是却不是同一个对象,地址不相同导致最终结果不相等。如果作为键的对象没有重写equals,这肯定是有问题的。
hashCode方法,是唯一标识一个对象的方法,Object默认实现时返回对象的地址。
HashCode重写时需要注意以下几点:
- hashCode方法中不能包含equals方法中没有的字段。
- String和其它包装类型已有的hashCode可以直接调用。
- hash = 31 * hash + (field != null ? field.hashCode : 0);可以应用于hashCode方法当中。因为任何数 n * 31都可以被JVM优化为 (n<<5)-n 这个表达式,移位和减法要比其它的操作快速的多。
《Effective Java》中提出了一种简单通用的hashCode算法:
- 初始化一个整型的变量,并为此变量富裕一个非零的常数值,如 int code = 13;
- 如果对象中有String或其它包装类型,则递归调用该属性的hashCode,如果属性为空则处理为0。
案例
@Override
public int hashCode() {
int hash = 13;
hash = hash * 31 + (name != null ? name.hashCode() : 0);
hash = hash * 31 + (location != null ? location.hashCode() : 0);
return hash;
}
四、HashMap的扩容操作
1. 扩容操作概述
HashMap 的扩容在 put 操作中会触发扩容,主要是这个方法:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
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;
}
综合来说,HashMap一次扩容的过程:
1、取当前table的2倍作为新table的大小。
2、根据算出的新table的大小new出一个新的Entry数组来,名为newTab。
3、HashMap中的table指向newTable。
3、轮询原table的每一个位置,将每个位置上连接的Entry,算出在新table上的位置,并以链表形式连接。、原table上的所有Entry全部轮询完毕之后,意味着原table上面的所有Entry已经移到了新的table上。
2. 扩容例子
现在 hashmap 中有三个元素,Hash 表的 size=4, 所以hash = 1, 5, 9,在mod 4以后都冲突在 table[1]这里了。
按照上述resize方法中的代码对上图的Hash表进行扩容,从数组0位置开始遍历,遍历到下标1时,数组元素不为空,且数组元素不是单独的一个而是一个链表,此时会将链表拆分成两个链。那么这两个链,loHead的链放在原来下标为1的位置, hiHead的链放在下标为5(1 + 4)的位置。
决定链表元素放在哪个链的条件是 (e.hash & oldCap) == 0,如果它为 true 则放在loHead里面,如果它为 false 则放在hiHead里面。因为oldCap是一个2的n次方的值,如果在元素的hash值的32位中,从右到左第n+1位置上的状态是1,则其位置一定在新扩容的区域内。
这样循环下去,将 table[1]中的链表循环完成后,于是HashMap就完成了扩容。
3. 扩容时数组的定位
如图可知,扩容前的Hash值[A - F]都在同一个链表上,扩容后则链表就会拆开[ABC]在4位置,[DEF]在20位置。扩容后原来的链表元素仅可能出现在两个位置,如果链表元素原来在 x 位置上,那么扩容后的两个点位就是 x 和 x + tableSize/2的位置上。
原来是16,现在变成32了,原来的hash函数散列的值范围就增加了1个位置。原来数组长度为16的时候只需要关注 1111 这低四个1的位置。现在变成32了就要照顾到 11111 五个1的位置。 故上图原来都在一个链表上,那么扩容就是4和20两个位置,多出来的那个1就是16嘛。
五、HashMap带着问题出发
1. 为什么容量大小必须是2的次方?
// 默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
HashMap中多处用到了位运算,在特定场景下,2的幂次可以提高计算效率。
2. HashMap和HashTable有哪些不同?
- 初始容量:HashMap是16,HashTable是11。为什么会出现这个问题?
HashMap为了效率违背了算法导论的推荐,所以是有弊端的,HashMap为了弥补这个弊端,就重写了hash算法,加入了高位特征的扰动函数。使得h(k)结果足够分散。
-
HashTable是线程安全的,HashMap是线程不安全的。HashTable在读写方法前使用了synchronized,保证该HashTable同时只能被一个线程使用,故它是线程安全的。HashMap就没有这些安全机制,多线程环境下使用是有问题的。
-
HashTable 没有树化的操作,就仅仅是数组加链表。 HashMap由于被到处引用,为了避免Hash冲突导致链表过长的问题,就引入了红黑树树化操作。
3. 扩容因子为什么是0.75?
// 元素的填充比例,负载因子,扩容因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
如果是1,那么极大概率会有Hash冲突。
如果是0.5,那么会很浪费空间。
故0.75,是一个空间与时间的一个权衡。
3. 树化条件为什么是8?
static final int TREEIFY_THRESHOLD = 8;
上图是JDK1.8HashMap源码中提供的泊松分布的注释,泊松分布链表中出现8个元素的概率是极低的,所以出现红黑树概率很低。并且树化条件并不只是链表长度为8,数组长度也要是64及以上才行。
五、HashMap小结
HashMap在1.8有了一些变化,多是查找相关的优化。这种数据结构在日常开发过程中太常用了,原理是一定要摸清楚的。懂得HashMap原理并没有让我在开发技能上有显著的提高,但是在数据结构转换使用上扩展了自身的一些想法。