您的位置:首页 > 理论基础 > 数据结构算法

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

2017-11-20 10:36 489 查看
本章节我们从数据结构的角度谈谈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;    //下一个结点: p
int n, i;
/**
* 判断当table为null或者tab的长度为0时,即table尚未初始化;
* 此时通过resize()方法得到初始化的table。
* */
//1---首先判断hash表是否是空的,如果空,则resize扩容进行初始化:哈希桶容量--16
if ((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的情况做处理;如果不满足此条件,就在链表最后添加结点,并返回null
V 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方法图解不错

面试链接:点击打开链接






                                            
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐