HashMap底层源码解析上(超详细图解+面试题)

Posted 温文艾尔

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap底层源码解析上(超详细图解+面试题)相关的知识,希望对你有一定的参考价值。

HashMap底层源码解析下


最近看了黑马和刘老师的源码视频,故总结一篇HashMap的源码解析,文章分为上下两部分

HashMap集合简介

HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,即主要用来存放键值对。HashMap的实现是不同步的,这意味着他不是线程安全的,它的key,value都可以为null

此外,HashMap中的映射不是有序的

  1. JDK1.8之前HashMap由数组+链表组成。数组是HshMap的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode方法计算的哈希值一致导致计算的数组索引值相同(哈希寻址算法[hash&length-1]))而存在的(“拉链法”解决冲突)
  2. JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为8,且数组中桶的数量大于64,此时此索引位置上的所有数据改为使用红黑树存储)

图示:

hashMap扩容简介

  1. 初始数组长度为16,若链表节点数量小于等于8时,会将节点连接到链表的下一个位置
  2. 若某个链表上阈值大于8时,会根据cap(容量)进行判断,若此时数组容量小于64,则会进行数组扩容(从32扩容为64)
  3. 若链表节阈值大于8,且数组容量大于64,则链表进化为红黑树

问题:为什么要在数组长度大于64之后,链表才会进化为红黑树?

  • 在数组比较小时如果出现红黑树结构,反而会降低效率,因为红黑树需要进行左旋右旋,变色这些操作来保持平衡,同时数组长度小于64时,搜索时间相对要快些,所以综上所述为了提高性能和减少搜索时间。

hashMap特点总结

  • hashMap存取是无序
  • 键和值位置都可以是null,但是键位置只能是一个null
  • 键位置是唯一的,底层的数据结构是控制键的
  • jdk1.8前数据结构是:链表+数组jdk1.8之后是:数组+链表+红黑树
  • 阈值(边界值)>8并且数组长度大于64,才将链表转换成红黑树,变成红黑手的目的是为了高效的查询’

hashMap底层数据结构存储数据的过程

在了解源码之前,我们通过一个案例来说明hashMap底层数据结构存储数据的过程

我们先创建一个hashMap实例

        HashMap<String, Integer> MapTest = new HashMap<>();
        MapTest.put("a",1);
        MapTest.put("b",2);
        MapTest.put("c",3);
        MapTest.put("a",44);
        System.out.println(MapTest);

结果:

a=44, b=2, c=3

存储过程分析

HashMap<String, Integer> MapTest = new HashMap<>();

1.在创建集合对象的时候,在jdk8前,在构造方法中创建一个长度为16的Entry[] table数组用来存储键值对数据,在jdk8以后不是在HashMap构造方法底层创建数组了,是在第一次调用put方法时创建的数组Node[] table

不在构造方法中创建的原因是:创建散列表需要耗费内存,而有些时候我们只是创建hashMap,并不向其中put元素

2.假设向哈希表中存储“a”-1数据,根据"a"调用String类中重写之后的hashCode方法计算出hash值,利用寻址算法(hash&(cap-1))cap为其容量,来寻找在哈希表中的索引,如果该索引空间没有数据,则直接将数据存储到数组中


3.向哈希表中存储数据"b"-2,假设"b"计算出的hashCode方法结合数组长度计算出的索引值也是3,如果此时数组空间不是null,此时底层会比较a和b的hash值是否一致,如果不一致,则在此空间上划出一个节点来存储键值对数据“b”-2,这种方式称为“拉链法”

4.假设向哈希表中存储数据"a"-44,根据“a”调用hashCode方法结合数组长度计算出索引值为3,此时数组空间不为null,此时比较后存储的数据留言和已经存在的数据的hash值是否相等,如果相等,此时发生hash碰撞,那么底层会调用“a”所属类String中的equals方法比较两个内容是否相等

  1. 相等:则将后添加的数据的value覆盖之前的value
  2. 不相等,此时出现hash冲突,继续向下和其他的数据key比较,如果都不相等,则划出一个节点存储数据

相同hash值的元素内容可能不同,例如"重地"和"通话"

System.out.println("重地".hashCode()=="通话".hashCode());
true

索引值->hash值->key值

为什么引入红黑树的进一步解答

JDK1.8以前HashMap的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当HashMap中有大量的元素都存放在同一个桶中时,这个桶下有一条长长的链表,此时HashMap就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就从O(1)退化成O(n),完全失去了它的优势,针对此种情况,JDK1.8中引入了红黑树(查找的时间复杂度为O(logn))来优化这种问题。

总结图


说明:

  • size表示HashMap中K-V的实时数量,注意这个不等于数组的长度
  • threshold(临界值)=capacity(容量)*loadFactory(加载因子),这个值是当前已占用数组长度的最大值。size超过这个临界值就重新resize扩容,扩容后的HashMap容量是之前容量的两倍

了解了hashMap的存储流程之后我们来看几道面试题加深印象

面试题

面试题1:哈希表底层采用何种算法计算hash值?还有哪些算法可以计算出hash值?

  • 底层采用的是key的hashCode方法的值结合数组长度进行无符号右移(>>>),按位异或(^),按位与(&)计算出索引。还可以采用平方取中法,取余数,伪随机数法

面试题2:当两个对象的hashCode相等时会怎么样?

  • 会产生hash碰撞,若key值内容相等则替换旧的value,反之则连接到链表后面,链表长度超过阈值8就转换成红黑树存储

面试题3:何时发生哈希碰撞和什么是哈希碰撞,如何解决哈希碰撞

  • 只要两个元素的key计算的hash码值相同就会发生hash碰撞,jdk8前使用链表解决哈希碰撞,jdk8之后使用链表+红黑树解决哈希碰撞

面试题4:如果两个键的hashcode相同,如何存储键值对

  • hashcode相同则会调用equals比较内容是否相同,如果相同则会用新value值覆盖老value值,如果不相同则会连接到链表后面

HashMap集合的继承关系



从图中可以看出HashMap实现了Map,Cloneable,Serializable接口

  • Cloneable空接口,表示可以克隆,创建并返回HashMap对象的一个副本。
  • Serializable序列化接口,属于标记性接口,HashMap对象可以被序列化和反序列化
  • AbstracMap父类提供了Map实现接口,以最大限度的减少实现此接口所需的工作

知识补充:
通过上述继承关系我们发现一个很奇怪的现象,HashMap已经继承了AbstractMap而AbstractMap已经实现了Map接口,那为什么HashMap还要再实现Map接口呢?同样在ArrayList中LinkedList中都是这种结构

据java集合框架的创始人Josh Bloch描述,这样的写法是一个失误,在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些地方可能是有价值的,直到他意识错了。显然的,JDK的维护者后来不认为这个小小的失误值得去修改,所以就这样存在下来了

代码如下(示例):

HashMap集合类的成员

1.序列化版本号

private static final long serialVersionUID = 362498820763181265L;

2.集合的初始化容量(必须是2的n次幂)

//默认的初始容量是16 -- 1<<4相当于1*2的4次方---1*16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

问题:为什么必须是2的n次幂?如果输入值不是2的n次幂比如10会怎样

HashMap的构造方法可以指定集合的初始化容量大小:

    public HashMap(int initialCapacity) 
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    

根据上述讲解我们已经知道,当向HashMap中添加一个元素的时候,需要根据key的hash值去确定其在数组中的具体位置。HashMap为了存取高效,要尽量减少碰撞,把数据均匀分配,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法中

这个算法实际就是取模hash%length,计算机中直接求余效率不如位移运算(这点上述已经讲解)。所以源码中做了优化,使用hash&(length-1),而实际上hash%length等于hash&(length-1)的前提是length是2的n次幂

为什么使用hash&(length-1)能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n-1次方实际就是n个1

举例:

说明:按位与运算:相同的二进制数位上,都是1的时候,结果为1,否则为0。

如果我们设置的数组大小不是2的幂会怎么样

    public HashMap(int initialCapacity) 
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    

我们以设置initialCapacity为10为例,他会在itableSizeFor方法中将非2次幂的数转换成离他最近且比它大的二次幂数

    static final int tableSizeFor(int cap) 
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    

下面分析这个算法
1.首先,为什么要对cap做减1操作。int n = cap - 1;

这是为了防止cap已经是2的幂,如果cap已经是2的幂,没有减1操作的话,方法最后返回的capacity将是这个cap的2倍

2.如果这时n为0,经过cap-1之后,则经过后面的几次无符号右移依然是0,最后返回的capacity是1(最后有个n+1的操作),我们这里只讨论n不等于0的情况

3.|(按位或运算):运算规则,相同的二进制数位上,都是0的时候,结果为0,否则为1。

  • 第一次右移n |= n >>> 1;
  • 第二次右移n |= n >>> 2;
  • 第三次右移n |= n >>> 4;
  • 以此类推,无论我们再右移,也只是与0做|运算,结果不变
  • 请看下面一个完整的例子

  • 所以执行完tableSizeFor方法后,我们所传入的非2次幂cap会被转化成大于等于它的一个离他最近的2次幂数

3.默认的负载因子,默认值是0.75

static final float DEFAULT_LOAD_FACTOR = 0.75f;
  • 我们的扩容阈值为cap*DEFAULT_LOAD_FACTOR,当容量为16时,他会在容量达到12时便触发扩容

4.集合的最大容量

//集合最大容量的上限是:2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;

5.链表的值超过8则会转成红黑树

//当桶(bucket)上的节点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;

问题:为什么Map桶中节点个数超过8才转为红黑树

8这个阈值定义在HashMap中,针对这个成员变量,在源码的注释中只说明了8是bin(bin就是nucket桶)从链表转成树的阈值,但是并没有说明为什么是8

在HashMap中有一段注释说明:我们继续往下看:

 * Because TreeNodes are about twice the size of regular nodes, we
 * use them only when bins contain enough nodes to warrant use
 * (see TREEIFY_THRESHOLD). And when they become too small (due to
 * removal or resizing) they are converted back to plain bins.  In
 * usages with well-distributed user hashCodes, tree bins are
 * rarely used.  Ideally, under random hashCodes, the frequency of
 * nodes in bins follows a Poisson distribution
 * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
 * parameter of about 0.5 on average for the default resizing
 * threshold of 0.75, although with a large variance because of
 * resizing granularity. Ignoring variance, the expected
 * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
 * factorial(k)).

翻译:

因为树节点的大小大约是普通节点的两倍,所以我们只在箱子包含足够的节点时才使用树节点(参见TREEIFY_THRESHOLD)。
当他们边的太小(由于删除或调整大小)时,就会被转换回普通的桶,在使用分布良好的hashcode时,很少使用树箱。
理想情况下,在随机哈希码下,箱子中节点的频率服从泊松分布
第一个值是:

 * 0:    0.60653066
 * 1:    0.30326533
 * 2:    0.07581633
 * 3:    0.01263606
 * 4:    0.00157952
 * 5:    0.00015795
 * 6:    0.00001316
 * 7:    0.00000094
 * 8:    0.00000006
 * more: less than 1 in ten million

TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转化成TreeNodes,而是否足够多就是由TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin,并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin

这样就解释了不是一开始就将其转换成TreeNodes而是需要一定节点才转成TreeNodes,说白了就是空间和时间的权衡,红黑树节点占用空间比较大,如果一开始就是用红黑树,就会消耗大量的空间资源

这段内容还说到:当hashCode离散性很好的时候,树形bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件,所以之所以选择8,不是随便决定的,而是根据概率统计决定的。

补充:

泊松分布


泊松分布的参数
是单位时间(或单位面积)内随机事件的平均发生次数。泊松分布适合于描述单位时间内随机事件发生的次数

2.以下是我在研究问题时,在一些资料上面翻看的为什么长度为8时链表进化为红黑树,长度小于等于6时红黑树又退化成链表的解释:供大家参考

红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,
平均查找长度为4,此时才有转换成树的必要,链表长度如果是小于等于6,链表的平均查找长度为6/2=3,而红黑树此
时为log(6)=2.6,虽然速度也很快
但是转化为树结构和生成树的时间并不会太短,两者此时所用时间相差无几

6.当链表中的值小于6则会从红黑树转回链表

//当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;

7.链表转红黑树时数组最大容量

当Map里面的数量超过这个值时,表中的桶才能进行树形化,否则桶内元素太多时会扩容,而不是树形化,为了避免进行扩容、树形化的选择的冲突,这个值不能小于4*TREEIFY_THRESHOLD(8)

//桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;

8.table用来初始化(必须是二的n次幂)(重点)

//存储元素的数组
transient Node<K,V>[] table;

在JDK1.8中我们了解到HashMap是由数组+链表+红黑树来组成的结构,其中table就是HashMap中的数组,jdk8之前数组类型是Entry<K,V>类型。从jdk1.8之后是Node<K,V>类型。只是换了个名字,都实现了一样的接口:Map.Entry<K,V>,负责存储键值对数据

9.存放缓存

//存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;

10.HashMap中存放元素的个数(重点)

//存放元素的个数,注意这个不等于数组的长度
transient int size;

size为HashMap中K-V的实时数量,不是数组table的长度

11.用来记录HashMap的修改次数

//每次扩容和更改map结构的计数器
transient int modCount;

12.用来调整大小下一个容量的值,计算方式为(容量*负载因子)

//临界值,当实际大小(容量*负载因子)超过临界值时,会进行扩容
int threshold;

13.哈希表的加载因子(重点)

//加载因子
final float loadFactor;

loadFactor说明:

  • loadFactory加载因子,是用来衡量HashMap满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率,计算HashMap的实施加载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。capacity是桶的数量,capacity是桶的数量,也就是table的长度length
  • - loadFactory太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactory的默认值为0.75f是官方给出的一个比较好的临界值
  • 当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要进行扩容,而扩容这个过程涉及到rehash,复制数据等操作,非常消耗性能。所以开发中尽量减少扩容的次数,可以通过创建HashMap集合对象时指定初始容量来尽量避免
  • 同时在HashMap的构造器中可以定制loadFactory
  • public HashMap(int initialCapacity, float loadFactor) //构造一个带指定初始容量和加载因子的空HashMap

为什么加载因子设置为0.75,初始化临界值是12?

loadFactory越趋近于1,那么数组中存放的数据(entry也就越多),也就越密集,也就会有更多的链表长度处于一个更长的数值,导致查询效率降低,每当我们添加数据,产生hash冲突的概率也会更高

loadFactory越小,也就是越趋近于0,数组中存放的数据(entry)也就越少,表现得更加稀疏


如果希望链表尽可能少些,要提前扩容,有的数组空间有可能一直没有存储数据,加载因子尽可能小一些

  • 加载因子是0.4,那么16*0.4=6如果数组中满6个空间就扩容,很有可能许多空间内并没有元素或元素很少,会造成大量的空间浪费
  • 加载因子是9,16*0.9=14,这样就会导致扩容之前查找元素的效率很低

HashMap构造方法

HashMap中最重要的构造方法,他们分别如下:

HashMap()

构造一个空的HashMap,默认初始容量(16)和默认负载因子(0.75)

    public HashMap() 
        this.loadFactor = DEFAULT_LOAD_FACTOR; // 将默认的加载因子0.75赋值给loadFactory,并没有创建数组
    

HashMap(int initialCapacity)

构造一个具有指定的初始容量和默认负载因子的HashMap

    public HashMap(int initialCapacity) 
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    

HashMap(int initialCapacity, float loadFactor)

构造一个具有指定的初始容量和负载因子的HashMap

    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;
        this.threshold = tableSizeFor(initialCapacity);
    

对于this.threshold = tableSizeFor(initialCapacity)的问题:

tableSizeFor(initialCapacity)判断指定的初始化容量是否是2的n次幂,如果不是那么会变为

比指定初始化容量大的最小的2的n次幂,但是注意,在tableSizeFor方法体内部将计算后的数

据返回给调用这里了,并且直接赋值给threshold边界值了,有些人会觉得这里是一个bug,应该这样书写,

this.threshold = tableSizeFor(initialCapacity)*this.loadFactor;

这样才符合threshold的意思(HashMap的size到达threshold这个阈值时会扩容)

但是请注意,在jdk8以后的构造方法中并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算

public HashMap(Map<? extends K, ? extends V> m)

包含另一个Map的构造函数

        public HashMap(Map<? extends K, ? extends V> m) 
        //将loadFactor赋值为默认的负载因子
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        //如果传进来map中有数据,把数据移植到新的map集合中
        putMapEntries(m, false);
    

最后调用了putMapEntries,来看一下方法实现

    final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) 
        //记录传进来的map的大小
        int s = m.size();
        //证明里面有数据
        if (s > 0) 
            //判断table是否进行过初始化
            if (table == null)  // pre-size
                //为求数组最大容量最准备,为避免除出来是小数,1.0F强转
                float ft = ((float)s / loadFactor) + 1.0F;
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                if (t > threshold)
                    threshold = tableSizeFor(t);
            
            //如果s数据数量超过新集合阈值,则进行扩容
            else if (s > threshold)
                resize();
            //如果s数据数量小于新集合阈值,则将s集合中的数据添加到新集合中
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) 
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);
            
        
    

面试问题:

注意float ft = ((float)s / loadFactor) + 1.0F;这一行代码为什么要加1.0F?

  • s/loadFactor的结果是小数,加1.0F与(int)ft相当于是对小数做一个向上取整以尽可能地保证更大容量,更大的容量能够减少resize的调用次数,所以+1.0F是为了获取更大的容量
  • 例如:原来集合的元素个数是6个,那么6/0.75是8,是2的n次幂,那么新数组大小就是8,然后原来数组的数据就会存储到长度是8的新的数组中了,这样会导致在存储元素的时候,容量极有可能不够,需要继续扩容,那么性能降低了,如果加1,数组长度则直接为16,就能避免这种情况的发生减少数组的扩容次数

HashMap底层源码解析下

以上是关于HashMap底层源码解析上(超详细图解+面试题)的主要内容,如果未能解决你的问题,请参考以下文章

HashMap底层红黑树原理(超详细图解)+手写红黑树代码

源码那些事超详细的ArrayList底层源码+经典面试题

图解面试题:ConcurrentHashMap是如何保证线程安全的

Java高薪面试宝典Day3图解HashMap高频面试及底层实现架构!

Java并发集合类ConcurrentHashMap底层核心源码解析

大厂Java高级面试题汇总解答,超详细