HashMap的实现原理、看完直接再也不怕面试被问了!

78 阅读13分钟

HashMap

一、什么是Map

Map其实就是双列集合,所谓双列集合,就是说集合中的元素是一对一对的。Map集合中的每一个元素是以key=value的形式存在的,一个key=value就称之为一个键值对,而且在Java中有一个类叫Entry类,Entry的对象用来表示键值对对象。

所有的Map集合有如下的特点:键不能重复,值可以重复,每一个键只能找到自己对应的值。

二、什么是Java HashMap

HashMap是Java集合框架中最常用的实现Map接口的数据结构,它使用哈希表实现,允许null作为键和值,可以存储不同类型的键值对。HashMap提供了高效的存取方法,但是并非线程安全的。在Java中,HashMap被广泛应用于各种场景,如缓存、数据库连接池、路由器等。

三、HashMap存储数据时底层是如何工作的

当HashMap存储数据时,

  1. 创建HashMap:创建一个空的HashMap实例 它并不会立即分配内存来初始化数组。如果在创建HashMap时未定义任何参数时,将使用默认的初始容量(16)和默认的加载系数(0.75)。 就是说该HashMap最多可以包含 16 个元素,并且在插入第 13 个元素时会调整HashMap的大小。 这是因为负载系数为 75% (0.75),并且在添加第 13 个元素(12 + 1)时将超过此阈值。

  2. 插入键值对:当第一次使用put(key, value)方法插入一个键值对时,首先判断哈希表是否为空,如果为空它才会根据初始容量创建一个具有相应大小的数组。这个大小就是默认的初始容量(16)

    然后通过哈希函数计算键的哈希值。哈希函数通常会对键进行一系列的位运算和散列算法处理,以生成一个整数作为哈希值。

  3. 哈希值映射到数组下标:通过对哈希值进行模运算,将其映射到数组的下标位置。具体计算方法是将哈希值与数组长度进行取余运算,得到的余数就是键值对在数组中存储的位置。

  4. 解决哈希冲突:如果多个键的哈希值映射到了同一个数组下标位置,就会发生哈希冲突。为了解决冲突,HashMap使用链表或红黑树来存储冲突的键值对。当发生冲突时,新插入的键值对将添加到链表或红黑树的头部。

    JDK7版本:链表采用头插法(新元素往链表的头部添加)因多线程情况下,可能会造成环形链表 JDK8版本:链表采用尾插法(新元素往链表的尾部添加)

  5. 存储键值对:将键值对存储在数组的相应位置上。如果是链表结构,键值对会被添加到链表中。如果是红黑树结构,键值对会被插入到红黑树中。

  6. 动态扩容:如果数组的负载因子(元素数量除以数组长度)超过了阈值,就会触发动态扩容操作。HashMap会创建一个新的更大的数组,通常会将数组的大小扩大为原来的两倍(初始容量为16时),这样可以在保持数组长度为2的幂次方的同时,降低哈希碰撞的概率。

    然后将原有的键值对重新映射到新数组中,以减少碰撞的可能性。在扩容过程中,元素会按照哈希值重新分布到新的数组位置上。

  7. 查询键值对:通过给定的键,使用相同的哈希函数计算键的哈希值,并通过模运算映射到数组的下标位置。如果发生冲突,就迭代链表或红黑树中的节点,直到找到匹配的键值对。

  8. 更新或删除键值对:通过哈希函数计算键的哈希值,并通过模运算映射到数组的下标位置。找到对应的链表或红黑树节点后,就可以进行更新或删除操作。更新操作会更新对应节点的值,而删除操作会将节点从链表或红黑树中移除。

四、Java HashMap的实现原理

HashMap使用哈希表(Hash Table)实现,哈希表是一种以键值对(key-value)的形式进行存储和快速查找的数据结构。HashMap内部维护了一个数组,每个数组元素都是一个链表节点,每个节点包含一个键值对,以及指向下一个节点的指针。当需要查找或插入一个元素时,HashMap首先计算该元素的哈希值,根据哈希值确定它在数组中的位置,然后在对应的链表上进行查找或插入操作。

1.哈希值的计算方法

首先,HashMap会调用键值对(Entry)对象的hashCode()方法,获取到该对象的哈希码。哈希码是一个int类型的整数,用于表示该对象的标识号。但是,由于哈希码的范围很大,因此通常需要对它进行下一步处理,转换成一个比较小的数值,以便存储到数组中。这样,就用到了哈希函数,哈希函数用于将大范围的哈希码映射到较小的数组索引范围内。

HashMap的哈希函数有多种实现方式,其中一种常用的方法是将哈希码与数组长度进行一个类似取模的运算后的余数作为数组下标

判断这个索引下标的位置是否为null,如果为null,就直接将这个Entry对象存储到这个索引位置 如果不为null,则还需要进行下一步的判断 继续调用equals方法判断两个对象键是否相同 如果equals返回false,则以链表的形式往下挂 如果equals方法true,则认为键重复,此时新的键值对会替换就的键值对。

这个方法的优点是简单、快速,但缺点也很明显:当哈希码分布不均衡时,容易出现哈希冲突(Haah Collision),即不同的键对象具有相同的哈希码,导致它们被映射到同一个数组位置上,形成一个链表。

2.解决哈希冲突的方法

为了解决哈希冲突,HashMap使用链表法来处理。链表法是将哈希冲突的元素以链表的形式组织起来,所有哈希值相同的元素作为同一个链表的节点,并按照插入顺序排列。

链表法的实现非常简单,每个数组元素都是一个链表节点,如果该元素已经存在链表中,则将新元素插入到链表的末尾,否则创建一个新的节点,并将其插入到链表头部。

这样做的好处是在查询和插入时可以更快地访问最近插入的元素。

但是当链表长度变长时,查找、插入和删除操作的效率会降低。为了解决这个效率问题,JDK1.8引入了红黑树,当链表长度超过阈值(默认为8)时,将链表转换为红黑树,以提高效率。

3.数组扩容机制

数组扩容是HashMap内部的一个重要操作,当调用put()方法时,若当前的元素数量已经达到了扩容阈值,则需要进行数组扩容操作。其扩容机制如下:

首先,创建一个新的空数组,大小为原数组的两倍;然后遍历原数组中的每个元素,重新计算它们在新数组中的位置,然后将这些元素放到新数组中相应的位置上;最后,再将新数组设置为HashMap内部的数组。因此,在扩容过程中,需要重新计算哈希值,重新映射数组下标,并将元素复制到新数组,这个过程是很费时间和空间的。因此,为了减少扩容的次数,一般情况下,将HashMap的初始化容量设置为能够存放预计元素数量的1.5倍。

4.加载因子

HashMap内部还维护着一个加载因子(load factor)属性,默认为0.75。它表示当元素数量与数组长度的比值超过了这个阈值时,就会进行扩容操作,以便保持哈希表的性能。一般来说,较小的负载因子会增加哈希表的存储空间,但会减少哈希冲突的发生机率,提高查询效率;而较大的负载因子则会减少存储空间,但会增加哈希冲突的概率,降低查询效率。因此,在决定负载因子的大小时,需要根据应用场景、数据量和时间复杂度等因素进行合理的取舍。

五、底层源码解析

JDK7

1、数据结构

  • 哈希表【数组 + 链表】
哈希表【数组 + 链表】

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;
​
    /**
     * An empty table instance to share when the table is not inflated.
     */
    static final Entry<?,?>[] EMPTY_TABLE = {};
​
    /**
     * 存元素的数组
     */
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
​
    /**
     * The number of key-value mappings contained in this map.
     */
    transient int size;
​
    /**
     * 扩容阈值
     */
    int threshold;
​
    /**
     * 装载因子
     */
    final float loadFactor;
​
    /**
     * The number of times this HashMap has been structurally modified
     * Structural modifications are those that change the number of mappings in
     * the HashMap or otherwise modify its internal structure (e.g.,
     * rehash).  This field is used to make iterators on Collection-views of
     * the HashMap fail-fast.  (See ConcurrentModificationException).
     */
    transient int modCount;
​
    /**
     * The default threshold of map capacity above which alternative hashing is
     * used for String keys. Alternative hashing reduces the incidence of
     * collisions due to weak hash code calculation for String keys.
     * <p/>
     * This value may be overridden by defining the system property
     * {@code jdk.map.althashing.threshold}. A property value of {@code 1}
     * forces alternative hashing to be used at all times whereas
     * {@code -1} value ensures that alternative hashing is never used.
     */
    static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

3、无参构造器

public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}    
//   initialCapacity=16    loadFactor=0.75
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;    //0.75
    threshold = initialCapacity;     //16
    init();
}

4、增加元素 put方法

    
    public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            //第一次增加元素,肯定是 true
            inflateTable(threshold);   //16
        }
        if (key == null)   //key=null的情况
            return putForNullKey(value);
        // key != null
        int hash = hash(key);  //计算key的hash,通过算法生成的【不仅仅是底层 hashCode()】
        int i = indexFor(hash, table.length);   // 数组中的下标 0-15 的一个值
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            //下标i有元素
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                //如果key相同,则覆盖值
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
​
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }
​
​
​
    private void inflateTable(int toSize) {
        // 找一个比转入参数大,且最接近参数的值,并且是2的幂次方
        int capacity = roundUpToPowerOf2(toSize);
​
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//12
        table = new Entry[capacity];  //初始化数组,长度16
        initHashSeedAsNeeded(capacity);
    }
​
​
    //处理key=null的情况
    private V putForNullKey(V value) {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            //如果key=null且是第一个元素上有东西,则替换value值,并返回旧值
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;   //修改次数
        addEntry(0, null, value, 0);
        return null;
    }
        
    //                 0,   null,   value,       0
    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
//                   0,   null,   value,       0  
        createEntry(hash, key, value, bucketIndex);
    }
​
//                     0,      null,  value,       0 
    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];   //e=null
        //                                 0, null, value, null
        table[bucketIndex] = new Entry<>(hash, key, value, e);   //头插法
        size++;
    }
​
​
    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;  //value
        next = n;   //null
        key = k;   //null
        hash = h;   //0
    }

5、扩容

条件:当元素个数 >= 阈值 且 当前要插入的下标有元素,去扩容

    void resize(int newCapacity) {
        //获取老数组
        Entry[] oldTable = table;
        //获取老数组长度 16
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        //造新数组   newCapacity=32
        Entry[] newTable = new Entry[newCapacity];
        //迁移数据   老数组-->新数组
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }    
​
​
void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {    //循环的老数组
            while(null != e) {   //循环链表
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

6、环形链表

  • 增加元素使用的是头插法

多线程情况下,可能会造成环形链表

7、面试题

  1. 为什么HashMap初始化数组容量为16?

    • 基于性能和空间,综合考虑,官方给出16
    • 2的幂次方【2^4】
  2. 为什么HashMap的长度或者扩容是是2的幂次方?因为计算索引时,跟取模值一样。因为 & 效率高于取模

    • 减少Hash碰撞
  3. 为什么JDK8要使用尾插法 替换 JDK7的头插法

    • 多线程情况下,可能会造成环形链表

JDK8

1、数据结构

  • 哈希表【数组 + 链表】+ 红黑树(特殊的二叉平衡树)
哈希表【数组 + 链表】

2、成员变量

    /**
     * 树化值  由链表转成红黑树的一个阈值
     */
    static final int TREEIFY_THRESHOLD = 8;
​
    /**
     * 退树化值  由红黑树退化成链表
     */
    static final int UNTREEIFY_THRESHOLD = 6;
​
    /**
     * 最小树化初始化值
     */
    static final int MIN_TREEIFY_CAPACITY = 64;
​
    /**
     * 初始化容量
     */
    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;
​
    /**
     * An empty table instance to share when the table is not inflated.
     */
    static final Entry<?,?>[] EMPTY_TABLE = {};
​
    /**
     * 存元素的数组
     */
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
​
    /**
     * The number of key-value mappings contained in this map.
     */
    transient int size;
​
    /**
     * 扩容阈值
     */
    int threshold;
​
    /**
     * 装载因子
     */
    final float loadFactor;

3、无参构造器

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; //只初始化加载因子
}

4、增加元素 put方法

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    
    //计算key的hash
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }  
    //              hash(key), key,  value,       false,            true
    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)
            //如果数组是null 或者长度为0
            //第一次向map增加元素时,初始化数组长度16,初始化阈值为12
            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) {
                        //当前节点的next为null,则使用尾插法
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            //链表长度到达8,考虑树化
                            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;
    }
​
​
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            //链表长度到8,但是数组长度没到64,则扩容
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

5、扩容

条件:当元素个数 >= 阈值 且 当前要插入的下标有元素,去扩容

 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) {
            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
        }
        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);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            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
//当下标对应的元素是长度超过1链表时,会通过高低算法,把老数组下标对应的链表进行拆分,一半放新数组对应老数组原下标的位置,另一半放新数组下标【老数组下标 + 老数组长度】
                        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 {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

6、面试题

  1. 为什么JDK8要使用尾插法 替换 JDK7的头插法

    • 多线程情况下,可能会造成环形链表
  2. HashMap 链表跟红黑树是如何转换的?

    • 当链表长度>=8且数组长度到64,则转成红黑树
    • 扩容时,会进行数据迁移,当红黑树长度<=6时,由红黑树转成链表
  3. 为什么JDK8的HashMap要引入红黑树

    • 是因为Hash碰撞可能造成链表长度过长,影响查询性能。
    • 红黑树在一些情况下,查询效率要高于链表
  4. 扩容时,是如何迁移元素?

    • 如果链表长度超过1,会通过高低算法,把老数组下标对应的链表进行拆分,一半放新数组对应老数组原下标的位置,另一半放新数组下标【老数组下标 + 老数组长度】