HashMap源码分析
Posted Icedzzz
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap源码分析相关的知识,希望对你有一定的参考价值。
文章目录
0 Hash算法
Hash算法,是把任意长度的输入通过特定的算法变换成固定长度的输出,输出的值就是hash值。这个特定的算法就叫hash算法
常见的Hash算法
- 直接定址法:直接以关键字k或者k加上某个常数(k+c)作为哈希地址。
- 数字分析法:提取关键字中取值比较均匀的数字作为哈希地址。
- 除留余数法:用关键字k除以某个不大于哈希表长度m的数p,将所得余数作为哈希表地址。
- 分段叠加法:按照哈希表地址位数将关键字分成位数相等的几部分,其中最后一部分可以比较短。然后将这几部分相加,舍弃最高进位后的结果就是该关键字的哈希地址。
- 平方取中法:如果关键字各个部分分布都不均匀的话,可以先求出它的平方值,然后按照需求取中间的几位作为哈希地址。
- 伪随机数法:采用一个伪随机数当作哈希函数
优秀的Hash算法需要满足哪些特点:
- 快速性,效率高
- 不可逆性
- 敏感性
- 低碰撞性
常见的Hash算法
MD4,MD5,SHA
Java中的Hash算法
- HashMap,侧重点是计算速度
- Object.hashCode,直接获取内存地址
- Integer.hashCode,直接返回int值
- String.hashCode,根据字符串内容生成hashCode,字符串内容一样则hashCode一样
解决碰撞算法:
- **开放定址法:**一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
- **链地址法:**将哈希表的每个单元作为链表的头结点,所有哈希地址为i的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部(HashMap)
- 再哈希法: 当哈希地址发生冲突用其他的函数计算另一个哈希函数地址,直到冲突不再产生为止。
- **建立公共溢出区:**将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。
其他常用的Hash算法
MD4,MD5,SHA
1 HashMap JDK1.7
JDK1.8 之前的HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突。
Hash冲突:两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同
当初始化HashMap时,会自动创建应该默认大小为DEFAULT_INITIAL_CAPACITY=16的数组,如果插入数据超过DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR时,会对这个容器自动扩容,且扩容后数组大小必须为2的N次方。
接着往这个数组PUT数据,通过对Key数据计算其HashCode,计算数组下标,插入数据(头插法)。
JDK1.7中HashMap计算下标方式:
static int indexFor(int h, int length)
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
**h & (length-1)**的含义是将hashcode的值与数组长度进行与操作,即将h的高位结果均至0,保证低位与结果在数组长度范围内。
如:h:0101 0101 length:0000 1111 => return 0000 0101
问题:为什么HashMap扩容大小必须为2的N次方?
如果此时长度不为2的N次方,h与17-1(0001 0000)进行与操作,结果为0001 0000,则不一定在数组范围内,因此需要数组长度为2的N次方
JDK1.7中,HashMap通过链地址法,解决在PUT的时候发生Hash冲突问题,对应存储图如下,当新节点下标与当前节点相同时,将新节点插在链表的头部,此时新节点就是当前这个链表的头节点,接下来把头节点移动到数组位置即可。如果put时候,发现新节点的key值已经存在,则在遍历的过程中如果找到了相同的key则会进行value的覆盖,并且返回oldvalue。
JDK1.7中PUT方法:
public V put(K key, V value)
if (table == EMPTY_TABLE)
inflateTable(threshold);
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) // 先遍历
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
modCount++;
addEntry(hash, key, value, i); // 再插入
return null;
2 HashMap JDK1.8
1.与1.7中的区别:
- 数据结构改为数组+链表+红黑树,当链表长度大于阈值时,将链表转化成红黑树,以减少搜索时间
- 新节点插入链表的顺序不同(JDK1.7是插入头结点,JDK1.8因为要遍历链表把链表变为红黑树,所以采用插入尾节点)
- Hash算法的简化
- resize的逻辑修改,避免了JDK1.7中会出现死循环(死锁)的问题
2.JDK8中为什么要使用红黑树?
因为JDK7中是用数组+链表来作为底层的数据结构的,但是如果数据量较多,或者hash算法的散列性不够,可能导致链表上的数据太多,导致链表过长,考虑一种极端情况:如果hash算法很差,所有的元素都在同一个链表上。那么在查询数据的时候的时间复杂度和链表查询的时间复杂度差不多是一样的,我们知道链表的一个优点是插入快,但是查询慢,所以如果HashMap中出现了很长的链表结构会影响整个HashMap的查询效率,我们使用HashMap时插入和查询的效率是都要具备的,而红黑树的插入和查询效率处于完全平衡二叉树和链表之间,所以使用红黑树是比较合适的。
3.HashMap的一些属性:
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的填充因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table;
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
int threshold;
// 填充因子
final float loadFactor;
4.JDK1.8中HashMap的Hash算法:
先将HashCode右移16位,将高半区和低半区做异或。这样做是为了混合原始哈希码的高位和低位,混合后的低位同时包含了高位和低位的特征信息,以此加大低位的随机性。
static final int hash(Object key)
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
然后与数组长度-1进行与运算,使高位全部至0,保证所得数字在数组长度范围内。
相比于JDK1.7的HashMap的hash方法,JDK1.8原理一致,但JDK1.7的性能会差一点,发生了四次扰动。
JDK1.7HashMap的Hash算法:
static int hash(int h)
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
5.JDK1.8中HashMap的put方法:
put方法插入一个元素的步骤:
- 如果定位到的数组位置没有元素 就直接插入。
- 如果定位到的数组位置有元素就和要插入的key比较,如果key相同就直接覆盖;
- 如果key不相同,就判断p是否是一个树节点,如果是就调用e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)将元素添加进入;
- 如果不是就遍历链表插入。
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)
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素
else
Node<K,V> e; K k;
// 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给e,用e来记录
e = p;
// hash值不相等,即key不相等;为红黑树结点
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;
// 判断链表中结点的key值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
// 表示在桶中找到key值、hash值与插入元素相等的结点
if (e != null)
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
// 结构性修改
++modCount;
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
6.JDK1.8中HashMap的resize方法:
当Table中的元素个数大于阈值时,会调用resize方法 进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。
resize的过程:
1 如果table == null, 则为HashMap的初始化, 生成空table返回即可;
2 如果table不为空, 需要重新计算table的长度, newLength = oldLength << 1(注, 如果原oldLength已经到了上限, 则newLength = oldLength);
3 遍历oldTable:
- 首节点为空, 本次循环结束;
- 无后续节点, 重新计算hash位, 本次循环结束;
- 当前是红黑树, 走红黑树的重定位;
- 当前是链表, JDK7时还需要重新计算hash位, 但是JDK8做了优化, 通过**(e.hash & oldCap) == 0来判断是否需要移位; 如果为真则在原位不动, 否则则需要移动到当前hash槽位 + oldCap的位置;**
e.hash & oldCap用来判断在oldCap的二进制对应位上是否为1,如果为1就加上oldCap,结果于e.hash&newCap结果一致,加快运算速度。
参考
例:
e.hash : 0000 0000 1111 0010
oldCap16: 0000 0000 0001 0000
oldCap-1: 0000 0000 0000 1111
e.hash & oldCap: 0000 0000 0001 0000
old Index:0000 0000 0000 0010
需要移位
e.hash : 0000 0000 1111 0010
newCap32: 0000 0000 0010 0000
newCap-1: 0000 0000 0000 1111
new Index:0000 0000 0001 0010
=old Index+oldCap
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;
// 没超过最大值,就扩充为原来的2倍
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
signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
// 计算新的resize上限
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)
// 把每个bucket都移动到新的buckets中
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
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;
// 原索引+oldCap
else
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null)
loTail.next = null;
newTab[j] = loHead;
// 原索引+oldCap放到bucket里
if (hiTail != null)
hiTail.next = null;
newTab[j + oldCap] = hiHead;
return newTab;
JDK1.8中对resize的修改可以避免JDK1.7中Hashmax在多线程resize下发生死锁,生成环形链表:
原因:
- 1.7中的HashMap采用的是头插法
- 1.7中的HashMap是对链表的结点不断循环进行移位,而不是直接将结点reHash后插入链表
JDK1.7中对结点进行重定位的代码:
void transfer(Entry[] newTable, boolean rehash)
int newCapacity = newTable.length;
for (Entry<K,V> e : table)
while(null != e)
Entry<K,V> next = e.next;
if (rehash)
e.hash = null == e.key ? 0 : hash(e.key);
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
具体分析可以看下面这个视频分析
分析:https://www.yuque.com/renyong-jmovm/kb/syatz1
7.为什么重写Equals方法时,要重写HashCode方法,跟HashMap有什么关系吗?
跟HashMap有关系,或者说因为HashMap中用到了对象的hashcode方法所以会有关系,因为我们如果在设计两个对象相等的逻辑时,如果只重写Equals方法,那么一个类有两个对象A1, A2,他们的A1.equals(A2)为true,A1.hashcode和A2.hashcode不一样,当将A1和A2都作为HashMap的key时, HashMap会认为它两不相等,因为HashMap在判断key值相不相等时会判断key的hashcode是不是一样, hashcode一样相等,所以在这种场景下会出现我们认为这两个对象相等,但是hashmap不这么认为,所以会有问题。
8. HashMap是线程安全的吗?
HashMap不是线程安全的, ConcurrentModificationException这个异常通常会出现在多线程环境中,比如两个线程共享一个hashmap,一个线程在遍历,一个线程在删除,那么就有可能出现ConcurrentModificationException异常,假设如果不出现这个异常,那么则可能出现并发问题,可能遍历的线程发现hashmap存的元素少了, HashMap为了防止这种情况出现,所以直接会抛出。ConcurrentModificationException异常,这是Fast-Fail机制,让错误尽快出现,不让用户继续“错下去”。
参考:https://github.com/wangzhiwubigdata/God-Of-BigData/blob/master/Java%E9%AB%98%E7%BA%A7%E7%89%B9%E6%80%A7%E5%A2%9E%E5%BC%BA/%E5%A4%A7%E6%95%B0%E6%8D%AE%E6%88%90%E7%A5%9E%E4%B9%8B%E8%B7%AF-Java%E9%AB%98%E7%BA%A7%E7%89%B9%E6%80%A7%E5%A2%9E%E5%BC%BA(HashMap).md
https://www.bilibili.com/video/BV17K4y1E7LD?p=22
https://blog.csdn.net/weixin_39667787/article/details/86678215
以上是关于HashMap源码分析的主要内容,如果未能解决你的问题,请参考以下文章