java程序员从笨鸟到菜鸟之(三十)集合之HashMap数据结构和扩容机制

来源:互联网 发布:道教神仙体系 知乎 编辑:程序博客网 时间:2024/06/06 15:51

本章节我们从数据结构的角度谈谈HashMap的实现以及HashMap的扩容机制

1  回顾HashMap数据结构

      要知道HashMap是什么?首先要搞清楚它的数据结构;在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外。HashMap实际上是一个数组和链表的结合体(在数据结构中,一般称之为“链表散列“),请看下图:


说明:横排表示数组,纵排表示数组元素【实际上是一个链表】

说明:此图来源来点击打开链接

那么从数据结构上来看HashMap的,就要看看其源码。看源码之前首先要明确一下几个概念:

1---哈希桶:哈希表中每个位置,也即table数组的每一个元素

2--容量:哈希表中哈希桶的数量;如上述图所示,容量为8

3--大小:size--元素的个数【键值对的个数----集合中存储的元素个数】

如何表示一个哈希表呢?

Node<K,V>[] tab; Node<K,V> p;int n, i;
说明:tab就是一个哈希表----数组

那么看一下每一个结点Node<K,V>的内部结构

static class Node<K,V> implements Map.Entry<K,V> {        final int hash; //hash值        final K key;    //键        V 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;     ...... } }
看看与扩容有关的常量字段

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;             //是否将list转换成tree的阈值static final int UNTREEIFY_THRESHOLD = 6;           //在resize操作中,决定是否untreeify的阈值static final int MIN_TREEIFY_CAPACITY = 64;         //决定是否转换成tree的最小容量

扩容机制
问题1  为什么要扩容?
     如果哈希桶数组很大,即使较差的hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少hash碰撞。那么通过什么方式来控制Map使得Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?答案就是好的Hash算法和扩容机制。
问题2  链表散列是如何初始化的
      首先,Node[] table的初始化长度length(默认值是16),load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数;threshold = length * Load factor,也就是说在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。结合负载因子的定义公式可知,threshold就是在此Load factor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍;为解决拉链过长,在JDK1.8版本中对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。红黑树数据结构的工作原理可以参考点击打开链接点击打开链接
明确:哈希桶中的元素数目(键值对个数)超过length * Load factor,就开始扩容;而不是某个hash桶中的结点数目超过length * Load factor开始扩容。

负载因子:load factor=size/capacity
问题3负载因子为什么选择0.75?
默认的负载因子0.75是对空间和时间效率的一个平衡选择。除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
size字段理解:就是HashMap中实际存在的键值对数量。注意和table的长度length、容纳最大键值对数量threshold的区别。而modCount字段理解:主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对;但是某个key对应的value值被覆盖不属于结构变化

问题4什么时候扩容(resize)

当向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶;  

问题5:  jdk8关于HashMap在哪些地方改进了          

(1)  键值对在哈希桶中的存储方式

我们知道,当发生hash冲突时,HashMap首先是采用链表将重复的值串起来,并将最后放入的值置于链首。在jdk1.8中,当节点个数多了之后,也即当前哈希桶中链表结点个数>= TREEIFY_THRESHOLD - 1时(链表长度大于8时),使用红黑树存储。这样做的好处是:最坏的情况下即所有的key都Hash冲突,采用链表的话查找时间为O(n),而采用红黑树为O(logn),时间复杂度降低,性能提高
(2)  hash值计算 

在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的;这么做可以在数组table的长度比较小的时候,也能保证考虑到高低位都参与到Hash的计算中,同时不会有太大的开销

由于HashMap的put()方法是理解其它方法的基础,分析一下HashMap的源码

 //HashMap类中puu()之putVal()方法/**参数说明: * 1--onlyIfAbsent * 表示只有在该key(键)对应原来的value(值)为null的时候才插入, * 也就是说如果value之前存在了,就不会被新put的元素覆盖; * 2---hash * 是值对象(value)的hash码 * 3---evict * 4---关于返回值类型V--随后补充(hashSet中V=Object) * **************************         * 成员变量的说明: * 1--tab---是将要操作的Node数组引用; * 2--p-----表示tab上的某Node节点(对象的引用); * 3--n-----为tab的长度;//tab数组长度 * 4--i-----为tab的下标;//数组索引 * */final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {Node<K, V>[] tab;//哈希桶(bucket)Node<K, V> p;    //下一个结点: pint n, i;/** * 判断当table为null或者tab的长度为0时,即table尚未初始化; * 此时通过resize()方法得到初始化的table。 * *///1---首先判断hash表是否是空的,如果空,则resize扩容进行初始化:哈希桶容量--16if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;//切口---resize()/** * HashMap类的resize()方法,返回的类型是:Node<K,V>[]---相当于对tab进行了初始化,扩容了 * ********************************** * 1)(n-1)&hash计算出的值:作为tab的下标i, * 2)并外p表示tab[i]:也就是该链表第一个节点的位置, * 3)并判断p是否为null? *  * 说明: * (n - 1)&hash作用:求出元素在node数组的下标; * 计算下标的过程,主要分三个阶段: * 1)hashCode * 2)高位运算 * 3)取模运算 * *///2---通过key计算得到hash表下标,如果下标处为null,就新建链表头结点,在方法最后插入即可if ((p = tab[i = (n - 1) & hash]) == null) /** * if作用:判断当前hash值是否冲突? * 说明:如果初始化n=15,那么[(n - 1) & hash]返回的是0-15的数据 * 当p为null时:表明tab[i]上没有任何元素, * 那么接下来就new第一个Node节点,添加到bucket中, * 调用newNode方法返回新节点,然后赋值给tab[i]。 * 即:将该键值对添加到table[i]中 * */tab[i] = newNode(hash, key, value, null);//注意newNode返回值类型/**下面进入p不为null的情况, * 有三种情况: * 1)p为红黑树节点; * 2)p为链表节点; * 3)p是链表节点但长度为临界长度TREEIFY_THRESHOLD,再插入任何元素就要变成红黑树了。      *///3---如果下标处已经存在节点(p!=null),则进入到这里,看equals()是否键相同else { Node<K, V> e;//定义e引用,即将插入的Node节点----临时变量K k;        //从下文可以看出 k = p.key//4---先看hash表该处的头结点是否和key一样(hashcode和equals比较),一样就更新if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))/**  *  说明:&&:提高运算速度,hash码不同,肯定不是同一个对象,不用向后判断 *  如果hash相同,可能会发生hash碰撞,此时检查数组链表tab中的值对象(value)是否相等? *  或者同一hash值,尚未加入新的key(只有一个),只有一个值对象(value) *  ********************************************************* *  HashMap中判断key相同的条件是key的hash相同,并且符合equals方法。 *  这里判断了p.key是否和插入的key相等?(如果相等,则将p的引用赋给e) *  这一步的判断其实是属于一种特殊情况: *  即HashMap中已经存在了key,于是插入操作就不需要了,只要把原来的value覆盖就可以了。     **/e = p;//如果相等,则将p的引用赋给e/** *注意:这里为什么要把p赋值给e,而不是直接覆盖原值呢? *原因:现在我们只判断了第一个节点,后面还可能出现key相同,所以需要在最后一并处理 *即:多个key(键对象)相同时,只会用相同键对象的最后一个键值对覆盖原来的 *//** * 现在开始了第一种情况: * 如果p是红黑树节点,那么肯定插入后仍然是红黑树节点, * 所以我们直接强制转型p后调用TreeNode.putTreeVal方法,返回的引用赋给e。 *///5---hash表头结点和key不一样,则判断节点是不是红黑树,是红黑树就按照红黑树(jdk8的特性)处理else if (p instanceof TreeNode)//TreeNode(后续会提到)e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);/** *  * 红黑树---随后看(重难点) * 你可能好奇:这里怎么不遍历tree,看看有没有key相同的节点呢? * 其实putTreeVal内部进行了遍历,存在相同hash时返回被覆盖的TreeNode,否则返回null * 注意:上行转型代码也说明了TreeNode是Node的一个子类 *///6---如果不是红黑树,则按照之前的hashmap原理处理else { //1)遍历链表/** * 接下里就是p为链表节点的情形? * 也就是上述说的另外两类情况: * 1)插入后还是链表; * 2)插入后转红黑树; */for (int binCount = 0;; ++binCount) { /** * binCount说明:计数器 * 需要一个计数器来计算当前链表的元素个数,并遍历链表。 */if ((e = p.next) == null) {/** * next:是Node类中的成员变量(Node<K,V> next) * p.next--表示当前将要添加的Node结点对象 * 遍历过程中当发现p.next为null时; * 说明链表到头了,直接在p的后面插入新的链表节点, * 即把新节点的引用赋给p.next,插入操作就完成了。 * 注意:此时e赋给p。 * 补充:遍历---只有hash值相同的时候才去遍历元素,用equals方法比较value值是否相等 * 疑问?!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! * 虽然自定义类型是Student但是通过源码可以看到, * 用值对象的类型的equals()方法来比较的值对象是否相等 * 为何还要重写equals()方法,如果值对象(value)是自定义类型还能理解 * 解答:如果HashSet添加集合对象,其中键对象是Student(自定义)类型对象,当然要重写equals()方法了 * 认识:主要是对键值(对象认识不清) * ****************************** * 新问题又来了,如果值对象是自定义类型,需要重写equals()方法吗? */p.next = newNode(hash, key, value, null);/** * 最后一个参数为新节点的next, * 这里传入null,保证了新节点继续为该链表的末端。 *///2)显然当链表长度大于等于7的时候,也就是说大于8(由于插入一个元素)的话,//就转化为红黑树结构,针对红黑树进行插入(logn复杂度)if (binCount >= TREEIFY_THRESHOLD - 1)//TREEIFY_THRESHOLD常量字段为8/** * 插入成功后,要判断是否需要转换为红黑树? * 因为插入后链表长度加1,而binCount并不包含新节点,所以判断时要将临界阈值减1。 */treeifyBin(tab, hash);                                /**                                 * 当新长度满足转换条件(if条件)时,调用treeifyBin方法;                                 * treeifyBin()方法:将该链表转换为红黑树                                 * 问题:链表转换为红黑树?                                 * 至于如何转有时间看看源码再解答                                 */break;    /**     * 当然如果不满足转换条件,     * 那么插入数据后结构也无需变动,所有插入操作也到此结束了,     * break退出;     */}////3)如果hash码相同,则调用相应的集合元素(值对象参数类型)的equals()方法判断,涉及到重写if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))/** * 在遍历链表的过程中, * 上面提到了:有可能遍历到与插入的key相同的节点, * 此时只要将这个节点引用赋值给e,最后通过e去把新的value覆盖掉就可以了。 * if内容:(老样子)判断当前遍历的节点的key是否相同。 */break;//找到了相同key的节点,那么插入操作也不需要了,直接break退出循环进行最后的value覆盖操作。/** * 在上面我提到过: * e是当前遍历的节点p的下一个节点, * p=e就是依次遍历链表的核心语句, * 每次循环时p都是下一个node节点。//p.next */p = e;//p.next---继续转到上面判断if((e = p.next) == null)}}if (e != null) { //针对已经存在key的情况做处理;如果不满足此条件,就在链表最后添加结点,并返回nullV oldValue = e.value;//定义oldValue,即原存在的节点e的value值if (!onlyIfAbsent || oldValue == null)/** * onlyIfAbsent=falase---表示只有在该key(键)对应原来的value(值)不为空---原来已经有值 * oldValue==null--------值对象是(相同键对象的第一个)或者说原来没有此键对象,默认值对象为null * 前面提到,onlyIfAbsent表示只有在该key(键)对应原来的value(值)为null的时候才插入 * 这里作为判断条件, * 可以看出当onlyIfAbsent为false或者oldValue为null时,进行覆盖操作。 */e.value = value;//覆盖操作:将原节点e上的value设置为插入的新value。afterNodeAccess(e);//这个函数在HashMap中没有任何操作,是个空函数,他存在主要是为了linkedHashMap的一些后续处理工作。return oldValue;  //相同键对象的话,覆盖原来的值对象,但是返回的是被覆盖的值对象/** * 这里很有意思:它返回的是被覆盖的oldValue。 * 我们在使用put方法时很少用他的返回值,甚至忘了它的存在, * 这里我们知道,他返回的是被覆盖的oldValue,而不是覆盖的值 */}}/** * 收尾工作: * 值得一提的是,对key相同而覆盖oldValue的情况, * 在前面已经return,不会执行这里, * 所以那一类情况不算数据结构变化,并不改变modCount值。 * ************************************* * 如果没有找到该key(元素)的结点,则执行插入操作,需要对modCount增1。 */++modCount;/** * 同理覆盖oldValue时显然没有新元素添加,除此之外都新增了一个元素, * 这里++size与threshold判断是否达到了扩容标准。 */if (++size > threshold) resize();//在执行插入操作之后,当HashMap中存在的node节点大于threshold时,hashMap进行扩容。afterNodeInsertion(evict);//这里与前面的afterNodeAccess同理,是用于linkedHashMap的尾部操作,HashMap中并无实际意义。return null;//最终,对于真正进行插入元素的情况,put()函数一律返回null}
put方法的步骤   点击打开链接----put的解析

①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;

②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;

③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;

④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

jdk1.7与jdk1.8的其它差异性,有时间了再补充

关于resize()方法有时间了再补充,暂时明白什么时候扩容就行了。

相关链接:点击打开链接---分析hashMap的put方法图解不错

面试链接:点击打开链接

 
阅读全文
1 0
原创粉丝点击