笔记整理——HashMap底层原理详解

Posted 茀园日记

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了笔记整理——HashMap底层原理详解相关的知识,希望对你有一定的参考价值。

1. HashMap的数据结构

    数据结构中有数组和链表来实现对数据的存储,但这两者基本上是两个极端。

    数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;

链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达ON)。链表的特点是:寻址困难,插入和删除容易。

    那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。

哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法—— 拉链法,我们可以理解为链表的数组” ,如图:

或者:

笔记整理——HashMap底层原理详解

(方块表示Entry对象,横排表示数组table[],纵排表示哈希桶bucket【实际上是一个由Entry组成的链表,新加入的Entry放在链头,最先加入的放在链尾】,)

二、实现原理

成员变量

源码分析:

/** 初始容量,默认16 */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4// aka 16

    /** 最大初始容量,2^30 */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /** 负载因子,默认0.75,负载因子越小,hash冲突机率越低 */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /** 初始化一个Entry的空数组 */
    static final Entry<?,?>[] EMPTY_TABLE = {};

    /** 将初始化好的空数组赋值给table,table数组是HashMap实际存储数据的地方,并不在EMPTY_TABLE数组中 */
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

    /** HashMap实际存储的元素个数 */
    transient int size;

    /** 临界值(HashMap 实际能存储的大小),公式为(threshold = capacity * loadFactor) */
    int threshold;

    /** 负载因子 */
    final float loadFactor;

    /** HashMap的结构被修改的次数,用于迭代器 */
    transient int modCount;

构造方法

源码分析:

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);
        // 设置负载因子,临界值此时为容量大小,后面第一次put时由inflateTable(int toSize)方法计算设置
        this.loadFactor = loadFactor;
        threshold = initialCapacity;
        init();
    }

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

    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

    public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        inflateTable(threshold);
        putAllForCreate(m);
    }

关于负载因子不理解:

1先从hashCode()与equals()方法理解

1.1Java语言对hashCode()与equals()的要求

a.  在java应用程序运行时,无论何时多次调用同一个对象时的hsahCode()方法,这个对象的hashCode()方法的返回值必须是相同的一个int值

b.  如果两个对象equals()返回值为true,则他们的hashCode()也必须返回相同的int值

c.  如果两个对象equals()返回值为false,则他们的hashCode()返回值也必须不同

 

1.2为什么要重写euqals()

1在很多类中已经重写了equals()方法,例如String,Integer,Character…等类型。已经不是比较两个引用是否为一个对象,而是比较逻辑上的值是否相等,此时”==”与equals()是两个概念。按照原则,我们重写了equals()方法,也要重写hashCode()方法,要保证上面所述的b,c则;所以java中的很多类都重写了这两个方法,例如String类,包装类

1.3为什么重写了equals()方法,也要重写hashCode()方法?

总的来说是要保证上诉的b,c原则!

本质来说,先看代码:

public class HashCodeClass
{
    private String str0;
    private double dou0;
    private int       int0;

    public boolean equals(Object obj)
    {
        if (obj instanceof HashCodeClass)
        {
            HashCodeClass hcc = (HashCodeClass)obj;
            if (hcc.str0.equals(this.str0) && 
                hcc.dou0 == this.dou0 &&
                hcc.int0 == this.int0)
            {
                return true;
            }
            return false;
        }
        return false;
    }
}
public class TestMain
{
    public static void main(String[] args)
    
{
        System.out.println(new HashCodeClass().hashCode());
        System.out.println(new HashCodeClass().hashCode());
        System.out.println(new HashCodeClass().hashCode());
        System.out.println(new HashCodeClass().hashCode());
        System.out.println(new HashCodeClass().hashCode());
        System.out.println(new HashCodeClass().hashCode());
    }
}

打印出来的值是:

1901116749

1807500377

355165777

1414159026

1569228633

778966024

        我们希望两个HashCodeClass类equals的前提是两个HashCodeClass的str0、dou0、int0分别相等。OK,那么这个类不重写hashCode()方法是有问题的。

        现在我的HashCodeClass都没有赋初值,那么这6个HashCodeClass应该是全部equals的。如果以HashSet为 例,HashSet内部的HashMap的table本身的大小是16,那么6个HashCode对16取模分别为13、9、1、2、9、8。第一个放入 table[13]的位置、第二个放入table[9]的位置、第三个放入table[1]的位置。。。但是明明是全部equals的6个 HashCodeClass,怎么能这么做呢HashSet本身要求的就是equals的对象不重复,现在6个equals的对象在集合中却有5份(因 为有两个计算出来的模都是9)。

        那么我们该怎么做呢?重写hashCode方法,根据str0、dou0、int0搞一个算法生成一个尽量唯一的hashCode这样就保证了 str0、dou0、int0都相等的两个HashCodeClass它们的HashCode是相等的,这就是重写equals方法必须尽量要重写 hashCode方法的原因。

2.再来看put方法

 

         HashMap中我们最长用的就是put(K, V)和get(K)。我们都知道,HashMap的K值是唯一的,那如何保证唯一性呢?我们首先想到的是用equals比较,没错,这样可以实现,但随着内部元素的增多,put和get的效率将越来越低,这里的时间复杂度是O(n),假如有1000个元素,put时需要比较1000次。

        实际上,HashMap很少会用到equals方法,因为其内通过一个哈希表管理所有元素,哈希是通过hash单词音译过来的,也可以称为散列表,哈希算法可以快速的存取元素,当我们调用put存值时,HashMap首先会调用K的hashCode方法,获取哈希码,通过哈希码快速找到某个存放位置,这个位置可以被称之为bucketIndex,通过上面所述hashCode的协定可以知道,如果hashCode不同,equals一定为false,如果hashCode相同,equals不一定为true。因为有冲突现象所以理论上,hashCode可能存在冲突的情况,有个专业名词叫碰撞,当碰撞发生时,计算出的bucketIndex也是相同的,这时会取到bucketIndex位置已存储的元素,最终通过equals来比较,equals方法就是哈希码碰撞时才会执行的方法,所以前面说HashMap很少会用到equals。HashMap通过hashCode和equals最终判断出K是否已存在,如果已存在,则使用新V值替换旧V值,并返回旧V值,如果不存在 ,则存放新的键值对<K, V>到bucketIndex位置。文字描述有些乱,通过下面的流程图来梳理一下整个put过程。


现在我们知道,执行put方法后,最终HashMap的存储结构会有这三种情况,情形3是最少发生的,哈希码发生碰撞属于小概率事件。到目前为止,我们了解了两件事:

· HashMap通过键的hashCode来快速的存取元素。

· 当不同的对象hashCode发生碰撞时,HashMap通过单链表来解决,

新元素加入链表表头,通过next指向原有的元素。单链表在Java中的实现就是对象的引用(复合)。

 

put()源码分析:

public V put(K key, V value{  
    // 如果table引用指向成员变量EMPTY_TABLE,那么初始化HashMap(设置容量、临界值,新的Entry数组引用)
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    // 若“key为null”,则将该键值对添加到table[0]处,遍历该链表,如果有key为null,则将value替换。没有就创建新Entry对象放在链表表头
    // 所以table[0]的位置上,永远最多存储1个Entry对象,形成不了链表。key为null的Entry存在这里 
    if (key == null)  
        return putForNullKey(value);  
    // 若“key不为null”,则计算该key的哈希值
    int hash = hash(key);  
    // 搜索指定hash值在对应table中的索引
    int i = indexFor(hash, table.length);  
    // 循环遍历table数组上的Entry对象,判断该位置上key是否已存在
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
        Object k;  
        // 哈希值相同并且对象相同
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
            // 如果这个key对应的键值对已经存在,就用新的value代替老的value,然后退出!
            V oldValue = e.value;  
            e.value = value;  
            e.recordAccess(this);  
            return oldValue;  
        }  
    }  
    // 修改次数+1
    modCount++;
    // table数组中没有key对应的键值对,就将key-value添加到table[i]处 
    addEntry(hash, key, value, i);  
    return null;  
}

3. 终于到了负载因子了!

 

3.1前面我们讲到比较hashMap里的key值的时候说HashMap很少会用到equals方法

因为equals比较,随着内部元素的增多,put和get的效率将越来越低,这里的时间复杂度是O(n),假如有1000个元素,put时需要比较1000次。但是hashCode也是有可能相同,我们称之为哈希冲突这个时候再用equals来比较,如果为false,新元素占据这个位置,next指向原有的Entry对象通过一个单链表来维护这种关系(hash碰撞解决方案)。所以我们要尽可能的减少哈希冲突。


3.2如何尽可能的减少哈希冲突?

HashMap负载因子为什么是0.75?

HashMap有一个初始容量大小,默认是16

static final int DEAFULT_INITIAL_CAPACITY = 1 << 4; // aka 16    

为了减少冲突概率,当HashMap的数组长度达到一个临界值就会触发扩容,把所有元素rehash再放回容器中,这是一个非常耗时的操作。而这个临界值由负载因子和当前的容量大小来决定:

DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR

即默认情况下数组长度是16*0.75=12时,触发扩容操作。所以使用hash容器时尽量预估自己的数据量来设置初始值。

那么,为什么负载因子要默认为0.75,在HashMap注释中有这么一段:

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)). The first values are:*

0:    0.60653066* 1:    0.30326533*

2:    0.075816333:    0.01263606*

4:    0.00157952* 5:    0.00015795*

6:    0.00001316* 7:    0.00000094

8:    0.00000006* more: less than 1 in ten million*

    在理想情况下,使用随机哈希吗,节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素的个数和概率的对照表。

    从上表可以看出当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为负载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。

hash容器指定初始容量尽量为2的幂次方。HashMap负载因子为0.75是空间和时间成本的一种折中。


负载因子为什么会影响HashMap性能

首先回忆HashMap的数据结构,

我们都知道有序数组存储数据,对数据的索引效率都很高,但是插入和删除就会有性能瓶颈(回忆ArrayList),

链表存储数据,要一次比较元素来检索出数据,所以索引效率低,但是插入和删除效率高(回忆LinkedList),

两者取长补短就产生了哈希散列这种存储方式,也就是HashMap的存储逻辑.

而负载因子表示一个散列表的空间的使用程度,有这样一个公式:initailCapacity*loadFactor=HashMap的容量。

所以负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素,元素多了,链表大了,所以此时索引效率就会降低。

反之,负载因子越小则链表中的数据量就越稀疏,此时会对空间造成烂费,但是此时索引效率高

 

如何科学设置 initailCapacity,loadFactor的值

HashMap有三个构造函数,可以选用无参构造函数,不进行设置。默认值分别是160.75.

官方的建议是initailCapacity设置成2n次幂,laodFactor根据业务需求,如果迭代性能不是很重要,可以设置大一下。

 

为什么initailCapacity要设置成2n次幂,网友解释了,我觉得很对,以下摘自网友博客:深入理解HashMap

左边两组是数组长度为1624次方),右边两组是数组长度为15。两组的hashcode均为89,但是很明显,当它们和1110“的时候,产生了相同的结果,也就是说它们会定

位到数组中的同一个位置上去,这就产生了碰撞,89会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以

发现,当数组长度为15的时候,hashcode的值会与141110)进行,那么最后一位永远是0,而0001001101011001101101111101这几个位置永远都不能

存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!

 

 所以说,当数组长度为2n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用

遍历某个位置上的链表,这样查询效率也就较高了。

 

resize()方法

 initailCapacityloadFactor会影响到HashMap扩容。

HashMap每次put操作是都会检查一遍 size(当前容量)>initailCapacity*loadFactor 是否成立。如果不成立则HashMap扩容为以前的两倍(数组扩成两倍),

然后重新计算每个元素在数组中的位置,然后再进行存储。这是一个十分消耗性能的操作。


以上是关于笔记整理——HashMap底层原理详解的主要内容,如果未能解决你的问题,请参考以下文章

HashMap底层原理及jdk1.8源码解读吐血整理1.3w字长文

Java集合详解:HashMap原理解析

浅谈HashMap 的底层原理

HashMap原理详解

HashMap底层实现和原理

面试官再问你 HashMap 底层原理,就把这篇文章甩给他看