A股上市公司传智教育(股票代码 003032)旗下技术交流社区北京昌平校区

 找回密码
 加入黑马

QQ登录

只需一步,快速开始

前言
作为java开发人员,HashMap可谓是业务中的一把利器,9龙再次捡起这老生常谈的知识点,深入源码,细细品味。
首先,我们抛出几个关于HashMap的问题,带着问题去学习,就像捉迷藏一样有意思。
1、为什么要使用HashMap?HashMap有什么特性?
2、HashMap的主要参数有哪些?都有什么作用?
3、HashMap是基于什么数据结构实现的?
4、构造HashMap时传入的初始容量是如何处理的?为什么要这样做?
5、HashMap在什么时候扩容?扩容的时候都做了什么事?hash碰撞8次一定会转换为红黑树吗?
6、在foreach时对hashMap进行增删操作会发生什么?
1、为什么要使用HashMap?
我们在使用一种工具的时候,肯定是因为其的某种特性很符合我们的需求,能够快速准确的解决我们的问题。那我们为什么要使用HashMap呢?
This implementation provides constant-time performance for the basic operations (get and put), assuming the hash function disperses the elements properly among the buckets.
源码注释里有这样一句话,这就是我们使用HashMap的原因。
意为:HashMap为基本操作(get和put)提供了常数时间性能(即O(1)),假设散列函数将元素适当地分散到各个bucket中。
我们可以这样理解,如果当你需要快速存储并查询值,可以使用HashMap,它可以保证在O(1)的时间复杂度完成。前提是你键的hashCode要足够不同
Map还有一个特性就是key不允许重复。下面我们就来看看HashMap如何保证O(1)进行get和put。
2、细嚼HashMap主要参数2.1、静态常量    //默认的初始化桶容量,必须是2的幂次方(后面会说为什么)    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;    //最大桶容量    static final int MAXIMUM_CAPACITY = 1 << 30;    //默认的负载因子    static final float DEFAULT_LOAD_FACTOR = 0.75f;    //判断是否将链表转化为树的阈值    static final int TREEIFY_THRESHOLD = 8;    //判断是否将树转化为链表的阈值    static final int UNTREEIFY_THRESHOLD = 6;    //判断是否可以执行将链表转化为树,如果当前桶的容量小于此值,则进行resize()。避免表容量过小,较容易产生hash碰撞。    static final int MIN_TREEIFY_CAPACITY = 64;2.2、字段    //hash表    transient Node<K,V>[] table;    //缓存的EntrySet,便与迭代使用    transient Set<Map.Entry<K,V>> entrySet;    //记录HashMap中键值对的数量    transient int size;    //当对hashMap进行一次结构上的变更,会进行加1。结构变更指的是对Hash表的增删操作。    transient int modCount;    //判断是否扩容的阈值。threshold = capacity * load factor    int threshold;    //负载因子,用于计算threshold,可以在构造函数时指定。    final float loadFactor;3、嗅探HashMap数据结构
上面我们看到一个Node<K,V>[] table的Node数组。
为什么要使用数组呢?
答:为了能快速访问元素。哦,说的什么鬼,那我得追问,为什么数组能快速访问元素了?
  • 数组只需对 [首地址+元素大小*k] 就能找到第k个元素的地址,对其取地址就能获得该元素。
  • CPU缓存会把一片连续的内存空间读入,因为数组结构是连续的内存地址,所以数组全部或者部分元素被连续存在CPU缓存里面。
让我们看看Node的结构。
static class Node<K,V> implements Map.Entry<K,V> {        final int hash; //key 的hash        final K key;    //key对象        V value;        //value对象        Node<K,V> next; //链接的下一个节点        Node(int hash, K key, V value, Node<K,V> next) {            this.hash = hash;            this.key = key;            this.value = value;            this.next = next;        } }
我们看到,Node节点内部保留了一个next节点的引用,太熟悉了,这不就是链表嘛。
到这,我们知道了HashMap的底层数据结构是基于数组+链表。但是,这就完了吗?在jdk1.7确实只是这样,jdk1.8为了提高hash碰撞时链表查询效率低的问题,在hash碰撞达到8次之后会将链表转化为红黑树,以至于将链表查询的时间复杂度从O(N)提高到O(logN)。
到这我们就可以明白,HashMap如果能够均匀的将Node节点放置到table数组中,我们只要能够通过某种方式知道指定key的Node所在数组中的索引,基于数组,我们就可以很快查找到所需的值。
接着我们就要看看如何定位到table数组中。
4、走进HashMap构造函数
有了上面的基础知识,知道字段含义及数据结构,我们就有一点信心可以正式进入源码阅读。我觉得了解一个类,得从构造函数入手,知道构造对象的时候做了哪些初始化工作,其次再深入常用的方法,抽丝剥茧。
    public HashMap(int initialCapacity) {        //如果只传入初始值,则负载因子使用默认的0.75        this(initialCapacity, DEFAULT_LOAD_FACTOR);    }   public HashMap(int initialCapacity, float loadFactor) {        if (initialCapacity < 0)            throw new IllegalArgumentException("Illegal initial capacity: " +                                               initialCapacity);       //保证初始容量最大为2^30        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);    }
我们可以看到,构造函数主要是为了初始化负载因子及hash表的容量。可能大家会疑问,这不是初始化的是threshold吗?不要被表面所欺骗,这只是临时将hash表的容量存储在threshold上,我想是因为HashMap不想增加多余的字段来保存hash表的容量,因为数组的length就可以表示,只是暂时数组还未初始化,所以容量暂先保存在threshold。
我们看到将用户指定的initialCapacity传入tableSizeFor方法返回了一个值,返回的值才是真正初始化的容量。???搞毛子这是?然我们揭开它神秘的面纱。
/** * Returns a power of two size for the given target capacity. */static final int tableSizeFor(int cap) {        int n = cap - 1;        n |= n >>> 1;        n |= n >>> 2;        n |= n >>> 4;        n |= n >>> 8;        n |= n >>> 16;        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;    }
好吧, 我们还是把它盖上吧,9龙也没去推算过。我们从jdk给的方法注释看出,该方法返回一个目标值的2的幂次方,进一步9龙翻译为:返回大于或等于目标值的第一个数,该数必须是2的幂次方。
举例说一下:
如果输入10,大于等于10的第一个数,又是2的幂次方的数是16;
如果输入7,大于等于7的第一个数,又是2的幂次方的数是8;
如果输入20;大于等于20的第一个数,又是2的幂次方的是32;
到这我们又得问自己,为什么hash表的容量必须是2的幂次方呢?
5、解剖HashMap主要方法5.1、put
当我们new出HashMa的对象,都会调用put方法进行添加键值对。我跟那些直接贴代码的能一样吗?有啥不一样,哈哈哈。9龙会先读源码,再贴流程图,这样大家会更理解一点。
public V put(K key, V value) {    return putVal(hash(key), key, value, false, true);}static final int hash(Object key) {        int h;    //将key的高16位与低16位异或,减小hash碰撞的机率        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);    }
让我们看看putVal干了什么。
    /**     * 此方法用于将(k,v)键值对存储到HashMap中     *     * @param hash key的hash     * @param key key对象     * @param value key对应的value对象     * @param onlyIfAbsent 如果是true,则不覆盖原值。     * @param evict if false, the table is in creation mode.     * @return 返回旧值,如果没有,则返回null。     */    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,                   boolean evict) {        Node<K,V>[] tab; Node<K,V> p; int n, i;        //在第一次put的时候,此时Node表还未初始化,上面我们已经知道,构造HashMap对象时只是初始化了负载因子及初始容量,但并没有初始化hash表。在这里会进行第一次的初始化操作。        if ((tab = table) == null || (n = tab.length) == 0)            n = (tab = resize()).length;        //如果得到了一个hash值,并且hash值在很少相同的情况下,如何均匀的分布到table数组里呢?最容易想到的就是用hash%n,n为table数组的长度。但是%运算是很慢的,我们知道位运算才是最快的,计算机识别的都是二进制。所以如果保证n为2的幂次方,hash%n 与 hash&(n-1)的结果就是相同的。这就是为什么初始容量要是2的幂次方的原因。        //当找到的hash桶位没有值时,直接构建一个Node进行插入        if ((p = tab[i = (n - 1) & hash]) == null)            tab = newNode(hash, key, value, null);        else {            //否则,表明hash碰撞产生。            Node<K,V> e; K k;            //判断hash是否与桶槽的节点hash是否相同并且key的equals方法也为true,表明是重复的key,则记录下当前节点            if (p.hash == hash &&                ((k = p.key) == key || (key != null && key.equals(k))))                e = p;            //如果桶槽节点是树节点,则放置到树中,并返回旧值            else if (p instanceof TreeNode)                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);            else {                //表明是链表,还未转换为红黑树。                for (int binCount = 0; ; ++binCount) {                    //如果节点的next索引是null,表明后面没有节点,则使用尾插法进行插入                    if ((e = p.next) == null) {                        p.next = newNode(hash, key, value, null);                        //此时链表长度为9,即hash碰撞8次,会将链表转化为红黑树                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st                            treeifyBin(tab, hash);                        break;                    }                    //如果key是同一个key,则跳出循环链表                    if (e.hash == hash &&                        ((k = e.key) == key || (key != null && key.equals(k))))                        break;                    p = e;                }            }            //判断是否是重复的key            if (e != null) { // existing mapping for key                //拿到旧值                V oldValue = e.value;                //因为put操作默认的onlyIfAbsent为false,所以,默认都是使用新值覆盖旧值                if (!onlyIfAbsent || oldValue == null)                    e.value = value;                afterNodeAccess(e);                //返回旧值                return oldValue;            }        }        //到这里,表明有新数据插入到Hash表中,则将modCount进行自增        ++modCount;        //判断当前键值对容量是否满足扩容条件,满足则进行扩容        if (++size > threshold)            resize();        afterNodeInsertion(evict);        return null;    }
总结一下:
  • put方法先通过计算key的hash值;
  • 如果hash表没有初始化,则进行初始化;
  • 然后计算该hash应该处于hash桶的哪个位置;
  • 如果该位置没有值,则直接插入;
  • 如果有值,判断是否为树节点,是的话插入到红黑树中;
  • 否则则是链表,使用尾插法进行插入,插入后判断hash碰撞是否满足8次,如果满足,则将链表转化为红黑树;
  • 插入后判断key是否相同,相同则使用新值覆盖旧值;
  • 进行++modCount,表明插入了新键值对;再判断是否进行扩容。、
以上内容转载于网络

2 个回复

倒序浏览
有任何问题欢迎在评论区留言
回复 使用道具 举报
或者添加学姐微信
DKA-2018
回复 使用道具 举报
您需要登录后才可以回帖 登录 | 加入黑马