Java中常用的数据结构封装在java.util包下的一些类中,util包中有这样两个接口:

wKiom1mvwu6xIv3tAAAP0r6n4EA276.png

这里面都是日常开发所常用的容器,下面以Java7中HashMap源码为参考,分析HashMap的实现过程。

一、HashMap介绍

Hashmap是基于哈希表的Map接口的实现,以key-value的形式存在。在HashMap中,key-value总是会当做一个整体来处理,系统会根据hash算法来来计算key-value的存储位置,我们可以通过key快速地存、取value。

二、JDK中Hashmap的定义:

publicclass HashMap<K,V>

    extends AbstractMap<K,V>

    implements Map<K,V>,Cloneable, Serializable

{

...

}


HashMap实现了Map接口,继承AbstractMap。其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map。

三、HashMap的构造函数

HashMap提供了三个构造函数:

  • HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。

  • HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。

  • HashMap(int initialCapacity, floatloadFactor):构造一个带指定初始容量和加载因子的空HashMap。

这里提到了两个参数:初始容量,加载因子。这两个参数是影响HashMap性能的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。

四、HashMap的数据结构

我们知道在Java中最常用的两种结构是数组和模拟指针(引用),几乎所有的数据结构都可以利用这两种来组合实现,HashMap也是如此。实际上HashMap是基于Hash table来实现了,而此Hash Table的数据结构由“数组+链表”来组成。

wKioL1mvwuXjjWgKAAA03lpfNP0115.jpg

从图中可以看出HashMap底层实现还是数组,只是数组的每一项都是一条链。其中构造函数中第一个参数initialCapacity就代表了该数组的长度。下面为HashMap构造函数的源码:

public HashMap(intinitialCapacity, floatloadFactor) {

        //初始容量不能小于0

        if (initialCapacity < 0)

            thrownew IllegalArgumentException("Illegal initialcapacity: " + initialCapacity);

        //初始容量不能大于最大容量(2^30

        if (initialCapacity > MAXIMUM_CAPACITY)

           initialCapacity= MAXIMUM_CAPACITY;

        //负载因子不能小于0

        if (loadFactor <= 0 || Float.isNaN(loadFactor))

            thrownew IllegalArgumentException("Illegal loadfactor: " + loadFactor);

 

        //算出一个最小的大于initialCapacity2次幂,HashMap容量为这个2的幂

       //initialCapacity默认值是16 2^4

        //为什么要这样做下面再探讨

        intcapacity = 1;

        while (capacity < initialCapacity)

            capacity <<= 1;

 

        this.loadFactor = loadFactor;

        intthreshold = (int)(capacity * loadFactor);

       table = new Entry[capacity];

        init();

}

从这段代码中可以看出,每次新建一个HashMap时,都会初始化一个table数组。table数组的元素为Entry节点。

Entry定义:

staticclass Entry<K,V>implements Map.Entry<K,V>{

   finalK key;

   V value;

   Entry<K,V> next;

   finalinthash;

   /**

    * Creates new entry.

     */

   Entry(inth, K k, V v, Entry<K,V> n) {

        value = v;

        next = n;

        key = k;

        hash = h;

   }

   .......

}

其中Entry为HashMap的内部类,它不仅包含了键key、值value还有下一个节点next,以及hash值,这是非常重要的,Entry才构成了table数组的链表。

五、存储的实现

Hashmap刚创建出来是空的,往hashmap中存值用到put(key,vlaue)方法。

public V put(K key, V value) {

    /**

     * keynull,调用putForNullKey方法,保存nulltable第一个位置

     * 中,这是HashMap允许为null的原因

     */

    if (key == null)

        return putForNullKey(value);

    //计算keyhash

   inthash = hash(key.hashCode());

    //计算keyhash值在table数组中的位置

   inti = indexFor(hash, table.length);

    //循环链表,找到key的保存位置

   for(Entry<K,V> e = table[i]; e != null; e = e.next) {

        Object k;

        //判断该条链上是否有hash值相同的(key相同)

         //若存在相同,则直接覆盖value,返回旧value

         if (e.hash == hash && ((k = e.key) ==key || key.equals(k))) {

             VoldValue = e.value;

              e.value= value;

              e.recordAccess(this);

              return oldValue;

        }

   }

   //修改次数增加1

   modCount++;

   //keyvalue添加至i位置处

   addEntry(hash, key, value, i);

   returnnull;

}

首先判断key是否为null,若为null,则直接调用putForNullKey方法。通过这段源码我们可以清晰看到HashMap保存数据的过程:

  1. 若key不为空则先计算key的hash值,然后根据hash值搜索在table数组中的索引位置。

  2. 如果table数组在该位置处有元素,则比较是否存在相同的key,若存在则覆盖原来key的value,否则将该元素保存在链头。

  3. 若table数组在该处没有元素,则直接保存。

效率问题:

我们知道对于HashMap的table而言,数据分布的越均匀越好(最好每项都只有一个元素,这样就可以直接找到),太紧会导致查询速度慢,太松则浪费空间。

来看一下Key在hashmap的table中怎么实现定位的

int hash = hash(key.hashCode());

inti = indexFor(hash, table.length);

staticintindexFor(inth, intlength) {

    returnh & (length-1);

}

拿到hash值后是跟数组长度-1做了与运算操作,这是因为在计算机中做二进制位元算效率最高。没毛病。首先将key的hashcode经过一次散列运算得到一个hash值,得到hash值后调用indexFor方法,拿到的h&(length - 1)就是key的位置。

其次考虑为什么返回的这个值会跟其他数返回来值冲突的情况少。

假设初始长度为默认的16,也就是hash()后的值跟15做与运算,会发现永远是h的低四位在进行与操作,很容易发生hash冲突。

如果返回的h散列的跟均匀些,那么冲突的概率就会变小,所以hash方法应该是会使得hash值的位值在高低位上尽量均匀分布的算法。

通过一个例子来做说明:

两个key,调用Object的hashCode方法后值分别为:32,64,然后entry数组大小依旧为:16,不调hash()方法直接返回hashCode,即在调用indexFor时参数分别为[32,15],[64,15],这时分别对它们调用indexFor方法:

32计算过程:

        100000 & 1111 =>  000000 =>0

64计算过程如下:

        1000000 & 1111 =>  000000 =>0

可以看到indexFor在Entry数组大小不是很大时只会对低位进行与运算操作,高位值不参与运算,很容易发生hash冲突。

现在让32和64经过hash()算法:

原始h为32的二进制:100000

        h>>>20:

        000000

        h>>>12:

        000000

接着运算 h^(h>>>20)^(h>>>12):

结果:100000

然后运算: h^(h>>>7)^(h>>>4),

过程如下:

        h>>>7:    000000

        h>>>4:    000010

最后运算: h^(h>>>7)^(h>>>4),

结果:    100010,即十进制34

再调用indexFor方法:

        100010 & 1111 => 2,即存放在Entry数组下标2的位置上

同样,64进过hash运算结果为:1000100,十进制值为68

再调用indexfor方法:

        1000100 & 1111 => 4,即存放在Entry数组下标4的位置上

由此可见hash()的作用。

 

除此之外我们再假设h得到的为5、6、7,length为16(2^n)和15

length=16

        h       

        length-1       

        h&(length - 1)       


        5       

15

0101&1111=00101

        5       

        6       

15

0110&1111=00110

6

        7       

15

0111&1111=00111

7

length=15

5

14

0101&1110=00101

5

6

14

0110&1110=00110

6

7

14

0111&1110=00110

6

这里做了个对比,因为在构造器有这样一步:

//算出一个最小的大于initialCapacity2次幂,HashMap容量为2的幂

        intcapacity = 1;

        while (capacity < initialCapacity)

           capacity<<= 1;


 它的意义就是当length =2^n时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度相应较快。

另外,找到在table中的位置后,会迭代这个Entry,利用key.equals()方法判断是否存在hash值(key)冲突,如果冲突,用新value替换旧value,这里并没有处理key,这就解释了HashMap中没有两个相同的key。

这里也强调重写equals方法后要重写hashCode方法。

六、读取的实现

相对于HashMap的存而言,取就显得比较简单了。通过key的hash值找到在table数组中的索引处的Entry,然后返回该key对应的value即可。

public V get(Object key) {

// 若为null,调用getForNullKey方法返回相对应的value

        if (key == null)

            return getForNullKey();

// 根据该 key 的 hashCode 值计算它的 hash 码

        inthash = hash(key.hashCode());

        // 取出 table 数组中指定索引处的值

        for (Entry<K,V> e = table[indexFor(hash, table.length)];

             e != null;

             e = e.next){

            Object k;

            //若搜索的key与查找的key相同,则返回相对应的value

            if (e.hash== hash&& ((k = e.key) == key || key.equals(k)))

                returne.value;

        }

        returnnull;

}

七、HashMap在Java1.7与1.8中的区别

在JDK1.7之前

使用一个Entry数组来存储数据,用hash()和indexFor(int h, intlength)来决定key会被放到数组里的位置,如果hashcode相同,或者hashcode取模后的结果相同(hash collision),那么这些key会被定位到Entry数组的同一个格子里,这些key会形成一个链表。

在hashcode特别差的情况下,比方说所有key的hashcode都相同,这个链表可能会很长,那么put/get操作都可能需要遍历这个链表,也就是说时间复杂度在最差情况下会退化到O(n)。

在JDK1.8中

使用一个Node数组来存储数据,但这个Node可能是链表结构,也可能是红黑树结构。如果插入的key的hashcode相同,那么这些key也会被定位到Node数组的同一个格子里。

如果同一个格子里的key不超过8个,使用链表结构存储。

如果超过了8个,那么会调用treeifyBin函数,将链表转换为红黑树。

那么即使hashcode完全相同,由于红黑树的特点,查找某个特定元素,也只需要O(log n)的开销,也就是说put/get的操作的时间复杂度最差只有O(log n)

可参考:http://blog.csdn.net/xs521860/article/details/59484291详解