从源码分析HashMap实现

来源:互联网 发布:苹果自定义铃声mac 编辑:程序博客网 时间:2024/05/16 01:20

HashMap可能是Java程序员最常用的数据结构之一了。网上关于它的解析也不少,可是看完之后,有些细节还不是很清楚。所以干脆直接看了HashMap的源码,然后在这里总结一下。

原理

先从它的基本原理开始讲起。HashMap内部使用数组来存储Node节点。其中Node节点是一种链表的结构(Node节点内部包含一个指向next的引用)
当put()的时候,首先map根据Key的hash值定位到数组中的某个入口,如果此时数组中的值为null,则构造Node节点并存储在数组中,如果不为null,即与其他Key发生了碰撞时,则构造新节点后附加在链表的最后。

当get()的时候,过程正好相反,首先根据key的hash值定位到数组中的某个入口,然后从入口开始依次遍历,当定位到某个元素(Node),如果Node中的key与要查询的key相等(equal),则将Node元素的value返回,否则返回null。

注意:hashmap允许key和value都为null,所以当你使用get(key)获取到null时,并不一定真的代表为空,也许他的值真的为空。所以当想判断是否存在key时,请使用containsKey(key)。

hashMap如何实现key和value为null呢?请见这里

java中有两个参数对系统性能有很大的影响,一个是capacity,另外一个就是load factor,capacity指数组的大小,而load factor则为一种类似于一种饱和度的参数。与此对应的就是threshold,它是capacity*load_factor。当map中数组的大小超过了threshold之后,hashmap将自动扩容。需要注意的是hashmap中并没有一个field是capacity,只有一个load factor 和threshold。

构造

hashMap有四种构造方法:

1public HashMap();2public HashMap(Map<? extends K,? extends V> m);3public HashMap(int initialCapacity);4public HashMap(int initialCapacity, float loadFactor);

上面这些构造方法在java doc中描述的很清楚。方法1、3、4实际上并没有真正的构建Node数组,因此它的花销很小。
方法1:capacity == DEFAULT_INITIAL_CAPACITY; aka 16
load_factor = DEFAULT_LOAD_FACTOR; aka 0.75f
方法3:load_factor = DEFAULT_LOAD_FACTOR; aka 0.75f
方法3、方法4:传入的initialCapacity,经过tableSizeFor处理后,传给了threshold。

tableSizeFor的作用很简单,就是取不小于当前数的以2为底的幂。tableSizeFor的实现,这里,将Node数组的大小设置为以2为底的幂的作用,这里

在这里面,我觉得将capacity省略,而将threshold作为一个临时代表capacity的存储的唯一原因就是可以省略一个字节的空间。但是这样做,至少读起来会很奇怪。
在之后的创建数组的过程中,首先会创建一个根据threshold创建一个Node数组,同时,将threshold恢复成数组的大小(tables.length)与loadfactor的乘积,也就是恢复成threshold原有的语义。

方法2:将创建一个Node数组A,其中A.threshold = tableSizeFor(max(m.size()/loadFactor + 1,threshold));当然了,这个时候的threshold代表要创建的Node数组的大小。然后将map中的元素依次复制进去。

put

在使用hashmap时最常用的函数可能就是put和get了。我们先来看put。

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

put方法将通过putVal来完成数据的插入
putVal稍微长一点,下面简单介绍一下:

  1. 如果table为null(table为Node数组),则调用resize()来分配Node数组
  2. 查看index为hash & (table.length - 1)的数组是否为null,如果为null,构建Node节点,并将该数组项设为Node节点。
  3. 如果不为null,判断头节点是否为TreeNode(instanceOf)。
    • 如果头节点为TreeNode节点,则调用putTreeVal来完成数据的插入
    • 如果头节点不为TreeNode节点,则表示为普通链表,遍历链表,如果找到与KEY对应的Node时,将Node中的value设置为新的Value,如果没找到,则构造一个新的节点并插入到链表的最后。同时,判断该链表的长度是否大于TREEIFY_THRESHOLD,如果大于,就调用treeifyBin().
  4. 判断该节点是插入还是更新,如果是插入操作,那么++size,并判断 size 是否大于threshold,如果大于则重新resize()

这里可能会产生这样几个问题:
1、TreeNode是什么,treeifyBin又是什么?可以看这里;
2、resize()是什么?可以看HashMap扩容一部分
3、获取index的hash值与hashcode什么关系?可以看hashcode一节
4、如何判断Node中的KEY是否与传入的KEY是否匹配?可以看hashcode一节。

get

get操作正好是put操作的逆操作。理解了put,那么理解get就容易的多了。

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

从字面意义上,首先去getNode(),判断是否为null,如果为null,返回null,否则返回e.value。这也就是为什么get(Key)的时候,返回值为null并不一定是不存在,可能e.value就是null。那看到这个,我们似乎也猜到为什么containsKey()可以判断key是否存在了,对,它直接判断getNode()是否为null。如果为null代表不存在,不为null代表存在。

接下来看下getNode的实现。

  1. 首先根据hash值计算数组的索引项,hash&(table.length -1 )
  2. 判断头节点是否为null,判断头节点是否就是我们要找的节点。如果是返回
  3. 判断头节点是否为TreeNode,如果是,则调用getTreeNode来返回相应的节点
  4. 如果不为TreeNode,那么表示为节点链表,于是可以从前向后遍历,判断Node中的KEY是否为给定的KEY相同,如果相同返回。一直到最后,返回null。

这里可能有的问题是:

  • 为什么计算数组的索引项,采用hash&(table.length - 1)而非常用的hash%table.length?可以看这里

扩容

扩容的具体实现是在resize()函数中完成的,但是resize()可不光做扩容的操作。它还负责为刚刚创建的hashmap创建数组,具体逻辑为:put—>resize()—->new Node[threshold]。下面主要讲resize如何实现扩容的。

  1. 首先计算newCap,方法很简单,oldCap《1也就是直接数组扩大一倍,即使大于MAXIMUM_CAPACITY也是这样。
  2. 区别仅仅在于,如果newCap大于MAXIMUM_CAPACITY,那么将threshold变成Integer.MAX_VALUE,也即不再扩容。
  3. 如果newCap不大于MAXIMUM_CAPACITY,那么threshold为oldThreshold《1,即扩大一倍
  4. 下面就是很有意思的扩容了
  5. 创建一个新的Node数组,大小为newCAP
  6. 遍历oldNode数组,对每个不为null的项(oldNode[i] != null),遍历Node链表,对当前的Node,如果node.hash & oldCap == 0 ,添加到lowList中,如果不为0,则添加到highList中。
  7. 当遍历完成后,将newCap[i] = lowList,newCap[i+oldCap] = highList

这里可能产生的问题:

  • 为什么当newCap大于MAXIMUM_CAPACITY时,newCap不设置为MAXIMUM_CAPACITY
    • 因为hashMap要永远保持Node数组的大小为以2为底的幂,这个至关重要,如果不能满足这个条件,则hashMap的很多方法都可能出现问题。
  • lowList 和 highList是什么鬼,我再对oldCap中的Node进行rehash时,直接根据hash结果放在对应的newCap数组中不就行了?
    • 没错,这样错确实很好,但是实现者们处于效率的考量,并没有这样做,而是选择上述实现方法
    • 首先,注意node.hash & oldCap的一个操作,要注意的是oldCap为2的幂,这个操作实际上是判断在oldCap的最高有效位的值。因为newCap为oldCap左移一位的结果,因此在对newCap做hash的时候,调用newCap[(newCap.length-1) & hash]时,其实可以发现,hash的结果与oldCap哈希的结果只有最高有效位可能不同,如果hash在最高有效位为0,那么rehash之后其实还是存储在于oldCap相同的索引项中,如果hash在最高有效位为1,那么node在rehash之后,将存储在oldCap原来的节点+oldCap的位置处。这个地方感觉描述的不是很清楚,读者可以简单画一下。
    • 这也就解释了为什么要有lowList和highList存在了,因为这样的话,相当于在遍历OldCap中的某个入口项时,将链表分为了两部分,lowList将存储在newCap[i]处,而highList将存储在newCap[i+oldCap]的位置处
  • 什么情况下扩容?这里很多人的博客上都没说清楚,又有很多人认为是当Node[i]!=null的个数大于扩容的阈值时进行扩容
    • 我可以很确定的说,no!从代码上看,在putVal时,根据if(++size > threshold) resize();而size无论从put的代码还是从remove的代码来看,都代表的是map中的`
     /**     * The number of key-value mappings contained in this map.     */    transient int size;
所以,下次有人再忽悠你,直接把代码甩他脸上。

HashCode

hashcode()是Object的默认实现,也就是任何对象都有一个hashCode()方法,而且它是native实现,根据Bruce Eckel的介绍,

它默认是使用对象的地址计算散列码

因此,对于默认实现而言,任何new之后的对象的hashCode是不相同的。
equal()和hashcode()经常在一起出现,equal()的Object默认实现非常简单,

public boolean equals(Object obj) {        return (this == obj);    }

它通过比较两个引用是否相等来判断。引用是否相等代表什么呢?代表两个引用是否指向同一个对象。从这点看,equals和hashcode很有关联。

再回到hashmap,不管在put还是get的时候,都有一个操作是hash(Object obj)。

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

从结构上看就是计算hashcode的结果,然后将它的低位与高位作异或,然后返回。

而且在get或者put操作时,对链表中的node节点的KEY与输入的KEY进行比较时,判断代码如下:

 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))      return e;

其中e表示遍历时的Node节点,e.hash代表在构造Node节点时,所保存的hash(Key)的结果。这也就是说,如果后期该对象的某些域发生了变化,导致计算出的hashcode与之前put时的hashcode不一致,那么将无法取得该对象的Node节点。
在判断完hash值之后,还要再比较KEY。而KEY的比较可以有两种,一是判断引用是否相同(是否指向同一个对象),二是调用对象的equals方法进行比较。

总结一下,再进行对象比较时,首先要判断对象的hashcode是否相同,其次调用equals来进行对象比较。

因此,以后在重写equals,请务必将hashcode进行重写,否则无法起作用。此外,hashcode的产生以及equals的实现应该不依赖某些变量,即之后再改变的。否则将无法取出相应的结果。

其他问题


hashmap如何实现key和value为null呢?
这个问题其实只要顺着代码捋一遍就会发现,hashmap根本就没有管key到底是不是null,首先定位的时候,相应调用hash方法,hash方法对null返回0(查看hash实现),然后找到第一个索引项,然后遍历Node链表,对null而言,只有null==null为true,否则均返回false,这样就保证了null的唯一性。
至于value值嘛,那寻址定位过程中根本没有使用到。


tableSizeFor的实现
tableSizeFor很有意思,它希望实现比如说不小于cap的最小的以2为底的幂。那如果我来实现,我会选择找到最高有效位,然后在最高有效位+1的位置置1,然后其他项都置0,这个过程免不了会出现%等操作。效率一下子就低了很多。而Doug Lea则采用了下面的算法,它的核心是:
假设最高有效位在第k位(不用管其他位到底什么样),经过了步1,则k-1位一定为1,经过第二步,那么k-1,k-2,k-3一定为1,它相当于一个复制的算法,经过第五步,无论最高有效位在哪里,最高位以下一定全为1。

 /**     * Returns a power of two size for the given target capacity.     */    static final int tableSizeFor(int cap) {        int n = cap - 1;        n |= n >>> 1;//1        n |= n >>> 2;//2        n |= n >>> 4;//3        n |= n >>> 8;//4        n |= n >>> 16;//5        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;    }


为什么Node节点的数组的大小要为以2为底的幂
这个问题也很有意思,为什么数组大小要为power of two,其他数不行吗?当然可以。只是对于power of two的数组,有个天然的好处,就是 index % num == index & (num-1),其中num表示数组的大小。也就是取余的结果正好等于index中的末尾几位。而这个意义在于%的操作的CPU时间比取&操作花费时间多得多。所以,Doug Lea等作者采用了这种方式来提高定位效率。


TreeNode是什么,treeifyBin又是什么
上面也提到了,当元素在定位到数组中的某项时,很容易发生冲突,一旦冲突发生,就将元素添加到链表的后面。可是假如说设计的hashcode很差,然后产生大量的冲突,导致链表的长度很长怎么办?那么不管在get还是put时都需要遍历整个list,这部分的开销时间复杂度为O(K),当K很小时无所谓,当K很大时,这部分开销不能忽略。那么作者就采用了一种方法,当链表长度很大时(大于TREEIFY_THRESHOLD - 1时),将链表重构为一颗红黑树,其中TreeNode节点就为红黑树的节点。红黑树在插入、删除、查找的最坏时间复杂度均为O(logK)。而treeifyBin的工作就是将链表重构为红黑树的过程。但是Doug Lea做的很保守,他只有在数组大小大于某个阈值的情况下,才会重构,否则的话,它会直接调用resize进行扩容。

那问题来了,既然红黑树的结构这么好,为什么不从一开始就构造红黑树,而非要先构造链表呢?
答案是,TreeNode所占用的内存空间大约是Node所占空间的两倍!不信的同学可以数数他们的字段。而且,在正常情况下,不应该调整为TreeNode,当调整为TreeNode时代表自己设计的hashcode很有问题。

0 0
原创粉丝点击