Java基础:集合——深入解剖HashMap底层原理

一、HashMap底层原理

要分为JDK1.8和之前的版本讲解。

我将从三个方面来介绍,分别是底层数据结构、存储结构、源码

1.1、HashMap原理

(1)从底层数据结构来说,JDK1.8之前底层是数组+链表(散列链表),1.8之后链表到达阈值长度就会变成红黑树存储;

(2)从存储结构来说,内部包含了一个Entry类型的数组table,Entry存储着键值对,数组中每一个位置被当成一个桶(Entry),一个桶存放一个链表;

(3)从源码角度来说,

向HashMap添加元素时调用put方法,put方法源码里通过hash函数得到key的hashCode值再来确定桶(Entry)下标;

——>该下标即为元素存放的位置,如果该位置没有元素则直接存该键值对对象(两个对象)在该位置;

——>如果当前位置存在元素,就发生了碰撞,就判断该位置原有元素与要存入的元素的key的hashCode值是否相等,那么需要通过key的equals方法判断这两个对象是否同一个key对象,如果是则直接覆盖,即新值覆盖旧值;

——>如果不同,就要通过拉链法解决冲突,就把 新的键值对对象保存到 旧的键值对对象的next变量中,构成链表。这个链表长度在JDK1.8后如果超过阈值8就会变为红黑树,以提高性能。

get()方法查找原理 简单可分为6步:

(1)定位键所在数组的下标索引,并获取索引位置;

(2)判断索引位置是否为null,为null则直接返回null;

(3)判断 索引位置的key 和要查找传进来的key是否相同(key相同指hashCode和equals都相同),若相同则返回 索引位置 并结束;

(4)判断是否有 后续节点,若没有则结束;

(5)判断 后续节点 是否为红黑树,若为红黑树则遍历红黑树,在遍历的过程中如果遇到一个节点与key要找的key相同,则返回该节点。若不是红黑树则遍历链表;

(6)遍历链表,若存在一个节点的key与要找的key相同则返回该节点。

注意:因为无法调用key为null的hashCode()方法,也就无法确定键值对的桶下标,只能通过指定一个桶下标来存放。HashMap使用第0个桶来存放键为null的键值对。

1.2、再介绍一下底层存储结构

拉链法解决冲突:

拉链法就是 将链表和数组结合。也就是创建一个链表数组,数组中的每一格就是一个链表。若遇到哈希冲突,得到数组下标,把数据放在对应下标元素的链表上即可。

(1)数组

 HashMap是key-value键值对的集合,每一个键也叫一个Entry(桶),这些Entry分散存储在每一个数组中,该数组是HashMap的主干。

(2)链表 

因为数组Table的长度是有限的,使用hash函数计算时可能会出现index冲突的情况,所以要链表来解决冲突

数组Table的每一格元素不单纯只是一个Entry对象还有链表的头结点,每一格Entry对象通过Next指针指向下一个Entry节点;

当新来的Entry映射到冲突数组位置时,只需要插入对应的链表位置即可(一般采用头插法)。

(3)红黑二叉树

 当链表长度超过阈值(8)会将链表转化为红黑树,用于提高性能

注意:什么是红黑二叉树?)

关于详细JDK1.8版本HashMap知识,推荐阅读美团技术团队分享:https://zhuanlan.zhihu.com/p/21673805 

二、HashMap的其余注意点

 HashMap,默认大小为 16

2.1、举一个index冲突的例子

比如调用 hashMap.put("China", 0) ,插入一个Key为“China"的元素;这时候我们需要利用一个哈希函数来确定Entry的具体插入位置(index)。

(1)通过index = Hash("China"),假定最后计算出的index是2,那么Entry的插入结果如下:

(2)但是,因为HashMap的长度是有限的,当插入的Entry越来越多时,再完美的Hash函数也难免会出现index冲突的情况。比如下面这样:

(3)经过hash函数计算发现即将插入的Entry的index值也为2,这样就会与之前插入的Key为“China”的Entry起冲突;这时就可以用链表来解决冲突。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可;此外,新来的Entry节点插入链表时使用的是“头插法”,即会插在链表的头部,因为HashMap的发明者认为后插入的Entry被查找的概率更大。

2.2、采用红黑树的集合都有哪些?

TreeMap、TreeSet、JDK1.8版HashMap底层都用了红黑二叉树,红黑二叉树是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况会退化成一个线性结构。

三、HashMap源码解刨(太血腥了!!)

3.1、get()方法操作源码

实际是根据输入节点的hash值和key值,底层利用getNode方法进行查找

get()方法查找原理 简单可分为6步:

(1)定位键所在数组的下标索引,并获取索引位置;

(2)判断索引位置是否为null,为null则直接返回null;

(3)判断 索引位置的key 和要查找传进来的key是否相同(key相同指hashCode和equals都相同),若相同则返回 索引位置 并结束;

(4)判断是否有 后续节点,若没有则结束;

(5)判断 后续节点 是否为红黑树,若为红黑树则遍历红黑树,在遍历的过程中如果遇到一个节点与key要找的key相同,则返回该节点。若不是红黑树则遍历链表;

(6)遍历链表,若存在一个节点的key与要找的key相同则返回该节点。

 public V get(Object key) {
        Node<K,V> e;
        //实际上是根据输入节点的hash值和key值,利用getNode方法进行查找
         return (e = getNode(hash(key), key)) == null ? null : e.value;
   }

    

在getNode()方法中,如果定位到的节点是TreeNode节点则在红黑树中查找,反之,在链表中查找 

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
         if ((tab = table) != null && (n = tab.length) > 0 &&
             (first = tab[(n - 1) & hash]) != null) {
             if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
             if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                     //若定位到的节点是TreeNode节点,则在树中进行查找
                     return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                      //反之,在链表中查找
                     if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                         return e;
                 } while ((e = e.next) != null);
             }
        }
        return null;
     }

 

3.2、put操作源码

(1)主体源码

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    // 键为 null 单独处理
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    // 确定桶下标
    int i = indexFor(hash, table.length);
    // 先找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value
    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;
        }
    }

    modCount++;
    // 插入新键值对
    addEntry(hash, key, value, i);
    return null;
}

(2)put操作时,遇到key为null的键值对

HashMap允许插入键为null的键值对,但是因为无法调用null的hashCode()方法,也就无法确定键值对的桶下标,只能通过指定一个桶下标来存放。HashMap使用第0个桶来存放键为null的键值对:

private V putForNullKey(V value) {
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(0, null, value, 0);
    return null;
}

(3)put操作时,使用链表的“头插法”,也就是新的键值对插在链表的头部,而不是尾部

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    // 头插法,链表头部指向新的键值对
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}
Entry(int h, K k, V v, Entry<K,V> n) {
    value = v;
    next = n;
    key = k;
    hash = h;
}

3.3、确定桶下标源码

int hash = hash(key);
int i = indexFor(hash, table.length);

(1)计算 hash 值

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
public final int hashCode() {
    return Objects.hashCode(key) ^ Objects.hashCode(value);
}

(2)取模

确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算:

static int indexFor(int h, int length) {
    return h & (length-1);
}

3.4、扩容机制 源代码

什么是扩容机制:

扩容是为了防止HashMap中的元素个数超过了阈值,从而影响性能,而数组是无法自动扩容的,HashMap扩容是申请了一个容量为原来大小两倍的新数组,然后遍历旧数组,从新计算每个数组的索引位置,并复制到新数组中;哈希桶数组大小总是2的幂次方,所以重新计算后的索引位置要么在原来位置不变,要吗是“原来位置+就数组长度”。

参考:连接

设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此查找的复杂度为 O(N/M)。

为了让查找的成本降低,应该使 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。

和扩容相关的参数主要有:capacity、size、threshold 和 load_factor。

参数 含义
capacity table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方。
size 键值对数量。
threshold size 的临界值,当 size 大于等于 threshold 就必须进行扩容操作。
loadFactor

装载因子,table 能够使用的比例,threshold = (int)(capacity* loadFactor)。

static final int DEFAULT_INITIAL_CAPACITY = 16;

static final int MAXIMUM_CAPACITY = 1 << 30;

static final float DEFAULT_LOAD_FACTOR = 0.75f;

transient Entry[] table;

transient int size;

int threshold;

final float loadFactor;

transient int modCount;

(1)当需要扩容时,令 capacity 为原来的两倍

void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    if (size++ >= threshold)
        resize(2 * table.length);
}

(2)扩容使用 resize() 实现,需要注意的是,扩容操作同样需要把 oldTable 的所有键值对重新插入 newTable 中,因此这一步是很费时的。

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++) {
        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);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

(3) 扩容-重新计算桶下标

在进行扩容时,需要把键值对重新计算桶下标,从而放到对应的桶上。在前面提到,HashMap 使用 hash%capacity 来确定桶下标。HashMap capacity 为 2 的 n 次方这一特点能够极大降低重新计算桶下标操作的复杂度

(4)计算数组容量

HashMap 构造函数允许用户传入的容量不是 2 的 n 次方,因为它可以自动地将传入的容量转换为 2 的 n 次方。

以下是 HashMap 中计算数组容量的代码:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
发布了52 篇原创文章 · 获赞 116 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/RuiKe1400360107/article/details/103694260
今日推荐