HashMap实现原理及源码分析

HashMap是什么
HashMap是Java经常使用的用来储存键值对的数据结构,它是线程不安全的,能够储存null键值,这些你们常常用,也都知道,接下来根据源码分析一下HashMap的实现。java

1、实现原理
HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每个Entry包含一个key-value键值对。键值对的对象实现以下:数组

//HashMap的主干数组,能够看到就是一个Entry数组,初始值为空数组{},主干数组的长度必定是2的次幂,至于为何这么作,后面会有详细分析。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

Entry是HashMap中的一个静态内部类。代码以下 安全

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
        int hash;//对key的hashcode值进行hash运算后获得的值,存储在Entry,避免重复计算

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

简单来讲,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,若是定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操做很快,仅需一次寻址便可;若是定位到的数组包含链表,对于添加操做,其时间复杂度为O(n),首先遍历链表,存在即覆盖,不然新增;对于查找操做来说,仍需遍历链表,而后经过key对象的equals方法逐一比对查找。因此,性能考虑,HashMap中的链表出现越少,性能才会越好。数据结构

其余几个重要字段函数

/实际存储的key-value键值对的个数
transient int size;
//阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold通常为 capacity*loadFactory。HashMap在进行扩容时须要参考threshold,后面会详细谈到
int threshold;
//负载因子,表明了table的填充度有多少,默认是0.75
final float loadFactor;
//用于快速失败,因为HashMap非线程安全,在对HashMap进行迭代时,若是期间其余线程的参与致使HashMap的结构发生变化了(好比put,remove等操做),须要抛出异常ConcurrentModificationException
transient int modCount;

HashMap有4个构造器,其余构造器若是用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值源码分析

initialCapacity默认为16,loadFactory默认为0.75性能

咱们看下其中一个this

public HashMap(int initialCapacity, float loadFactor) {
     //此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(230)
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        this.loadFactor = loadFactor;
        threshold = initialCapacity;
     
        init();//init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现
    }

2、put方法,写入键值对

public V put(K key, V value){

 //若是table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(24=16)
    if (table == EMPTY_TABLE) {
            inflateTable(threshold);
     }
 
 //若是 key 为 null,调用 putForNullKey 方法写入null键的值
 if (key == null){
    return putForNullKey(value);
}

//根据 key 的 keyCode 计算 Hash 值 
int hash = hash(key.hashCode());
//查找hash值在table中的索引
int i = indexFor(hash, table.length);
// 若是 i 索引处的 Entry 不为 null,经过循环不断遍历链表查找是否在链表中有相同key的Entry
for (Entry<K,V> e = tablei; e != null; e = e.next) {
    Object k;
    //找到与插入的值的key和hash相同的Entry
    if (e.hash == hash && ((k = e.key) == key|| key.equals(k)){

        //key值相同时直接替换value值,跳出函数
        V oldValue = e.value;
        e.value = value;
    
       e.recordAccess(this);
 
       return oldValue;
     
   }
    
}
// 若是 i 索引处的 Entry 为 null 或者key的hash值相同而key不一样  ,则须要新增Entry
modCount++; 
// 将 key、value 添加到 i 索引处
addEntry(hash, key, value, i); 
return null; 
}

在put方法中解决hash碰撞的方式很清楚,即当两个entry的hash值相同时,须要对key值是否相同进行判断,只有key和hash都相同,才能进行修改,不然认为不是同一个entry。spa

 先来看看inflateTable这个方法线程

private void inflateTable(int toSize) {
        int capacity = roundUpToPowerOf2(toSize);//capacity必定是2的次幂
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy必定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

 inflateTable这个方法用于为主干数组table在内存中分配存储空间,经过roundUpToPowerOf2(toSize)能够确保capacity为大于或等于toSize的最接近toSize的二次幂,好比toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.

private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    }

roundUpToPowerOf2中的这段处理使得数组长度必定为2的次幂,Integer.highestOneBit是用来获取最左边的bit(其余bit位为0)所表明的数值.

hash函数

//这是一个神奇的函数,用了不少的异或,移位等运算,对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽可能分布均匀
final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

以上hash函数计算出的值,经过indexFor进一步处理来获取实际的存储位置

  /**
     * 返回数组下标
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

再来看看addEntry的实现:

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);//当size超过临界阈值threshold,而且即将发生哈希冲突时进行扩容
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

经过以上代码可以得知,当发生哈希冲突而且size大于阈值的时候,须要进行数组扩容,扩容时,须要新建一个长度为以前数组2倍的新的数组,而后将当前的Entry数组中的元素所有传输过去,扩容后的新数组长度为以前的2倍,因此扩容相对来讲是个耗资源的操做。

咱们来继续看上面提到的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, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

若是数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,须要从新计算index,咱们先来看看transfer这个方法

void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
     //for循环中的代码,逐个遍历链表,从新计算索引位置,将老数组数据复制到新数组中去(数组不存储实际数据,因此仅仅是拷贝引用而已)
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
          //将当前entry的next链指向新的索引位置,newTable[i]有可能为空,有可能也是个entry链,若是是entry链,直接在链表头部插入。
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

这个方法将老数组中的数据逐个链表地遍历,扔到新的扩容后的数组中,咱们的数组索引位置的计算是经过 对key值的hashcode进行hash扰乱运算后,再经过和 length-1进行位运算获得最终数组索引位置。

3、get方法

public V get(Object key) 
{ 
// 若是 key 是 null,调用 getForNullKey 取出null的 value 
if (key == null) 
      return getForNullKey(); 
// 根据该 key 的 hashCode 值计算它的 hash 码 
int hash = hash(key.hashCode()); 
// 直接取出 table 数组中指定索引处的值, // 搜索该 Entry 链的下一个对象 
for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) 
{ 
    Object k; 
    // 若是该 Entry 的 key和hash 与被搜索 key 相同 
   if (e.hash == hash && ((k = e.key) == key || key.equals(k))) 
    return e.value; 
   } 
return null; 
}

能够看出,get方法的实现相对简单,key(hashcode)-->hash-->indexFor-->最终索引位置,找到对应位置table[i],再查看是否有链表,遍历链表,经过key的equals方法比对查找对应的记录。要注意的是,有人以为上面在定位到数组位置以后而后遍历链表的时候,e.hash == hash这个判断不必,仅经过equals判断就能够。其实否则,试想一下,若是传入的key对象重写了equals方法却没有重写hashCode,而恰巧此对象定位到这个数组位置,若是仅仅用equals判断多是相等的,但其hashCode和当前对象不一致,这种状况,根据Object的hashCode的约定,不能返回当前对象,而应该返回null。

4、总结

HashMap的工做原理

HashMap基于hashing原理,咱们经过put()和get()方法储存和获取对象。当咱们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,经过键对象的equals()方法找到正确的键值对,而后返回值对象。HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap在每一个链表节点中储存键值对对象。

相关文章
相关标签/搜索