飞道的博客

HashMap 源码超详细分析

231人阅读  评论(0)

集合框架总体结构

Collection 顶层接口下细分为三个类型,分别为

  • List,一种有序的集合。根据索引可以方便的插入,访问。允许重复元素。
    • LinkedList,较高性能的删除插入操作,毕竟指针指一下就好了。
    • ArrayList,根据下标的高性能随机访问。
    • Vector,线程安全的和 ArrayList 几乎功能一样的结构。
  • Set,存储不重复元素的集合。通过 equals 方法来判断
    • HashSet,基于哈希表,保证存储唯一元素的结构。
    • TreeSet,底层利用了许多 TreeMap 的 API。
  • Queue/Deque,Java 提供的标准队列实现,其中 Deque 双端队列支持队列的两端操作。
  • Map,最常用的键值对存储结构。
    • HashMap,提供高效的插入,访问,无序存储。
    • LinkedHashMap,HashMap 的子类,保存了记录的插入顺序。一般通过 Iterator 去遍历。
    • Hashtable,线程安全的 HashMap,已经不建议使用。可以使用同步性能更高的** juc.ConcurrentHashMap**
    • TreeMap,基于红黑树的映射结构, log(n) 时间复杂度的基本操作。提供顺序访问,通过指定的 Comparator 指定,默认升序。其 key 必须实现 Comparable 接口或传入自定义的 Comparator。

(图来自美团点评团队)

常用类分析

对任何集合类的性能分析,具体场景中的适用情况,都不能脱离其底层数据结构的特点。

例如 ArrayList 底层采用动态数组实现,那对于保证有序的,且频繁的插入删除场景其性能是有限的。LinkedList 底层使用双向链表,随机访问的性能就没那么友好了。

HashMap

底层结构

首先要知道的就是构成 HashMap 的内部结构,是 数组(Node<K,V>[] table) + 链表 + 红黑树(1.8 后引入)。

static class Node<K,V> implements Map.Entry<K,V> {}

HashMap 将要存储的键值通过其哈希值确定元素在桶中存储的位置。因为桶的位置有限,而要存储的元素可能无限,所以不同的元素很可能被映射到同一个桶,就是我们常说的哈希冲突。
到这你就要想到为什么重写了 equals 方法,对应的 hashCode 也需要重写。 当一个类提供了自己的相等逻辑时(重写了 equals 方法),这时如果没有重写 hashCode 方法,就无法保证两个 equals 返回 true 的实例的 哈希值相同。

举一个更新的例子,那么当使用该类作为哈希表的键值时就会出现预期外的行为。例如:

User userA = new User(24,"crayon"); // id,昵称
Map<User,String> userMap = new HashMap<>();
userMap.put(userA,"用户A");
//在下一次我们构建一个和 userA “逻辑相同”的对象去取值
User userB = new User(24,"crayon");
userMap.get(userB);

在我们调用 userMap 的 get 方法时,我们预期中返回的正确结果应该是 “crayon”,但很可能取出来的是一个 null。
关键在于,我们自己提供了对象“逻辑相等”的概念,但二者调用 hashCode 时返回的 hash 值却不同。当去 get 的时候,根据 userB 的 hash 值找桶中的位置,而 Object 中默认的 hashCode 计算方法是随着对象的内存地址在改变的,导致最终结果是定位不到 userA 存储的桶位置。

重写 hashCode 方法保证了 equals 相等时,调用 hashCode 返回的 int 值也相同

源码分析

// The default initial capacity - MUST be a power of two.
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 结点树化的阈值
static final int TREEIFY_THRESHOLD = 8;
// 退化为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
// The smallest table capacity for which bins may be treeified.
// 就是说当桶的数目大于这个值是拉出来的链表才会树化,不然就直接 resize。
static final int MIN_TREEIFY_CAPACITY = 64;

DEFFAULT_LOAD_FACTOR 一般叫负载因子,他是触发哈希扩容的重要条件。
再看一个成员变量,size(哈希表实际存储的元素个数)超过 threshold 就触发扩容。

//The next size value at which to resize (capacity * load factor).
int threshold;

插入方法

看来秘密都在 putVal 中

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

建议自己去源码再实际分析一遍

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 1. 空表的话,再去 resize() 进行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2. (n - 1) & hash 就是哈希寻桶位置的实现
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 3. 发生哈希冲突
    else {
        Node<K,V> e; K k;
        // p - 已存储元素,hash - 待存储元素的哈希值
        // 4. 判断是不是相同的元素,是的话跳到 10.
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 5. 不同元素,是红黑树结构,调红黑树的插入
        else if (p instanceof TreeNode)
        e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 6. 
        else {
            for (int binCount = 0; ; ++binCount) {
                // 一直没有重复元素,到末尾直接newNode 放进去就行 
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                    treeifyBin(tab, hash);
                    break;
                }
                // 在遍历链表的过程中,看有没有重复元素,有的话跳出来
                // 去 10. 处更新旧value
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                // p = p.next
                p = e;
            }
        }
        // 10. 
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // 只有插入新的值才会改变 modCount
    ++modCount;
    // size 有没有超过 阈值
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

从 p = tab[i = (n - 1) & hash] 我们知道是通过 (n - 1) & hash 去把哈希值映射到数组下标中。n 是桶数组长度。
这里就解释了为什么 HashMap 的桶长度 must be power of two.

当长度为 2 的幂时,其二进制表示中只有一位为 1,例如 16 表示为

0001 0000,32 表示为 0010 0000。那么对应为 n - 1时,其表示为

0000 1111。我们假设某个要存储的哈希值的二进制表示为 1101 1001,是完全没有规律的。那么当这两个数做 与 操作(同 1 才为 1,否则为 0)时,

0000 1111

1101 1001


0000 1001

有没有发现,高几位的值不管哈希值是什么,都算出是 0,也就保证 & 出来的结果一定在桶长度内。(优化思路就是把简单的取余操作换成位运算)

桶长度 must be power of two 还有一个好处,往下看你就知道了。

扩容方法

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // 超过最大长度了,随你碰撞好了
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 可以看到,新的容量是旧容量的两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 初始化操作
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 计算哈希值的高一位是 0 还是 1
                        if ((e.hash & oldCap) == 0) { // 为 0
                            ...
                        }
                        else { // 高一位为 1
                            ...
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        // 高一位为 0 时,元素的存储位置数组下标没有变化
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        // 为 1,元素存储位置为 旧数组下标 + 原容量 。
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

大家可以先自己思考一下为什么通过判断(e.hash & oldCap) == 0 就能判断扩容后元素的存储位置。

有没有看出来点什么?扩容前和扩容后的 & 计算值差别就只是元素 hash 值中和原容量 16 二进制表示中的那个 1 的位置是 0 还是 1 有关。

如果旧元素的二进制表示为 1100 1001,那么计算结果就是 0000 1001。

看到这说明你是一个一直在提高自己的人,给自己点个赞!

如果你觉得对你有帮助的话,也可以点赞支持一下。😃


转载:https://blog.csdn.net/FangHX25/article/details/105271787
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场