1.散列表(哈希表)
如果让我们设计一个可以存储“键值对”的容器,我们会想到什么方法。
有可能是这样的:
用一个数组来持有映射对象。但是这样的容器性能非常低下,例如我们想取出键为C的值,我们需要遍历这个数组,一一对比键是否相同。存入一个新的映射对象时,也是要遍历数组,看当中是否有相同的键。
1.1 散列函数 hashCode()
有没有一种方式,可以直接通过索引定位呢?
例如,我们将a、b、c、d这些键通过某种方式转换成比较小的整数,将这个整数作为数组的索引呢?
而散列函数就是为此诞生的,通过散列函数,我们可以得到任意一个对象的散列值。
在java中,每个对象都会拥有一个hashCode()方法,这就是散列函数,通过这个方法会返回一个32位的整数。使用这么大的值作为散列值是为了尽量避免碰撞,例如两个不同对象的hashCode一样的话就是碰撞了。
1.2 除留余数法
直接使用32位的hashCode作为数组的索引明显是不可行的,int的取值范围可以有40亿之多(最小值:Integer.MIN_VALUE= -2147483648(-2的31次方),最大值:Integer.MAX_VALUE= 2147483647(2的31次方-1)),所以我们就需要将这个hashCode缩小到规定数组长度的范围内。
假定有一个初始长度位M的数组,那么我们需要根据hashCode计算出一个0 ~ M-1的整数,一般情况下会使用除留余数法,实际上就是取模。
f( key ) = key mod p ( p ≤ m )
在java中我们可以这样使用:
private int hash(Object key) {
return (key.hashCode() & 0x7fffffff) % M;
}
因为hashCode()可能返回一个负数,所以可以去掉最高位后再使用除留余数法(F的二进制码为 1111
,7的二进制码为 0111,所以0x7FFFFFFF 的二进制表示就是除了首位是 0,其余都是1,就是说,这是最大的整型数 int(因为第一位是符号位,0 表示他是正数))。
这样不同的Key可能就会得计算出相同的值,也就是碰撞了。而碰撞处理有两种常用的方式:拉链法和线性探测法。
1.3 基于拉链法的散列表
长度为M的数组中的每个元素指向一条链表,链表中的每个节点都存储了散列值为该元素索引的键值对,这种方式称为拉链法。而拉链法的基本思想就是选择足够大的M,使得所有链表都尽可能短以保证高效的查找。
当我们选择数组的长度时应该大于元素个数,这样可以最大程度的减少碰撞,缩短链表的长度,毕竟链表的作用是处理碰撞。
但是大多数情况,我们并不知道需要存入多少个元素,所以一般情况下会在存入元素时检查元素个数,然后扩充数组。
1.4 基于线性探测法的散列表(开放地址散列表)
处理碰撞的另一种方式就是用长度为M的数组保存N个键值对,其中M>N。
然后我们需要利用数组中的空位解决碰撞冲突,基于这种策略的所有方法被统称为开放地址散列表。
开放地址散列表中最简单的方法叫做线性探测法:当碰撞发生时,我们直接检查散列表的下一个位置(索引+1)
线性探测法的地址增量di = 1, 2, … , m-1,其中,i为探测次数。该方法一次探测下一个地址,知道有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。
线性探测容易产生“聚集”现象。当表中的第i、i+1、i+2的位置上已经存储某些关键字,则下一次哈希地址为i、i+1、i+2、i+3的关键字都将企图填入到i+3的位置上,这种多个哈希地址不同的关键字争夺同一个后继哈希地址的现象称为“聚集”。聚集对查找效率有很大影响。
2. HashMap源码分析
懂了散列表的原理,那么HashMap分析起来就容易多了,HashMap是使用拉链法的方式实现的,并且某个链表长度超过8时,会将该链表转换成红黑树提高性能。
而HashMap的内部数组长度初始为16,如果需要扩展数组,那么规定是2的次方。例如16会扩充到32–>64–>128–>256–>512…
这样扩充有两个原因:
选择足够大的数组,让键值对更平均的分布在各个索引位,尽量减少链表长度。
当使用除留余数法时,能使用位运算代替取模运算,很大程度上提高效率(据说是5~8倍)。
N % M 等价于N & (M - 1),M为2的次方。
2.1 继承关系
java.lang.Object
java.util.AbstractMap<K,V>
java.util.HashMap<K,V>
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
2.2 主要成员
/**
* 数组的默认初始长度,java规定hashMap的数组长度必须是2的次方
* 扩展长度时也是当前长度 << 1。
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 数组的最大长度
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子,当元素个数超过这个比例则会执行数组扩充操作。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 树形化阈值,当链表节点个大于等于TREEIFY_THRESHOLD时,
// 会将该链表换成红黑树。
static final int TREEIFY_THRESHOLD = 8;
// 解除树形化阈值,当链表节点小于等于这个值时,会将红黑树转换成普通的链表。
static final int UNTREEIFY_THRESHOLD = 6;
// 最小树形化的容量,即:当内部数组长度小于64时,不会将链表转化成红黑树,而是优先扩充数组。
static final int MIN_TREEIFY_CAPACITY = 64;
// 这个就是hashMap的内部数组了,而Node则是链表节点对象。
transient Node<K,V>[] table;
// 下面三个容器类成员,作用相同,实际类型为HashMap的内部类KeySet、Values、EntrySet。
// 他们的作用并不是缓存所有的key或者所有的value,内部并没有持有任何元素。
// 而是通过他们内部定义的方法,从三个角度(视图)操作HashMap,更加方便的迭代。
// 关注点分别是键,值,映射。
transient Set<K> keySet; // AbstractMap的成员
transient Collection<V> values; // AbstractMap的成员
transient Set<Map.Entry<K,V>> entrySet;
// 元素个数,注意和内部数组长度区分开来。
transient int size;
// 容器结构的修改次数,fail-fast机制。
transient int modCount;
// 阈值,超过这个值时扩充数组。 threshold = capacity * load factor,具体看上面的静态常量。
int threshold;
// 装在因子,具体看上面的静态常量。
final float loadFactor;
2.3 Node和TreeNode
// 这个就是hashMap的内部数组了,而Node则是链表节点对象。
transient Node<K,V>[] table;
上面说过拉链法的散列表是通过链表解决碰撞问题的,所以hashMap的内部数组是节点类型。
static class Node<K,V> implements Map.Entry<K,V> {
// hash是经过hash()方法处理过的hashCode,为了使hashCode分布更加随机,
// 待会会深入这个hash()方法。
final int hash;
final K key;
V value;
Node<K,V> next; // 下一个节点
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final int hashCode() {
// key的hashCode异或value的哈希Code
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
// 其余略
}
TreeNode是Node是子类,继承关系如下:Node是单向链表节点,Entry是双向链表节点,TreeNode是红黑树节点。
java.util.HashMap<K, V>.Node<K, V>
java.util.LinkedMap<K, V>.Entry<K, V>
java.util.HashMap<K, V>.TreeNOde<K, V>
TreeNode的代码是实现红黑树的节点
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
// 略
}
2.4 HashMap原理总结(重要)
到了这里,其实我们对HashMap的实现原理已经有了个大概的了解,剩下的只是分析实现细节了。在此之前,我们先来总结下它的实现原理,面试如果问到HashMap,回答原理应该就能让面试官满意。
HashMap是基于拉链法实现的一个散列表,内部由数组和链表实现。
- 数组的初始容量为16,而容量是以2的次方扩充的,一是为了提高性能使用足够大的数组,二是为了能使用位运算代替取模预算。
- 数组是否需要扩充是通过负载因子判断的,如果当前元素个数为数组容量的0.75时,就会扩充数组。这个0.75就是默认的负载因子,可由构造传入。我们也可以设置大于1的负载因子,这样数组就不会扩充,牺牲性能,节省内存。
- 为了解决碰撞,数组中的元素是单向链表类型。当链表长度到达一个阈值时,会将链表转换成红黑树提高性能。而当链表长度缩小到另一个阈值时(6),又会将红黑树转换回单向链表提高性能,这里是一个平衡点。
- 对于第三点补充说明,检查链表长度转换成红黑树之前,还会先检测当前内部数组长度是否到达一个阈值(64),如果没有到达这个容量,会放弃转换,先去扩充数组。
2.5 构造函数
// 默认数组初始容量为16,负载因子为0.75f
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// NaN:Not a Number。例如给-1开方就会得到NaN。
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
// 这个方法可以将任意一个整数转换成2的次方。
// 例如输入10,则会返回16。
// 另外,有人可能疑惑,不是说threshold是 数组容量 * loadFactor得到的吗?
// 是的,在第一次put操作,扩充数组时,会将这个threshold作为数组容量,然后再重新计算这个值。
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
2.6 tableSizeFor方法
public HashMap(int initialCapacity, float loadFactor)这个构造可以由我们指定数组的初始容量和负载因子。
但是前面说过,数组容量必须是2的次方。所以就需要通过某个算法将我们给的数值转换成2的次方。
tableSizeFor(int cap)就是这个作用。
// 这个方法可以将任意一个整数转换成2的次方。
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;
}
原理实际上就是补位,将原本为0的空位填补为1,最后加1时,最高有效位进1,其余变为0。
例如1010 0101最后会变成1111 1111,然后+1变成了 1 0000 0000,为2的8次方,上面代码中int n = cap - 1先减去1就是为了最后面这个+1操作。
为了演示这个原理,我选一个比较特别的数字,1024,它的二进制为0000 0100 0000 0000。这样可以更直观的看到这些位运算是如何补位的(这里就不做减1和加1的操作演示了)。
首先,将n 右移 1位,再进行或运算:
0000 0100 0000 0000
| 0000 0010 0000 0000
-------------------------------
0000 0110 0000 0000
可以看到,我们最高有效位的右边就错位复制了一个1出来。
这个时候再右移两位:
0000 0110 0000 0000
| 0000 0001 1000 0000
-------------------------------
0000 0111 1000 0000
将原有的两个1错位复制到了4个。
再右移4位:
0000 0111 1000 0000
| 0000 0000 0111 1000
------------------------------
0000 0111 1111 1000
错位复制到了8个1。
再右移8位:
0000 0111 1111 1000
| 0000 0000 0000 0111
------------------------------
0000 0111 1111 1111
这个时候,我们已经成功将低位的0全部变成了1,真的是十分巧妙的算法。
而后面还有个右移16位的操作,我们的测试数字比较小,所以用不上了,但是这样可以保证一个32位的int类型将所有低位的0变为1。
这个时候如果再加1,那么就会进位,后面全补0,那么这个数就是2的次方了,因为二进制中每一位都是2的次方。
另外,可以看看Integer.highestOneBit方法,也是运用的这个原理。
2.7 如何将hashCode转换成数组的索引(hash方法和取模运算)
上面的例子中我们知道了通过构造传入初始容量时,数组的初始容量是如何计算的。
那么现在,我们也要知道,HashMap是如何将hashCode转换成数组索引的。
数组的索引 bucket
HashMap采用hash算法来决定集合中元素的存储位置,每当系统初始化HashMap时,会创建一个为capacity的数组,这个数组里面可以存储元素的位置被成为桶(bucket), 每个bucket都有其指定索引。可以根据该索引快速访问存储的元素。
首先我们看在put方法中,用到了hash方法。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 特殊处理的hashCode
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
在Java中每个对象都会拥有一个hashCode()方法,这个就是散列函数,通过这个方法会返回一个32位的整数,使用这么大的值作为哈希值其实是为了尽量避免发生碰撞(相同),例如两个不同对象的hashCode一样的话那就是发生了碰撞。但是如果用这么长的数字来当做索引肯定是不行的,那需要数组有多大才行?所以我们需要把这个hashCode缩小到规定数组的长度范围内。
上面的代码只是用hashCode的高16位与低16位进行异或运算。hash()方法就是将hashCode进一步的混淆,增加其“随机度”,试图减少插入HashMap时的hash冲突。
为什么就能提高离散性能呢?
这里还是要看hashCode转换成数组索引时的取模运算。
在putVal方法中(不仅仅只在putVal中),有这么一行代码
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
i = (n - 1) & hash,n是数组长度,hash就是通过hash()方法进行高低位异或运算得出来的hash值。
这个表达式就是hash值的取模运算,上面已经说过当除数数为2的次方时,可以用与运算提高性能。
那么我们想想,大多数情况下,内部数组的容量一般都不会很大,基本分布在16~256之间。所以一个32位的hashCode,一直都用最低的4到8位进行与运算,而高位几乎没有参与。
所以通过hash()方法,将hashCode高16位与低16位进行异或运算,能有效的提高离散性能。
2.8 取模和位运算
科普:
实际上java中%符号是求余(rem),而不是取模(mod)。在除数和被除数都为正数时,取模和取余的结果是一样的,区别在于有负数出现的时候。
所以我们容易混淆这两个概念,而在某些编程语言中%符号代表取模,所以很难区分它的叫法,我们知道%在自己使用的编程语言中代表什么就行了。
现在,我们可以来解释下当除数为2的次方时,取余运算可以用与运算代替的原理。
首先,我们知道2的次方的数,用二进制表示如下,是不会出现有两个1的情况。
0000 0001 // 1
0000 0010 // 2
0000 0100 // 4
0000 1000 // 8
0001 0000 // 16
0010 0000 // 32
0100 0000 // 64
1000 0000 // 128
当我们除以2的n次方时,可以看作是将二进制右移n位。例如123除以8,实际上就是123的二进制右移3位。
123 / 8
// 用移位运算可以表示为右移3位:
1111011 >> 3
123除以4的结果为15,那么余数呢?
余数正是被移位运算移走的最低3位,011,也就是余数为3。
是不是好想发现了新大陆?原来移位运算移走的那些二进制就是余数!
那么,我们只要将被移走的这3位保存起来,实际上就得到了余数,问题是怎么保存呢?
没错,通过与运算,例如一个数和15进行与运算(二进制为0000 1111),就可以取到该数的低4位。
而上面例子中,我们只要能和7(二进制为0000 0111)做与运算就可以直接得到余数了!
大家发现了什么没有,8的二进制为 0000 1000,如果减去1,是不是正好为0000 0111?
那么,只要我们 123 & (8 - 1),就可以取到123 / 8的余数了!也就是 N % M == N & (M - 1)
2.9 put添加键值对
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// onlyIfAbsent:当存入键值对时,如果该key已存在,是否覆盖它的value。false为覆盖,true为不覆盖。
// 参考putIfAbsent()方法。
// evict:用于子类LinkedHashMap。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab:内部数组
// p:hash对应的索引位中的首节点
// n:内部数组的长度
// i:hash对应的索引位
HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
// 首次put时,内部数组为空,扩充数组。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 计算数组索引,获取该索引位置的首节点,如果为null,添加一个新的节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
HashMap.Node<K,V> e; K k;
// 如果首节点的key和要存入的key相同,那么直接覆盖value的值。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果首节点是红黑树的,将键值对插添加到红黑树
else if (p instanceof HashMap.TreeNode)
e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 此时首节点为链表,如果链表中存在该键值对,直接覆盖value。
// 如果不存在,则在末端插入键值对。然后判断链表是否大于等于8,尝试转换成红黑树。
// 注意此处使用“尝试”,因为在treeifyBin方法中还会判断当前数组容量是否到达64,
// 否则会放弃次此转换,优先扩充数组容量。
else {
// 走到这里,hash碰撞了。检查链表中是否包含key,或将键值对添加到链表末尾
for (int binCount = 0; ; ++binCount) {
// p.next == null,到达链表末尾,添加新节点,如果长度足够,转换成树结构。
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
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 覆盖value的方法。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount; // fail-fast机制
// 如果元素个数大于阈值,扩充数组。
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
细心看注释部分,总结来说就是以下几个步骤:
- 检查数组是否为空,执行resize()扩充;
- 通过hash值计算数组索引,获取该索引位的首节点。
- 如果首节点为null,直接添加节点到该索引位。
- 如果首节点不为null,那么有3种情况
① key和首节点的key相同,覆盖value;否则执行②或③
② 如果首节点是红黑树节点(TreeNode),将键值对添加到红黑树。
③ 如果首节点是链表,将键值对添加到链表。添加之后会判断链表长度是否到达TREEIFY_THRESHOLD这个阈值,“尝试”将链表转换成红黑树。 - 最后判断当前元素个数是否大于threshold,扩充数组。
2.10 treeifyBin转换成红黑树
/**
- Replaces all linked nodes in bin at index for given hash unless
- table is too small, in which case resizes instead.
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果当前数组容量太小(小于64),放弃转换,扩充数组。
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
} else if ((e = tab[index = (n - 1) & hash]) != null) {
// 将链表转成红黑树,略
}
}
补充下上面,为什么说添加节点到链表后,会尝试将链表转换成红黑树。
因为如果当前数组容量太小,会先去扩充数组。
2.11 resize扩充数组
重新计算数组索引的解释部分来自:知乎-美团:Java 8系列之重新认识HashMap
扩充数组不单单只是让数组长度翻倍,将原数组中的元素直接存入新数组中这么简单。
因为元素的索引是通过hash&(n - 1)得到的,那么数组的长度由n变为2n,重新计算的索引就可能和原来的不一样了。
在jdk1.7中,是通过遍历每一个元素,每一个节点,重新计算他们的索引值,存入新的数组中,称为rehash操作。
而java1.8对此进行了一些优化,因为当数组长度是通过2的次方扩充的,那么会发现以下规律:
元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为数组的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置(因为从一个链表存遍历到另一个链表时导致倒置了),但是从上图可以看出,JDK1.8不会倒置。有兴趣的同学可以研究下JDK1.8的resize源码,写的很赞,如下:
下面代码可以分为两部分来看,上半部分是从新计算数组的长度和阈值。下半部分是将原数组的元素拷贝到新数组中。
final HashMap.Node<K,V>[] resize() {
HashMap.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的N次方)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 如果数组还没创建,但是已经指定了threshold(这种情况是带参构造创建的对象),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);
}
// 可能是上面newThr = oldThr << 1时,最高位被移除了,变为0。
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"})
HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
table = newTab;
// 下面代码是将原来数组的元素转移到新数组中。问题在于,数组长度发生变化。
// 那么通过hash%数组长度计算的索引也将和原来的不同。
// jdk 1.7中是通过重新计算每个元素的索引,重新存入新的数组,称为rehash操作。
// 这也是hashMap无序性的原因之一。而现在jdk 1.8对此做了优化,非常的巧妙。
if (oldTab != null) {
// 遍历原数组
for (int j = 0; j < oldCap; ++j) {
// 取出首节点
HashMap.Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 如果链表只有一个节点,那么直接重新计算索引存入新数组。
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果该节点是红黑树,执行split方法,和链表类似的处理。
else if (e instanceof HashMap.TreeNode)
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 此时节点是链表
else { // preserve order
// loHead,loTail为原链表的节点,索引不变。
HashMap.Node<K,V> loHead = null, loTail = null;
// hiHeadm, hiTail为新链表节点,原索引 + 原数组长度。
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
// 遍历链表
do {
next = e.next;
// 新增bit为0的节点,存入原链表。
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 新增bit为1的节点,存入新链表。
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;
}
2.12 线程安全性
在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。那么为什么说HashMap是线程不安全的,下面举例子说明在并发的多线程使用场景中使用HashMap可能造成死循环。
public class HashMapInfiniteLoop {
private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);
public static void main(String[] args) {
map.put(5, "C");
new Thread("Thread1") {
public void run() {
map.put(7, "B");
System.out.println(map);
};
}.start();
new Thread("Thread2") {
public void run() {
map.put(3, "A);
System.out.println(map);
};
}.start();
}
}
其中,map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1,也就是说当put第二个key的时候,map就需要进行resize。
通过设置断点让线程1和线程2同时debug到transfer方法的首行。
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
注意此时两个线程已经成功添加数据。放开thread1的断点至transfer方法的“Entry next = e.next;” 这一行;然后放开线程2的的断点,让线程2进行resize。结果如下图。
注意,Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。
线程一被调度回来执行,先是执行 newTalbe[i] = e, 然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。
e.next = newTable[i] 导致 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。
于是,当我们用线程一调用map.get(11)时,悲剧就出现了——Infinite Loop。