HashMap底层原理、扩容

515 阅读18分钟

1、说说HashMap的底层实现

(底层实现可以从数据结构哈希函数哈希冲突扩容等方面阐述)

在jdk1.7及之前基于数组 + 链表,而且采用头插法

而jdk1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。采用尾插法

HashMap默认的初始化大小为 16(这个16指的是HashMap内部的一个数组的初始长度,有的资料说jdk1.7的时候才是16,jdk1.8不是)。当HashMap中的元素个数之和大于负载因子 * 当前容量的时候就要进行扩充,容量变为原来的 2 倍。(这里注意不是数组中的个数,而是数组中和链/树中的所有元素个数之和!)

当红黑树中的元素个数小于等于6时,会将红黑树转换回链表,以节省内存空间。在Java 7及之前的版本中,HashMap的键可以为null,但在Java 8中,HashMap的键不再允许为null。如果尝试将null作为键插入HashMap中,会抛出NullPointerException。但是,HashMap的值仍然可以为null。

2、数组和链表区别

  • 数组:采用一段连续的存储单元来存储数据(特点:查询O(1),删除插入O(N);总结:查询快、删除插入慢)

  • 链表:链表是一种物理存储单元上非连续、非顺序的存储结构(特点:插入,删除时间复杂度O(1),查找遍历时间复杂度O(N);总结:插入快、查找慢)

数组和链表区别:

  1. 存储方式:数组是连续的内存块,而链表由节点组成,通过指针连接。
  2. 插入和删除操作:数组插入和删除元素可能需要移动其他元素,而链表可以在常数时间内执行插入和删除。
  3. 访问效率:数组通过索引直接访问元素,效率高;链表需要顺序遍历节点才能访问元素,效率较低。
  4. 内存占用:链表需要额外的指针来连接节点,占用更多内存;数组只需要连续内存块。
  5. 随机访问 vs. 顺序访问:数组支持随机访问,链表只支持顺序访问。

数组适用于需要快速随机访问元素的场景,而链表适用于频繁执行插入和删除操作的场景。

3、hashmap哈希算法详解

哈希算法(也叫散列),就是把任意长度值(Key)通过散列算法变换成固定长度的地址(key),通过这个地址进行访问的一种结构。它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。

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

image.png

Hashcode:通过字符串算出它的 ASCII 码,进行mod(取模),算出哈希表中的下标

image.png 取模:节省空间;问题:哈希碰撞、冲突

4、哈希冲突产生原因详解

哈希冲突:即不同key值产生相同的地址

image.png

HashMap引入链表来解决哈希冲突。

解决哈希冲突的方法:拉链法(Chaining)是一种解决哈希冲突的方法,它使用一个数组来存储哈希桶(也称为桶数组),每个桶数组位置都指向一个链表。当发生哈希冲突时,新元素会被添加到对应位置的链表中。因此,使用拉链法的哈希表可以支持多个元素位于同一个哈希桶中。

5、HashMap默认加载因子为什么是0.75?

默认的loadFactor是0.75,0.75是对空间和时间效率的一个平衡选择,一般不要修改。(较高的值会降低空间开销,但提高查找成本)

6、JDK1.8以后为什么引入红黑树?

为了提高HashMap的查询效率。

假设一种情况:当HashMap中的碰撞越来越多,链表越来越长的时候,其获取单个元素所需要的时间就会越来越高(因为链表的查询速度比较慢)。为了解决这个问题jdk1.8引入了红黑树。因为红黑树的查询速度比链表要高很多。

红黑树是一种平衡二叉树,它的查找、插入和删除的时间复杂度都是O(logn),比链表的O(n)要快得多。

7、HashMap为什么用红黑树,而不是B+树或B树?

HashMap中使用的红黑树是一种自平衡二叉搜索树,它在查找、插入、删除等操作上的时间复杂度为O(logN),而且它的实现相对简单,易于理解和使用。

与B+树或B树相比,红黑树的实现更为高效,因为它可以使用普通的指针来实现红黑树节点之间的连接,而B+树或B树需要使用指针数组来实现节点之间的连接,这会导致B+树或B树的空间占用更大,且节点的访问和操作相对更加复杂。

除此之外,HashMap中使用红黑树实现的主要原因是在处理哈希冲突时具有更高的效率。(具体来说,当发生哈希冲突时,HashMap需要找到一个开放的地址来存储键值对。如果使用B+树或B树,HashMap需要执行多次查找操作才能找到开放的地址,因为这两种数据结构在处理冲突时采用的是线性搜索。而红黑树则不同,它采用二分查找法来寻找开放的地址,因此可以在较少的操作次数内找到开放的地址,从而提高HashMap的效率)

B+树或B树通常应用于磁盘存储,而HashMap是基于内存的数据结构,红黑树由于其优秀的查询、插入和删除性能,更适合作为HashMap的底层结构。

补充:为什么用红黑树而不用平衡二叉树?(面试理解第一)

  1. 首先,红黑树的平衡性能比平衡二叉树更好。红黑树的平衡性能是通过对节点进行颜色标记旋转操作来实现的,而平衡二叉树只能通过旋转操作来实现平衡。因此,红黑树的平衡性能更好,可以更快地进行插入、删除和查找操作。

  2. 其次,红黑树的空间利用率比平衡二叉树更高。红黑树的节点结构比平衡二叉树的节点结构更紧凑,因此在存储大量数据时,红黑树的空间利用率更高。

  3. 最后,红黑树的实现比平衡二叉树更简单。红黑树的实现比平衡二叉树的实现更简单,因为红黑树的平衡性能是通过颜色标记和旋转操作来实现的,而平衡二叉树需要更复杂的平衡算法来实现平衡

因此,HashMap使用红黑树来实现内部的数据结构,以提高性能和空间利用率。

注意理解而非死记)红黑树,它是数据结构的一部分,它是个动态的查找树而且是个二叉树。我们最简单的动态二叉树是二叉查找树,然后往后走就变成了我们的平衡二叉树,后面又有了新的数据结构红黑树。

红黑树和平衡二叉树在查询效率上没有太大区别,因为它俩都是压缩的。这个树它不是深度,为什么会出现平衡二叉树,因为二叉查找树可能会很深很高,查询效率会很慢,那么平衡二叉树就用平衡因子把它给压缩了。红黑树也是用红节点和黑节点,把这个树的高度把它压缩了,那么它的查询效率就差不多。

为什么不用平衡二叉树而用红黑树呢,因为这个动态树就是节点,数据会不断的增加或者是删除,那你的树要调整,因为树有他的要求,你的高度必须满足,那红黑树的调整效率是比平方二叉树要好。有朋友说那为什么要好,这个点不做解答,因为面试的时候不会去考你红黑树的调整策略,因为那个调整是很复杂的,可能有十多种情况。

8、为什么JDK1.8中红黑树阈值设为8?

和hashcode碰撞次数的泊松分布有关,主要是为了寻找一种时间和空间的平衡。

java源码贡献者通过大量实验发现,哈希碰撞发生8次以上的情况几乎不可能发生,为极端情况,此时链表的性能已经非常差了,权衡之下才换用红黑树,来提高性能,而链表转红黑树的过程也是需要消耗性能的,因此大多数情况下hashmap还是会使用链表。

8、hashmap扩容为啥是2的次幂

9、扩容过程

HashMap的扩容是指当HashMap存储的元素数量达到一定阈值时,系统自动将HashMap的容量扩大,以保证HashMap的性能和存储空间。具体来说,当HashMap中存储的元素数量达到负载因子load factor(默认为0.75)与当前容量的乘积时,就会触发扩容操作。具体的扩容过程如下:

  1. 创建一个新的Entry数组,长度是原数组的两倍
  2. 遍历原数组中的每个Entry,重新计算它们的hash值,然后将它们存储到新的数组中。这个过程需要重新调用hash方法,并且会涉及到重新计算Entry的索引位置,因此比较耗时。
  3. 将新数组设置为当前数组,并且释放原数组的空间。

注意:在扩容过程中,可能会出现多个线程同时对HashMap进行操作的情况。为了保证线程安全,Java采用了一种叫做“分段锁”的机制,即将整个HashMap分成多个段,每个段上都有一把锁,不同的线程可以同时对不同的段进行操作,从而提高了并发性能。

总的来说,HashMap的扩容机制可以保证在容量不足时仍能正常工作,但是扩容操作会涉及到重新计算hash值、重新分配内存等耗时操作,并且可能会影响并发性能,因此需要合理设置负载因子和初始容量来减少扩容的次数。

10、HashMap扩容原数组怎么处理的?一定会回收吗?

HashMap在扩容时会创建一个新数组,将原数组中的元素重新分配到新数组中,然后释放原数组的内存空间。需要注意的是,虽然HashMap在扩容时会释放原数组的内存空间,但并不是立即回收,而是等待垃圾回收器来回收。因此,在HashMap扩容时,原数组并不一定会立即被回收。

11、HashMap的内部类Entry结点,这个Entry类实例里面有什么数据?

在Java 8及之前的版本中,HashMap的内部类Entry表示HashMap中的一个键值对。每个Entry对象包含四个字段:key、value、hash和next。

具体来说,Entry类实例里面包含以下数据:

  1. key:键对象
  2. value:值对象
  3. hash:键的hash值
  4. next:指向下一个Entry对象的引用,用于处理hash冲突时形成的链表。

在Java 8之后,HashMap的内部类Entry被替换成了Node,并且在处理hash冲突时,会根据链表长度判断是否需要将链表转换成红黑树。因此,Node对象包含的字段比Entry更多。具体来说,Node类实例里面包含以下数据:

  1. key:键对象
  2. value:值对象
  3. hash:键的hash值
  4. next:指向下一个Node对象的引用,用于处理hash冲突时形成的链表。
  5. parent:红黑树节点的父节点
  6. left:红黑树节点的左子节点
  7. right:红黑树节点的右子节点
  8. color:红黑树节点的颜色,用于维护红黑树的平衡。

12、什么对象可以作为 hashmap 的 key

在Java中,可以用任何对象作为HashMap的键,只要这个对象遵守了以下两个约定:

  1. 实现hashCode方法:hashCode方法返回的是一个int类型的哈希码,用于确定对象在HashMap中的存储位置。不同的对象可能返回相同的哈希码,因此即使两个对象的哈希码相同,它们实际存储在HashMap中的位置也可能不同。
  2. 实现equals方法:equals方法用于比较两个对象是否相等。在HashMap中,如果两个对象的哈希码相同并且equals方法也返回true,则两个对象被视为相等的键,只会存储一个。

要注意的是,由于哈希表中的元素不是按照插入顺序有序的,因此在使用自定义对象作为HashMap键时,一定要注意这些对象的相等性和哈希码的计算方式,以确保正确的查找和插入。一般来说,如果自定义对象的属性基本不变,或者只有在属性变化后才会重新计算哈希码,那么可以把这个对象作为HashMap的键。而如果属性可能随时变化,或者哈希码计算有副作用,就要谨慎使用,或者采用不可变类(Immutable class)实现。

13、hashmap创建一个初始化为10的对象之后容量会是多少?

当创建一个初始容量为10的HashMap对象时,其容量将为10,即HashMap将在内部创建一个大小为10的数组作为其容器。但是,这个数组并不是HashMap的最终容量,它可以动态地调整大小以适应存储更多的键值对。当HashMap的负载因子达到某个阈值时,它会自动调整其容量,以保证其性能和空间使用的平衡。因此,要注意区分HashMap的初始容量和实际容量。

14、HashMap是如何实现元素查找的?

HashMap是基于哈希表实现的一种键值对存储结构,通过将元素的键值经过哈希函数计算得到哈希码,然后通过数组下标访问数组中的对应元素来实现元素的查找。

具体来说,HashMap内部维护了一个大小为 n 的数组,每个元素称为“桶”(bucket),每个桶可以容纳一个或多个元素,每个元素都包含一个 key-value 对。在使用put()方法向HashMap中添加元素时,会根据元素的key值计算出哈希码,然后通过哈希码对数组进行访问,并在该位置上寻找对应的桶。

由于HashMap采用了哈希表,可以保证查找元素的时间复杂度为 O(1),因为只需要通过已知的键值计算出对应的哈希码,然后根据哈希码就可以直接访问对应的元素。在理想情况下,每个桶中只包含一个元素,这样可以使元素的查找时间更快。然而,当桶中有多个元素时,HashMap需要通过链表或红黑树等方法来处理冲突,这会略微增加查找元素的时间。

15、为什么HashMap会产生死循环?

HashMap死循环只发生在JDK1.7版本中,主要原因是JDK1.7中的HashMap,在头插法 + 链表 + 多线程并发 + 扩容这几个情形累加到一起就会形成死循环。多线程环境下建议采用ConcurrentHashMap替代。在JDK1.8中,HashMap改成了尾插法,解决了链表死循环的问题。

避免HashMap发生死循环的常用解决方案有三个:

  1. 使用线程安全的ConcurrentHashMap替代HashMap,个人推荐使用此方案。
  2. 使用线程安全的容器Hashtable替代,但它性能较低,不建议使用。
  3. 使用synchronized或Lock加锁之后,再进行操作,相当于多线程排队执行,也会影响性能,不建议使用。

16、手写实现HashMap并性能测试

定义接口规范,后面会创建类将其具体实现

package com.bnuz.hashmap;

public interface Map<K,V> {
    V put(K k,V v);
    V get(K K);
    int size();

    interface Entry<K,V>{
        K getKey();
        V getValue();
    }
}

hashmap的实现核心方法为put()和get(),遍历链表查询这里采用了递归的写法

package com.bnuz.hashmap;

public class HashMap<K,V> implements Map<K,V> {
    private Entry<K,V> table[] = null;
    int size = 0;
    public HashMap(){
        this.table = new Entry[16];
    }

    /*
    通过hash算法算出传入key的哈希值
    取膜后找到index下标数组 为当前下标的对象
    判断当前对象是否为空 若为空 则直接存储
    若不为空 则哈希冲突(碰撞)next链表
    返回当前的节点对象

    * */
    @Override
    public V put(K k, V v) {
        int index = hash(k);
        Entry<K,V> entry = table[index];
        if (entry==null){
            //没有冲突,直接存储
            table[index] = new Entry<>(k,v,index,null);//赋值给当前下标结点
            size++;
        }else{
            //出现哈希冲突(碰撞)
            table[index] = new Entry<>(k,v,index,entry);//头插链表
        }

        return table[index].getValue();
    }
//计算下标,哈希值取膜16
    private int hash(K k) {
        int index = k.hashCode() % 16;
        return index>=0?index:-index;//由于数组下标由0开始,需返回正数
    }

    /*
    * 通过传入的key 进行hash计算
    * 找到index下标数组对象
    * 判断当前index下标对象是否为空 若为不为空
    * 判断是否相等 若不相等
    * 则判断index.next是否为空 若不为空
    * 再判断是否相等 直到找到相等或为next为null 返回
    * */
    @Override
    public V get(K k) {
        if (size==0){
            return null;
        }
        int index = hash(k);
        Entry<K,V> entry = findValue(table[index],k);

        return entry==null?null:entry.getValue();
    }
    //k是期望查询的key值 entry为取出下标的结点
    private Entry<K,V> findValue(Entry<K,V> entry, K k) {
        if(k.equals(entry.getKey())||k==entry.getKey()){
            return entry;
        }else {
            if (entry.next!=null){
                return findValue(entry.next,k);
            }
        }
        return null;
    }

    @Override
    public int size() {
        return size;
    }

    class Entry<K,V> implements Map.Entry<K,V>{
        K k;V v;
        //带参数的构造器
        public Entry(K k, V v, int hash, Entry<K, V> next) {
            this.k = k;
            this.v = v;
            this.hash = hash;
            this.next = next;
        }

        int hash;Entry<K,V> next;
        @Override
        public K getKey() {
            return k;
        }

        @Override
        public V getValue() {
            return v;
        }
    }
}

测试代码:

package com.bnuz.hashmap;

public class test {
    public static void main(String[] args) {
        Map<String,String> map = new HashMap<>();
        map.put("B","百度");
        map.put("A","阿里");
        map.put("T","腾讯");
        System.out.println(map.get("A"));
        System.out.println(map.get("T"));

    }
}

运行结果:

阿里

腾讯

17、JDK1.7和1.8的Hashmap有哪些区别?

(1)JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法
JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后使用尾插法,能够避免出现逆序且链表死循环的问题。

(2)扩容后数据存储位置的计算方式不一样
在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&运算,见下:

/**
 * Transfers all entries from current table to newTable.
 */
 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;
         }
     }
}

JDK1.8是扩容前的原始位置+扩容的大小值=JDK1.8的计算方式,而不再是JDK1.7中异或的方法。扩容后长度为原hash表的2倍,于是把hash表分为两半,分为低位和高位,如果能把原链表的键值对, 一半放在低位,一半放在高位,而且是通过e.hash & oldCap == 0来判断,见下图:

image.png

e.hash & oldCap == 0,这个判断有什么优点呢?
举个例子:n = 16,二进制为10000,第5位为1,e.hash & oldCap 是否等于0就取决于e.hash第5 位是0还是1,这就相当于有50%的概率放在新hash表低位,50%的概率放在新hash表高位。

(3)hash计算规则不一样
在计算hash值的时候,JDK1.7用了9次扰动处理=4次位运算+5次异或,见下图:

image.png

而JDK1.8只用了2次扰动处理=1次位运算+1次异或,见下图:

image.png

(4)底层数据结构不一样
JDK1.7使用的是数组 + 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当阈值是默认阈值0.75,链表的深度大于等于8,扩容的时候会把链表转成红黑树,时间复杂度从O(n)变成O(logN))