-
对key对象的hashcode进行扰动
-
通过取模求得数组下标
扰动是为了让hashcode的随机性更高,第二步取模就不会让所以的key都聚集在一起,提高散列均匀度。扰动可以看到hash()
方法:
static final int hash(Object key) {
int h;
// 获取到key的hashcode,在高低位异或运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
也就是低16位是和高16位进行异或,高16位保持不变。一般的数组长度都会比较短,取模运算中只有低位参与散列;高位与地位进行异或,让高位也得以参与散列运算,使得散列更加均匀。具体运算如下图(图中为了方便采用8位进行演示,32位同理):
对hashcode扰动之后需要对结果进行取模。HashMap在jdk1.8并不是简单使用%
进行取模,而是采用了另外一种更加高性能的方法。HashMap控制数组长度为2的整数次幂,好处是对hashcode进行求余运算和让hashcode与数组长度-1进行位与运算是相同的效果。如下图:
但位与运算的效率却比求余高得多,从而提升了性能。在扩容运算中也利用到了此特性,后面会讲。取模运算的源码看到putVal()
方法,该方法在put()
方法中被调用:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
...
// 与数组长度-1进行位与运算,得到下标
if ((p = tab[i = (n - 1) & hash]) == null)
...
}
完整的hash计算过程可以参考下图:
上面我们提到HashMap的数组长度为2的整数次幂,那么HashMap是如何控制数组的长度为2的整数次幂的?修改数组长度有两种情况:
-
初始化时指定的长度
-
扩容时的长度增量
先看第一种情况。默认情况下,如未在HashMap构造器中指定长度,则初始长度为16。16是一个较为合适的经验值,他是2的整数次幂,同时太小会频繁触发扩容、太大会浪费空间。如果指定一个非2的整数次幂,会自动转化成大于该指定数的最小2的整数次幂。如指定6则转化为8,指定11则转化为16。结合源码来分析,当我们初始化指定一个非2的整数次幂长度时,HashMap会调用tableSizeFor()
方法:
public HashMap(int initialCapacity, float loadFactor) {
...
this.loadFactor = loadFactor;
// 这里调用了tableSizeFor方法
this.threshold = tableSizeFor(initialCapacity);
}
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;
}
tableSizeFor()
方法的看起来很复杂,作用是使得最高位1后续的所有位都变为1,最后再+1则得到刚好大于initialCapacity的最小2的整数次幂数。如下图(这里使用了8位进行模拟,32位也是同理):
那为什么必须要对cap
进行-1
之后再进行运算呢?如果指定的数刚好是2的整数次幂,如果没有-1结果会变成比他大两倍的数,如下:
00100 --高位1之后全变1--> 00111 --加1---> 01000
第二种改变数组长度的情况是扩容。HashMap每次扩容的大小都是原来的两倍,控制了数组大小一定是2的整数次幂,相关源码如下:
final Node<K,V>[] resize() {
...
if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 设置为原来的两倍
newThr = oldThr << 1;
...
}
小结:
- HashMap通过高16位与低16位进行异或运算来让高位参与散列,提高散列效果;
- HashMap控制数组的长度为2的整数次幂来简化取模运算,提高性能;
- HashMap通过控制初始化的数组长度为2的整数次幂、扩容为原来的2倍来控制数组长度一定为2的整数次幂。
再优秀的hash算法永远无法避免出现hash冲突。hash冲突指的是两个不同的key经过hash计算之后得到的数组下标是相同的。解决hash冲突的方式很多,如开放定址法、再哈希法、公共溢出表法、链地址法。HashMap采用的是链地址法,jdk1.8之后还增加了红黑树的优化,如下图:
出现冲突后会在当前节点形成链表,而当链表过长之后,会自动转化成红黑树提高查找效率。红黑树是一个查找效率很高的数据结构,时间复杂度为O(logN),但红黑树只有在数据量较大时才能发挥它的优势。关于红黑树的转化,HashMap做了以下限制
-
当链表的长度>=8且数组长度>=64时,会把链表转化成红黑树。
-
当链表长度>=8,但数组长度<64时,会优先进行扩容,而不是转化成红黑树。
-
当红黑树节点数<=6,自动转化成链表。
那就有了以下问题:
- 为什么需要数组长度到64才会转化红黑树?
当数组长度较短时,如16,链表长度达到8已经是占用了最大限度的50%,意味着负载已经快要达到上限,此时如果转化成红黑树,之后的扩容又会再一次把红黑树拆分平均到新的数组中,这样非但没有带来性能的好处,反而会降低性能。所以在数组长度低于64时,优先进行扩容。
- 为什么要大于等于8转化为红黑树,而不是7或9?
树节点的比普通节点更大,在链表较短时红黑树并未能明显体现性能优势,反而会浪费空间,在链表较短是采用链表而不是红黑树。在理论数学计算中(装载因子=0.75),链表的长度到达8的概率是百万分之一;把7作为分水岭,大于7转化为红黑树,小于7转化为链表。红黑树的出现是为了在某些极端的情况下,抗住大量的hash冲突,正常情况下使用链表是更加合适的。
注意,红黑树在jdk1.8之后出现的,jdk1.7采用的是数组+链表模式。
小结:
- HashMap采用链地址法,当发生冲突时会转化为链表,当链表过长会转化为红黑树提高效率。
- HashMap对红黑树进行了限制,让红黑树只有在极少数极端情况下进行抗压。
当HashMap中的数据越来越多,那么发生hash冲突的概率也就会越来越高,通过数组扩容可以利用空间换时间,保持查找效率在常数时间复杂度。那什么时候进行扩容?由HashMap的一个关键参数控制:装载因子。
装载因子=HashMap中节点数/数组长度,他是一个比例值。当HashMap中节点数到达装载因子这个比例时,就会触发扩容;也就是说,装载因子控制了当前数组能够承载的节点数的阈值。如数组长度是16,装载因子是0.75,那么可容纳的节点数是16*0.75=12。装载因子的数值大小需要仔细权衡。装载因子越大,数组利用率越高,同时发生哈希冲突的概率也就越高;装载因子越小,数组利用率降低,但发生哈希冲突的概率也降低了。所以装载因子的大小需要权衡空间与时间之间的关系。在理论计算中,0.75是一个比较合适的数值,大于0.75哈希冲突的概率呈指数级别上升,而小于0.75冲突减少并不明显。HashMap中的装载因子的默认大小是0.75,没有特殊要求的情况下,不建议修改他的值。
那么在到达阈值之后,HashMap是如何进行扩容的呢?HashMap会把数组长度扩展为原来的两倍,再把旧数组的数据迁移到新的数组,而HashMap针对迁移做了优化:使用HashMap数组长度是2的整数次幂的特点,以一种更高效率的方式完成数据迁移。
JDK1.7之前的数据迁移比较简单,就是遍历所有的节点,把所有的节点依次通过hash函数计算新的下标,再插入到新数组的链表中。这样会有两个缺点:**1、每个节点都需要进行一次求余计算;2、插入到新的数组时候采用的是头插入法,在多线程环境下会形成链表环。**jdk1.8之后进行了优化,原因在于他控制数组的长度始终是2的整数次幂,每次扩展数组都是原来的2倍,带来的好处是key在新的数组的hash结果只有两种:在原来的位置,或者在原来位置+原数组长度。具体为什么我们可以看下图:
从图中我们可以看到,在新数组中的hash结果,仅仅取决于高一位的数值。如果高一位是0,那么计算结果就是在原位置,而如果是1,则加上原数组的长度即可。这样我们只需要判断一个节点的高一位是1 or 0就可以得到他在新数组的位置,而不需要重复hash计算。HashMap把每个链表拆分成两个链表,对应原位置或原位置+原数组长度,再分别插入到新的数组中,保留原来的节点顺序,如下:
前面还遗留一个问题:头插法会形成链表环。这个问题在线程安全部分讲解。
小结:
- 装载因子决定了HashMap扩容的阈值,需要权衡时间与空间,一般情况下保持0.75不作改动;
- HashMap扩容机制结合了数组长度为2的整数次幂的特点,以一种更高的效率完成数据迁移,同时避免头插法造成链表环。
HashMap作为一个集合,主要功能则为CRUD,也就是增删查改数据,那么就肯定涉及到多线程并发访问数据的情况。并发产生的问题,需要我们特别关注。
HashMap并不是线程安全的,在多线程的情况下无法保证数据的一致性。举个例子:HashMap下标2的位置为null,线程A需要将节点X插入下标2的位置,在判断是否为null之后,线程被挂起;此时线程B把新的节点Y插入到下标2的位置;恢复线程A,节点X会直接插入到下标2,覆盖节点Y,导致数据丢失,如下图:
jdk1.7及以前扩容时采用的是头插法,这种方式插入速度快,但在多线程环境下会造成链表环,而链表环会在下一次插入时找不到链表尾而发生死循环。
那如果结果数据一致性问题呢?解决这个问题有三个方案:
-
采用Hashtable
-
调用
Collections.synchronizeMap()
方法来让HashMap具有多线程能力 -
采用
ConcurrentHashMap
前两个方案的思路是相似的,均是每个方法中,对整个对象进行上锁。Hashtable是老一代的集合框架,很多的设计均以及落后,他在每一个方法中均加上了synchronize
关键字保证线程安全
// Hashtable
public synchronized V get(Object key) {...}
public synchronized V put(K key, V value) {...}
public synchronized V remove(Object key) {...}
public synchronized V replace(K key, V value) {...}
...
第二种方法是返回一个SynchronizedMap
对象,这个对象默认每个方法会锁住整个对象。如下源码:
这里的mutex是什么呢?直接看到构造器:
final Object mutex; // Object on which to synchronize
SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
// 默认为本对象
mutex = this;
}
SynchronizedMap(Map<K,V> m, Object mutex) {
this.m = m;
this.mutex = mutex;
}
可以看到默认锁的就是本身,效果和Hashtable其实是一样的。这种简单粗暴锁整个对象的方式造成的后果是:
-
锁是非常重量级的,会严重影响性能。
-
同一时间只能有一个线程进行读写,限制了并发效率。
ConcurrentHashMap的设计就是为了解决此问题。他通过降低锁粒度+CAS的方式来提高效率。简单来说,ConcurrentHashMap锁的并不是整个对象,而是一个数组的一个节点,那么其他线程访问数组其他节点是不会互相影响,极大提高了并发效率;同时ConcurrentHashMap读操作并不需要获取锁,如下图:
关于ConcurrentHashMap和Hashtable的更多内容,限于篇幅,我会在另一篇文章讲解。
那么,使用了上述的三种解决方案是不是绝对线程安全?先观察下面的代码:
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
map.put("abc","123");
Thread1:
if (map.containsKey("abc")){
String s = map.get("abc");
}
Thread2:
map.remove("abc");
当Thread1调用containsKey之后释放锁,Thread2获得锁并把“abc”移除再释放锁,这个时候Thread1读取到的s就是一个null了,也就出现了问题了。所以ConcurrentHashMap
类或者Collections.synchronizeMap()
方法或者Hashtable都只能在一定的限度上保证线程安全,而无法保证绝对线程安全。
关于线程安全,还有一个fast-fail问题,即快速失败。当使用HashMap的迭代器遍历HashMap时,如果此时HashMap发生了结构性改变,如插入新数据、移除数据、扩容等,那么Iteractor会抛出fast-fail异常,防止出现并发异常,在一定限度上保证了线程安全。如下源码:
final Node<K,V> nextNode() {
...
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
...
}
创建Iteractor对象时会记录HashMap的modCount
变量,每当HashMap发生结构性改变时,modCount
会加1。在迭代时判断HashMap的modCount
和自己保存的expectedModCount
是否一致即可判断是否发生了结构性改变。
fast-fail异常只能当做遍历时的一种安全保证,而不能当做多线程并发访问HashMap的手段。若有并发需求,还是需要使用上述的三种方法。
小结
- HashMap并不能保证线程安全,在多线程并发访问下会出现意想不到的问题,如数据丢失等
- HashMap1.8采用尾插法进行扩容,防止出现链表环导致的死循环问题
- 解决并发问题的的方案有
Hashtable
、Collections.synchronizeMap()
、ConcurrentHashMap
。其中最佳解决方案是ConcurrentHashMap
- 上述解决方案并不能完全保证线程安全
- 快速失败是HashMap迭代机制中的一种并发安全保证
关键变量的理解
HashMap源码中有很多的内部变量,这些变量会在下面源码分析中经常出现,首先需要理解这些变量的意义。
// 存放数据的数组
transient Node<K,V>[] table;
// 存储的键值对数目
transient int size;
// HashMap结构修改的次数,主要用于判断fast-fail
transient int modCount;
// 最大限度存储键值对的数目(threshodl=table.length*loadFactor),也称为阈值
int threshold;
// 装载因子,表示可最大容纳数据数量的比例
final float loadFactor;
// 静态内部类,HashMap存储的节点类型;可存储键值对,本身是个链表结构。
static class Node<K,V> implements Map.Entry<K,V> {...}
扩容
HashMap源码中把初始化操作也放到了扩容方法中,因而扩容方法源码主要分为两部分:确定新的数组大小、迁移数据。详细的源码分析如下,我打了非常详细的注释,可选择查看。扩容的步骤在上述已经讲过了,读者可以自行结合源码,分析HashMap是如何实现上述的设计。
final Node<K,V>[] resize() {
// 变量分别是原数组、原数组大小、原阈值;新数组大小、新阈值
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 如果原数组长度大于0
if (oldCap > 0) {
// 如果已经超过了设置的最大长度(1<<30,也就是最大整型正数)
if (oldCap >= MAXIMUM_CAPACITY) {
// 直接把阈值设置为最大正数
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 设置为原来的两倍
newThr = oldThr << 1;
}
// 原数组长度为0,但最大限度不是0,把长度设置为阈值
// 对应的情况就是新建HashMap的时候指定了数组长度
else if (oldThr > 0)
newCap = oldThr;
// 第一次初始化,默认16和0.75
// 对应使用默认构造器新建HashMap对象
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
# 总结
如果你选择了IT行业并坚定的走下去,这个方向肯定是没有一丝问题的,这是个高薪行业,但是高薪是凭自己的努力学习获取来的,这次我把P8大佬用过的一些学习笔记(pdf)都整理在本文中了,如果你有需要的话,请一定**点赞分享本文,[然后点击这里获取免费下载方式!](https://gitee.com/vip204888/java-p7)**
**《Java中高级核心知识全面解析》**
![](https://img-blog.csdnimg.cn/img_convert/d7ad292b656a721579600eadc17f66b9.png)
**小米商场项目实战,别再担心面试没有实战项目:**
/ 原数组长度为0,但最大限度不是0,把长度设置为阈值
// 对应的情况就是新建HashMap的时候指定了数组长度
else if (oldThr > 0)
newCap = oldThr;
// 第一次初始化,默认16和0.75
// 对应使用默认构造器新建HashMap对象
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
# 总结
如果你选择了IT行业并坚定的走下去,这个方向肯定是没有一丝问题的,这是个高薪行业,但是高薪是凭自己的努力学习获取来的,这次我把P8大佬用过的一些学习笔记(pdf)都整理在本文中了,如果你有需要的话,请一定**点赞分享本文,[然后点击这里获取免费下载方式!](https://gitee.com/vip204888/java-p7)**
**《Java中高级核心知识全面解析》**
[外链图片转存中...(img-G55fwFAi-1628267716068)]
**小米商场项目实战,别再担心面试没有实战项目:**
![](https://img-blog.csdnimg.cn/img_convert/ce5f65759882b907654d6d54eecc91da.png)