前言

HashMap 在我们的实际开发中是非常频繁使用到的一个类,也非常的清楚它保存的是一组键值对。不过,HashMap 在面试当中也是非常高频面试的一个点。经过我的了解及研究,我也开始写一遍日志,来展现我对 HashMap 的一些看法。

在了解 HashMap 之前,来聊聊哈希表是什么吧。

了解哈希表

哈希表,再也非常熟悉不过的一种数据结构了。

它是根据关键码值来直接进行访问的数据结构,也就是说,它通过把关键码值映射到表中一个位置,来访问当前位置的内容,以加快查找的速度。这个映射函数叫做散列函数,存放记录的容器叫做散列表(哈希表)。

码值:可以理解为键的 HashCode 经过哈希算法计算所得元素的位置

一般来说,存放记录的容器是数组。因为数组查询的效率很高,时间复杂度为 O(1) ,随机性也是非常强的。当然,用链表也是可以的,只不过查询的效率低,时间复杂度为 O(n)。

我并不认同,哈希表就是“数组 + 链表”的结合体,因为我觉得使用链表只是解决哈希冲突的一种解决办法而已。这个问题到后面会提及到。

哈希算法

哈希算法并没有固定的一种算法,哈希算法它是一种思想,目的就是算出元素所在的位置。同时,更好的算法让元素在散列的时候尽量变得均匀起来。

在 Java 中的 HashMap ,它是通过 ( 哈希表的长度 - 1 ) & 哈希值来计算的,也就是通过与运算来算出元素要放的位置。

哈希冲突

产生:通过哈希算法得到的下标,被另一个元素所占用,就是哈希冲突了。

解决办法:开放寻址法、再散列法、链地址法、建立一个公共溢出区。

基本原理

在 JDK 1.7 中,它的 HashMap 是通过“数组 + 链表”来实现的。但是到了 1.8 之后,它的实现方式是通过“数组 + 链表 + 红黑树”实现的。

扩容

这是 HashMap 的哈希表,通过 HashMap 的中的与运算来得到要放置的位置。但是给哈希表分配的长度始终是 2 的 n 次幂来分配空间。

并且它是通过数组的形式来保存的。

/**
 * The table, initialized on first use, and resized as
 * necessary. When allocated, length is always a power of two.
 * (We also tolerate length zero in some operations to allow
 * bootstrapping mechanics that are currently not needed.)
 */
transient Node<K,V>[] table;

但是,我们在创建 HashMap 的时候并没有指定哈希表的长度,原因是这里面默认创建了一个长度为 16 的哈希表。这个再 HashMap 的常量中、无参的构造方法中都有非常明确的描述。

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

然而,无参构造方法中并没有直接初始化这个值。

/**
 * Constructs an empty <tt>HashMap</tt> with the default initial capacity
 * (16) and the default load factor (0.75).
 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

在 HashMap 集合中添加一条数据的时候,会调用 resize 方法,才给它进行扩容。

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) {
        //此处省略......
    }
    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);
    }
    //此处省略......
    return newTab;
}

此时新的容量就是 DEFAULT_INITIAL_CAPACITY ,也就是 16,同时也计算出阈值,阈值是什么?阈值就是临界值,计算方法是:阈值 = 容量 * 负载因子。就比如,当哈希表的容量为 16 时,阈值为 12 ,当哈希表中元素的个数扩容,它将会将原来的容量扩大到两倍。

newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

默认的负载因子为 0.75。

/**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

为什么会取 0.75,因为此时的空间利用率比较高,而且避免了相当多的哈希冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。

添加键值对

在向 HashMap 中添加第一组键值对,它是如何添加的呢?

1、从调用 HashMap 的 put 方法开始,它实际上调用的是 putVal 方法,putVal 共有 5 个参数,分别是,获取 key 的哈希值、key、value、这个键是否存在和是否插入到后结点。

最后一个参数是给 LinkedHashMap(基于链表实现的 HashMap)所使用

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

2、初始化容量,一开始会判断 Node 数组是否为 null。开始肯定是 null,所以肯定会调用 resize 方法初始化容量,且值为 16。

初始化容量完成之后,然后再去利用与运算得到数组的下标,调用 newNode方法 创建出来的 Node 对象需要放入到数组的哪个位置。而这个位置则通过哈希算法来得到的,在上面也提及到计算方法。 i = (n - 1) & hash

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 判断表是否为 null 或者表的长度是否为 0
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 此处省略很多代码......
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

3、第一个元素成功的添加进来了,添加完成之后给 HashMap 中键值对的个数 +1 ,如果 HashMap 中键值对的个数超过了阈值,那么将会触发扩容,扩容到原来的两倍。

不可避免的冲突

HashMap 按与运算求得的下标位置是会造成哈希冲突的,它的解决办法就是通过链地址法(拉链法)的方式来解决。所以这也是为什么 HashMap 底层数据结构包括数组和链表。

所以 HashMap 可以想象成是古代皇帝所戴的帽子(冕旒)。数组就是冕,链表就是帘子。

img

假如哈希表中的第 3 个元素已经存在了一个 A 元素,然而另外一个元素 B 得到的下标也是 3 ,此时通过尾插法的形式,将 B 元素插入到 A 的后面。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 判断表是否为 null 或者表的长度是否为 0,代码略
    // 判断当前下标的元素是否存在,不存在的代码已经省略
    else {
        Node<K,V> e; K k;
        // 这段代码拿到的当前的节点以及当前节点的 key
        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);
        //////// 这是尾插法的关键代码部分 /////////
        for (int binCount = 0; ; ++binCount) {
            // 如果当前元素的下一个节点是空,则插入一个新的节点。
            if ((e = p.next) == null) {
                p.next = newNode(hash, key, value, null);
                // 如果数量超过了8,则将链表进行树化,即转成红黑树
                if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                    treeifyBin(tab, hash);
                break;
            }
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                break;
            p = e;
        }
        // 此处省略很多代码......
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

变成红黑树的条件

当链表的长度变为 8 时,此时链表就转换成红黑树。

static final int TREEIFY_THRESHOLD = 8;

当红黑树的元素个数减为 6 时,此时红黑树又会降成链表。

static final int UNTREEIFY_THRESHOLD = 6;

获取值

获取值使用的是 get 方法进行获取,它的获取流程其实和 put 差不多。

首先进行与运算,与运算完成之后得到的数组下标,比较得到的 key 是否为传入的 key,如果是的话就返回出来,如果不是,在链表或者红黑树中寻找。找到了就返回出去,没找到就返回 null。

在调用 get 方法时,其实是去调用 getNode 方法。

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) {
        // 判断 key 是否为传入的 key 以及哈希值是否相同。
        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)
                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;
}

红黑树

下次接着写吧,我先学学它。💫💫