HashMap源码剖析

1.前言

为什么要读源码?

  • 知其然知其所以然:阅读源码有助于我们了解开源软件的核心原理和执行流程,这样有助于我们快速定位并修复问题。
  • 有助于我们自己写出结构更优质的代码:学习源码可以让我们站在巨人的肩膀上,我们可以学习源码中的优秀编码技巧和巧妙的设计思路,以及设计模式的运用,还有一些经典的编码规范和命名规则等。以此来约束和改进自己的编程代码,有助于我们写成更好的代码。

2.HashMap基本属性

// 真正存储元素的数组,他的长度总是2的幂次方
// 不管你自己设不设定长度,都不影响他的长度为2的幂次方
transient Node<K,V>[] table;

// HashMap的默认初始化容量16(桶的个数)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

// HashMap设定的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;

// HashMap的默认加载因子,用于判断什么时候什么时候table需要进行扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 树化阈值,决定了什么时候桶上的元素超过多少个,就会转变成红黑树
// 所谓“桶”就是哈希表中盛放不同key链表的容器,在这里我们可以把每个key的位置看作是一个桶
static final int TREEIFY_THRESHOLD = 8;

// 反树化阈值,当桶上的元素小于这个值的时候,将把树转换为链表
static final int UNTREEIFY_THRESHOLD = 6;

// 桶中结构转化为红黑树时,table中的最小容量(桶的最少个数),只有超过64个桶,才能进行树化
static final int MIN_TREEIFY_CAPACITY = 64;

// HashMap中存放的所有元素个数,要与桶的个数区分开来
transient int size;

// 对HashMap进行结构修改的计数器(增加节点、删除节点、扩容、树化等不包括修改节点的值)
transient int modCount;

// 扩容临界值,当不为null的桶的个数超过了这个值,就会对table进行扩容
// threshold = loadFactor * capacity
int threshold;

// 加载因子,默认为0.75
// 加载因子越大,table数组中存放的元素越密集,反之则不然。
// 0.75是官方给出的一个较好的临界值,一般不会手动设置加载因子
final float loadFactor;

3.HashMap的构造方法

// (1)无参构造方法,指定加载因子为默认加载因子0.75
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}


// (2)指定“容量大小”和“加载因子”的构造函数
public HashMap(int initialCapacity, float loadFactor) {
    // 校验初始化Capacity值是否合理
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    
    // 如果指定初始化的Capacity值超过HashMap规定的最大值则将initialCapacity重置为MAXIMUM_CAPACITY
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    
    // 校验loadFactor是否合理
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    
    this.loadFactor = loadFactor;
    // 初始化容量,保证容量是2的幂次方
    this.threshold = tableSizeFor(initialCapacity);
}


// (3)
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}


// (4)传入Map集合的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    // 将Map中的键值对复制到该HashMap
    putMapEntries(m, false);
}

至此我们就了解了所有构造方法,现在我们具体看一下构造方法2中的tableSizeFor(initialCapacity)方法以及构造方法4中的putMapEntries(m, false)方法

tableSizeFor()

// 该方法用于初始化容量,保证容量是2的幂次方,且这个数不会超过太多我们自定义的初始化容量
static final int tableSizeFor(int cap) {
    // 减一的目的是为了让初始化的值刚好大于cap,而不是多出很多
    // 举个例子来说,cap = 10,我们减一操作后的结果n为16,不减一进行操作后的结果为32
    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;
}

注意!!!
到这里你可能对this.threshold = tableSizeFor(initialCapacity);有点懵,说好的threshold是扩容阈值,怎么就把table的初始化容量值(tableSizeFor(int cap)的执行结果)赋值给了threshold

你先别急,等看到后面resize()方法,你就明白了。总而言之这里只是暂存一下容量值,真正的扩容阈值并不是此时的值。

putMapEntries()

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    // 获得传入map的大小
    int s = m.size();
    if (s > 0) {
        // 判断table是否已经初始化
        if (table == null) { // pre-size
            // 未初始化,s为m的实际元素个数
            float ft = ((float)s / loadFactor) + 1.0F;
            // 保证计算出来的ft不会超过HashMap定义的最大容量
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            // 计算得到的t大于阈值,则初始化阈值
            if (t > threshold)
                threshold = tableSizeFor(t);
        }
        // 已初始化,并且m元素个数大于阈值,进行扩容处理
        else if (s > threshold)
            resize();
        // 将m中的所有元素添加至HashMap中
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            putVal(hash(key), key, value, false, evict);
        }
    }
}

4.hash()方法

该方法的目的是为了在table长度不太大的情况下,让高16位参与寻址过程中。我们首先要了解get()方法中寻址的操作为(n - 1) & hash这么个操作,如果table长度不太大的话(比如初始化的16),hash值只有低十六位会参与寻址过程,为了避免碰撞,最好让高16位也参与到寻址过程中,所以hash()让key的高16位先与其hash值进行一次扰动,将扰动结果作为key的hash值进行寻址运算。

// 比如说你的table长度只有16,但是hashCode有32位,高16位无法参与到寻址运算(hash & (n - 1))这个过程中,而经过hash()函数以后,
// 将高16位与原本的hash值相与,获得最终的hash值,这样高16位就参与到了寻址运算过程中。
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

5.put()方法

可以结合着我画的思维导图看。

作图不易,转载请声明出处。
请添加图片描述

// 调用putVal方法
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}


/**
 * @param hash key的哈希值
 * @param key 
 * @param value 需要存放的值
 * @param onlyIfAbsent 如果为true,则不会修改已经存在的值
 * @param evict 如果为false,代表table处于创建模式
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    // 指向table的索引副本
    Node<K,V>[] tab;
    // 指向当前节点
    Node<K,V> p;
    // n表示table.length - 1,i表示路由寻址结果,即当前节点需要插入位置的索引
    int n, i;
    
    // 1.table未初始化或者长度为0,进行扩容
    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);
    // 桶中已经存在元素(处理hash冲突)
    else {
        // 节点e用于指向key相同的节点,也就是我们要找的节点,后面会根据onlyIfAbsent判断是否将旧值覆盖
        Node<K,V> e;
        K k;
        
        // (1)在HashMap中查找我们需要找到的节点,存在则将e指向该节点,不存在则插入新节点
        // 判断当前桶的位置的节点是否是我们要找的节点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 判断插入的是否是红黑树节点
        else if (p instanceof TreeNode)
            // 放入树中
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 不是红黑树节点则说明为链表结点
        else {
            // 接着遍历链表,如果有同key相等的节点,那么就使用e指向它
            // 如果没有找到key相等的节点,那么就会在链表的尾部插入节点,并且在此之前需要判断是否达到阈值TREEIFY_THRESHOLD,是否需要将链表转换为红黑树
            for (int binCount = 0; ; ++binCount) {
                
                // 到达链表尾部
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法
                    // 这个方法会根据 HashMap 数组来决定是否转换为红黑树。
                    // 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    // 跳出循环
                    break;
                }
                
                // 未到达链表的尾部
                // 判断链表中结点的key值与插入的元素的key值是否相等,如果key相等的话,就意味着e已经指向key相等且已存在的节点
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 相等,跳出循环
                    break;
                
                // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                p = e;
            }
        }
        
        //(2)找到了我们需要找的元素,根据onlyIfAbsent判断是否进行值覆盖
        // 表示在桶中找到key值、hash值与插入元素相等的结点,然后根据onlyIfAbsent判断对其进行覆盖操作
        if (e != null) { // existing mapping for key
            // 记录e的value
            V oldValue = e.value;
            // onlyIfAbsent为false,才会进行值覆盖
            // 如果onlyIfAbsent为true则不会进行值覆盖,只有value==null才会进行赋值
            if (!onlyIfAbsent || oldValue == null)
                //用新值替换旧值
                e.value = value;
            // 访问后回调
            afterNodeAccess(e);
            // 返回旧值,执行完毕,下面的代码不会再执行
            return oldValue;
        }
    }
    
    // 3.最后处理
    // 表示散列表结构被修改的次数,替换Node的val不算
    ++modCount;
    // 走到这里就意味着插入了新的节点而不是替换了某个节点的val,实际大小大于阈值则扩容
    if (++size > threshold)
        resize();
    // 插入后回调
    afterNodeInsertion(evict);
    return null;
}

putVal()方法的注释写的有点多,不过大体结构可以这样来看:

  1. table未初始化或者长度为0,进行扩容
  2. 接下来将进行存值操作,也就是节点(链表节点/树节点)的插入
    • 判断当前桶位是否为空,为空直接插入新节点tab[i] = newNode(hash, key, value, null)
    • 当前桶位不为空
      • 判断当前桶的位置的节点是否是我们要找的节点,是的话就将Node<K,V> e指向该节点
      • 判断当前桶位置存放的节点是否是树节点,是的话将向树中存值
      • 走到这里就意味着桶位置存放的节点为链表头节点,然后对其进行遍历,如果有key相等的节点就将e指向该节点,没有则插入新节点,插入之后还需要对链表的长度进行判断,如果超过了树化阈值,那么就需要进行树化操作
      • 最后判断e指向的节点是否为空,不为空,就根据onlyIfAbsent是否进行值覆盖,且该方法到此返回,不进行第三步
  3. 最后处理
    • 走到这里意味着本次方法调用用掉了一个桶位或者在链表/红黑树中插入了一个新节点
    • modCount加1
    • 判断插入节点后的table是否需要进行扩容

6.resize()方法

在此之前可以看看我的画的思维导图:
在这里插入图片描述

resize()方法主要用于扩容,也用于对table进行初始化操作,扩容的目的是为了解决哈希冲突导致的链化影响查询效率的问题,扩容可以缓解该问题。

final Node<K,V>[] resize() {
    
    // 引用扩容前的哈希表
    Node<K,V>[] oldTab = table;
    // 表示扩容前的table数组长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 表示扩容之前的扩容阈值(也就是触发本次扩容的阈值)
    int oldThr = threshold;
    // newCap表示扩容之后table数组的大小,newThr表示下次再触发扩容的条件
    int newCap, newThr = 0;
    
    // 1.对newCap和newThr进行赋值
    
    // oldCap > 0
    // 如果oldCap大于0,意味着table已经被初始化过了,现在是一场正常扩容
    if (oldCap > 0) {
        // 如果oldCap已经到达了HashMap规定的最大值,就意味着不能继续扩容了,将扩容阈值设置为Integer.MAX_VALUE(无法继续扩容了)
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 更多的还是这种情况,一般很难达到MAXIMUM_CAPACITY
        // newCap等于oldCap左移1位,意味着扩大了两倍,如果newCap合理(小于MAXIMUM_CAPACITY且大于等于DEFAULT_INITIAL_CAPACITY),
        // 那么会将oldThr同样左移1位,意味着扩容阈值也跟着翻倍,threshold = capacity * loadFactor
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    
    // 从下面的代码我们也可以了解到为什么new HashMap(initCap, loadFactor)中将initCap赋值给了threshold
    // oldCap == 0
    // 走到这里意味着oldCap不大于0,也就是说oldCap等于0,table还未进行初始化(table == null)
    // 什么时候table=null,而oldThr又大于0呢
    // 1.new HashMap(initCap, loadFactor)
    // 2.new HashMap(initCap)
    // 3.new HashMap(map)
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
        
    // oldCap == 0, oldThr == 0
    // 走到这里意味着该HashMap调用了默认构造函数进行了初始化
    // new HashMap()
    else {               
        // 初始化cap
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 初始化Thr
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    
    // 如果new Thr仍然为0,意味着前面的代码还没有对其进行初始化,在这里我们将对其进行初始化
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    
    // 2.对table进行扩容
    
    // 创建一个更大的数组,如果扩容前原数组有数据,将原数组的数据迁移到新数组中,没有则直接将扩容后的新数组直接返回。
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // oldTab == null说明这是初始化操作,不等于null则意味着是正常扩容过程
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            
            // 用于指向当前节点
            Node<K,V> e;
            
            // 将e指向当前节点,当前节点是单个节点还是链表头节点还是红黑树根节点,我们都还未知,接下来进行详细处理。
            if ((e = oldTab[j]) != null) {
                // 此时e已经指向该节点了,将该节点置为null方便jvm进行垃圾回收
                oldTab[j] = null;
                
                // 第一种情况,当前桶为只有一个元素(e.next==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;
                        // 根据hash & oldCap的结果分别存放到高位链表或者低位链表中
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 断开高位链表和低位链表,将其存入newTab
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

我们再来理一下思路:

  1. newCapnewThr进行赋值(对四种构造方法进行不同的赋值操作)

    • oldCap>0,意味着本地resize()方法的执行是为了扩容
    • oldCap=0,意味着本次resize()方法的执行是为了初始化
      • 不管oldThr的值为不为0,后续都会初始化newCap的值,以及newThr的值,newThr的值本来是保存着构造方法传入的initCap的值,在这里将真正转变为扩容阈值(loadFactor * capacity
  2. 对table进行扩容,创建一个新的数组newTab,大小为newCap

    • 如果oldTab等于null,意味着当前resize()的目的是进行初始化,直接将newTab返回即可
    • 如果oldTab不等于null,意味着当前resize()的目的是为了进行扩容,需要将原table(oldTab)中的数据迁移到新数组(newTab)中
      • 第一种情况,当前桶为只有一个元素(e.next==null说明是单个节点),将其迁移到新数组中即可
      • 第二种情况,该桶位的节点已经树化
      • 第三种情况,当前桶位已经形成链表,即当前节点为链表的头节点

到此,resize方法执行完毕。

要注意的是,第三种情况并不是直接将原链表存入newTab中,而是分别创建高低位链表,将链表中的节点通过(e.hash & oldCap) == 0分别存放到高位链表和低位链表,最后低位链表和高位链表又将分别存入newTab[j]newTab[j + oldCap]中,至此resize()方法执行完毕,这样的目的是将数据更加分散,而不是大量的数据都存放在一个桶上,这样一来有利于降低get的时间复杂度。

再贴一下图:
请添加图片描述

7.get()方法

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    
    // 指向table的索引副本
    Node<K,V>[] tab;
    
    // first指向索引处的第一个节点(桶中的头元素),e用来指向临时Node节点
    Node<K,V> first, e;
    
    // n = table.length - 1,k为key
    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) {
            
            // 在树中get
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            
            // 在链表中get
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

经过了resize()put()的摧残,相信对你来说get()方法还是很好理解的,这里我就不细说了哈哈哈

8.写在最后

为什么树化阈值要设置为8?

TreeNodes(树) 占用空间是普通 Nodes(链表) 的两倍,所以只有当 bin(bucket 桶) 包含足够多的结点时才会转成 TreeNodes,而是否足够多就是由 TREEIFY_THRESH〇LD 的值决定的。当 bin(bucket 桶) 中结点数变少时,又会转成普通的 bin(bucket 桶)。并且我们查看源码的时候发现,链表长度达到 8 就转成红黑树,当长度降到 6 就转成普通 bin(bucket 桶)。

这样就解释了为什么不是一开始就将其转换为 TreeNodes,而是需要一定结点数之后才转为 TreeNodes,更直白的说就是权衡空间和时间。

hashCode 算法下所有 桶 中结点的分布频率会遵循泊松分布,这时一个桶中链表长度超过 8 个元素的槪率非常小,权衡空间和时间复杂度,所以选择 8 这个数宇。

为什么HashMap中的table的长度要设定为2的幂次方?

因为在HashMap中的table的length等于2的n次方的时候,才会有hash%length等于hash&(length-1),哈希算法的目的是为了加快哈希计算以及减少哈希冲突,所以此时&操作更合适,因此table的长度总是2的幂次方。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

少不入川。

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值