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