数据结构基础14:深入解析HashMap源代码

前言:本文的 HashMap 源码是基于Jdk1.8版本的。

 

一、HashMap的底层实现原理

Java 8.0后,HashMap的底层实现是数组+链表+红黑树。

HashMap的底层实现是用哈希表,而Java中实现哈希表的数据结构是数组+链表。其中,当链表长度超过8时,会自动使用红黑树代替,红黑树的查找时间复杂度为O(logn)。

1、HashMap的底层主要是基于数组和链表来实现的,Java8.0后加入红黑树以解决哈希函数设计不合理导致的性能问题:

要知道HashMap是什么,首先要搞清楚它的数据结构,在Java中,最基本的数据结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,比如栈、队列、树和图等等,HashMap也不例外。即数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式)。因为在数组中根据下标查找某个元素,一次定位就可以达到,哈希表就利用了这种特性,哈希表的主干就是数组。比如我们要新增或查找某个元素,我们通过把当前元素的关键字通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作,这样就能提高搜索效率。

它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap中主要是通过key的hashCode来计算hash值的,只要hashCode相同,计算出来的hash值就一样。如果存储的对象对多了,就有可能不同的对象所算出来的hash值是相同的,这就出现了所谓的hash冲突。学过数据结构的同学都知道,解决hash冲突的方法有很多,HashMap底层是通过链表来解决hash冲突的,把出现哈希冲突的新节点插入链表中,这样HashMap的主干数组的每个元素就是一个链表,可以理解为“链表的数组”。所以Hashmap实际上是一个数组和链表的结合体。

2、HashMap采用拉链法解决哈希冲突,下图是HashMap的存储结构:

数据结构基础14:深入解析HashMap源代码_第1张图片 图片转自前辈博客

3、如果哈希函数不合理,即使扩容也无法减少箱子中链表的长度,

如果Hash函数设计不合理,导致出现大量Hash冲突时,链表就会特别长。在最坏情况下,HashMap就相当于一个线性链表了,搜索时间复杂度为O(N),效率极其低下。因此,Java 8.0里面的HashMap针对这种情况提供了解决方案,就是当链表长度超过8时,链表会自动转为红黑树,红黑树的搜索时间复杂度为O(logN),搜索时间呈对数级增长,而非线性增长,查找速度较快。故Java8.0后,HashMap底层是用数组+链表+红黑树实现的

 

 二、HashMap源代码解析

1、HashMap的继承关系

打开HashMap源代码,可以看到它是继承自 AbstractMap抽象类,并实现了Java集合的根接口Map,以及Cloneable和Serializable接口。

数据结构基础14:深入解析HashMap源代码_第2张图片

数据结构基础14:深入解析HashMap源代码_第3张图片

2、HashMap的静态内部类Node

Node是HashMap的基本组成单元,每一个Node包含一个key-value键值对。HashMap的主干就是一个Node数组。Node中的next就是下一个节点,解决的是hash冲突,所有hash冲突形成一个链表。

static class Node implements Map.Entry 
{
        final int hash;
        final K key;
        V value;
        Node next;
 
        Node(int hash, K key, V value, Node next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
 
        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }
 
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }
 
        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
 
        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry e = (Map.Entry)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前node的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,HashMap中的链表出现越少,性能才会越好。

3、HashMap的成员变量和常量

在了解HashMap的成员变量和常量之前,首先了解一下HashMap的两个重要属性。

负载因子(load factor):它用来衡量哈希表的 空/满 程度,一定程度上也可以体现查询的效率。负载因子越大,意味着哈希表越满,越容易导致冲突,性能也就越低。解决负载因子过大的一般方法就是扩容,以增加数组容量,改变hashCode映射的数组索引位置。计算公式为:

负载因子 = 总键值对数 / 箱子个数

负载因子决定了 HashMap 中的元素在达到多少比例后可以扩容 (rehash),当HashMap的元素数量超过了负载因子与当前容量的乘积后,就需要对哈希表做扩容操作。

在HashMap中,负载因子默认是0.75,这是结合时间、空间成本均衡考虑后的折中方案,因为负载因子太大的话发生冲突的可能性会变大,查找的效率反而低;太小的话频繁rehash,降低性能。

初始容量(initial capacity)

即数组的大小,默认初始容量为16,必须为2的n次幂。箱子的个数不能太多或太少。如果太少,很容易触发扩容,如果太多,遍历哈希表会比较慢。

③源代码:

//常量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

static final int MAXIMUM_CAPACITY = 1 << 30;

static final float DEFAULT_LOAD_FACTOR = 0.75F;

static final int TREEIFY_THRESHOLD = 8;

static final int MIN_TREEIFY_CAPACITY = 64;

//变量

transient HashMap.Node[] table;

transient Set> entrySet;

transient int size;

int threshold;

final float loadFactor;

可以看出,HashMap主要的成员属性比较多,下面一个个来做解释:

  • DEFAULT_INITIAL_CAPACITY: 初始容量,也就是默认会创建 16 个箱子,必须是2的整数次方。
  • MAXIMUM_CAPACITY: 哈希表最大容量,一般情况下只要内存够用,哈希表不会出现问题。
  • DEFAULT_LOAD_FACTOR: 默认的负载因子为0.75。在初始情况下,当键值对的数量大于 16 * 0.75 = 12 时,就会触发扩容。
  • TREEIFY_THRESHOLD: 树形阈值,当链表长度超过8时,把链表转为红黑树。
  • UNTREEIFY_THRESHOLD: 在哈希表扩容时,如果发现链表长度小于 6,则会由树重新退化为链表。
  • MIN_TREEIFY_CAPACITY: 在转变成树之前,还会有一次判断,只有键值对数量大于 64 才会发生转换。这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。
  • table:哈希表的链表数组,对应桶的下标。
  • entrySet:键值对集合。其实还有keySet变量,表示key的集合。
  • size:键值对的数量,也就是HashMap的大小。
  • threshold:阈值,下次需要扩容时的值,等于容量*负载因子。
  • loadFactor:加载因子。

4、HashMap的构造函数

//默认构造函数
public HashMap() 
{
   this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
} 

//指定初始容量,负载因子默认为0.75
public HashMap(int initialCapacity)
 {
     this(initialCapacity, DEFAULT_LOAD_FACTOR);
 }

//指定初始容量和初始负载因子
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);
}

//加载默认大小的加载因子,并创建一个内容为参数 m 的内容的哈希表
public HashMap(Map m) 
{
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
}

不难发现,上面第三个构造函数可以自定义加载因子和容量,首先判断传入的加载因子是否符合要求,然后根据制定的容量执行 tableSizeFor() 方法,它会根据容量来指定扩容阈值,为何要多这一步呢?

因为buckets数组的大小约束对于整个HashMap都至关重要,为了防止传入一个不是2次幂的整数,必须要有所防范。tableSizeFor()函数会尝试修正一个整数,并转换为离该整数最近的2次幂。

数据结构基础14:深入解析HashMap源代码_第4张图片
比如传入一个整数244,经过位移,或运算后会返回最近的2次幂 256

数据结构基础14:深入解析HashMap源代码_第5张图片 图片摘自大神博客

注意:在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组。那么接下来我们就来看看put操作的实现吧

5、HashMap的put()函数(重点)

在集合中最常用的操作是存储数据,也就是插入元素的过程,在HashMap中,插入键值对数据用的是 put() 方法。

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

put方法没有做多余的操作,只是传入 keyvalue 还有 hash 值 进入到 putVal方法中并返回对应的值,点击进入方法,一步步跟进源码:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)
 {
    Node[] tab;
    Node p; 
    int n, i;

    //哈希表如果为空,就做扩容操作 resize()
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    //要插入位置没有元素,直接新建一个包含key的节点
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    //如果要插入的桶已经有元素,替换
    else {
        Node e; K k;
        //key要插入的位置发生碰撞,让e指向p
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //没碰撞,但是p是属于红黑树的节点,执行putTreeVal()方法
        else 
            if (p instanceof TreeNode)
            e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
            //p是链表节点,遍历链表,查找并替换
            else 
            {
        	//遍历数组,如果链表长度达到8,转换成红黑树
                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;
                   }
                    // 找到目标节点,退出循环,e指向p
                    if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                    p = e;
               }
        }
        // 节点已存在,替换value,并返回旧value
        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;
}

代码看上去有点复杂,参数有点乱,但理清逻辑后容易理解多了,源码大概的逻辑如下:

数据结构基础14:深入解析HashMap源代码_第6张图片

 

put方法的代码中有几个关键的方法,分别是:

    hash():哈希函数,计算key对应的数组位置
    resize():扩容
    putTreeVal():插入红黑树的节点
    treeifyBin():树形化容器

前面两个是HashMap的桶链表操作的核心方法,后面的方法是Jdk1.8之后有关红黑树的操作。
1)hash():

①哈希函数是HashMap中的核心函数,对key的hashCode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀。之后还需要把 hash() 的返回值与table.length - 1做与运算,得到的结果即是数组的下标(为什么这么算,下面会说)

从源码中可以看出,传入key之后,hash() 会获取key的hashCode进行无符号右移 16 位,然后进行按位异或,并把运算后的值返回,这个最终值才是key的哈希值。这样运算是为了减少碰撞冲突,因为大部分元素的hashCode在低位是相同的,不做处理的话很容易造成哈希冲突。

数据结构基础14:深入解析HashMap源代码_第7张图片
table.length - 1就像是一个低位掩码(这个设计也优化了扩容操作的性能),它和hash()做与操作时必然会将高位屏蔽(因为一个HashMap不可能有特别大的buckets数组,至少在不断自动扩容之前是不可能的,所以table.length - 1的大部分高位都为0),只保留低位,这样一来就总是只有最低的几位是有效的,就算你的hashCode()实现得再好也难以避免发生碰撞。这时,hash()函数的价值就体现出来了,它对hashcode的低位添加了随机性并且混合了高位的部分特征,显著减少了碰撞冲突的发生。

②put方法中有这样一段代码:

这里为什么要用 i = (n - 1) & hash 作为数组索引运算呢?

答案:这其实是一种优化手段,由于数组的大小永远是一个2次幂,在扩容之后,一个元素的新索引要么是在原位置,要么就是在原位置加上扩容前的容量。这个方法的巧妙之处全在于&运算,之前提到过&运算只会关注n– 1(n =数组长度)的有效位,当扩容之后,n的有效位相比之前会多增加一位(n会变成之前的二倍,所以确保数组长度永远是2次幂很重要),然后只需要判断hash在新增的有效位的位置是0还是1就可以算出新的索引位置,如果是0,那么索引没有发生变化,如果是1,索引就为原索引加上扩容前的容量。

数据结构基础14:深入解析HashMap源代码_第8张图片 效果图

这样在每次扩容时都不用重新计算hash,省去了不少时间,而且新增有效位是0还是1是带有随机性的,之前两个碰撞的Entry又有可能在扩容时再次均匀地散布开,真可谓是非常精妙的设计。

2)resize():

在HashMap中,初始化数组或者添加元素个数超过阈值时都会触发 resize() 方法,它的作用是动态扩容。

数据结构基础14:深入解析HashMap源代码_第9张图片

数据结构基础14:深入解析HashMap源代码_第10张图片

数据结构基础14:深入解析HashMap源代码_第11张图片

上面的源码有点长,但总体逻辑就三步:

1) 计算新桶数组的容量大小 newCap 和新阈值 newThr

2) 根据计算出的 newCap 创建新的桶数组,并初始化桶的数组table

3)将键值对节点重新映射到新的桶数组里。如果节点是 TreeNode 类型,则需要拆分红黑树 (调用**split()**方法 )。如果是普通节点,则节点按原顺序进行分组。

前面两步的逻辑比较简单,这里不多叙述。重点是第三点,涉及到了红黑树的拆分,这是因为扩容后,桶数组变多了,原有的数组上元素较多的红黑树就需要重新拆分,映射成链表,防止单个桶的元素过多。红黑树的拆分是调用TreeNode.split() 来实现的,这里不单独讲。

简单来说,自动扩容过程:逐个遍历链表,重新计算索引位置,将老数组数据复制到新数组中去。

3)总结:

a.我们的数组索引位置的计算是通过 对key值的hashcode进行hash扰乱运算后,再通过和 length-1进行位运算得到最终数组索引位置。

b.HashMap在自动扩容时,一般会创建两倍于原来个数的箱子。HashMap的数组长度一定保持为2的n次幂

c.数组长度发生变化,即使 key 的哈希值不变,存储位置 index = h&(length-1)也可能会发生变化,需要重新计算index,因此所有键值对的存放位置都有可能发生改变,这个过程也称为重哈希(rehash)。

6、HashMap的get()

1)get()方法通过key值返回对应value,如果key为null,直接去table[0]处检索。

public V get(Object key) 
{
     //如果key为null,则直接去table[0]处去检索即可。
        if (key == null)
            return getForNullKey();
        Entry entry = getEntry(key);
        return null == entry ? null : entry.getValue();
 }
final Entry getEntry(Object key) 
{
            
        if (size == 0) {
            return null;
        }
        //通过key的hashcode值计算hash值
        int hash = (key == null) ? 0 : hash(key);
        //indexFor (hash&length-1) 获取最终数组索引,然后遍历链表,通过equals方法比对找出对应记录
        for (Entry e = table[indexFor(hash, table.length)];  e != null; e = e.next)                         {
            Object k;
            if (e.hash == hash && 
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
}

可以看出,get方法的实现相对简单,key(hashcode)-->hash-->indexFor-->最终索引位置,找到对应位置table[i],再查看是否有链表,遍历链表,通过key的equals方法比对查找对应的记录。要注意的是,有人觉得上面在定位到数组位置之后然后遍历链表的时候,e.hash == hash这个判断没必要,仅通过equals判断就可以。其实不然,试想一下,如果传入的key对象重写了equals方法却没有重写hashCode,而恰巧此对象定位到这个数组位置,如果仅仅用equals判断可能是相等的,但其hashCode和当前对象不一致,这种情况,根据Object的hashCode的约定,不能返回当前对象,而应该返回null。

2)为什么重写equals方法需同时重写hashCode方法。

public class MyTest 
{
    private static class Person
    {
        int idCard;
        String name;

        public Person(int idCard, String name) {
            this.idCard = idCard;
            this.name = name;
        }
        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()){
                return false;
            }
            Person person = (Person) o;
            //两个对象是否等值,通过idCard来确定
            return this.idCard == person.idCard;
        }

    }

    public static void main(String []args){
        HashMap map = new HashMap();
        Person person = new Person(1234,"乔峰");
        //put到hashmap中去
        map.put(person,"天龙八部");
        //get取出,从逻辑上讲应该能输出“天龙八部”
        System.out.println("结果:"+map.get(new Person(1234,"萧峰")));
    }
}

实际输出结果:null

如果我们已经对HashMap的原理有了一定了解,这个结果就不难理解了。尽管我们在进行get和put操作的时候,使用的key从逻辑上讲是等值的(通过equals比较是相等的),但由于没有重写hashCode方法,所以put操作时,key(hashcode1)-->hash-->indexFor-->最终索引位置 ,而通过key取出value的时候 key(hashcode1)-->hash-->indexFor-->最终索引位置,由于hashcode1不等于hashcode2,导致没有定位到一个数组位置而返回逻辑上错误的值null(也有可能碰巧定位到一个数组位置,但是也会判断其entry的hash值是否相等,上面get方法中有提到。)

所以,在重写equals的方法的时候,必须注意重写hashCode方法,同时还要保证通过equals判断相等的两个对象,调用hashCode方法要返回同样的整数值。而如果equals判断不相等的两个对象,其hashCode可以相同(只不过会发生哈希冲突,应尽量避免)。

 

三、为什么HashMap的数组长度一定保持为2的n次幂?

比如16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h&(length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换),个人理解。

数据结构基础14:深入解析HashMap源代码_第12张图片

还有,数组长度保持2的n次幂,length-1的低位都为1,会使得获得的数组索引index更加均匀,比如:

数据结构基础14:深入解析HashMap源代码_第13张图片

我们看到,上面的&运算,高位是不会对结果产生影响的(hash函数采用各种位运算可能也是为了使得低位更加散列),我们只关注低位bit,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。这也是数组长度设计为必须为2的n次幂的原因。

数据结构基础14:深入解析HashMap源代码_第14张图片

如果不是2的n次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了。

 

四、心得体会

在写这篇文章之前,我对HashMap只是的了解仅仅停留在用过的层面,没有对源码做深入的了解,直到很多公司面试时很喜欢问的HasMap底层实现,我才去看HashMap的源码,看完源码后,我被深深的震撼了,一个HashMap就涉及到了如此众多的技术知识,比如红黑树,链表转换,hash运算,还有相关的计算机基础逻辑运算和位运算等等,通过简单的代码就整合了这些知识点,而且还保证了HashMap的高效性能。所以真正掌握HashMap与否,很考验程序员的数据结构基础。

最后,感谢几位前辈博客:

Java集合类:HashMap (基于JDK1.8)

HashMap实现原理及源码分析

深入理解哈希表

面试官从浅到深必问的HashMap八大问题

 

 

 

你可能感兴趣的:(数据结构与算法)