Java面试重点_9. HashMap常用方法和扩容, 树化, 链化源码详尽分析
文章目录
一, HashMap
数组与链表在处理数据时各有优缺点,数组查询速度很快而插入很慢,链表在插入时表现优秀但查询无力。哈希表则整合了数组与链表的优点,能在插入和查找等方面都有不错的速度。我们之后要分析的HashMap就是基于哈希表实现的,不过在JDK1.8中还引入了红黑树,其性能进一步提升了。本文主要分析JDK中关于Map的定义。
HashMap底层基于哈希表, 采用数组存储数据,使用链表来解决哈希碰撞.
- 在JDK1.8中还引入了红黑树来解决链表长度过长导致的查询速度下降问题;
二, HashMap的结构如下所示:

三, 构造函数与成员变量

四, 成员变量

modCount: hashMap在迭代的时候,会把modCount传入,作为期望值(类似于乐观锁)如果迭代的时候其他线程进行了增删改操作,modCount就会改变,改变后继续迭代会抛出异常ConcurrentModificationException。
1, 概述
HashMap 最早出现在 JDK 1.2中,底层基于散列算法实现。HashMap 允许 null 键和 null 值,在计算哈键的哈希值时,null 键哈希值为 0。HashMap 并不保证键值对的顺序,这意味着在进行某些操作后,键值对的顺序可能会发生变化。另外,需要注意的是,HashMap 是非线程安全类,在多线程环境下可能会存在问题。
2, 原理

五, 源码分析
3.0 构造方法
HashMap 的构造方法不多,只有四个。HashMap 构造方法做的事情比较简单,一般都是初始化一些重要变量,比如 loadFactor 和 threshold。而底层的数据结构则是延迟到插入键值对时再进行初始化。HashMap 相关构造方法如下:

上面4个构造方法中,大家平时用的最多的应该是第一个了。
- 第一个构造方法很简单,仅将 loadFactor 变量设为默认值。
- 构造方法2调用了构造方法3,
- 而构造方法3仍然只是设置了一些变量。
- 构造方法4则是将另一个 Map 中的映射拷贝一份到自己的存储结构中来,这个方法不是很常用。
3.1 预备知识(初始容量, 负载因子, 阈值)
我们在一般情况下,都会使用无参构造方法创建 HashMap。但当我们对时间和空间复杂度有要求的时候,使用默认值有时可能达不到我们的要求,这个时候我们就需要手动调参。在 HashMap 构造方法中,可供我们调整的参数有两个,一个是初始容量 initialCapacity,另一个负载因子 loadFactor。通过这两个设定这两个参数,可以进一步影响阈值大小。但初始阈值 threshold 仅由 initialCapacity 经过移位操作计算得出。他们的作用分别如下:

Q:什么是负载因子?
- 在 HashMap 中,临界值(threshold) = 负载因子(loadFactor) * 容量(capacity)。
- loadFactor 是装载因子,表示 HashMap 满的程度,默认值为 0.75f,也就是说默认情况下,当 HashMap 中元素个数达到了容量的 3/4 的时候就会进行自动扩容。
- 在向 HashMap 中添加元素过程中,如果 元素个数(size)超过临界值(threshold) 的时候,就会进行自动扩容(resize),并且,在扩容之后,还需要对 HashMap 中原有元素进行 rehash,即将原来桶中的元素重新分配到新的桶中。
HashMap的成员变量如下:

默认情况下,HashMap 初始容量是16,负载因子为 0.75。这里并没有默认阈值,原因是阈值可由容量乘上负载因子计算而来(注释中有说明),即threshold = capacity * loadFactor。但当你仔细看构造方法3时,会发现阈值并不是由上面公式计算而来,而是通过一个方法算出来的。这是不是可以说明 threshold 变量的注释有误呢?还是仅这里进行了特殊处理,其他地方遵循计算公式呢?关于这个疑问,这里也先不说明,后面在分析扩容方法时,再来解释这个问题。接下来,我们来看看初始化 threshold 的方法长什么样的的,源码如下:
/**
 * Returns a power of two size for the given target capacity.
 */
 //传入容量, 计算扩容的阈值
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;
}
上面的代码长的有点不太好看,反正我第一次看的时候不明白它想干啥。不过后来在纸上画画,知道了它的用途。总结起来就一句话:找到大于或等于 cap 的最小2的幂。至于为啥要这样,后面再解释。我们先来看看 tableSizeFor 方法的图解:

说完了初始阈值的计算过程,再来说说负载因子(loadFactor)。对于 HashMap 来说,负载因子是一个很重要的参数,该参数反应了 HashMap 桶数组的使用情况(假设键值对节点均匀分布在桶数组中)。
 通过调节负载因子,可使 HashMap 时间和空间复杂度上有不同的表现。
- 当我们调低负载因子时,HashMap 所能容纳的键值对数量变少。扩容时,重新将键值对存储新的桶数组里,键与键之间产生的碰撞会下降,链表长度变短。此时,HashMap 的增删改查等操作的效率将会变高,这里是典型的拿空间换时间。
- 相反,如果增加负载因子(负载因子可以大于1),HashMap 所能容纳的键值对数量变多,空间利用率高,但碰撞率也高。这意味着链表长度变长,效率也随之降低,这种情况是拿时间换空间。至于负载因子怎么调节,这个看使用场景了。一般情况下,我们用默认值就可以了。
思考一个问题: 为什么负载因子默认为0.75?
3.2 查找
HashMap 的查找操作比较简单,查找步骤与原理篇介绍一致,即先定位键值对所在的桶的位置,然后再对链表或红黑树进行查找。通过这两步即可完成查找,该操作相关代码如下:
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) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 1. 定位键值对所在桶的位置
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
         // 永远检查第一个node
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            // 2. 如果 first 是 TreeNode 类型,则调用黑红树查找方法
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                
            // 2. 对链表进行查找
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
查找的核心逻辑是封装在 getNode 方法中的,getNode 方法源码我已经写了一些注释,应该不难看懂。我们先来看看查找过程的第一步 - 确定桶位置,其实现代码如下:
// index = (n - 1) & hash
first = tab[(n - 1) & hash]
这里通过(n - 1)& hash即可算出桶的在桶数组中的位置,可能有的朋友不太明白这里为什么这么做,这里简单解释一下。HashMap 中桶数组的大小 length 总是2的幂,此时,(n - 1) & hash 等价于对 length 取余。但取余的计算效率没有位运算高,所以(n - 1) & hash也是一个小的优化。举个例子说明一下吧,假设 hash = 185,n = 16。计算过程示意图如下:

在 Java 中,hashCode 方法产生的 hash 是 int 类型,32 位宽。前16位为高位,后16位为低位,所以要右移16位。
上面所说的是重新计算 hash 的一个好处,除此之外,重新计算 hash 的另一个好处是可以增加 hash 的复杂度。当我们覆写 hashCode 方法时,可能会写出分布性不佳的 hashCode 方法,进而导致 hash 的冲突率比较高。通过移位和异或运算,可以让 hash 变得更复杂,进而影响 hash 的分布性。这也就是为什么 HashMap 不直接使用键对象原始 hash 的原因了。
面试点, HashMap的get流程


3.3 遍历
和查找一样,遍历操作也是大家使用频率比较高的一个操作。对于 遍历 HashMap,我们一般都会用下面的方式:
for(Object key : map.keySet()){
    //do something like map.get(key)= value;
}
或者
for(HashMap.Entry entry : map.entrySet()){
    //do some thing
    // entry.getKey();
    //entry.getValue();
}
从上面代码片段中可以看出,大家一般都是对 HashMap 的 key 集合或 Entry 集合进行遍历。上面代码片段中用 foreach 遍历 keySet 方法产生的集合,在编译时会转换成用迭代器遍历,等价于:
Set keys = map.keySet();
Iterator ite = keys.iterator();
while (ite.hasNext()) {
    Object key = ite.next();
    // do something
}
大家在遍历 HashMap 的过程中会发现,多次对 HashMap 进行遍历时,遍历结果顺序都是一致的。但这个顺序和插入的顺序一般都是不一致的。产生上述行为的原因是怎样的呢?大家想一下原因。我先把遍历相关的代码贴出来,如下:
public Set<K> keySet() {
    Set<K> ks = keySet;
    if (ks == null) {
        ks = new KeySet();
        keySet = ks;
    }
    return ks;
}
/**
 * 键集合
 */
final class KeySet extends AbstractSet<K> {
    public final int size()                 { return size; }
    public final void clear()               { HashMap.this.clear(); }
    public final Iterator<K> iterator()     { return new KeyIterator(); }
    public final boolean contains(Object o) { return containsKey(o); }
    public final boolean remove(Object key) {
        return removeNode(hash(key), key, null, false, true) != null;
    }
    // 省略部分代码
}
/**
 * 键迭代器
 */
final class KeyIterator extends HashIterator 
    implements Iterator<K> {
    public final K next() { return nextNode().key; }
}
abstract class HashIterator {
    Node<K,V> next;        // next entry to return
    Node<K,V> current;     // current entry
    int expectedModCount;  // for fast-fail
    int index;             // current slot
    HashIterator() {
        expectedModCount = modCount;
        Node<K,V>[] t = table;
        current = next = null;
        index = 0;
        if (t != null && size > 0) { // advance to first entry 
            // 寻找第一个包含链表节点引用的桶
            do {} while (index < t.length && (next = t[index++]) == null);
        }
    }
    public final boolean hasNext() {
        return next != null;
    }
    final Node<K,V> nextNode() {
        Node<K,V>[] t;
        Node<K,V> e = next;
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        if (e == null)
            throw new NoSuchElementException();
        if ((next = (current = e).next) == null && (t = table) != null) {
            // 寻找下一个包含链表节点引用的桶
            do {} while (index < t.length && (next = t[index++]) == null);
        }
        return e;
    }
    //省略部分代码
}

3.4 插入
3.4.1 插入逻辑
首先肯定是先定位要插入的键值对属于哪个桶,定位到桶后,再判断桶是否为空。如果为空,则将键值对存入即可。如果不为空,则需将键值对接在链表最后一个位置,或者更新键值对。这就是 HashMap 的插入流程,是不是觉得很简单。当然,大家先别高兴。这只是一个简化版的插入流程,真正的插入流程要复杂不少。首先 HashMap 是变长集合,所以需要考虑扩容的问题。其次,在 JDK 1.8 中,HashMap 引入了红黑树优化过长链表,这里还要考虑多长的链表需要进行优化,优化过程又是怎样的问题。引入这里两个问题后,大家会发现原本简单的操作,现在略显复杂了。在本节中,我将先分析插入操作的源码,扩容、树化(链表转为红黑树,下同)以及其他和树结构相关的操作,随后将在独立的两小结中进行分析。接下来,先来看一下插入操作的源码:

插入操作的入口方法是 put(K,V),但核心逻辑在V putVal(int, K, V, boolean, boolean) 方法中。putVal 方法主要做了这么几件事情:
- 当桶数组 table 为空时,通过扩容的方式初始化 table
- 查找要插入的键值对是否已经存在,存在的话根据条件判断是否用新值替换旧值
- 如果不存在,则将键值对链入链表中,并根据链表长度决定是否将链表转为红黑树
- 判断键值对数量是否大于阈值,大于的话则进行扩容操作
以上就是 HashMap 插入的逻辑,并不是很复杂,这里就不多说了。接下来来分析一下扩容机制。
面试点, HashMap的put流程

 
3.4.2 扩容机制
在 Java 中,数组的长度是固定的,这意味着数组只能存储固定量的数据。但在开发的过程中,很多时候我们无法知道该建多大的数组合适。建小了不够用,建大了用不完,造成浪费。如果我们能实现一种变长的数组,并按需分配空间就好了。好在,我们不用自己实现变长数组,Java 集合框架已经实现了变长的数据结构。比如 ArrayList 和 HashMap。对于这类基于数组的变长数据结构,扩容是一个非常重要的操作。下面就来聊聊 HashMap 的扩容机制。
在详细分析之前,先来说一下扩容相关的背景知识:
在 HashMap 中,桶数组的长度均是2的幂,阈值大小为桶数组长度与负载因子的乘积。当 HashMap 中的键值对数量超过阈值时,进行扩容。
HashMap 的扩容机制与其他变长集合的套路不太一样,HashMap 按当前桶数组长度的2倍进行扩容,阈值也变为原来的2倍(如果计算过程中,阈值溢出归零,则按阈值公式重新计算)。扩容之后,要重新计算键值对的位置,并把它们移动到合适的位置上去。以上就是 HashMap 的扩容大致过程,接下来我们来看看具体的实现:

resize()主要做了三件事
- 计算新桶数组的容量newCap和新阈值newThr
- 根据计算出的newCap创建新的桶数组, 桶数组table也是在这里进行初始化的;
- 将键值对节点重新映射到新的桶数组中, 如果结点是TreeNode类型, 则需要拆分红黑树, 如果是普通结点, 则结点按与原顺序进行分组;
面试点, HashMap的resize(扩容)流程

下面我们说一下newCap和newThr, 即扩容的容量和阈值的计算过程
首先, 先复习下HashMap的三种构造方法, 你会发现resize 的前半段辣么长的if…else就是针对这三种构造法方法求出newCap, newThr 的过程.


3.4.3 链表树化, 红黑树链表化与拆分
JDK 1.8 对 HashMap 实现进行了改进。最大的改进莫过于在引入了红黑树处理频繁的碰撞,代码复杂度也随之上升。比如,以前只需实现一套针对链表操作的方法即可。而引入红黑树后,需要另外实现红黑树相关的操作。红黑树是一种自平衡的二叉查找树,本身就比较复杂。
1. 链表 --> 红黑树 (树化)
// 树化时必要的成员变量
static final int TREEIFY_THRESHOLD = 8;  //树化阈值, 
/**
 * 当桶数组容量小于该值时,优先进行扩容,而不是树化
 */
static final int MIN_TREEIFY_CAPACITY = 64; // 树化时需要的最小的桶数组容量
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
}

TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

2. 红黑树拆分

3. 红黑树链化

3.5 删除
如果大家坚持看完了前面的内容,到本节就可以轻松一下。当然,前提是不去看红黑树的删除操作。不过红黑树并非本文讲解重点,本节中也不会介绍红黑树相关内容,所以大家不用担心。
HashMap 的删除操作并不复杂,仅需三个步骤即可完成。第一步是定位桶位置,第二步遍历链表并找到键值相等的节点,第三步删除节点。相关源码如下:
public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        // 1. 定位桶位置
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        // 如果键的值与链表第一个节点相等,则将 node 指向该节点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {  
            // 如果是 TreeNode 类型,调用红黑树的查找逻辑定位待删除节点
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                // 2. 遍历链表,找到待删除节点
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        // 3. 删除节点,并修复链表或红黑树
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}
删除操作本身并不复杂,有了前面的基础,理解起来也就不难了,这里就不多说了。
3.6 其他细节

呼, HashMap的源码真特么难啊, 本文大量摘录自: 点我, 写的是真不错, 之后再融入了一些鄙人对代码的一些个人理解.
 
                    
                
 
                
            
         浙公网安备 33010602011771号
浙公网安备 33010602011771号