HashMap底层是一个Entry数组,当发生hash冲突的时候,hashmap是采用链表的方式来解决的,在对应的数组位置存放链表的头结点。对链表而言,新加入的节点会从头结点加入。
javadoc中关于hashmap的一段描述如下:
此实现不是同步的。如果多个线程同时访问一个哈希映射,而其中至少一个线程从结构上修改了该映射,则它必须 保持外部同步。(结构上的修改是指添加或删除一个或多个映射关系的任何操作;仅改变与实例已经包含的键关联的值不是结构上的修改。)这一般通过对自然封装该映射的对象进行同步操作来完成。如果不存在这样的对象,则应该使用 Collections.synchronizedMap 方法来“包装”该映射。最好在创建时完成这一操作,以防止对映射进行意外的非同步访问,如下所示:
Map m = Collections.synchronizedMap(new HashMap(…));
1、在hashmap做put操作的时候会调用到以下的方法。
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
现在假如A线程和B线程同时对同一个数组位置调用addEntry,两个线程会同时得到现在的头结点,然后A写入新的头结点之后,B也写入新的头结点,那B的写入操作就会覆盖A的写入操作造成A的写入操作丢失。
2、删除键值对的代码如下:
final Entry<K,V> removeEntryForKey(Object key) {
int hash = (key == null) ? 0 : hash(key.hashCode());
int i = indexFor(hash, table.length);
Entry<K,V> prev = table[i];
Entry<K,V> e = prev;
while (e != null) {
Entry<K,V> next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
modCount++;
size--;
if (prev == e)
table[i] = next;
else
prev.next = next;
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
return e;
}
当多个线程同时操作同一个数组位置的时候,也都会先取得现在状态下该位置存储的头结点,然后各自去进行计算操作,之后再把结果写会到该数组位置去,其实写回的时候可能其他的线程已经就把这个位置给修改过了,就会覆盖其他线程的修改。
3、addEntry中当加入新的键值对后,键值对总数量超过门限值的时候会调用一个resize操作,
这个操作会新生成一个新的容量的数组,然后对原数组的所有键值对重新进行计算和写入新的数组,之后指向新生成的数组。代码如下:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
当多个线程同时检测到总数量超过门限值的时候就会同时调用resize操作,各自生成新的数组并rehash后赋给该map底层的数组table,结果最终只有最后一个线程生成的新数组被赋给table变量,其他线程的均会丢失。而且当某些线程已经完成赋值而其他线程刚开始的时候,就会用已经被赋值的table作为原始数组,这样也会有问题
一、扩容过程
我们知道,HashMap在扩容的时候,是通过重新创建一个新的hash表,把原来旧数组中的Entry一个个迁移到新数组的,这个过程的实现方法如下
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
//将每条链中的元素迁移过去
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
//遍历第j条链
do {
Entry<K,V> next = e.next;
// 计算在newTable中的位置,原来在同一条链上的元素可能被分配到不同的位置
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
该方法中最核心的便是链的重新生成,代码如下
Entry<K,V> next = e.next;
// 计算在newTable中的位置,原来在同一条链上的元素可能被分配到不同的位置
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
举个例子,现在有一条链,里面有四个元素,分别是
经过计算,它们中e1,e2,e4要迁移到newTable[2]中,初始状态为
e指向e1,next指向e2
1.迁移e1
由于一开始newTable[2]里没有元素,所以newTable[2]为null
e.next=newTable[2];将e1连接到了null
newTable[2]=e; 此时新数组中链头放了e1
e=next;指针后移,状态如下
2.迁移e2
e.next=newTable[2];将e2的连接到了e1
newTable[2]=e; 此时新数组中链头放了e2
e=next;指针继续后移,状态如下
3.迁移e3
e3会迁移到另一个桶里,原理跟迁移e1是一样的
4.迁移e4
同理e2
最后迁移完的链为 e4—>e2—>e1
newTable[2]桶里装着e4
小总结:其实就是对着原链表,利用一个桶位进行重链(把新进来的元素连接上链头元素,并占用这个链头)
二、多线程下出现的问题
假设有两个线程,1和2
线程1执行到第一步,也就是这个状态,就被挂起了
然而线程2顺利的完成了该子链的迁移,如下
此时我们发现,线程1的e和next如上,还是指向在e1和e2,然而在内存中整条链的顺序都变了
在这时,线程1又继续工作,按照指令
迁移第1次:
e.next=newTable[2]; 此时的链头已经由线程2改为e4了,所以e1会连上e4
newTable[2]=e;
e=next; 这里e1又指向e2
next=e.next;变为
迁移第2次:
将e2连接上链头e1,e2作为链头,变为
迁移第3次:
将e1连接上链头e2,e1作为链头,e指向next,next指向e的下一个,变为
我们会发现迁移来迁移去都无法跳出这个环形链表,所以会发生扩容错误
以上是我个人的理解,如果读者发现有误还请不吝提醒