jdk1.7的HashMap
HashMap从jdk1.8以后有较大改动,主要有两点:
- 插入元素改成尾插法(1.7是头插法)
- 链表长度超过8个转成红黑树(1.7一直是链表)
jdk为何会做这两点改变呢?
下面我们通过1.7的源码来看看这两点有什么问题或者不足之处
头插法改成尾插法
我们先来看看jdk1.7HashMap中插入元素的源码
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
//通过key计算hash值
int hash = hash(key.hashCode());
//通过hash值计算数组下标
int i = indexFor(hash, table.length);
//遍历这个下标位置的链表,如果有相同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;
}
}
//没有相同key,就需要插入元素,修改次数+1
modCount++;
//添加元素
addEntry(hash, key, value, i);
return null;
}
上面是put()方法,主要思路是通过添加元素的key计算出对应数组位置,如果这个位置是空的就直接走下面addEntry(),如果不是空的就遍历里面的链表,看看是否有相同key,如果有就覆盖旧值,如果没有就走下面的addEntry()。下面我们来看看addEntry()里添加元素的具体细节
void addEntry(int hash, K key, V value, int bucketIndex) {
//e表示原链表
Entry<K,V> e = table[bucketIndex];
//设置新链表
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
if (size++ >= threshold)
//size+1以后如果大于阈值,就会扩容成原来的两倍
resize(2 * table.length);
}
//原链表添加新元素==>新链表
//h=hash值,k=新元素key,v=新元素value,n=原链表
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
//next指向了原链表,新元素插入到了原链表头部
next = n;
key = k;
hash = h;
}
//扩容方法
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];
//老table重新hash到新table上
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
//遍历老table
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
//①
Entry<K,V> next = e.next;
//重新计算数组下标
int i = indexFor(e.hash, newCapacity);
//下面三步重新组装链表挂在此下标位置
//但是顺便颠倒了,因为采用头插法(newTable[i]=next)
e.next = newTable[i];
newTable[i] = e;
//v
e = next;
} while (e != null);
}
}
}
通过上面源码看出1.7确实采用头插法,那头插法有什么问题呢?下面我们画图来解释一波
如果两个线程(线程A和线程B)同时进行扩容,线程A走到步骤①时被挂起,然后线程B走完全程,最后线程A再继续执行。因为扩容以后,元素会重新插入到新下标下,插入采用的是头插法,那么到新下标以后,链表顺序会反转,这时,如果线程并发扩容,会形成一个环形链表。
如果形成环形链表,当调用get()方法时,刚好映射到环形链表,就会出现死循环。
为了避免出现环形链表,jdk1.8开始使用尾插法。我们平时开发的时候,如果在并发环境下最好使用ConcurrentHashMap。
链表变成红黑树
我们都知道哈希表是否完美要看里面的哈希算法,但是再完美的哈希算法也不能完全避免哈希冲突,jdk中的HashMap则是采用数组加链表来解决哈希冲突,但是如果出现链表很长的情况,查询数据时,效率也会很差,因为链表的优势在于插入和删除操作,如果只是在头部或尾部插入和删除,那时间复杂度只要O(1),但是查询操作的平均时间是n/2。
如果链表长度过长,那查询效率自然也会很低。jdk1.8为了提高查询效率,引入了红黑树,因为红黑树的平均查询时间是log(n)。
为什么链表长度达到8才会转成红黑树呢?
这是因为如果长度达到8,红黑树的平均时间是log(8)=3,如果使用链表,平均时间是8/2=4;但是长度没有达到8,那么这两个查询效率差不多,而红黑树有时候需要左旋右旋等操作,也会影响效率,所以当长度没有到8时,使用链表,而且原来是红黑树,当长度小于6时,红黑树会转回链表。
最后注意一点:链表转成红黑树的前提是HashMap的长度大于64