探究 HashMap 的工作原理

715 阅读4分钟
原文链接: www.jianshu.com

摘要:经过大学四年,我也刚刚毕业了3个月了,学习android也有一两年了,可是在大学的生活中,磨练出来的解决实际问题的能力是有的,完成开发任务还是可以的,但是总觉得技术遇到了瓶颈期,没有办法进步很多。因此想静下心来看JDK的源码,看看这些人是怎么设计出来的?为什么要这么设计?我能从中体会到什么?对我今后的职业生涯有什么帮助?跟着优秀的人一起成长吧。

我们用HaspMap最关心的两个方法和几个变量是:

/** 默认的初始化容量,2^4 */
static final int DEFAULT_INITIAL_CAPACITY = 16;

/** 默认的负载因子 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/*数组,Entry是存放Key value的基础bean */
transient Entry[] table;

/** 负载因子*/
final float loadFactor;

/** *下一次的负载table容量 (capacity * load factor) *
int threshold;

public V put(K key,V value)

public V get(Object key)

为了帮助理解和记忆,贴上两张图:


Paste_Image.png

Paste_Image.png

从上图我们可以发现哈希表是由数组+链表组成的,一个长度为 我们看到的第一个变量DEFAULT_INITIAL_CAPACITY=16 的数组中,每个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。一般情况是通过hash(key.hashCode()%table.length)获得,也就是元素的key的哈希值对数组长度取模得到。
  HashMap其实也是一个线性的数组实现的,所以可以理解为其存储数据的容器就是一个线性数组。HashMap里面实现一个静态内部类Entry,其重要的属性有 key , value, next,从属性key,value我们就能很明显的看出来Entry就是HashMap键值对实现的一个基础bean,我们上面说到HashMap的基础就是一个线性数组,这个数组就是Entry[],Map里面的内容都保存在Entry[]里面。

HashMap存取过程

public V put(K key, V value) {    
     if (key == null)       
     return putForNullKey(value);    
     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;        
        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;
}

看源码得出几个结论:

1.HashMap可以存储null的key。

2.通过key.hashCode找到hash值,然后跟table.length的取模,得到数组下标。然后遍历链表,如果hash值且key值相等,则替换原来的值。

3.table添加新的元素时并没有奇怪,奇怪的是我们知道数据的默认容量是16,超过了16怎么办,HashMap是怎么处理的呢?

所以要看一下上述代码中addEntry这个方法里面到底做什么操作。

 void addEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        if (size++ >= threshold)
            resize(2 * table.length);
    }

到这里已经很明显,我们发现了HashMap的处理方式,是size > threshold的时候就会去扩容。那么这个threshold是什么东西呢?文章的开头我们已经把他列出来了,threshold = capacity loadFactor的结果,loadFactor是负载因此,capacity是容量。那么我们可以得出很简单的结论就是首次初始化的时候threshold = DEFAULT_INITIAL_CAPACITY(16)DEFAULT_LOAD_FACTOR (0.75) ,就是超过了容量的75%的时候就会去扩容。

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

我带着好奇心继续探索resize这个方法。

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);
    }

    /**
     * Transfers all entries from current table to newTable.
     */
    void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;
                    //重新计算index
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

到这里基本上清楚了HashMap的进行put时候的整个过程了。相同get方法我就不贴代码了。有兴趣可以去研究源码。

当我看源码看到这一步我可能比较困惑,HashMap究竟优势在哪儿?
我们要分析一下HashMap的数据结构,上述我们已经了解HashMap的数据结构是数组加链表。
数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;

链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。

那么哈希表((Hash table)综合这两者的特性

OK了,这酸爽!!!