Java基础知识强化之集合框架笔记79:HashMap的实现原理

1. HashMap的实现原理之 HashMap数据结构

HashMap是对数据结构中哈希表(Hash Table)的实现, Hash表又叫散列表。Hash表是根据关键码Key来访问其对应的值Value的数据结构。

它通过一个映射函数把关键码Key映射到Hash表中一个位置来访问该位置的值Value,从而加快查找的速度。这个映射函数叫做Hash函数存放记录的数组叫做Hash表

Java基础知识强化之集合框架笔记79:HashMap的实现原理

在Java中,HashMap的内部实现结合了链表和数组的优势,链接节点的数据结构是Entry<k,v>,每个Entry对象的内部又含有指向下一个Entry类型对象的引用,如以下代码所示:

1 static class Entry<K,V> implements Map.Entry<K,V> {  
2       final K key;  
3       V value;  
4       Entry<K,V> next; //Entry类型内部有一个自己类型的引用,指向下一个Entry  
5       final int hash;   
6       ...
7 }  

哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法--- 拉链法,我们可以理解为"链表的数组" ,如图:

Java基础知识强化之集合框架笔记79:HashMap的实现原理

2. HashMap的实现原理之 HashMap的存取实现

 既然是线性数组,为什么能随机存取?这里HashMap用了一个小算法,大致是这样实现:

1 // 存储时:
2 int hash = key.hashCode(); // 这个hashCode方法这里不详述,只要理解每个key的hash是一个固定的int值
3 int index = hash % Entry[].length;
4 Entry[index] = value;
5 
6 // 取值时:
7 int hash = key.hashCode();
8 int index = hash % Entry[].length;
9 return Entry[index];

(1)put

疑问:如果两个key通过hash%Entry[].length得到的index相同,会不会有覆盖的危险?

  这里HashMap里面用到链式数据结构的一个概念。上面我们提到过Entry类里面有一个next属性,作用是指向下一个Entry。打个比方, 第一个键值对A进来,通过计算其key的hash得到的index=0,记做:Entry[0] = A。一会后又进来一个键值对B,通过计算其index也等于0,现在怎么办?HashMap会这样做:B.next = A,Entry[0] = B,如果又进来C,index也等于0,那么C.next = B,Entry[0] = C;这样我们发现index=0的地方其实存取了A,B,C三个键值对,他们通过next这个属性链接在一起。所以疑问不用担心。也就是说数组中存储的是最后插入的元素,HashMap同一index下使用头插法(每次插入数据,从链头部插入)

到这里为止,HashMap的大致实现,我们应该已经清楚了。

 public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value); //null总是放在数组的第一个链表中
        int hash = hash(key.hashCode());
        int i = indexFor(hash, table.length);
        //遍历链表
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //如果key在链表中已存在,则替换为新value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

 

void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e); //参数e, 是Entry.next
//如果size超过threshold,则扩充table大小。再散列
    if (size++ >= threshold)
            resize(2 * table.length);
}

  当然HashMap里面也包含一些优化方面的实现,这里也说一下。比如:Entry[]的长度一定后,随着map里面数据的越来越长,这样同一个index的链就会很长,会不会影响性能?

回答:会影响性能,HashMap里面设置一个因子,随着map的size越来越大,Entry[](对应index的链表,每个元素都是Entry)会以一定的规则加长长度。

(2)get

 public V get(Object key) {
        if (key == null)
            return getForNullKey();
        int hash = hash(key.hashCode());
        //先定位到数组元素,再遍历该元素处的链表
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        return null;
}

(3)null key 的存取

null key总是存放在Entry[]数组的第一个元素

  private V putForNullKey(V value) {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }
 
    private V getForNullKey() {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }

(4)确定数组的index:hashcode % table.length取模

HashMap存取时,都需要计算当前key应该对应Entry[]数组哪个元素,即计算数组下标;算法如下:

 /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }
按位取并,作用上相当于取模mod或者取余%。
这意味着数组下标相同并不表示hashCode相同
 
(5)table(哈希表)初始大小
public HashMap(int initialCapacity, float loadFactor) {
        .....
        // Find a power of 2 >= initialCapacity
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;
this.loadFactor = loadFactor;
        threshold = (int)(capacity * loadFactor);
        table = new Entry[capacity];
        init();
    }

注意table初始大小并不是构造函数中的initialCapacity!!

而是 >= initialCapacity的2的n次幂!!!!

3. HashMap的实现原理之 解决hash冲突的办法

  1. 开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)
  2. 再哈希法
  3. 链地址法
  4. 建立一个公共溢出区

Java中hashmap的解决办法就是采用的链地址法

4. HashMap的实现原理之 哈希表rehash过程(扩容机制)

当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

当哈希表的容量超过默认容量时,必须调整table的大小。当容量已经达到最大可能值时,那么该方法就将容量调整到Integer.MAX_VALUE返回,这时,需要创建一张新表,将原表的映射到新表中。

HashMap 类中包含3个和扩容相关的常量:

DEFAULT_INITIAL_CAPACITY 是初始容量,默认是 2^4 = 16;

MAXIMUM_CAPACITY是最大容量,默认是 2^30;

DEFAULT_LOAD_FACTOR是增长因子,当占用率超过这个值时,就会触发扩容操作。

DEFAULT_INITIAL_CAPACITY是table数组的容量,DEFAULT_LOAD_FACTOR则是为了最大程度避免哈希冲突,提高HashMap效率而设置的一个影响因子,将DEFAULT_LOAD_FACTOR乘以DEFAULT_INITIAL_CAPACITY就得到了一个阈值threshold,当HashMap的容量达到threshold时就需要进行扩容,这个时候就要进行ReHash操作了,可以看到下面addEntry函数的实现,当size达到threshold时会调用resize()函数进行扩容

HashMap的默认扩容机制,是存储的key超过容量的75%时,容量翻番。其实,这些和有序无序没关系。

比如:当前大小是16,当占用超过16*0.75=12时,就把容量扩充到16*2=32

resize()方法的源码如下:

 1   /**
 2      * Rehashes the contents of this map into a new array with a
 3      * larger capacity.  This method is called automatically when the
 4      * number of keys in this map reaches its threshold.
 5      *
 6      * If current capacity is MAXIMUM_CAPACITY, this method does not
 7      * resize the map, but sets threshold to Integer.MAX_VALUE.
 8      * This has the effect of preventing future calls.
 9      *
10      * @param newCapacity the new capacity, MUST be a power of two;
11      *        must be greater than current capacity unless current
12      *        capacity is MAXIMUM_CAPACITY (in which case value
13      *        is irrelevant).
14      */
15     void resize(int newCapacity) {
16         Entry[] oldTable = table;
17         int oldCapacity = oldTable.length;
18         if (oldCapacity == MAXIMUM_CAPACITY) {
19             threshold = Integer.MAX_VALUE;
20             return;
21         }
22         Entry[] newTable = new Entry[newCapacity];
23         transfer(newTable);
24         table = newTable;
25         threshold = (int)(newCapacity * loadFactor);
26     }
27 
28  
29 
30     /**
31      * Transfers all entries from current table to newTable.
32      */
33     void transfer(Entry[] newTable) {
34         Entry[] src = table;
35         int newCapacity = newTable.length;
36         for (int j = 0; j < src.length; j++) {
37             Entry<K,V> e = src[j];
38             if (e != null) {
39                 src[j] = null;
40                 do {
41                     Entry<K,V> next = e.next;
42                     //重新计算index
43                     int i = indexFor(e.hash, newCapacity);
44                     e.next = newTable[i];
45                     newTable[i] = e;
46                     e = next;
47                 } while (e != null);
48             }
49         }
50     }

在扩容的过程中需要进行ReHash操作,而这是非常耗时的,在实际中应该尽量避免