【面试造火箭2】之HashMap底层实现

这一天,程序员们终于想起「面试」支配的恐惧。

 

图片

 

 

今天又复习了一下程序猿常用的HashMap,发现呀,HashMap的优化程度是非常滴高~但同时可读性也非常的差,特别是JDK1.8。这里打算简单的科普一下HashMap中的底层实现。

HashMap的实现在JDK1.7 和 JDK1.8 是不一样的,所以抛开JDK版本来讲HashMap是不严谨的,JDK1.8对HashMap的数据结构作出了优化(引入了红黑树),同时也解决了多线程扩容的死循环链表问题。

  

 

 

JDK1.HashMap

HashMap的数据结构如下图所示,由一个数组+链表组成。

 

图片

 

基本组成

基础结点

每个结点用Entry表示,key-value对应当前table桶里的键值

next指向当前链表中的key-value对

 Entry(int h, K k, V v, Entry<K,V> n) {

            value = v;

            next = n;

            key = k;

            hash = h;

        }

 

基础信息

如代码所示,分别为初始默认值,最大默认值,负载因子,扩容阀值

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka

    static final int MAXIMUM_CAPACITY = 1 << 30;

    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    int threshold;  // 扩容阀值

 

代码分析

1.初始化,new HashMap() 只初始化基本信息,不做对象的新建,在put中实现

 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;

// 目前扩容阀值等于初始容量,在真正构建数组的时候,其值为 容量*负载因子

        threshold = initialCapacity;

        init();

    }

 

 

2.put流程图与代码

图片

补充1:其中hash()方法会对key进行hash,并用^=方法主要是为了让hash更散列

补充2:indexFor()方法会对hash进行size()-1的&方法代替对table[i]的求余%

原因a.前期限制了size大小必须是2进制数

原因b.用key.hash() & table.size()-1 实际上就是对key.hash()的后4位进行保留

E.g. hash : 1010 1010  (任意随机树)

Size-1:0000 1111  (size = 16)

&

Result:0000 1010

补充3:addEntry()插入HashMap实际上是用头插法,当遇到了哈希冲突,加入链表时是插入到头部。(JDK1.8以后改成尾插法)

 

图片

 

代码详见下面代码块put()

 

 

3.扩容

在同一瞬间会存在两个HashMap,一个新的一个旧的,并通过链表的指针关联,把旧的链表list同步到新链表中。但是在并发环境中由于是头插法,链表的顺序会改变,同时并发场景下next指针会指向头结点,导致扩容死循环链表。

解决并发死循环问题:1.升级到JDK1.8 2.尽量不扩容,设置默认容量和扩容因子。

代码详见下面代码块resize()/transfer()

 

 

 

 

代码块

public V put(K key, V value) {        if (table == EMPTY_TABLE) {            inflateTable(threshold);        }        if (key == null)            return putForNullKey(value);// 根据key计算哈希值        int hash = hash(key);// 根据哈希值和数组长度计算数组下标        int i = indexFor(hash, table.length);        for (Entry<K,V> e = table[i]; e != null; e = e.next) {            Object k;// 哈希值相同再比较key是否相同,相同的话值替换,否则将这个槽转成链表            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {                V oldValue = e.value;                e.value = value;                e.recordAccess(this);                return oldValue;            }        }         modCount++;// fast-fail,迭代时响应快速失败,还未添加元素就进行modCount++,将为后续留下很多隐患        addEntry(hash, key, value, i);// 添加元素,注意最后一个参数i是table数组的下标        return null;    }
   void resize(int newCapacity) { // 保存旧的数组        Entry[] oldTable = table;        int oldCapacity = oldTable.length;// 判断数组的长度是不是已经达到了最大值        if (oldCapacity == MAXIMUM_CAPACITY) {            threshold = Integer.MAX_VALUE;            return;        } // 创建一个新的数组        Entry[] newTable = new Entry[newCapacity]; // 将旧数组的内容转换到新的数组中        transfer(newTable, initHashSeedAsNeeded(newCapacity));        table = newTable; // 计算新数组的扩容阀值        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);}
    /**     * Transfers all entries from current table to newTable.     */    void transfer(Entry[] newTable, boolean rehash) {        int newCapacity = newTable.length; // 遍历旧数组得到每一个key再根据新数组的长度重新计算下标存进去,如果是一个链表,则链表中的每个键值对也都要重新hash计算索引        for (Entry<K,V> e : table) {// 如果此slot上存在元素,则进行遍历,直到e==null,退出循环            while(null != e) {                Entry<K,V> next = e.next;// 当前元素总是直接放在数组下标的slot上,而不是放在链表的最后,所以扩容后的链表和旧的链表顺序是相反的                if (rehash) {                    e.hash = null == e.key ? 0 : hash(e.key);                }                int i = indexFor(e.hash, newCapacity);// 把原来slot(槽)上的元素作为当前元素的下一个(即如果哈希冲突,将当前元素插入到之前元素的前面)                e.next = newTable[i]; // 新迁移过来的节点直接放置在slot(槽)位置上                newTable[i] = e;                e = next;            }        }    } 

 

JDK1.8 HashMap

JDK1.8 / JDK1.7 异同点,1.8的实现优化:

(1)数据结构通过数组+链表+红黑树实现

(2)获取table[i]位置时简化了扰动函数

(3)链表插入方法改成尾插法/红黑树,不会出现死循环链表

 

图片

(这个图很好,但下一秒就是我的了)

JDK1.7/1.8 ConcurrentHashMap

JDK1.7中采用Segment+HashEntry实现,实际上的做法就是在HashMap之上再套一个table[i]数组,称为Segment[i]。

Segment继承了ReentranceLock有着锁的功能。ConcurrentHashMap初始化的时候会给出默认的SegmentSize,HashEntrySize。

每次put的时候,根据key的hash,定位到segment[i]的位置,如果没有初始化则通过CAS进行赋值,否则执行带锁的Segment put操作。

每次size的时候,统计每个segment元素个数进行累加。

 

图片

 

 

JDK1.8中放弃了Segment臃肿的设计,采用Node+CAS+Synchronized。

put的时候,根据key的hash在node数组中找到对应位置,如果未初始化,通过CAS插入。如果已经初始化了,则通过Synchronized锁住当前node,更新结点(链表/红黑树)。

size通过volatile类型的basecount实现,每次插入或删除的时候都会更新这个字段。图片

 

 

 

参考文献

https://www.cnblogs.com/yangyongjie/p/11015174.html

https://blog.csdn.net/qq_36520235/article/details/82417949

https://www.jianshu.com/p/e694f1e868ec

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值