jdk1.7的HashMap

HashMap从jdk1.8以后有较大改动,主要有两点:

  1. 插入元素改成尾插法(1.7是头插法)
  2. 链表长度超过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