HashMap深入理解详细分析原理以及常见面试问题

来源:互联网 发布:路渺渺何知礼 编辑:程序博客网 时间:2024/04/20 09:56

本章节主要给大家分析解答一下HashMap主要底层实现原理和相关机制。
首先,分析HashMap之前先给大家说一下常用的数据结构如下:

  • 数组
    这里写图片描述
    数组结构的优势:
    采用一段连续的存储单元来存储数据,因此寻址很快,
    对于指定下标的查找,时间复杂度为O(1)。
    通过给定值进行查找,需要全部遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n)。
    对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn) 。
    数组结构的不足:
    当插入或者删除的时候,所有的元素都为会移动,导致时间复杂度将根据元素个数N有关O(N),
    ps:java 语言中常用的集合ArrayList 采用此结构.

  • 链表
    这里写图片描述
    链式结构优势:
    由于链式底层采用指针指向的方式进行寻址,因此在对链表插入或者删除的时候,效率比较高时间复杂度O(1)。
    链式结构不足:
    由于链式接口因此索引查询的时候需要对每一个节点进行寻址遍历对比。因此查询的时候相对数组接口来说相对较慢时间复杂度是根据节点个数O(N).
    ps:java 语言中常用的集合LinkedList 采用此结构。

  • 二叉树
    这里写图片描述
    对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logN)
  • 哈希表(哈希表主干就是一个数组
    这里写图片描述
    从图上看该结构结合两种数据结构,将数组链式结构的优势结合到一起,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)。

哈希表
哈希表的主干就是数组。
在看了以上的数据在数据结构中,物理存储结构主要又两种:

  • 链式存储结构
  • 顺序存储结构
    哈希表主要实现:通过key值 生成对应关键字key的值hashkey,然后在通过hashkey 映射key 的存储值。哈希表主要存储多余Key.

HashMap实现原理

HashMap的主干是一个Entry数组。Entry【Node】是HashMap的基本组成单元,每一个Entry【Node】包含一个key-value键值对。【1.8版本Entry改成Node】结构大概如图:
这里写图片描述

Entry代码:

static class Entry<K,V> implements Map.Entry<K,V> {  final K key;  V value; Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构 int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算 公式 hash=hash(key.hashcode())        /**         * Creates new entry.         */        Entry(int h, K k, V v, Entry<K,V> n) {            value = v;            next = n;            key = k;            hash = h;        }

**

HashMap详细分析

**
这里写图片描述

从图上 可以看出,**HashMap结构是链表+数组结构。HashMap主体是一个数组。下面的链表主要解决Hash冲突存在的。对于 如果查询过程中查询的元素只存在数组 (情况1),此时对于查询来说只需要一次寻址就够了,添加时(情况3),最新的Entry会插入链表头部,只需链表指针指向就可以改变就行。因此查询和添加的 都是时间复杂度都能维持在O(1),注意:情况2 和情况 3 的区别是 遍历的时候 查询会存在不同,对于情况2来说 遍历的时候 需要遍历Entry[2]下面的链表数据进行逐一对比查找。因此,性能考虑,HashMap中的链表出现越少,性能才会越好。
主要注意的是:

  • Entry一个桶的概念:对于Entry数组而言,数组的每个元素处存储的是链表,而不是直接的Value。在链表中的每个元素才是真正的
//实际存储的key-value键值对的个数transient int size;//阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考thresholint threshold;//负载因子,代表了table的填充度有多少,默认是0.75final float loadFactor;//用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationExceptiontransient int modCount;

HashMap有4个构造函数 其他三个都会调研这个构造函数:

//initialCapacity 初始容量//loadFactor 负载因子 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);        init();//只有hashMap 子类才有这样方法。    }

对于该构造器 大部分人可能会有一个误区。初始一个HashMap 他的初始值是 1,但是发现依然可以进行put多个值这是为啥呢?

//初始一个容量 1  负载因子0.8Map map=new HashMap(1,0.8);map.put(1,1);map.put(2,1);map.put(3,1);

原理是这样的,随着条目数的增加HashMap类通过动态地扩展表来处理这个问题。initialCapacity*loadFactor 先计算一个临界值。每次新增一个条目。计数就会更新。当计数大于临界值的时候,表被重新ReHash
如图例子 临界值 1 当添加第二条的时候,重新计算空间。还有一点,当hashMap数组空间从1扩大到10的时候,当我们使用remove的之后,只是空间中的数据发生变化,还是10那么大。

HashMap-哈希冲突、扩容以及线程安全问题

通过上面的所讲述的,我们发现一个关键字-扩容,那么什么是ReHash呢?ReHash又是在什么时候进行的 呢?接下来我们看一下我们常用的put方法。

//ps:JDK1.7代码public V put(K key, V value) {    //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(24=16)    if (table == EMPTY_TABLE) {        inflateTable(threshold);    }   //如果key为null,存储位置为table[0]或table[0]的冲突链上    if (key == null)        return putForNullKey(value);    int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀    int i = indexFor(hash, table.length);//获取在table中的实际位置    for (Entry<K,V> e = table[i]; e != null; e = e.next) {    //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value        Object k;        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {            V oldValue = e.value;            e.value = value;            e.recordAccess(this);            return oldValue;        }    }    modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败    addEntry(hash, key, value, i);//新增一个entry    return null;}

inflateTable()方法源码

private void inflateTable(int toSize) {    int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次幂    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1    table = new Entry[capacity];    initHashSeedAsNeeded(capacity);}

hash()源码

//对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀final int hash(Object k) {        int h = hashSeed;        if (0 != h && k instanceof String) {            return sun.misc.Hashing.stringHash32((String) k);        }        h ^= k.hashCode();        h ^= (h >>> 20) ^ (h >>> 12);        return h ^ (h >>> 7) ^ (h >>> 4);    }

indexFor()源码

     /**     * 返回数组Index     */    static int indexFor(int h, int length) {        return h & (length-1);    }

addEntry()源码

void addEntry(int hash, K key, V value, int bucketIndex) {        if ((size >= threshold) && (null != table[bucketIndex])) {            resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容            hash = (null != key) ? hash(key) : 0;            bucketIndex = indexFor(hash, table.length);        }        createEntry(hash, key, value, bucketIndex);    }

大概流程如下:

  • 当put的时候 现判断主干数组是否为空,行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(24=16) inflateTable(threshold); 这个方法,其目的是为了给主干数组分配内存。
  • 调用putForNullKey方法 ,功能就是 如果key为null,存储位置为table[0]或table[0]的冲突链上 。 如果key 不为空,通过Hash函数重新计算hash(key) 使得主干离散均匀。
  • 遍历table 有几种情况:
    • 根据之前获取的hash值 获取获取table位置值i,如果table[i]的值存在的话,就直接 新替换旧。不存在就addEntry()
    • 如果hashCode相同,就能确定它存储的数据位置相同,这时判断他们的key是否相同,如果不相同就会出现哈希冲突。对于HashMap 而言采用【链表法】解决冲突问题,如图上情况2 此时存储的不是一个Entry 而是一个链状结构。(也就是bucket)
    • 如果出现冲突的话需要循环遍历冲突链表,知道找到未知然后替换。
  • .调用addEntry()方法 ,先判断 当前size> 临界值(初始值*负载因子)产生哈希冲突 需要进行扩容 Rehash,创建Entry。

为什么 HashMap 初始化2的幂?
如果数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index。目的为了是数组离散均匀,同时减少哈希冲突和不必要的数据空间浪费。

接下来我们看一下 我们看一下 HashMap() get() 请求:

public V get(Object key) {     //如果key为null,则直接去table[0]处去检索即可。        if (key == null)            return getForNullKey();        Entry<K,V> entry = getEntry(key);        return null == entry ? null : entry.getValue(); }

get方法通过key值返回对应value,如果key为null,直接去table[0]处检索。我们再看一下getEntry这个方法

final Entry<K,V> 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<K,V> 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 比较简单

  1. 先判断当前的key 是否存在为NULL 如果NULL , 由于之前put()方法

    //如果key为null,存储位置为table[0]或table[0]的冲突链上
    if (key == null)
    return putForNullKey(value);

    key 为空的 全在 table[0]或table[0]上 ,所以直接去table[0]处检索。遍历冲突链。
  2. 如果不为NULL ,根据key 的hashCode计算hash值
  3. 通过hash 值 遍历主干数组 通过equals 对比 找到 最终的数组索引对应的值。

本代码采用的是JDK1.7 HashMap 源码,但是如果有同学的是JDK 1.8的HashMap 源码大家会发现1.7和1.8是存在不同的差异和优化:

  • 1.7 用的是一个Entry数组来存储数据,但是这个Entry是链表结构,如果插入的key的hashcode相同,那么这些key会被定位到Entry数组的同一个格子里,这些key会形成一个链表。
  • 1.8采用的是Node数组来存储数据,但这个Node可能是链表结构,也可能是树型结构(网络上大神都说红黑树,具体细节代码我这边有没有细心研究 有兴趣的同学可以研究一下。)
  • 1.8 key的对象,必须正确的实现了Compare接口如果没有实现Compare接口,或者实现得不正确(比方说所有Compare方法都返回0)

常见的面试问题:

  • equals 和 hashcode() 区别?
    equals 源代码
    public boolean equals(Object obj) {
    return (this == obj);
    从源代码可以看出,如果只有当一个实例等于它本身的时候,equals()才会返回true值。既两个对象的引用同时指向的内存同一个对象。
    hashCode ()

    hashCode()返回该对象的哈希码值,该值通常是一个由该对象的内部地址转换而来的整数。
    通常 如果 两个对象 equal 相同那么,他们的hashcode() 一定相同
    但是 如果两个 对象 hashcode()相同的时候,那么两个equals 不一定相同
    如 在使用基于散列值(hash)的集合类的前提下,需要注意两种情况:

    1. 如果重新equals 方法的话,那么hashcode()方法也需要重写。从而导致该类无法与所有基于散列值(hash)的集合类结合在一起正常运行。
    2. 如何散列集合里比较两个对象是否相等的话,先比较两个对象hashCode是否相同。如果不相等的话,在通过equals 比较两个对象是否相等。

  • 什么是Hash冲突 已经对应的解决方法?
    这里写图片描述情况2 已经产生冲突对了HashMap来说,当put元素的时候,当前hashCode相同,但是对应key不同,此时产生哈希冲突。HashMap 获取值的时候遍历链表,通过equals获取值。
    哈希冲突解决方式常用两种:链表法 (情况2)和开放地址法开放地址法 此方法就是通过一个探测算法,当某个槽位已经被占据的情况下继续查找下一个可以使用的槽位。

  • HashMap什么时候rehash(扩容)?
    当HashMap 中的元素 大于(初始容量*负载因子==临界值)目前大家用的HashMap中元素个数超过16*0.75=12的时候 就会扩大一倍32。复制数组是非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

  • HashMap相比HashTable是线程不安全的?
    HashMap不是线程安全的
    1.put、addEntry 、resize 方法是不同步的。
    2.,当两个线程同时进行,同时对一个位置进行插入的时候,由于put方法的不同步行,会导致A,B 线程同时竞争资源,导致赋值的时候出现覆盖的情况。
    3.resize 扩容的时候会出现问题。当数组元素大于临界值的时候,数组会发生扩容情况,扩容就会对HashMap结构发生变化,当出现并发的时候,之前原来的Key 位置2 ,扩容之后变成key 18 了 【主干数组长度16-32扩大一倍】,但是扩容之前获取到16 ,再次从对应的Key 位置2取值发现table[index]获取时候发现那个key 值是null 。其实实际上是有值的。
    HashTable是线程安全的
    这里写图片描述
    从1源码中可以看出key是不允许为空的,除此之外,2 这个位置 发现该方法是同步的。
    还有一点 HashTable put 时候用的是对象的hashCode 不同于HashMap 计算出来HashCode。因此在在这一点上效率相对较高,但是由于方法是同步 对性能上相比HashMap 稍微慢些。
    如果 大家想用HashMap 也实现线程安全的话,可以用它的包装类
    Map