网站首页 文章专栏 还不了解HashMap底层实现原理?看着一篇就够了
还不了解HashMap底层实现原理?看着一篇就够了


 前言 :关于java中的集合,肯定是面试必问,在实际开发中使用的也比较多,只有了解底层才能正确的使用它们,本文就从常见的面试提问中,详细了解到底什么是HashMap。



1,HashMap简介。

   1), 继承关系:

public class HashMapextends AbstractMap    implements Map, Cloneable, Serializable

    2),实现接口:

Serializable, Cloneable, Map

    3),基本属性:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始化大小 16 
static final float DEFAULT_LOAD_FACTOR = 0.75f;     //负载因子0.75
static final Entry[] EMPTY_TABLE = {};         //初始化的默认数组
transient int size;     //HashMap中元素的数量
int threshold;          //判断是否需要调整HashMap的容量

    4),构造函数:

HashMap()    //无参构造方法
HashMap(int initialCapacity)  //指定初始容量的构造方法 
HashMap(int initialCapacity, float loadFactor) //指定初始容量和负载因子
HashMap(Map m)  //指定集合,转化为HashMap

HashMap提供了四个构造方法,构造方法中 ,依靠第三个方法来执行的,但是前三个方法都没有进行数组的初始化操作,即使调用了构造方法,此时存放HaspMap中数组元素的table表长度依旧为0 。在第四个构造方法中调用了inflateTable()方法完成了table的初始化操作,并将m中的元素添加到HashMap中。


2,底层实现。

        1),数据结构。

HashMap实现采用Entry数组来存储key-value对(数组默认大小为16),每一个键值对组成了一个Entry实体,Entry类实际上是一个单向的链表结构,它具有Next指针,可以连接下一个Entry实体,依次来解决Hash冲突的问题,因为HashMap是按照Key的hash值来计算Entry在HashMap中存储的位置的,如果hash值相同,而key内容不相等,那么就用链表来解决这种hash冲突

114006_HzFL_2927759.png

        2),put原理。


根据key获取对应的hash值:int  hash  =  hash(key.hash.hashcode()),根据hash值和数组长度确定对应的数组引 int i  =  indexFor(hash,table.length);简单来说就是 i = hash值%模以数组长度(其实是按位与运算)。如果不同的key都映射到了数组的同一位置,就将其放入单链表中。且新来的放在头结点。

(在1.8之前,新插入的元素都是放在了链表的头部位置,但是这种操作在高并发的环境下容易导致死锁,所以1.8之后,新插入的元素都放在了链表的尾部。)


public V put(K key, V value) {        
        if (table == EMPTY_TABLE) { //是否初始化
            inflateTable(threshold);
        }        
        if (key == null) //放置在0号位置
        return putForNullKey(value);        
        int hash = hash(key); //计算hash值
        int i = indexFor(hash, table.length);  //计算在Entry[]中的存储位置
        for (Entry e = table[i]; e != null; e = e.next) {
            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++;
        addEntry(hash, key, value, i); //添加到Map中
        return null;
}
   
   /*
 * hash hash值
 * key 键值
 * value value值
 * bucketIndex Entry[]数组中的存储索引
 * / 
void addEntry(int hash, K key, V value, int bucketIndex) {
     if ((size >= threshold) && (null != table[bucketIndex])) {
         resize(2 * table.length); //扩容操作,将数据元素重新计算位置后放入newTable中,链表的顺序与之前的顺序相反
         hash = (null != key) ? hash(key) : 0;
         bucketIndex = indexFor(hash, table.length);
     }

    createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}


在上面方法中,添加键值对时,首先进行table是否初始化的判断,如果没有进行初始化(分配空间,Entry[]数组的长度)。然后进行key是否为null的判断,如果key==null ,放置在Entry[]的0号位置。计算在Entry[]数组的存储位置,判断该位置上是否已有元素,如果已经有元素存在,则遍历该Entry[]数组位置上的单链表。判断key是否存在,如果key已经存在,则用新的value值,替换点旧的value值,并将旧的value值返回。如果key不存在于HashMap中,程序继续向下执行。将key-vlaue, 生成Entry实体,添加到HashMap中的Entry[]数组中。

添加方法的具体操作,在添加之前先进行容量的判断,如果当前容量达到了阈值,并且需要存储到Entry[]数组中,先进行扩容操作,扩充的容量为table长度的2倍。重新计算hash值,和数组存储的位置,扩容后的链表顺序与扩容前的链表顺序相反。然后将新添加的Entry实体存放到当前Entry[]位置链表的头部。


        3),get原理。

通过hash获得对应的数组位置,遍历该数组所在的链表。

    public V get(Object key) {     
        if (key == null)         //返回table[0] 的value值
        return getForNullKey();
        Entry entry = getEntry(key);     
        return null == entry ? null : entry.getValue();
    }
   final Entry getEntry(Object key) {     
        if (size == 0) {         
        return null;
     }     
     int hash = (key == null) ? 0 : hash(key);     
     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方法中,首先计算hash值,然后调用indexFor()方法得到该key在table中的存储位置,得到该位置的单链表,遍历列表找到key和指定key内容相等的Entry,返回entry.value值。


        4),remove原理。

public V remove(Object key) {
    Entry e = removeEntryForKey(key);     
    return (e == null ? null : e.value);
}
    final Entry removeEntryForKey(Object key) {     
        if (size == 0) {         
            return null;
         }     
         int hash = (key == null) ? 0 : hash(key);     
         int i = indexFor(hash, table.length);
         Entry prev = table[i];
         Entry e = prev;     
         while (e != null) {
             Entry next = e.next;
             Object k;         
             if (e.hash == hash &&
             ((k = e.key) == key || (key != null && key.equals(k)))) {
                 modCount++;
                 size--;             
                 if (prev == e)
                     table[i] = next;             
                 else
                     prev.next = next;
             e.recordRemoval(this);             
             return e;
         }
         prev = e;
         e = next;
    }    
    return e;
}

删除操作,先计算指定key的hash值,然后计算出table中的存储位置,判断当前位置是否Entry实体存在,如果没有直接返回,若当前位置有Entry实体存在,则开始遍历列表。定义了三个Entry引用,分别为pre, e ,next。 在循环遍历的过程中,首先判断pre 和 e 是否相等,若相等表明,table的当前位置只有一个元素,直接将table[i] = next = null 。若形成了pre -> e -> next 的连接关系,判断e的key是否和指定的key 相等,若相等则让pre -> next ,e 失去引用。


5),常见面试题。

  • hashmap的原理是什么,为什么叫hashMap?

答:HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当我们给put(key, value)方法传递键和值时,它先调用key.hashCode()方法,返回的hashCode值,用于找到bucket位置,来储存Entry对象。


  • 如果两个key的hashcode相同,你如何获取值对象?

答:当我们调用get(key)方法,HashMap会使用key的hashcode值,找到bucket位置,然后获取值对象,如果有两个值对象,储存在同一个bucket ,将会遍历链表直到找到值对象,此时并没有值对象,所以找到bucket位置之后,会调用keys.equals()方法,去找到链表中正确的节点,最终找到要找的值对象。


  • 什么是hash碰撞,怎么解决?

答:HashMap使用key的hashcode确定bucket位置,如果存储的对象对多了,就有可能不同的对象所算出来的hash值是相同的,当两个key的hashcode相同时,它们会储存在同一个bucket位置的链表中,并通过键对象key的equals()方法用来找到键值对key-value,HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。


  • HashMap在每个链表节点中,储存的是什么?

答:储存 键值对key-value 对象。


  • HashMap查询时间复杂度是多少,一直是这样吗?为什么查询速度快?

答:Hashmap查找时间复杂度为O(1),这种只是其理想的状态,因为可能存在hash冲突,HashMap的底层主要是基于数组和链表来实现的,它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。


  • HashMap是否允许null值,null键,是否是线程安全的?

答:HashMap允许一个null键,多个null值,线程不安全,如果想要线程安全的HashMap,可以通过Collections类的静态方法synchronizedMap获得线程安全的HashMap。Map map = Collections.synchronizedMap(new HashMap());   或者使用ConcurrentHashMap。


  • HashMap为什么不安全,并发时会导致什么问题?

答:HashMap在接近临界点时,若此时两个或者多个线程进行put操作,可能或造成扩容(resize)和rehash(为key重新计算所在位置),而rehash在并发的时候,在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,可能会形成链表环,在get的时候触发死循环,引起cpu100%问题,但是在1.8后修复了这个问题,扩容时保持了原来链表中的顺序,但是还是不安全。

在put的时候导致的多线程数据不一致
比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的 hash桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的 hash桶索引和线程B要插入的记录计算出来的 hash桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。


  • HashMap在1.8做了哪些优化?

答:在Jdk1.8中HashMap的实现方式做了一些改变,但是基本思想还是没有变得,只是在一些地方做了优化,数据结构的存储由数组+链表的方式,变化为数组+链表+红黑树的存储方式(如下图),在性能上进一步得到提升。如果再问你红黑树,那就是另一个问题了。

114845_9Vam_2927759.png


  • HashMap是怎么扩容的,如果需要多次向HashMap中put大量元素应该怎么做?

答:HashMap默认容量为16,负载因子为0.75,当达到当前容量的75%时,会进行一次扩容(至两倍),就是创建一个更大的数组,将元素复制过去,HashMap的扩容操作是一项很耗时的任务,所以如果能估算Map的容量,最好给它一个默认初始值,避免进行多次扩容。


以上就是暂时的对HashMap的总结,还有一些可以再研究的,比如扩容的过程,扩容大小的设置,怎么减少hash冲突,红黑树的理解等等,整体算是比较全面了,本来想一次将三个,把HashTable,ConcurrentHashMap一块讲了,但是太长了,还是分开吧,下一篇讲解个人对ConcurrentHashMap的理解。

理解有限,如有不同意见,欢迎留言提出批评。




版权声明:本文由星尘阁原创出品,转载请注明出处!

本文链接:http://www.52xingchen.cn/detail/49




赞助本站,网站的发展离不开你们的支持!
来说两句吧
最新评论