e : table) {
// Entry 不为空 ull != e) {
// 如果 hashSeed 发生变化,则重新计算 hash 计算
。钥匙);
}
// 重新计算数组结算,结果分为两种: 1. 原来的出价已经拉低了 2. 这次扩容是多久
int i = indexFor ( e.hash, newCapacity);
//Entry的next指向新数组的链表头
www.sychzs.cn = newTable[i]; 。 newTable[i] = e;
接下来的操作就是原数组链表的下一个
扩展步骤:
-
首先传入的newCapacity是数组容量的2倍,也是2的n次方
-
如果数组容量已达到2的30次方,则不进行扩容,直接返回
-
创建具有新数组长度 newCapacity 的 Entry 数组
-
initHashSeedAsNeeded 确定是否重新哈希并更新哈希种子
-
「传输方法进行特定的扩展处理:」
其实就是遍历旧数组,从旧数组的第一个桶中获取链表,从链表头部开始遍历,逐一取出新的下标(如果需要的话rehash,你将使用新的 hsah) 种子计算hsah值。如果不需要rehash,就使用原来的hsah值。最后用hsah值和新数组容量计算下标),然后用头插值法插入到新数组中。
「你会发现两条规则:」
-
-
计算出的新下标要么等于原始下标值,要么等于原始下标加上扩展长度。
我们通过一个例子来分析一下第二条规则。
假设table.length=16,现在有两个key,key1对应的hash值为68,key2对应的hash值为84。根据公式h&(length-1)计算,&运算规则遇到 0 时为 0,结果如下:
68 键1 0100 0100 & 0000 1111 =0000 0100 =4 84 键2 0101 0100 & 0000 1111 =0000 0100 =4
可以看出,两个值都落在table[4]桶中。
经过一次扩容后,table.length=32,然后根据公式h&(length-1)计算结果如下
68 键1 0100 0100 & 0001 1111 =0000 0100 =4 84 键2 0101 0100 & 0001 1111 =0001 0100 =20
可以看到68仍然放在新数组的table[4]中,而84则放在table[20]中
再次展开后,table.length=64,根据公式h&(length-1)计算结果如下
68 键1 0100 0100 & 0011 1111 =0000 0100 =4 84 键2 0101 0100 & 0011 1111 =0001 0100 =20
可以看到,这两个值还在原数组下标对应的桶中。
结论:同一个桶内的链表数据扩容后,新数组中的下标要么与原数组相同,要么是原数组下标加上扩容后的长度。
这个图案是怎么形成的呢?
这是因为数组的容量是2的n次方。数组每次扩容的结果都是原来数组容量的两倍。例如:16,32,64...,length-1的结果分别为15,31,63,对应的二进制如下:
0000 1111
0001 1111
0011 1111
可以看到,每次扩容时,高位都会加1,也就是说计算的时候,只需要看哈希值中高位对应的位是0还是1 ,这也会导致新数组中的下标。只有两种可能:如果是0,下标不会改变,如果是1,下标会改变。
这个规则有什么好处?
这个规则可以让原本在同一个桶中的数据分散到其他桶中,使得数组分区更加均匀,减少哈希冲突。在扩容过程中,同一个桶中的数据将被分区到新的数组中。只需判断高位就可以确定桶中的哪个桶,因此可以利用它来优化膨胀效率。
6。头部插入方式
//createEntry方法
void createEntry(int hash, K key, V value, int bucketIndex) {
// 获取当前数组索引处的链表头position
Entry e = table[bucketIndex];
// 在链表头部创建一个新的 Entry,next 指向原链表头(头插入方式)
table[ bucketIndex] = new Entry<>(hash, key, value, e);
// 数组长度 +1
size++;
}
头部插值方法的源代码非常简单。就是创建一个新的Entry对象。新Entry对象的next属性指向当前坐标处的头部Entry对象,然后将新Entry对象引用赋给当前数组下标。
这里的文字描述可能比你自己阅读源码还要晦涩难懂。自己看源码应该就清楚了。
问题总结
为什么要计算第一个大于等于指定数组长度的2的n次方值
只有当数组长度为2的n次方,且数组长度-1转换为二进制时,才能转换为低位全部为1的二进制,与哈希值的&运算可以等价以哈希值除法根据数组容量求余的结果。
为什么要使用头部穿透?
其实无论是头插入还是尾插入,都需要遍历链表。如果遍历过程中发现相同的key,则会更新并覆盖。在这种情况下,不会有插入操作,因此头部插入方法并不重要。和尾部插入,但是如果没有找到相同key的元素,那么此时肯定已经遍历完链表尾部了,所以任何插入,无论是头插入还是尾插入,都不会节省遍历时间链表,并且因为插入链表只是替换了next属性的指针,所以两种插入方式的效率没有区别。
Java 1.7之所以采用头部插值,应该和其自身的代码结构有关,因为插入方法是独立的。如果使用尾部插值,则遍历时必须记录最后一个元素的值,而头部插值则没有必要,但我认为这不是主要原因。就我个人而言,我认为Java开发人员只需选择两者之一即可。没有特殊考虑,否则不会出现循环链表问题。
为什么 hashMap 线程不安全
hashMap线程不安全主要表现在两个方面
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry e = table[bucketIndex ];
table[ BucketIndex] = 新条目<>(哈希,键,value,e);
大小++;
}
以上是头部插入方法的代码逻辑。多线程操作下,如果两个线程同时走到方法中的第一行,那么得到的e是相同的,然后两个线程分别创建Entry对象,并且给Entry对象的next属性赋值e值,这样一个线程的数据就会一直丢失。
循环链表发生在多线程扩展的情况下。以下是部分扩展代码:
for(条目 e :表){
而(空!= e){ “条目 next = www.sychzs.cn;
if(重排){
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity); |
这段代码的逻辑是使用头插值的方式将旧数组中的数据从链表头部一一插入到新数组中。
假设有两个线程同时扩展,并且两个线程都执行以下代码行:
条目 next = www.sychzs.cn;
此时第一个线程继续执行,第二个线程卡住了。直到第一个线程的整个循环完成后,第二个线程才会继续执行。
此时第一个线程的扩容完成,链表指向与原数组相反的顺序。假设原数组的一个bucket中链表的方向为1>2>3>4,扩容后恰好落入同一个新的bucket中,那么新的链表方向为4>3>2 >1.
此时第二个线程开始执行循环:
第一轮循环开始。 e指的是1,next指的是2。head插值方法插入一个新数组。新数组的链表为1>null
第二轮循环开始。 e指的是2,next指的是1。head插值方法插入一个新数组。新数组的链表为2>1>null
第三轮循环开始。 e 指 1,next 指 null。 head插值方法插入一个新数组。新数组的链表为1>2>1
这时候就出现了循环链表。