HashMap源码阅读

概述

开头注释说明:

        实现Map接口所有定义功能,与HashTable功能基本一样,除了HashTable是同步的,且HashMap可以保存<null, null>。

        HashMap使用bucket数组做Hash表,如果Hash散列的够好,get()、put()方法就可以在常量时间返回,效率高。但是对于使用Iterator迭代访问,就会消耗“桶数(Hash表大小)加桶大小”成比例的时间。因此,如果迭代性能很重要,则不要将初始容量设置得太高(或负载因子太低)非常重要。

        两个重要参数initialCapacity(初始化容量)、loadFactor(加载因子),HashMap提供了三个不同构造函数,允许设置这两个值或者不指定而使用默认值。InitialCapacity表示开始map桶数组的大小;当哈希表中的条目数超过initialCapacity和loadFactor的乘积时,哈希表重新哈希即重建内部数据结构),使哈希表扩容水桶数量为原来的两倍。

        loadFactor默认值是0.75,这个值是为了在时间和空间上有一个较好的平衡点,设置太高会在很多Map的操作中,譬如最常用的get()、put()方法中,减少空间成本、增加时间成本。初始化HashMap的时候要根据预期存储的eneity数量和loadFactor来设置HashMap的数量,如果 count(entiey)*loadFactor < initialCapacity时,永远都不会需要扩容重新散列。

        给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行第一次扩容,而扩容这个过程涉及到 rehash(这里为了减少hash运算代价,扩容为原来两倍,具体做法见后续源码分析)、复制数据等操作,所以非常消耗性能。

        HashMap非线程安全,需要多线程访问时请使用同步的ConcurrentHashMap。

       有个比较有意思的事情是:HashMap继承AbstractMap还实现Map接口,而AbstractMap也实现了Map接口,后面作者也解释说明了这是个mistake。

类成员

1. DEFAULT_INITIAL_CAPACITY

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

     默认初始值设置为了16,未设置HashMap的capacity,将会使用这个final变量来作为它的初始capacity。

2. MAXIMUM_CAPACITY

static final int MAXIMUM_CAPACITY = 1 << 30;

最大容量设置为了一个inter值中最大的2的次方。


3. DEFAULT_LOAD_FACTOR

static final float DEFAULT_LOAD_FACTOR = 0.75f;

默认加载因子为0.75,这个值是大量实验得出的权衡空间、时间的最优数据

4. TREEIFY_THRESHOLD

static final int TREEIFY_THRESHOLD = 8;
桶中链表转换为红黑树的阈值。这个值必须至少为8且为2的次方。

5. UNTREEIFY_THRESHOLD

static final int UNTREEIFY_THRESHOLD = 6;

         桶中红黑树由红黑树换成链表的阈值,只有在扩容后需要分裂一个树时使用,分裂是会用两个链表暂存分裂后的树节点,如果链表长度小于等于6则直接转换为Node类型节点链表放到对应桶位置,否则还会把裂成的链表变成树放到桶对应位置。这个值最大为6。

6. MIN_TREEIFY_CAPACITY

static final int MIN_TREEIFY_CAPACITY = 64;

        容器可以树化的最小容量标准。哈希表table数组长度超过这个值时才会考虑将桶中Node树化。(因为哈希冲突主要因素是哈希表不够大,所以哈希表很小的情况下要优先考虑扩容而不是树化,因为树化后如果发生扩容消耗的计算资源会更多),应该至少为4 * TREEIFY_THRESHOLD,以避免调整大小和树化阈值之间的冲突。

7. static class Node<K,V> implements Map.Entry<K,V>

HashMap桶中为链表状态时存放的Node实例化了Map中的Entry接口。请注意区分树化后会转化为TreeNode。

可以看到构造函数中传入的是hash、key、value、next参数。

Node静态内部类中需要注意的两点:

  1. equals()方法的实现,实际上就是比较两者是否是同一个对象,不是的话比较key.equals()和value.equals()。
  2. hashcode()方法实现,是取key.hashcode() ^ value.hashcode()。注意点,Objects.hashCode(Object object),如果object是null返回0,这也是为什么可以存放null key值。

 

8. transient Node<K,V>[] table

        HashMap中的hash表,它的大小始终会设置成2的幂次方(设置的capacity如果不是2的幂次方会取小于capacity的最大的2的幂次方),如果占用超过负载因子的时候会重新设置它的大小。transient关键字标识这个变量不可以被序列化。

 

9. transient Set<Map.Entry<K,V>> entrySet;

        用于操作HashMap时缓存Node

 

10. transient int modCount;

        用于记录当前HashMap操作次数,因为是非线程安全的,在使用迭代器操作HashMap的时候发现这个值发生变化,就知道有其他线程操作了HashMap,迭代器就会抛出ConcurrentModificationException()异常。

参考: https://blog.csdn.net/Super_Me_Jason/article/details/79741298

11. int threshold;

     首次实例化HashMap如果传入初始化容量时记录初始化哈希表大小,初始化hash表(table)后记录(capacity * load factor),用于判断是否需要扩容,扩容后要更新这个字段

12. final float loadFactor;

    加载因子,用于判断是否需要扩容hash表

构造函数

        有三个构造方法:

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);
    }
    
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

        需要注意的点是,如果使用 HashMap(int initialCapacity, float loadFactor)、或者 HashMap(int initialCapacity) 传入初始化容量,不会真的直接用这个值进行初始化HashMap中的hash表,会取“>=这个值的最小2的幂次方”的值来作为初始化容量。

代码分析

主要方法处理逻辑解析

插入节点

1、public V put(K key, V value)

将键值对加入map中,如果key值已经存在于map中,则替换原来map中的value值。主要调用两个方法,hash()和putVal(),调用hash()来计算在Hash表散列中使用的hash值;用putVal()放该Entry进入HashMap中。

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

2、static final int hash(Object key)

jdk1.7中并无此方法,直接使用key.hashcode()来进行散列放入对应桶中,以及查找对应的key值Entry。

用这个方法的目的是:因为key值hashcode有32位,jdk1.7中散列到hash表中使用的是hashcode/table.len()(真正的操作是使用位运算hashcode^(talbe.len-1),使用了table的长度必须位2^n,减一后得到的是000…11111形式,刚好使用高效率的位运算来计算原来的取余操作),hashcode的高位基本上在取余操作中被舍弃了,并没有用上。为了让高位数据与低位数据进行异或,以此加大低位信息的随机性,变相的让高位数据参与到计算中,从而使得散列的结果会更好。

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

3、final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)

  1. 当桶数组 table 为空时,通过扩容的方式初始化 table
  2. 桶数组table中对应位置为null,直接放入table对应位置
  3. 如果插入的键值对是否已经存在桶数组table中对应位置,用新值替换旧值,转到6
  4. 如果桶数组table对应位置中后面拉的是红黑树,且要插入的键值对不存在,将键值对插入到红黑树中,转到6
  5. 如果桶数组table对应位置中后面拉的链表,且要插入的键值对不存在,则将键值对链入链表中,并根据链表长度决定是否将链表转为红黑树,转到6
  6. 判断键值对数量是否大于阈值,大于的话则进行扩容操作
/**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent 如果为真,如果Map中已经存在key,不修改当前存在的key对应vlaue值,HashMap put(k, v)方法中会传入false,也就是会覆盖老值
     * @param evict 方法最后会用这个值作为参数调用afterNodeInsertion(evict)方法,供子类LinkedHashMap()来使用,对HashMap来说这个方法啥也没做
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
        //hash表table未初始化,调用resize()方法初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //hash表中对应位置为null,将k,v实例化一个Node放入hash表对应位置
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //hash表对应位置不为null,找出
        else {
            HashMap.Node<K,V> e; K k;
            //HashMap中存在该key值,且就放在hash表中
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //Hash表中节点后面已经被树化,如果树中存在该key值节点则返回该节点的引用,否则将(k, v)作为新节点插入树中
            else if (p instanceof HashMap.TreeNode)
                e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //Hash表中节点后面拉的链表,如果链表中存在该key值的节点则返回该节点引用,否则将(k, v)作为新节点插入链表尾部
            else {
                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);//插入时如果链表大于等于TREEIFY_THRESHOLD,将链表进行树化
                        break;
                    }
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //onlyIfAbsent=false或者key对应的老值为null时,Map中存在该key值,替换老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()进行扩容
            resize();
        afterNodeInsertion(evict);
        return null;
    }

4、final Node<K,V>[] resize()

        主要注意点是扩容为原来两倍的意义在哪里:

Jdk1.8中做出的优化,扩容为原来两倍的话,不用每个节点都重新计算hash表中散列位置。

计算hash表中位置采用方式为hash(key) ^ (table.length-1)

例如:初始化table大小为16

取余的操作结果为:

 

扩容后的取余结果为:

 

根据这个特性只需要计算:

hash(key) ^ 16 == 0 与否,如果等于0则不用动,不等于0直接放到哈希表中(oldCap+原下标)下标处就可以了。

  1. 这样比重新挨个计算Node在哈希表中散列位置高效很多。
  2. 这样重新拉好的链表和原来的顺序是相对一致的。

这个方法做的事情总结起来就是:

  1. 计算本次扩容后需要设置新table的大小。如果table还未初始化则使用初始大小;如果已经被初始化过,double原来的大小;如果当前table的大小已经超过了最大上限,不进行扩容,直接返回。
  2. 使用第一步计算的本地扩容容量实例化一个新的table
  3. 将oldtable上散列的Node移动到newtable的对应位置,具体操作如下:

           3.1、oldtable对应位置有节点,且改节点next=null(即还没有hash冲突,后面没链表或树),直接放到newtable对应位置
           3.2、oldtable对应位置有颗树,拆分这棵树为两个链表,如果这两个链表大小<UNTREEIFY_THRESHOLD则直接以链表   的形式放到newtable对应位置,否则需要树化后放到newtable对应位置
           3.3、oldtable对应位置是链表,拆分链表,将两个拆分后链表放到newtable对应位置

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length; //计算当前hash表大小,还未初始化过为0
        int oldThr = threshold;
        int newCap, newThr = 0;
        //之前初始化过hash表了,本次是扩容操作
        if (oldCap > 0) {
            //当前hash表已经大于等于最大限制大小了,不对原hash表扩容
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //满足扩容条件,将新的hash表大小设置为原来的两倍,扩容阈值threshold也设置为原来的两倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        /**
         * hash表还没有初始化过,需要进行初始化,
         * oldThr方法前面设置为threshold,实例化HashMap时如果传入初始化容量,初始化容量会用threshold来记录,后续才会用threshold来记录(capacity * load factor)
         */
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        //实例化HashMap时调用默认无参构造函数,不传入初始化容量时,threshold=0,这时采用默认大小初始化hash表
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //计算新的扩容阈值
        }
        //计算新的扩容阈值
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                    (int)ft : Integer.MAX_VALUE);
        }
        //设置下次需要扩容的阈值
        threshold = newThr;
        //使用新的容量初始化或扩容hash表
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        //将原来散列到老hash表上的节点放到新hash表的对应位置
        if (oldTab != null) {
                for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null) //hash表上未拉链表或树的节点重新散列
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)//树化的红黑树节点们裂成两个(树或者链表)放到新hash表对应位置
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // 链表节点们裂成两个链表,放到新hash表对应位置
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

5、final void treeifyBin(Node<K,V>[] tab, int hash)

如果链表超长的时候就要调用这个方法将对应哈希表后面拉的链表进行树化。这个方法中隐含的调用resize()方法。

所以调用这个方法将链表树化需要满足两个条件:

  1. 链表长度大于等于 TREEIFY_THRESHOLD(8)
  2. 桶数组容量大于等于 MIN_TREEIFY_CAPACITY(64)

原因在于:

当桶数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。毕竟高碰撞率是因为桶数组容量较小引起的,这个是主因。容量小时,优先扩容可以避免一些列的不必要的树化过程。同时,桶容量较小时,扩容会比较频繁,扩容时需要拆分红黑树并重新映射。所以在桶容量比较小的情况下,将长链表转成红黑树是一件吃力不讨好的事。---- https://segmentfault.com/a/1190000012926722#articleHeader6

6、final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit)

         拆解两个红黑树,用两个链表暂存拆分后的红黑树节点(拆分方法当然也是按照resize扩容1倍的中优化方式来计算Node重新哈希到哈希表的位置)。

    如果两个链表<= UNTREEIFY_THRESHOLD,直接将链表放到对应哈希表位置。

 

获取节点

相较put方法,get方法就简单多了

1、public V get(Object key)

这个方法就是调用了getNode()方法。

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

2、final Node<K,V> getNode(int hash, Object key)

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        // hash表对应位置有值,否则获取失败
        if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {
            // 要获取的节点就在hash表中存着,直接返回
            if (first.hash == hash && // always check first node
                    ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                // 要获取的节点在树上存者,找到它返回
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                // 要获取的节点在链表上存着,找到它返回
                do {
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

水平有限,如理解有误,多谢指出。

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值