hashmap

hashmap的数据结构 

 

要知道hashmap是什么,首先要搞清楚它的数据结构,在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,hashmap也不例外。Hashmap实际上是一个数组和链表的结合体(在数据结构中,一般称之为“链表散列“,jdk1.8中为数组+链表+红黑树),请看下图(横排表示数组,纵排表示数组元素【实际上是一个链表】)。 

从图中我们可以看到一个hashmap就是一个数组结构,当新建一个hashmap的时候,就会初始化一个数组。

当我们往hashmap中put元素的时候,先根据key的hash值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。从hashmap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。

hash算法

我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。 

所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java中时这样做的

static int indexFor(int h, int length) {

return h & (length-1);

}

首先算得key得hashcode值,然后跟数组的长度-1做一次“与”运算(&)。看上去很简单,其实比较有玄机。比如数组的长度是2的4次方,那么hashcode就会和2的4次方-1做“与”运算。很多人都有这个疑问,为什么hashmap的数组初始化大小都是2的次方大小时,hashmap的效率最高,我以2的4次方举例,来解释一下为什么数组大小为2的幂时hashmap访问的性能最高。 

看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率! 

所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。 

说到这里,我们再回头看一下hashmap中默认的数组大小是多少,查看源代码可以得知是16,为什么是16,而不是15,也不是20呢,看到上面annegu的解释之后我们就清楚了吧,显然是因为16是2的整数次幂的原因,在小数据量的情况下16比15和20更能减少key之间的碰撞,而加快查询的效率。 

所以,在存储大容量数据的时候,最好预先指定hashmap的size为2的整数次幂次方。就算不指定的话,也会以大于且最接近指定值大小的2次幂来初始化的,代码如下(HashMap的构造方法中):

// Find a power of 2 >= initialCapacity

int capacity = 1;

while (capacity < initialCapacity)

capacity <<= 1;

hashmap的resize 

当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。 

那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。

比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。 

key的hashcode与equals方法改写

在第一部分hashmap的数据结构中,annegu就写了get方法的过程:首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。所以,hashcode与equals方法对于找到对应元素是两个关键方法。 

Hashmap的key可以是任何类型的对象,例如User这种对象,为了保证两个具有相同属性的user的hashcode相同,我们就需要改写hashcode方法,比方把hashcode值的计算与User对象的id关联起来,那么只要user对象拥有相同id,那么他们的hashcode也能保持一致了,这样就可以找到在hashmap数组中的位置了。如果这个位置上有多个元素,还需要用key的equals方法在对应位置的链表中找到需要的元素,所以只改写了hashcode方法是不够的,equals方法也是需要改写滴~当然啦,按正常思维逻辑,equals方法一般都会根据实际的业务内容来定义,例如根据user对象的id来判断两个user是否相等。

在改写equals方法的时候,需要满足以下三点: 

(1) 自反性:就是说a.equals(a)必须为true。 

(2) 对称性:就是说a.equals(b)=true的话,b.equals(a)也必须为true。 

(3) 传递性:就是说a.equals(b)=true,并且b.equals(c)=true的话,a.equals(c)也必须为true。  

通过改写key对象的equals和hashcode方法,我们可以将任意的业务对象作为map的key(前提是你确实有这样的需要)。

总结:

本文主要描述了HashMap的结构,和hashmap中hash函数的实现,以及该实现的特性,同时描述了hashmap中resize带来性能消耗的根本原因,以及将普通的域模型对象作为key的基本要求。尤其是hash函数的实现,可以说是整个HashMap的精髓所在,只有真正理解了这个hash函数,才可以说对HashMap有了一定的理解。

什么时候转化为红黑树
在链表长度大于 8 并且 表的长度大于 64 的时候会转化红黑树!!!!

                 

  if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 并且如果 链表的长度 大于 8 会尝试调用  treeifyBin 方法
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }


 

treeifyBin
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        // 如果表的长度小于 64 会先扩容!!! 否则 扩容
        // MIN_TREEIFY_CAPACITY = 64;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }



查阅注释

Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0:    0.60653066
* 1:    0.30326533
* 2:    0.07581633
* 3:    0.01263606
* 4:    0.00157952
* 5:    0.00015795
* 6:    0.00001316
* 7:    0.00000094
* 8:    0.00000006
* more: less than 1 in ten million
*


从上表可以看出当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为负载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。(也就是超过8 可以转化为红黑树)

  • 并且如果 链表的长度 大于 8 会尝试调用 treeifyBin 方法
  • 在此判断 表的长度是否大于64

在 JDK7 版本下,很多人都知道 HashMap 会有链表成环的问题,但大多数人只知道,是多线程引起的,至于具体细节的原因,和 JDK8 中如何解决这个问题,很少有人说的清楚,百度也几乎看不懂,本文就和大家聊清楚两个问题:1:JDK7 中 HashMap 成环原因,2:JDK8 中是如何解决的。

JDK7 中 HashMap 成环原因

成环的时机

1:HashMap 扩容时。

2:多线程环境下。

成环的具体代码位置

在扩容的 transfer 方法里面,有三行关键的代码,如下:

假设原来在数组 1 的下标位置有个链表,链表元素是 a-b-null,现在有两个线程同时执行这个方法,我们先来根据线程 1 的执行情况来分别分析下这三行代码:

e.next = newTable[i];

newTable 表示新的数组,newTable[i] 表示新数组下标为 i 的值,第一次循环的时候为 null,e 表示原来链表位置的头一个元素,是 a,e.next 是 b,

e.next = newTable[i] 的意思就是拿出 a 来,并且使 a 的后一个节点是 null,如下图 1 的位置:

newTable[i] = e;

就是把 a 赋值给新数组下标为 1 的地方,如下图 2 的位置:

e = next;

next 的值在 while 循环一开始就有了,为:Entrynext = e.next; 在此处 next 的值就是 b,把 b 赋值给 e,接着下一轮循环。

从 b 开始下一轮循环,重复 1、2、3,注意此时 e 是 b 了,而 newTable[i] 的值已经不是空了,已经是 a 了,所以 1,2,3 行代码执行下来,b 就会插入到 a 的前面,如下图 3 的位置:

这个就是线程 1 的插入节奏。

重点来了,假设线程 1 执行到现在的时候,线程 2 也开始执行,线程 2 是从 a 开始执行 1、2、3、4 步,此时数组上面链表已经形成了 b-a-null,线程 2 拿出 a 再次执行 1、2、3、4,就会把 a 放到 b 的前面,大家可以想象一下,结果是如下图的:

从图中可以看出,有两个相同的 a 和两个相同的 b,这就是大家说的成环,自己经过不断 next 最终指向自己。

注意!!!这种解释看似好像很有道理,但实际上是不正确的,网上很多这种解释,这种解释最致命的地方在于 newTable 不是共享的,线程 2 是无法在线程 1 newTable 的基础上再进行迁移数据的,1、2、3 都没有问题,但 4 有问题,最后的结论也是有问题的

因为 newTable 是在扩容方法中新建的局部变量,方法的局部变量线程之间肯定是无法共享的,所以以上解释是有问题的,是错误的。

那么真正的问题出现在那里呢,其实线程 1 完成 1、2、3、4 步后就出现问题了,如下图:

总结一下产生这个问题的原因:

插入的时候和平时我们追加到尾部的思路是不一致的,是链表的头结点开始循环插入,导致插入的顺序和原来链表的顺序相反的。

table 是共享的,table 里面的元素也是共享的,while 循环都直接修改 table 里面的元素的 next 指向,导致指向混乱。

接下来我们来看下 JDK8 是怎么解决这个问题。

JDK8 中解决方案

JDK 8 中扩容时,已经没有 JDK7 中的 transfer 方法了,而是自己重新写了扩容方法,叫做 resize,链表从老数组拷贝到新数组时的代码如下:

//规避了8版本以下的成环问题

else { // preserve order

// loHead 表示老值,老值的意思是扩容后,该链表中计算出索引位置不变的元素

// hiHead 表示新值,新值的意思是扩容后,计算出索引位置发生变化的元素

// 举个例子,数组大小是 8 ,在数组索引位置是 1 的地方挂着一个链表,链表有两个值,两个值的 hashcode 分别是是9和33。

// 当数组发生扩容时,新数组的大小是 16,此时 hashcode 是 33 的值计算出来的数组索引位置仍然是 1,我们称为老值

// hashcode 是 9 的值计算出来的数组索引位置是 9,就发生了变化,我们称为新值。

NodeloHead = null, loTail = null;

NodehiHead = null, hiTail = null;

Nodenext;

// java 7 是在 while 循环里面,单个计算好数组索引位置后,单个的插入数组中,在多线程情况下,会有成环问题

// java 8 是等链表整个 while 循环结束后,才给数组赋值,所以多线程情况下,也不会成环

do {

next = e.next;

// (e.hash oldCap) == 0 表示老值链表

if ((e.hash oldCap) == 0) {

if (loTail == null)

loHead = e;

else

loTail.next = e;

loTail = e;

}

// (e.hash oldCap) == 0 表示新值链表

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;

}

}

解决办法其实代码中的注释已经说的很清楚了,我们总结一下:

JDK8 是等链表整个 while 循环结束后,才给数组赋值,此时使用局部变量 loHead 和 hiHead 来保存链表的值,因为是局部变量,所以多线程的情况下,肯定是没有问题的。

为什么有 loHead 和 hiHead 两个新老值来保存链表呢,主要是因为扩容后,链表中的元素的索引位置是可能发生变化的,代码注释中举了一个例子:

数组大小是 8 ,在数组索引位置是 1 的地方挂着一个链表,链表有两个值,两个值的 hashcode 分别是是 9 和 33。当数组发生扩容时,新数组的大小是 16,此时 hashcode 是 33 的值计算出来的数组索引位置仍然是 1,我们称为老值(loHead),而 hashcode 是 9 的值计算出来的数组索引位置却是 9,不是 1 了,索引位置就发生了变化,我们称为新值(hiHead)。

当红黑树的节点个数少于等于6时,HashMap会将其转化为链表。下面是一个节点个数为6的红黑树的示例结构:

     5B
    /  \
   3R   7R
  / \   / \
 1B  4B 6B 8B

该红黑树包含了6个节点,其中5个节点是黑色的,1个节点是红色的。红色节点的左右子树必须都是黑色的,因此,在这个红黑树中,3和7是红色节点,1、4、6、8是黑色节点。

需要注意的是,当节点个数少于等于6时,红黑树的高度较小,因此使用链表来存储键值对更加高效。当节点个数增多时,红黑树的高度也会增加,此时将链表转化为红黑树可以提高查找效率。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值