目录
5、为什么HashMap规定,table容量必须是2的N次幂
8、JDK 1.8之前为什么设计成头插法? 1.8为什么改成了尾插法?
9、是不是意味着JDK 1.8可以将HashMap用于多线程环境?
(1)HashMap概述
JDK 1.8 对HashMap做了很大的优化,底层实现从“数组+链表”,改成了“数组+链表+红黑树”。
简单来说,当发生哈希冲突时,节点会以链表的形式增加在table数组的索引上。如果链表超过一定长度,就会转成红黑树。
转成红黑树后,链表的结构依然存在,只不过会选择使用红黑树的结构来查找节点
。红黑树的每次操作都会维护链表的结构。
Map 取代了古老的 Dictionary 抽象类。
(2)HashMap的存储结构
map是用于存储键值对<key, value>的集合类
键用Set类型的KeySet存储,值用Collection类型的Values存储。
每个键值对都是一个Entry类型的数据。
(3)HashMap源码分析
HashMap的亮点:
- 链表节点的hashCode是针对key和value分别取hashCode,然后进行异或运算,将结果作为当前节点的hashCode
- 它的作用是,AbstractMap中定义了计算Map集合hash值的方式,就是累加每个节点元素的hash值,作为自身的hash值。
- 不管使用有参构造还是无参构造,都采用了懒分配空间的策略,在第一次放入元素时才会给数组分配空间
- 通过有参构造设置的容量不会直接采用,而是计算出大于它的最小的2次幂作为数组长度。设置的负载因子会直接采用
- 计算一个key的哈希值时,会扰动一下,把它的hashCode的高16位和完整的hashCode做异或运算,重新计算 hash 值。
- 这是因为在数组长度较小时,只有hash的低位会实际参加运算,这样能让hash值的低位获得更多的1,降低哈希冲突的概率
- 计算一个key的索引时,会将扰动后的 hash 值与 (table.length - 1) 进行 & 运算
- 因为数组长度严格规定为2的幂次,即高位为1,低位都为0。那么数组长度-1,得到的值就是高位为0,低位都为1
- 和hash值做与运算,规则是有0则0,可以实现均匀分布,而且保证不会大于数组长度,相当于取模的思想。
- 插入一个键值对时,是先插入节点之后才检查是否大于扩容阈值,需要扩容
- 扩容后数组长度变化,涉及到重哈希,JDK 1.8对此做了优化,只需要通过位运算判断hashcode的高位即可计算出新的数组下标
- 键和值都允许为null
(1)继承结构
继承AbstractMap,实现了Map接口、克隆、序列化。
(2)成员变量
常量
- DEFAULT_INITIAL_CAPACITY:默认初始容量:16
- MAXIMUM_CAPACITY :最大容量。若传入的容量过大,将被最大值替换
- DEFAULT_LOAD_FACTOR :默认负载因子:0.75
- TREEIFY_THRESHOLD :链表转换为红黑树的阈值:9个节点时转换
- UNTREEIFY_THRESHOLD :红黑树转换为链表的阈值:6个节点时转换
- MIN_TREEIFY_CAPACITY :转成红黑树时,table的最小长度,为64
可以看出一些HashMap的设计思想:
- HashMap的容量必须是2的幂次方。后面解释
- 必须在table长度不小于64,且链表节点数大于8时,才能转换成红黑树。如果table长度不满足,会进行一次扩容。
普通成员变量
- table:Node类型的数组
- entrySet:键值对数组
- size:当前存在的键值对数量
- modCount:用于快速失败
- threshold:扩容阈值。在当前负载因子和table长度下,允许的最大元素个数。超过这个值将发生一次扩容。
- loadFactor:负载因子
此外,HashMap还包含一个普通链表节点类
这就是HashMap的单链表基本存储单元,有四个属性:hash、key、value、next
其中key是final的,意味着创建对象后key不能被修改。
hash也是final的,它由有参构造从外部指定,在节点类没有计算hash的方法。
这个hash缓存的是key的hashCode(经过扰动的),用于在插入节点时快速获得当前节点的hash值
重写了两个方法:
- hashCode,
针对key和value分别求hashCode,然后进行异或运算,结果作为当前节点的hashCode
。- 好处:在保证效率的前提下,降低了哈希冲突的概率,更加可靠。
- equals,如果都是Entry的子类(可能是链表节点或红黑树节点),只要它们的键、值都相等,这两个节点就相等。
一个红黑树节点类
(3)构造函数
有三种有参构造器,一种无参构造器:
有参构造之间是相互调用的关系
Float.isNaN:如果指定的值不是数字,就返回true
可以看出:
- 构造函数只可以指定初始容量和负载因子,但底层只采用了我们设定的负载因子
- 初始容量没有直接采用,而是利用tableSizeFor方法,传入初始容量,指定了threshold的值。
- 在后续第一次调用resize()时,如果threshold不为空,会将它作为表的容量。
- 注意,
此时的扩容阈值threshold不是真正的阈值,而是代表首次初始化的数组的长度
,后面会重新计算。 - 创建对象时,没有为table分配存储空间
(4)tableSizeFor()
它的作用是,根据我们传入的容量,计算一个大于等于该容量的最小的2的N次方。比如传入7,会返回8;传入9,会返回16
这个公式会通过最高位的1,得到2个、4个、8个1...具体取决于高位有多高。
经过这5次运算后,会得到一个低位全是1的值,在返回时+1,就能得到1个比n大的2的N次幂
。
开头的cap -1,是为了防止cap本身就是2的N次幂的情况。
(5)确认一个Key的数组下标
HashMap中计算数组下标的步骤:
-
调用hash()
-
获得key的hashCode(),和它的高16位做异或运算,将结果作为哈希值
hash方法(扰动函数
,目的是在哈希函数的基础上,进一步降低哈希冲突)
计算一个键的哈希值,具体做法:
- 获得这个key的hashCode值
- 将 hashCode 右移16位,和hashCode做异或运算,重新计算 hash 值
HashMap如何通过哈希函数定位数组索引
hash值的范围是int的范围,有40亿长度,但不可能创建一个这么长的数组,所以要对数组长度做取模运算,把哈希值映射到数组的索引上。
源码的取模运算部分:
int n = tab.length;
// 将(tab.length - 1) 与 hash值进行&运算
int index = (n - 1) & hash;
将计算出来的 hash 值与 (table.length - 1) 进行 & 运算,而不是常见的取模运算。
取模运算消耗较大,计算机中最快的是位运算。JDK团队做了优化,用位运算替代了取模运算,达到一样的效果。
这是根据公式:x mod 2^n = x & (2^n - 1)
HashMap底层数组的长度规定必须是2的n次方,取模运算为 “h mod table.length”,对应上面的公式
可以得到该运算等同于“h & (table.length - 1)”
这就是HashMap规定,table容量必须为2的N次幂的原因,目的是优化取模运算。
这种做法可以保证,得到的索引一定小于数组的长度
为什么哈希函数特殊设计成,要将hashCode右移16位参与运算?
将 hashCode 的高 16 位与 hashCode 进行异或运算,主要是为了在 table 的 length 较小的时候,让hashCode的高位也参与运算,降低哈希冲突的概率
,并且不会有太大的开销。
- 因为,与运算的规则是,有0则0。异或是相同为0,否则为1
在length较小时,它的高位都是0,只有低位有少量的1,只有这些1和hashCode的低位进行了运算,得到了最终的索引
- 如果有两个hash值,低位的1比较少,大多数1都在高位,那么只有低位的1参与了运算,增加了哈希冲突的概率。
- 所以需要对hashCode做处理,
把高位也利用起来,让hash值的低位获得更多的1
。
异或的规则是,相同则0,否则为1。所以可以把hashCode右移,让它的1错开,然后通过异或运算,获得更多的1,进而获得更大范围的hashCode,进而减小哈希冲突的概率。
使用无符号右移16位,是因为HashCode有32位,正好把前半部分和后半部分错开,获得所有的1。
因为对于同样的键,HashCode是唯一的,所以采取这种算法,也能保证得到的hashCode是唯一的。
为什么用异或,不用或运算
如果单纯为了利用所有的1,那使用或运算更好,能保留全部的1
但是这样没有意义,因为会大大增加hash冲突的概率
而异或就比较好,又能提供高位的1,又保留了hash的多样性。
(6)put()
在HashMap中存入一个值,需要做这些事情:
-
如果table数组尚未初始化,调用resize进行初始化
-
根据传入的key和value构建Node对象
-
计算节点的hash值,即节点的存储索引
-
将节点存储到该索引上的链表或红黑树上
-
扩容机制
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 ((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;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
主要做的事情:
- 如果table为空,就调用resize()进行初始化
- 计算key值对应的索引位置
- 如果目标索引为空,直接新增一个Node节点,存入数据即可
- 如果发生了哈希冲突:
- 如果目标位置的节点,哈希值和key值都和当前节点一样,说明这是一次更新操作,用当前节点替换旧的节点
- 否则,判断目标位置节点是否是红黑树的节点。如果是,调用红黑树的putTreeVal()方法,把节点挂在红黑树上
- 否则,说明当前节点上解决哈希冲突的方式是链表。
- 遍历链表,如果遇到相同的节点,说明是更新操作,替换旧的节点。遍历链表时会用binCount记录当前链表长度。
- 如果没有相同节点,就插入新节点(尾插法)
- 然后判断链表长度,如果长度大于8,调用treeifyBin()方法
- 在treeifyBin()内会先判断数组长度是否大于64,不够就进行一次扩容,如果够就将链表转为红黑树
- 如果插入节点后,HashMap的size超过了扩容阈值,就调用resize()进行一次扩容。
注意:
- 只要执行了插入操作,不管对应的key是否存在,都会增加modCount
- 在节点插入成功后,才会查看是否需要扩容,以及是否需要将链表转为红黑树
(7)resize()
有两种使用情况:
- 如果table为空,初始化容量
- 数组容量大于扩容阈值,进行扩容操作
做的事情可以分为两个各部分:
- 根据旧表的表长度和扩容阈值,确定新表的表长度和扩容阈值。
- 将旧表的内容重新hash,装进新表
HashMap链表节点重hash的优化机制
这是JDK1.8的特性,JDK1.7之前还是使用的重hash策略。
由于HashMap的表长度一定是2的N次幂,则表长度扩容到2倍后,表长度只是多了一个二进制位。
一个Key的hash值 = 扰动后的hashcode & (表长度-1)。
hashcode 是不变的,表长度左移了一位,说明表长度的高位多了一位。hashMap的表长度有一个特点:肯定是2的幂。2的幂的特点是,高位是1,其他位都是0。
导致参与计算的hashcode值也多了一位,这个可能是0,可能是1。
将hashcode 和旧表的长度做与运算。之前的hash值是通过 表长度-1 计算出的,现在直接用表长度运算,hashcode的高一位会被利用上。
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;
}
做的事情可以分为两个各部分:
- 根据旧表的表长度和扩容阈值,确定新表的表长度和扩容阈值。
- 将旧表的内容重新hash,装进新表
- 如果当前容量大于HashMap最大容量,将扩容阈值设置为int类型最大值,不再扩容
- 如果当前容量没有超过最大值,就
将新的容量设置成原先的两倍
(通过位运算)。如果此时的新容量小于最大容量,而且旧的容量大于默认容量16,就将扩容阈值设置为原先的两倍。 - 如果表长度为0,但阈值大于0,说明通过有参构造函数指定了表的容量,让新的容量等于此时的扩容阈值。
- 如果表长度为0,扩容阈值为0,说明通过创建对象时没有指定表的容量,就将表长度设为默认值16,扩容阈值设为(默认表长 * 默认扩容因子 0.75) = 12
- 如果新表的阈值为空, 则通过新的容量*负载因子获得阈值
- 如果老表不为空,遍历旧数组,然后遍历该数组元素的所有节点,缓存当前访问的节点,然后把对应的数组元素置为null,让GC及时回收。
- 如果e.next为空,说明该桶内只有这一个节点,直接重新hash,存入新表的对应桶中。
- 注意,具体做法是 newTab[e.hash & (newCap - 1)] = e
- 如果e是红黑树节点,调用split()方法进行添加操作
-
如果结果为0,说明hashcode的高一位是0,那么如果和新表长度-1进行与运算,对应位的值肯定为0。后面的值,新表和旧表进行运算时后面都是1,所以后面的位不变。所以新的索引等于 旧的索引,不变。
-
如果结果为1,说明hashcode的高一位是1,那么如果和新表长度-1进行与运算,对应位的值肯定为1。后面的位不变,hash的结果只多了最高位的1。如果用这个高位的1,后面补0,和新表长度-1进行与运算,得到的值是旧表的长度。所以新的索引等于 原先的表长 + 旧索引。
- 如果e是链表节点,进行重hash。这里涉及到优化。它会直接遍历链表,把节点挂在链表上
HashMap链表节点重hash的优化机制
这是JDK1.8的特性,JDK1.7之前还是使用的重hash策略。
由于HashMap的表长度一定是2的N次幂,则表长度扩容到2倍后,表长度只是多了一个二进制位。
一个Key的hash值 = 扰动后的hashcode & (表长度-1)。
hashcode 是不变的,表长度左移了一位,说明表长度的高位多了一位。hashMap的表长度有一个特点:肯定是2的幂。2的幂的特点是,高位是1,其他位都是0。
导致参与计算的hashcode值也多了一位,这个可能是0,可能是1。
将hashcode 和旧表的长度做与运算。之前的hash值是通过 表长度-1 计算出的,现在直接用表长度运算,hashcode的高一位会被利用上。
-
如果结果为0,说明hashcode的高一位是0,那么如果和新表长度-1进行与运算,对应位的值肯定为0。后面的值,新表和旧表进行运算时后面都是1,所以后面的位不变。所以新的索引等于 旧的索引,不变。
-
如果结果为1,说明hashcode的高一位是1,那么如果和新表长度-1进行与运算,对应位的值肯定为1。后面的位不变,hash的结果只多了最高位的1。如果用这个高位的1,后面补0,和新表长度-1进行与运算,得到的值是旧表的长度。所以新的索引等于 原先的表长 + 旧索引。
注意:JDK1.7中rehash时,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但JDK1.8没有这个问题。
这部分的源码,它会保存该节点的前一个节点和后一个节点,所以不会乱序。
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;
}
(8)get()
通过key,获取对应的value。
调用getNode方法,传入key的哈希值和key,获取节点对象
做的事情:
- 获取key对应的数组中的索引位置。first指数组中目标位置的节点
- 通过hash和key一同判定,如果first就是目标节点,就返回它
- 如果不是,说明存在哈希冲突。判断first的next是链表节点还是红黑树节点,调用对应方法查找节点。
- 如果是红黑树节点,调用getTreeNode()方法查找
- 如果是链表节点,遍历单链表查找目标节点,找到就返回
- 没找到,返回null
getTreeNode
红黑树的查找目标节点方法
-
首先找到红黑树的根节点
-
使用根节点调用find方法
(9)remove()
removeNode()
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
做的事情可以分为两部分:
- 根据key查找到目标节点
- 删除目标节点
根据key查找到目标节点
- 根据key,计算出数组索引,将目标位置的元素记为p
- 如果p就是目标节点,赋值给node
- 否则,如果p.next存在,判断它的类型。
- 如果是红黑树节点,调用getTreeNode()查找节点,赋值给node
- 如果是链表节点,遍历单链表,找到目标节点,赋值给node。遍历时,维护node的上一个节点,赋值给p
删除目标节点
- 如果node不为空,那么它就是要删除的目标节点。
- 如果它是红黑树节点,调用removeTreeNode()方法移除这个节点
- 如果它是链表节点,存在两种情况:
- 如果它是桶内的第一个节点,即链表的头结点,就让桶内存储它的next即可
- 如果它是单链表中的节点,让它的前驱节点p的next域指向它的next域。
- size--,modCount++。
(4)常见问题
1、HashMap的底层数据结构
JDK 1.8是数组,解决哈希冲突的方案是链表+红黑树。
JDK 1.8之前是数组+链表
使用红黑树主要是为了提高在哈希冲突频繁时的查找性能,链表是O(n),红黑树是O(logn)。
2、什么时候用链表?什么时候用红黑树?
插入操作,默认会实例化一个链表节点,如果发生了哈希冲突,就进行判断:
- 如果有节点和当前节点的键值一样,就用新的节点替换这个旧的;
- 否则,将新节点插入链表末尾。
此时,检查新增节点后链表的长度。如果超过阈值(默认为8),即链表长度大于等于9时,判断数组大小:
- 如果数组长度大于64,链表节点转为红黑树节点
- 如果数组长度小于64,不会转换成红黑树,而是会进行一次扩容。
移除操作,当同一个索引上移除节点后,节点数量只有6个,且节点类型为红黑树节点,就转换成普通链表节点。
3、为什么链表转红黑树的阈值是8,红黑树转链表的阈值是6?
链表转红黑树阈值为8是时间和空间进行权量的结果。
红黑树的节点大小几乎是普通链表节点的两倍
。在节点数量少时,红黑树带来的性能提升并不明显,作者认为没有必要因此浪费空间。
理想情况下,节点分布在数组格子内的概率遵循泊松分布,则链表长度达到8的概率是0.00000006,非常小。所以红黑树的转换不会非常频繁。而且,节点个数大于8,红黑树的效率优势也开始体现了。
红黑树转链表的阈值是6,而没有复用8,原因是如果设置成节点少于8就立刻转回链表,当节点个数在8、9徘徊时,就会频繁进行链表和红黑树的转换,造成无意义的性能损耗。
而且,数组长度必须大于64才有机会将链表转为红黑树,在此之前会采用扩容+重哈希的操作。
4、HashMap的随机性
每次扩容时,元素的位置会改变,所以不能保证某一时刻的某个数据在HashMap中的相对位置。
5、为什么HashMap规定,table容量必须是2的N次幂
这是因为HashMap在计算数组下标时,对取模运算有特殊设计:
int n = tab.length;
// 将(tab.length - 1) 与 hash值进行&运算
int index = (n - 1) & hash;
这样运算需要保证,table的容量是2的n次幂。
而且扩容重哈希时也做出了优化。
6、HashMap如何保证键的唯一性
HashMap的每个数据都是一个Node节点类或TreeNode红黑树节点类,它们都带有final的key属性和hash属性,保证对象创建后key和hash不能修改。
在添加数据时,源码是这样设计的:
先比较目标节点的hash值,和当前数据的hash值是否相等。如果不相等,它们的key肯定不一样。如果相等,再进行下面的比较:
目标节点的key == 当前key,或key不为null时,利用equals()比较目标节点和当前的key。这是考虑到了key为null的情况。
所以,在key不为null时,会先比较hash值,而hash值和key的hashCode()有关。如果hash值相等,再去调用equals()比较。都相等,则证明这个key已经存在。
所以存储自定义对象时,需要一同重写hashCode()和equlas()。
进一步分析:
在Object类中,对equals()方法的初始定义:
可以看出,如果不对equals进行修改,那么底层就是直接调用的 ==
所以源码这里这样设计,是两头都提供了用户自定义的比较方法,用户可以自己重写hashCode、也可以自己重写equals()。
完整的逻辑是:比较两个hash值是否相等,如果相等,再判断两个key是否==。如果不==的话,如果key不为null,调用equals()比较,如果返回true,则可以被看做同样的key
。
具体到程序中,是通过哈希值找到了数组桶,再通过equals,在红黑树或链表上查找具体的节点。
7、如果key为null,会发生什么事情
在调用put()方法时,会调用hash()计算key的hash值,然后调用putVal()方法。
如果key为null,会直接返回0,不会调用hashCode()去计算。即HashMap的设计中,null的hash值是固定的0
所以null是可以作为key被存储的。存储位置比较特殊。
如果hash为0,则和任何数按位与 & ,结果都为0,所以key为null的值,存储空间一定在table[0]上
8、JDK 1.8之前为什么设计成头插法? 1.8为什么改成了尾插法?
在JDK 1.8之前,一个新的节点插入链表,会取代原有的值,原来的值顺延到当前节点的后面。这么设计可能是因为设计者认为,新插入的值被使用到的概率比较高,将它放在链表的头部,可以更快地查找到它。
但头插法会带来一个问题:在多线程环境下,可能会产生环形链表,此时进行取值,就可能会造成死循环。
在插入时不会发生问题,但一旦发生扩容,就会进行一次重哈希,此时也是采用的头插法,会导致扩容前后链表顺序倒置,增加了原先节点的引用关系,形成环。
官方认为这不属于BUG,因为HashMap本就不是线程安全的,但权衡影响和设计后,JDK 1.8还是调整为了尾插法,这样就保证了扩容前后链表节点顺序不变,从而杜绝了环的可能。
9、是不是意味着JDK 1.8可以将HashMap用于多线程环境?
不是。虽然不会产生死循环,但数据的读写方法都不是同步的,多线程下可能出现数据不一致问题
,线程安全无法保证。
put()方法,在并发场景下,如果计算出的索引值相同,可能导致覆盖,进而丢失数据
get()方法,可能某个线程进行了put(),触发了扩容,那么线程2此时执行get()就可能导致获取值为null。
10、有什么线程安全的类代替么?
多线程环境下,一般都会使用HashTable或者ConcurrentHashMap。
但HashTable并发度低,处理方式也仅仅是在读写方法上同步锁,
所以通常会使用ConcurrentHashMap。
此外,还有一种方式:使用Collections.synchronizedMap(Map)创建线程安全的map集合,但并发度也比较低。
11、给定了数据规模,计算合理的初始容量
初始容量 = 预知数据量 / 加载因子
默认的加载因子是 0.75
思想是,能正好装进这些数据,保证不会发生扩容。
12、一般用什么作为HashMap的Key?
一般用Integer、String这种不可变类作为HashMap的Key。
- 因为不可变类的hashcode能够被缓存下来,下次不需要再次计算,相比其他对象效率更高
- 这些类很规范地重写了equals()和hashCode()
13、如果数组长度不是2的幂次方
容量不是2的整数次幂 还用&(length-1) 怎样?
容量是2的整数次幂,n -1 后,高位为1后的0都变为1,如 16:10000, 16-1=15:1111, 1111 再与 hash 做 & 运算的时候,各个位置的取值取决于 hash。
如果不是2的整数次幂,必然会有0的位,0与任何数&肯定为0,会增加哈希冲突的概率。
14、HashMap的扩容机制
它的策略是,每次增加一个元素,就会检查当前元素个数是否超过扩容阈值,如果超过了就启动一次扩容。
这个扩容阈值是通过 当前最大容量 * 扩容因子 计算出来的。扩容因子可以在有参构造中指定,默认是0.75
扩容的策略
-
每次都会扩容成原先的2倍
-
容量上限是1>>30,如果容量超出了这个数,则不再增长,且阈值会被设置为 Integer.MAX_VALUE
-
新的扩容阈值也会被计算出来,也是 当前最大容量 * 扩容因子
-
扩容后负载因子是不变的
-
扩容后需要对所有的元素进行重hash,优化成了位运算。
这里遍历所有元素的方式是深度优先遍历,即顺着数组桶遍历,每次完整遍历一条链表或一颗红黑树
扩容的触发时机
- 扩容机制涉及到两点:懒分配数组空间、扩充数组长度
懒分配数组空间:
-
新创建的HashMap,初始时数组没有分配空间。
-
如果是无参构造器创建的HashMap,只会指定负载因子为默认的0.75,不会指定容量,默认为16
-
如果是有参构造器创建的HashMap,可以指定负载因子,也可以指定集合的容量。
不过它并不是直接设置了集合的容量,而是先计算出大于指定容量的最小的2次幂,然后把这个结果设置为当前的扩容阈值
-
-
后续调用put()方法,如果当前Map的数组为空,就调用resize()方法进行数组的初始化。
-
在resize()方法中,如果数组为空或者数组长度为0,但是扩容阈值不为0,就会把扩容阈值作为新的数组长度,初始化数组。
扩充数组长度:
- 在put()方法中,简单来说,会把一个键值对挂在红黑树上或者链表的末尾,此时有两个地方涉及到扩容:
- 如果挂上新节点后,当前链表长度大于8,就会触发转换为红黑树。在转换之前会先检查当前数组的长度是否大于64,如果不够64就进行一次扩容。
- 如果不涉及到转为红黑树的操作,那么在挂上新节点后,会检查当前集合的元素个数是否大于扩容阈值,如果大于就触发一次扩容。
(5)遍历HashMap的方式
有三种方式:获取Entry的集合、获取key的集合、获取Value的集合。
获取map的entrySet,遍历entrySet,取出每一个entry,可以获取key和value
有两种遍历entrySet的方式:
- 利用增强for循环
- 通过entrySet获取迭代器,手动迭代
增强for循环
Set<Map.Entry<Integer, Integer>> entrySet = map.entrySet();
for(Map.Entry<Integer, Integer> entry : entrySet){
System.out.print(entry.getKey());
System.out.println(entry.getValue());
}
通过entrySet获取迭代器
Iterator<Map.Entry<Integer, Integer>> iterator = entrySet.iterator();
while (iterator.hasNext()) {
// 遍历时,需先获取entry,再分别获取key、value
Map.Entry entry = iterator.next();
System.out.print(entry.getKey());
System.out.println(entry.getValue());
}
获取key的Set集合
有两种遍历keySet的方式:
- 增强for循环
- 通过keySet获取迭代器
增强for循环
Set<Integer> keySet = map.keySet();
for(Integer key : keySet){
System.out.print(key);
System.out.println(map.get(key));
}
通过keySet获取迭代器
Iterator<Integer> iterator1 = keySet.iterator();
Integer key = null;
while (iterator1.hasNext()) {
key = iterator1.next();
System.out.print(key);
System.out.println(map.get(key));
}
获取value的Collection集合
有两种遍历Collection的方式:
- 增强for循环
- 通过Collection集合获取迭代器
增强for循环
Collection<Integer> values = map.values();
for (Integer value : values) {
System.out.println("value = " + value);
}
通过Collection集合获取迭代器
Iterator<Integer> iterator2 = values.iterator();
while (iterator2.hasNext()){
System.out.println("iterator2 = " + iterator2);
}
总结
在遍历Map集合时,推荐使用EntrySet的方式,效率比较高。
对于遍历KeySet,经历了两个过程:首先获得每一个key,然后再去map中通过key获取每一个value。
而对于遍历EntrySet,只需要经历一个过程:取出每个Entry对象的key和value即可。