HashMap源码分析以及常见问题

HashMap基本用法

通过HashMap与Hashtable比较:

  • HashMap能接受为null的键和值,Hashtable键和值都不能为null(通过put方法跟踪源码就一目了然);
  • HashMap是非synchronized的,所以快,Hashtable是synchronized,相对慢(源码);
  • HashMap 数组+链表 的存储结构,存储键值对;而一般的集合List、Set则是存储单个对象

HashMap的工作原理

HashMap是基于hashing的原理,我们在使用put(key,value)存储对象到HashMap中,使用get(key)从HashMap中获取对象;当我们给put()方法传递键和值时,我们先对调用hashCode()方法,返回的hashCode用于找到bucket的位置来存储Entry对象。我们来看看这句话涉及的源码,首先从put(key, value)方法开始。

put()方法源码实现

HashMap存储结构在外层是数组,在源码中有:

 static final Entry<?,?>[] EMPTY_TABLE = {};

 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
  • 1
  • 2
  • 3

在下面的put方法中,第2~4行,若是第一次操作HashMap,这里table必然是空的,需要去初始化。

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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

在第5行,keynull时,并没有抛出异常,说明HashMap中允许键为null值的。 
在第7行,调用了hash方法,作为参数传入。

这里看看hash()方法具体实现:

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

对于hashCode(),它是一个本地方法,实质就是地址取样运算

 public native int hashCode();
  • 1

该方法第7行,调用了key的hashCode()方法,并通过一系列位运算,获取最后的hash值。 
反观HashTable中put()方法中调用的hash()方法实现:

private int hash(Object k) {
    // hashSeed will be zero if alternative hashing is disabled.
    return hashSeed ^ k.hashCode();
}
  • 1
  • 2
  • 3
  • 4

HashMap在HashTable的基础上做了优化,我们继续HashMap的put()方法的源码研究。 
在第8行:

int i = indexFor(hash, table.length);
  • 1

通过返回的hash值找到(table中)bucket的位置来存储Entry对象。

/** * Returns index for hash code h. * 返回hashCode在table中的下标,以便存储Entry对象 */
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);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

在第9~17行,在判断value值是不是在HashMap中已经存在,存在的话就返回旧值。 
在第20行,

addEntry(hash, key, value, i);
  • 1

看方法名就知道,这里就是真正将键值对添加到HashMap中的方法。传入四个参数:hash值,key-value,以及该Entry对象存储的位置。看看具体实现:

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);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

如果HashMap的大小(size)超过了阀值(threshold)并且该Entry对象存储的位置被占用了,这时候就需要“扩容”了。也就是所谓的rehash

 resize(2 * table.length)
  • 1

将HashMap的大小扩充为原来大小的两倍,并且重新计算该Entry对象存储的位置。

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);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

通过resize()方法,我们可以看到在第9,10行,重新创建了容量是原来两倍大小的新Entry数组,并且在方法transfer()中会把原来Entry对象中的数据迁移到新的Entry中。

/** * Transfers all entries from current table to newTable. * 把所有的Entry对象从当前table(旧)转义到刚创建的table(新)中 */
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;
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

这一过程还是相当耗时的。 
在addEntry方法中的第8行:

createEntry(hash, key, value, bucketIndex);
  • 1

创建具体的Entry对象:

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++;
}

/** * Creates new entry. */
Entry(int h, K k, V v, Entry<K,V> n) {
    value = v;
    next = n;
    key = k;
    hash = h;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

这里很清晰了,根据之前计算的Entry对象的位置和键值对,创建了Entry对象。这里需要注意这行代码:

Entry<K,V> e = table[bucketIndex];
  • 1

计算出来的该key在bucket中的下标bucketIndex,返回该下标在数组中存储的对象,然后通过Entry构造器,在新Entry对象中,最为next存储。这里就利用到了链表结构。后面会详细讲到,这就是整个put()方法的调用过程。

get()方法源码实现

关于通过键获取值的get(key)方法,我们需要了解其中的碰撞探测以及碰撞的解决办法。首先看看get()方法的实现:

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

    return null == entry ? null : entry.getValue();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

key为null值这里就不看了,逻辑很简单。 
在第4行,这里通过key获取到了Entry对象。我们看看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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

在第6行,这里调用的key的hash方法,计算key的hashCode值。 
在第7行,通过返回的hashCode值获取该key在bucket中的index(下标,索引),并返回该key对应的Entry对象在数组中的存在。 
在9行if语句中,首先判断计算的hash值和该Entry对象创建时存储的key的hash值是否相等,一般人会认为两个key比较时,只要hash值相等,这个key就相等,其实这是不对的,这里就涉及到了碰撞探测。换句话说,这里用到了HashMap的存储结构–数组+链表。 
首先是数组,我们上面的代码中计算hashCode值在bucket中的位置,这个bucket就是数组(table);

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

若有两个key对象的hash值相同的话,也就是说两个值对象存储在同一个bucket中,这时候怎么获取值对象(value)?继续看第9行后面的部分:

&& ((k = e.key) == key || (key != null && key.equals(k))
  • 1

首先用的运算符&&,也就是说光传入key的hash和存储在Entry中的hash值相等还不行,后面部分运算结果也得是true。后面“||”左面部分为true的条件是,该传入的key和获取的Entry对象中的key是同一个,这样肯定能精确获得想要的值对象(hash值相同,key也相同);“||”右面部分调用的key的equals()方法,返回结果为true,当然是同一个key,也就是说左右部分的运算都是为了找到同一个key。那么具体在同一个bucket中怎么同时存两个或多个hash值相同的不同key对象的呢?明白了怎么存的也就清楚了怎么取了。我们先回到:

// put() ==> addEntry() ==> 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++;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

若前面已经通过put(key, value)形式存储的一个Entry对象,这里又来了一个key1,通过计算key和key1拥有相同的hashCode值,也即在bucket中的位置是一致的,即bucketIndex相同。 
在第3行中,首先就是通过下标取出数组中的Entry对象; 
在第4行中,新建了一个Entry对象,将新的key,value,计算的hash值以及上一个相同hash值的Entry对象一起保存在该新Entry对象中,然后在相同的bucket位置返回新的Entry对象,旧的Entry对象被挂起了;后来再来一个不同的key2,若计算得出的hash值也相同,刚新建的Entry对象也将变成后来这个新Entry对象的next被挂起。这样就是链表的形式存储了具有相同hash值的不同的key对象。 
所以说,HashMap就是通过数组+链表的形式实现的。这里说到了怎么具有相同hash值的不同key对象,呢?

// get() ==> getEntry()
for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
    ....
 }
  • 1
  • 2
  • 3
  • 4

首先这里用到了for循环遍历,其实也说明了“数不止一个” 。在循环时用到了 e = e.next ,这里正是遍历挂起的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;
    }
    ....
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

以上就是HashMap中最重要的存取方法:put(key,value),get(key)源码解析过程。了解了这些,下面常见的问题也很容易回答。

常见问题

  1. 当两个不同的键对象的hashCode相同时会发生什么? 
    它们会存储在同一个bucket位置的HashMap.Entry组成的链表中。

  2. 若两个键的hashCode值相同,你如何正确取出值对象的? 
    当我们调用get(key)方法时,会先计算key的hashCode值,通过该值找到key在bucket(数组)中的位置,找到bucket位置后,循环遍历(next),并调用keys.equals()方法找到链表中正确的节点。

  3. 什么是hash,什么是碰撞? 
    hash:是一种信息摘要算法,它还叫做哈希,或者散列。我们平时使用的MD5就属于Hash算法,通过输入key进行Hash计算,就可以获取key的HashCode(),比如我们通过校验MD5来验证文件的完整性。 
    碰撞:好的Hash算法可以出计算几乎出独一无二的HashCode,如果出现了重复的hashCode,就称作碰撞; 
    就算是MD5这样优秀的算法也会发生碰撞,即两个不同的key也有可能生成相同的MD5。

  4. 如何减少碰撞? 
    使用不可变的,声明做final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生(若has不同对象的hashCode值都不相同,自然就不需要链表来存储了),提高效率。不可变性使得能够缓存不同键的hashCode,这将提高整个获取对象的速度(不需要遍历,速度当然就快了),使用String,Integer这样的wrapper类作为键是非常好的选择。

  5. 为什么String, Interger这样的wrapper类适合作为键? 
    因为String是不可变的,是final的,已经重写了hashCode()和equals()方法,其他的Wrapper类也有类似的特点。不可变性是必要的,因为为了要计算hashCode值,就要防止键值改变,如果键值在put和get时,返回了不同的hash值,也就不能正确的从HashMap中获取想要的对象了;如果可以仅仅通过将某个对象声明成final就能保证hashCode是不变的,就可以这么处理。因为获取对象时,需要调用hashCode()和equals()方法,对键值对象正确重写这两个方法时非常重要的。如果两个不相等的键值对象返回不同的hash值,那么碰撞的几率会小很多,这样较少了不必要的对链表的操作,就能提高HashMap的性能。

  6. 可以使用自定义的对象作为键吗? 
    当然可以,只要其遵守equals()方法和hashCode()方法规则,当键值对象插入HashMap中不会再改变就可以。

  7. 如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办? 
    默认的负载因子是0.75,也就是说,当一个HashMap中填满了75%的bucket时,将会创建原来两倍大小的新bucket数组,并将原来的对象迁移到新创建的bucket中。

    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    • 1
  8. 重新调整HashMap大小存在什么问题吗? 
    由于HashMap是非线程安全的,在多线程环境下,会产生条件竞争。因为若两个线程都发现需要调整HashMap时,都会尝试去调整。我们看下扩容的源码:

    // put() ==> addEntry() ==> resize(2 * table.length) ==> 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;
            }
        }
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    假设有两个不同的key:A,B对应了同一个hash值,假设当前bucket中存储最上面的是A,A.next = B。那么从上面的源码可以看到,当前是 A = ee.next就是B, 
    第11行,先将e.next(B)处“清空”,因为newTable[i]目前只是空位置; 
    第12行,将A放入新bucket位置; 
    第13行,将当前对象e对象设置成B,继续while循环。 
    B的循环中,B.next 为null, 
    第11行,此处newTable[i]是前面的A,这里赋值给了当前对象e(B)next对象; 
    第12行,将B对象存储在该bucket位置; 
    第13行,nextnull赋值给当前ewhile循环为false,结束循环。 
    也就是说新的bucket中存储的最上面的是B,B.next = A,整个链表反过来了。这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。所以在多线程环境下,不能使用HashMap。

  9. 能否让HashMap同步? 
    HashMap可以通过下面的手段实现同步:

    Collections.synchronizedMap(hashMap);
    • 1
  10. 如何提升HashMap的性能? 
    解决扩容损失:如果知道大致需要的容量,把初始容量设置好以解决扩容损失; 
    比如我现在有1000个数据,需要 1000/0.75 = 1333,又 1024 < 1333 < 2048,所以最好使用2048作为初始容量。 
    2048 = roundUpToPowerOf2(1333)

    public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        ...
    }
    
    private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);
        ...
    }
    
    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;
    }
    原文作者:DemonHunter211
    原文地址: https://blog.csdn.net/kwame211/article/details/79009266
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞