经常听人说起JDK1.7 HashMap死循环问题,一直没看过,今天试着看一下
HashMap数据结构主要有一个hash table(就一数组)和Node(每一个key-value组成一个Node)
hash表是数组,对于数组来说,读取任意位置的元素都是O(1),因为数组的首位置知道,读取任意下标的元素,只是偏移指定的位数,就是俗称的随机存取
如果hash碰撞,同一个hash值的Node会采用拉链式挂载在hash表同一个下标位置上,例如
为什么JDK1.7会有死循环的问题呢,主要是因为HashMap的扩容采用的是头插法,在多线程情况下会有线程安全问题
JDK1.7 HashMap的部分源码
public V put(K key, V value) {
//如果hash表时空的,说明是第一次put,在HashMap中,
//构造函数只是判断了初始容量,加载因子等参数的合法性,
//hash表的初始化需要等第一次使用put的时候才会创建
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key为空,会放在hash table的下标为0的位置
if (key == null)
return putForNullKey(value);
//计算hash值,这个采用了位异或运算,提高速度,同时保留了高低位,充分散列
int hash = hash(key);
//计算出来的hash值可能非常大,但是hash table只有那么点固定长度,
//这里和hash table的length-1进行与运算,听说还有什么扰动函数,有点高深,
//获得元素在数组中的下标位置
int i = indexFor(hash, table.length);
//遍历整个hash表,如果发现时相同的key覆盖原值,退出循环
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;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
//如果size大于阈值(loadFactor*initalCapacity),而且位置上有元素,
//对hash表进行扩容,length*2
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//如果老的hash表都=最大长度了,没法扩容了,直接退出
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
//new出新的hash表之后,重新散列元素到新的hash表中
//万恶之源
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//转移完了,将引用指向新表,旧的就让jvm自己去回收了
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//遍历整个旧的hash表
for (Entry<K,V> e : table) {
//如果某个位置有元素
while(null != e) {
//引用next指向这个位置的下一个元素
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//计算元素在新的hash表的位置下标
int i = indexFor(e.hash, newCapacity);
//元素的next指向新的hash表的下标为i的位置的元素
e.next = newTable[i];
//再把元素丢到新hash表的第i个位置
//这就是头插入,先连后断
newTable[i] = e;
//元素的引用指向下一个位置,直到遍历完整个链表
e = next;
}
}
}
这个不安全主要是多线程操作时,导致形成回环,进入死循环
这里假设两个线程T1和T2先后到了
resize的 Entry<K,V> next = e.next 位置,
此时T2因为某种原因阻塞了,T1继续往下transfer
此时大概是这样
Entry<K,V> next = e.next;
e.next = newTable[i];
因为原来的newTable没有值,所以指向null
newTable[i] = e;
e = next;
因为e不等于空,进入下一次循环
Entry<K,V> next = e.next;
B后面没有更多的元素了,指向的null,所以next也是指向null
e.next = newTable[i];
e.next指向newTable[i],newTable[i]就是A
newTable[i] = e;
e = next;
因为此时e=null了,T1完成任务,可以发现经过一轮扩容,A和B的指向相反了,这就是头插入导致的
此时T2唤醒了
此时引用指向是这样的,e2和next2依然分别指向A,B
此时B开始往table2往下走
e.next=newTable[i],这里的e是e2
因为newTable[i]是null,所以e.next还是null,不变
接着newTable[i]=e
将e指向的元素放在table2的第i个位置
e=next
此时e不为空,继续循环
next=e.next;
next指向了e的next,所以next=A
e.next=newTable[i]
e.next是B,B的next是A,newTable[i]也是A,所以没有变化…
newTable[i]=e
e=next
此时e=A,还是不为空继续循环,
next=e.next;
e.next=newTable[i]
因为newTable[i]=B,所以A的next指向了B
e=next
此时e指向了null,终于结束了循环
可以惊奇的发现,A和B构成了一个回环,下次Get或者Put操作时,将进入无线循环中,因为A的next指向了B,B的next又指向了A,…
好像JDK官方不承认这个是个bug,不提供fix,因为在源码的开头就注释了,多线程操作是不安全的,所有不安全操作都是用户导致的,所以拒绝修复,tomcat也因为这个原因限制了parameter的个数为10000个
不过好在jdk1.8已经修复了这个问题,这个死循环hashmap只能说成为过去了,但这个问题确实是很有趣