史上最详细的HashMap详解–源码分析
ps.本文所有源码都是基于jdk1.6
数据结构(数组+链表)
图1-1
如下代码所示,HashMap实际上是由Entry数组组成的,Entry就是一个单相链表。所以HashMap实际上就是一个数组和链表的结合体,如图1-1所示。
transient Entry[] table;//HashMap实际上是由Entry数组组成的
static class Entry<K,V> implements Map.Entry<K,V> {//这个Entry就是一个链表
final K key;
V value;
Entry<K,V> next;
final int hash;
...
}
基础方法
put方法
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);//key是null的情况单独处理
int hash = hash(key.hashCode());//算出key的hash
int i = indexFor(hash, table.length);//根据key的hash值计算出table数组的下标,下面有详细讲解
for (Entry<K,V> e = table[i]; e != null; e = e.next) { //如果之前存在这个key,更新value
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++;
//不存在这个key,插入新value,此处会引发线程不安全,后面会说为什么
addEntry(hash, key, value, i);
return null;
}
put方法是最常用的方法之一,了解put的实现,先得了解indexFor,addEntry方法,下面是它们的源码
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); //容量不足要扩容
}
//根据hash值计算出table数组的下标
//length必须是2的幂次(比如16,这样一个数与上15都会小于等于15),通过与操作来提升性能
static int indexFor(int h, int length) {
return h & (length-1);
}
remove方法
remove方法很简单,找到对应的元素,直接删除,没有什么特殊的
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
final Entry<K,V> removeEntryForKey(Object key) {
int hash = (key == null) ? 0 : hash(key.hashCode());
int i = indexFor(hash, table.length); //定位table下标,方式和put一样
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;
}
扩容
先介绍几个概念
- capacity:容量,也就是前面说的Entry数组的大小,默认为16
- size:map中元素的个数
- loadFactor:装载因子,用来衡量map满的程度,默认为0.75f
- threshold:表示当size>=threshold的时候触发resize(扩容)操作,threshold = capacity * loadFactor
添加数据的时候发现空间不够用了(size>=threshold),就会进行resize,容量为原来的2倍
过程如下:
1.如果旧table的容量为最大容量,threshold = Integer.MAX_VALUE,直接return,也就是不扩容了
2.否则,申请一个新的Entry数组,容量为之前的2倍
3.循环遍历旧table中的元素,并且释放旧table中的资源,且为元素重新计算在新table中的下标,并插入到新table中
4.将table指向新table
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); }
void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { //循环遍历原table Entry<K,V> e = src[j]; if (e != null) { src[j] = null; //释放原table中的资源 do { Entry<K,V> next = e.next; //为旧HashMap中的所有元素重新计算table下标 int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; //将重新计算完下标的元素插入到新的Table中 newTable[i] = e; e = next; } while (e != null); } } }
为什么说HashMap线程不安全
1、多线程put导致元素丢失
根据我们刚才的分析,put操作会调用addEntry函数
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线程同时对table的同一个下标链表调用addEntry,两个线程会同时得到现在的头结点,然后A写入新的头结点之后,B也写入新的头结点,那B的写入操作就会覆盖A的写入操作造成A的写入操作丢失
2、put一个非null的元素后,get出来的可能是null
我们知道put操作有可能会触发扩容,就是我们上面讲的resize,resize里面重新new一个Entry数组,其容量就是旧容量的2倍,这时候,需要重新根据hash方法将旧数组分布到新的数组中,也就是其中的transfer方法,在这个方法里,将旧数组赋值给src,遍历src,当src的元素非null时,就将src中的该元素置null,即将旧数组中的元素置null了,也就是这一句:
if (e != null) { src[j] = null;
此时若有get方法访问这个key,它取得的还是旧数组,当然就取不到其对应的value了。