jdk17HashMap的问题

58次阅读

共计 2419 个字符,预计需要花费 7 分钟才能阅读完成。

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


正文完
 0