源码解析HashMap

Posted 加冰雪碧

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了源码解析HashMap相关的知识,希望对你有一定的参考价值。

HashMap是在开发中使用频率较高的一个容器,其源码虽然不是很复杂但是还是有很多地方值得去挖掘和借鉴。今天就对HashMap的源码进行一个简要的分析~

在这里我们还是从构造方法来入手:

public HashMap(int initialCapacity) 
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    

 public HashMap() 
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    

public HashMap(Map<? extends K, ? extends V> m) 
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        inflateTable(threshold);

        putAllForCreate(m);
    

public HashMap(int initialCapacity, float loadFactor) 
        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();
    

HashMap一共有四个构造方法,但是前三个构造方法都调用了最后一个,所以先对最后一个构造方法进行分析。

initialCapacity代表了初始化的Map集合的容量(这个在后文中会有所解释)。

loadFactor代表了加载因子(后文也会有所解释)。

在构造方法中做的仅仅是将加载因子赋了一下值,将容量暂时赋给了threshold,然后调用init方法。这里跟进可以看到init是一个空实现,不再追究。

构造方法到这里就结束了,看起来非常简单,我们再来看一下经常使用的put方法:

 public V put(K key, V value) 
        if (table == EMPTY_TABLE) 
            inflateTable(threshold);
        
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        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;
    

代码也不长。首先进来就判断了一下table是否是EMPTY_TABLE,以下是table定义:

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

这个在最开始的时候一定是正确的,所以调用inflateTable方法,看一下:

private void inflateTable(int toSize) 
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);

        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    

这里首先调用了roundUpToPowerOf2方法,传入的值是threshold,也就是在构造方法中我们传入的initialCapacity。根据注释也可以看出来方法的意思就是找到大于等于当前threshold的最小的2的整数次方这么一个数,至于为什么要找这么一个数等下后文也会有说明。然后将threshold赋值为容量*填充因子的值,也就是一个扩容相关的阀值,后面扩容时会具体介绍。最后以容量创建出了一个Entry数组并赋给table。

先必须要提一下Entry这个内部类,不然后文没有办法继续进行,看一下定义:

static class Entry<K,V> implements Map.Entry<K,V> 
        final K key;
        V value;
        Entry<K,V> next;
        int hash;

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

        public final K getKey() 
            return key;
        

        public final V getValue() 
            return value;
        

        public final V setValue(V newValue) 
            V oldValue = value;
            value = newValue;
            return oldValue;
        

        public final boolean equals(Object o) 
            if (!(o instanceof Map.Entry))
                return false;
            Map.Entry e = (Map.Entry)o;
            Object k1 = getKey();
            Object k2 = e.getKey();
            if (k1 == k2 || (k1 != null && k1.equals(k2))) 
                Object v1 = getValue();
                Object v2 = e.getValue();
                if (v1 == v2 || (v1 != null && v1.equals(v2)))
                    return true;
            
            return false;
        

        public final int hashCode() 
            return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
        

        public final String toString() 
            return getKey() + "=" + getValue();
        

        /**
         * This method is invoked whenever the value in an entry is
         * overwritten by an invocation of put(k,v) for a key k that's already
         * in the HashMap.
         */
        void recordAccess(HashMap<K,V> m) 
        

        /**
         * This method is invoked whenever the entry is
         * removed from the table.
         */
        void recordRemoval(HashMap<K,V> m) 
        
    

如果有对链表熟悉的朋友看到这个一定不会觉得陌生,Entry是Map中的实体,并且它采取了链表的方式来进行维护。值得注意的是在这里key是final标注的,方法中也没有暴露给我们setKey方法,所以在这里面一旦key确定下来了就不会再发生变动,只能去设置其相应的value。

除了要对Entry先了解一下为了更好的去说明,也得先对整个HashMap的内部数据结构有一个认识,如下图:

横向是一个数组,纵向是一个链表。有了这个了解我们继续来看put方法。

当插入的key为null时调用了putForNullKey方法,进入看一下:

 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;
    

因为我们刚刚才给table赋值,所以它的第0个元素一定是null,那么不走for循环。

简单的将修改的次数+1然后调用addEntry,从名字上看就是添加一个元素的意思,我们等下再看这个方法。那如果我们已经在第一次向map中插入了一个key为null的键值对,然后又插了一个会怎么样?可以看到只是简单的替换了第一个出现的key为null键值对中的value值。

在这里我们就可以得出一个简单的结论:

可以向HashMap中添加键为null的键值对,并且它存储在HashMap的第0个位置。若后续再向map中添加键为null的键值对将会替换原来的key为null的键值对中的value。

好,继续回到put方法。接下来是获取了一下key的哈希值,在这里处理的方法我们就不进行深究了,里面使用位运算来提高速度。而后调用了indexFor方法,通过名字也可以看出来是要寻找插入位置的意思,进入看一下:

static int indexFor(int h, int length) 
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    

简单的返回了个这,不过这个返回值还是有很大讲究的。

在Hash运算中我们一般采用取模的方法来实现定位,但是在计算机中取模的成本是很高的,那么能不能将这个高成本转化为位运算的低成本呢?在这个方法中给了我们一个很好的说明,当length为2的整数次幂的时候使用上述方法进行计算出的结果正好就是取模出来的结果。并且2的整数次幂一定为偶数,那么length-1一定为奇数,所以得出值的奇偶性完全取决于h的奇偶性,这样做可以使插入在散列中分布的更均匀。试想一下如果length-1是一个偶数,那么最后一位一定为0,&运算出来的结果也一定为0,这样所有put的值都会被放到偶数下标的位置,浪费了一半的空间。

在清楚了怎么找到位置之后下一步就是插入数据了。for循环进行遍历,如果原来已经存储过key为新传入key的键值对,则直接将它的value做替换并且将旧值返回,可以看到for循环的迭代条件是e = e.next,也就是顺着链表下行,如果不太清楚可以回头看一下之前的那张图。

而后增加以下修改的计数器,并且调用addEntry方法,我们来看一下这个方法:

 void addEntry(int hash, K key, V value, int bucketIndex) 
        if ((size >= threshold) && (null != table[bucketIndex])) 
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        

        createEntry(hash, key, value, bucketIndex);
    

在这里的形参很形象的将位置i称为“桶”。首先进行了一下判断,如果当前map中包含的键值对数目大于了阀值(加载因子*容量),并且在这个桶中还有数据的话就会进行扩容,调用resize方法,并且重新计算一下hash值,然后重新计算应该向哪个桶中去放。而后调用createEntry方法,看一下:

void createEntry(int hash, K key, V value, int bucketIndex) 
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    

很简单,将其插入到桶中链表的末尾,然后将代表大小的size自增。

进行到这里整个put方法也就结束了,但是刚才还没有看扩容的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);
    

创建出一个原数组两倍大小的新数组,然后调用transfer方法:

void transfer(Entry[] newTable, boolean rehash) 
        int newCapacity = newTable.length;
        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);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            
        
    

很简单,就是把原来的数据插入到新的数组当中,但是因为桶的个数变了,所以每个键值对在哪个桶也就和上次不一样了,重新调用indexFor方法来寻找位置。

而在resize的最后来重新赋值了一下阀值。

从上面的分析中也可以发现如果数据量过大扩容的操作会非常的浪费时间,所以在创建HashMap的时候我们可以根据需求指定一个初始的容量来尽量避免扩容。

说完了put我们来看一下get方法:

public V get(Object key) 
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    

首先是getForNullKey方法,有了前面putForNullKey方法的介绍,这个方法也不难理解:

private V getForNullKey() 
        if (size == 0) 
            return null;
        
        for (Entry<K,V> e = table[0]; e != null; e = e.next) 
            if (e.key == null)
                return e.value;
        
        return null;
    

找到key为null的元素,然后把value值返回。再来看getEntry方法:

final Entry<K,V> getEntry(Object key) 
        if (size == 0) 
            return null;
        

        int hash = (key == null) ? 0 : hash(key);
        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 != null && key.equals(k))))
                return e;
        
        return null;
    

也是很明确,根据hash值来得到Entry实体然后返回,这里也不加赘述了。

接下来看一下remove方法:

public V remove(Object key) 
        Entry<K,V> e = removeEntryForKey(key);
        return (e == null ? null : e.value);
    

final Entry<K,V> removeEntryForKey(Object key) 
        if (size == 0) 
            return null;
        
        int hash = (key == null) ? 0 : hash(key);
        int i = indexFor(hash, table.length);
        Entry<K,V> prev = table[i];
        Entry<K,V> e = prev;

        while (e != null) 
            Entry<K,V> next = e.next;
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k)))) 
                modCount++;
                size--;
                if (prev == e)
                    table[i] = next;
                else
                    prev.next = next;
                e.recordRemoval(this);
                return e;
            
            prev = e;
            e = next;
        

        return e;
    

在这里也是简单的通过hash来进行定位,然后在桶的链表中进行删除。

在这里最后看一下两个常量:

 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

static final float DEFAULT_LOAD_FACTOR = 0.75f;

分别是HashMap初始化的容量和加载因子。在这里可以根据实际需求来修改一下初始容量,但是加载因子最好就不要修改了~


以上是关于源码解析HashMap的主要内容,如果未能解决你的问题,请参考以下文章

Java源码解析-- HashMap源码解析

HashMap 源码解析

[java源码解析]对HashMap源码的分析

Java中的容器(集合)之HashMap源码解析

HashMap底层源码解析下(超详细图解)

HashMap源码解析