欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

HashMap源码浅析

程序员文章站 2023-04-03 16:16:54
某次恰饭的时候,问头儿,他进来的时候,被面试了哪些内容。从java基础,到框架原理顺着一大堆东西就说出来了。一个HashMap,也可以问出花来。本次就以HashMap开个头,来探一探源码的实现。我们都知道Map是一种由多组键值对集合在一起的结构,其中key不可以重复,value值可以。HashMap作为Map接口的常用实现,在java8之前底层实现是数组+链表,在java8之后使用数组+链表+红黑树来实现。本文主要以java8为主进行展开,主要讲解了底层结构、put、get、扩容的相关实现机制和源码....

某次恰饭的时候,问头儿,他进来的时候,被面试了哪些内容。从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);
        }
    ...
}

这里的继承关系如下图所示:
HashMap源码浅析

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为继承预留的布尔值;

根据源码,流程图如下:

HashMap源码浅析

可见在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计算下标位置,然后分情况讨论:
HashMap源码浅析

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

源码如下:

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,计算出同样的下标:

HashMap源码浅析

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

HashMap源码浅析

不难看出,此时下标的结果不再相同,一个是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当中,造成数据的混乱。

本文地址:https://blog.csdn.net/netxiaozhe/article/details/109567865

相关标签: java hashmap