用户登录
用户注册

分享至

HashMap源码浅析

  • 作者: 段子能取二十字这么长的名字啊真的好长
  • 来源: 51数据库
  • 2021-10-19

某次恰饭的时候,问头儿,他进来的时候,被面试了哪些内容。从java基础,到框架原理顺着一大堆东西就说出来了。一个HashMap,也可以问出花来。本次就以HashMap开个头,来探一探源码的实现。

我们都知道Map是一种由多组键值对集合在一起的结构,其中key不可以重复,value值可以。HashMap作为Map接口的常用实现,在java8之前底层实现是数组+链表,在java8之后使用数组+链表+红黑树来实现。

本文主要以java8为主进行展开,主要讲解了底层结构、put、get、扩容的相关实现机制和源码分析。有关红黑树以及线程的部分,本次暂未涉及。

1 底层结构

hashMap总体上使用数组+链表来实现。

在java中hashcode是一个int值,实际使用当中,hashCode往往集中出现在某个区域,此时为了解决哈希冲突,引入链表对value进行存储,又因为链表可能很长,为了提高效率,在java8中引入了红黑树。

在java7及之前,主干是一个Entry数组,Entry<K,V>是一个实现了Map.Entry<K,V>接口的静态内部类;在java8中,数组的类型改为Node<K,V>[] table;,同样是实现了Map.Entry<K,V>接口的静态内部类。

不同的是,Node保留了链表的特点同时(具有指向下一个结点的指针),还有一个子类TreeNode<K,V>,可以实现树结构。

//数组
transient Node<K,V>[] table;
//数组类型
static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
    
        Node(int hash, K key, V value, Node<K,V> next) { ...  }
    
        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }
        public final int hashCode() { ... }
        public final V setValue(V newValue) { ... }
        public final boolean equals(Object o) { ... }
    }
//TreeNode
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);
        }
    ...
}

这里的继承关系如下图所示:

2 存入数据

put(K,V)方法源码如下:

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

主要进行的操作:①获取key的hashCode,②调用putVal()方法进行存值;

2.1 hash的获取和下标计算

hash()的源码如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这里在获取到key的hashCode之后,进行了哈希分散,将32位的哈希值的高16位异或低16位,使得在计算下标的时候,即便length很小,高低位都参与运算。

这里值得注意的是,在根据hashCode计算下标时,取模运算通过与运算实现,提高效率。

(length - 1) & hash 等价于 hash%length

2.2 putVal()

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)

hash代表key的hash值;

onlyIfAbsent代表是否取代已存在的值;

evict为继承预留的布尔值;

根据源码,流程图如下:

可见在put最开始和最后,会进行扩容的检查;并且通过key的比较和类型区分出各种情况,找到key进行value覆盖,或者进行链表尾插,或者红黑树插入。

关于红黑树的具体属性,代码操作,本文不再阐述,之后专门写一下。

源码如下:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    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);
    else {
        Node<K,V> e; K k;
        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 {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    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;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

3 取出数据

有了对put()的分析,get()就简单很多。

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

在get中,同样只做两步操作,获得key的hashCode,调用getNode()。

final Node<K,V> getNode(int hash, Object key) {}

参数分别是key的hashCode和key本身。

和put相比,get的开始和最后不再有扩容的判断,直接根据hashCode计算下标位置,然后分情况讨论:

从图中可以看出来,同样是分为下标指向元素为空、链表、红黑树的情况。

源码如下:

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)
                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;
}

4 扩容

4.1 相关常量&变量

当整个map当中的键值对数量 > 容量*负载因子的时候,进行扩容

梳理扩容,首先来了解一下HashMap的几个常量:

/*默认初始容量,必须是2的幂*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/*哈希表最大容量,在构造函数指定容量的时候,用于比较*/
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;
/*哈希表内数据大于该值,才进行转换,否则只扩容*/
static final int MIN_TREEIFY_CAPACITY = 64;

其中MIN_TREEIFY_CAPACITY 指的是:在转变成树之前,还会有一次判断,只有键值对数量大于该值才会发生转换,数量比较少就先扩容。这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。

此外还有一些成员变量:

int threshold;             // 所能容纳的key-value对极限 = length * Load factor
final float loadFactor;    // 负载因子
int modCount;  			   // 记录HashMap内部结构发生变化的次数
int size;				   // 实际存在的键值对数量

其中,modCount指的是结构的变化,值的覆盖不算,主要用于迭代的快速失败,(那么快速失败也是一个点,先留下)。

4.1 扩容机制

在java7及之前, 将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

因为使用的是2次幂的扩展,每次数组大小翻倍,所以元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。

上文已经说过,hashCode转化为下标,采用的是 (n-1)&hashCode

故有n=16时 两个不同的key的hashCode,计算出同样的下标:

进行扩容之后(n=32):

不难看出,此时下标的结果不再相同,一个是12,一个是16+12,对应二进制只是在增加的高位不同。

故新增高位为0的时候,位置不变;为 1则是oldIndex+OldCap 。

java8利用了这个规律进行扩容。

源码如下(增加了注释批注):

final Node<K,V>[] resize() {
    /*初始化一些变量*/
    Node<K,V>[] oldTab = table;
    /*旧表长*/
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    /*旧阈值  (= length * Load factor)*/
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        /*旧表长大于哈希表最大容量---少见的特殊情况,直接将map阈值置为Integer.MAX_VALUE*/
        /*超过最大值就不再扩充了*/
        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
    }
    /*表之前大小为0或为null,但是阈值>0,初始化容量为阈值 */
    else if (oldThr > 0) // 初始化容量为阈值
        newCap = oldThr;
    else {               // 零初始阈值表示使用默认值
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    /*计算新的resize上限*/
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    /*初始化扩充后的table数组*/
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
         /*把每个bucket都移动到新的buckets中*/
        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
                    /*链表重hash,将链表上的数据,分两类连接起来,最后再指向对应的表上*/
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            /*原索引*/
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                             /*oldIndex+OldCap*/
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    /*原索引放到bucket里*/
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    /*oldIndex+OldCap放到bucket里*/
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

5 equals()和hashCode()

1.若重写了equals(Object obj)方法,则有必要重写hashCode()方法。

以上说法已经是一种共识,以hashMap为例,理想情况下,只有hashCode一致和equals比对一致,才会认为是相同的对象。

hashMap会首先比较hashCode值,如果不等的话,会直接认为是两个对象,不再使用equals()进行比较。

如果只修改equals()方法,两个对象在业务上相等,hashCode不一致,则都会顺利存入map当中,造成数据的混乱。

软件
前端设计
程序设计
Java相关