5

简介

HashMapMap接口下比较常用的一个类,我们都知道它存储的是键值对(key-value),可以高效地插入和删除。这篇文章分析一下它内部的实现,由于源码比较长,只看一些重要的。

存储结构

首先,HashMap是基于哈希表存储的。它内部有一个数组,当元素要存储的时候,先计算其key的哈希值,根据哈希值找到元素在数组中对应的下标。如果这个位置没有元素,就直接把当前元素放进去,如果有元素了(这里记为A),就把当前元素链接到元素A的前面,然后把当前元素放入数组中。所以在Hashmap中,数组其实保存的是链表的首节点。下面是百度百科的一张图:
91ef76c6a7efce1b028711e2ad51f3deb48f656e.jpg

如上图,每个元素是一个Entry对象,在其中保存了元素的keyvalue,还有一个指针可用于指向下一个对象。所有哈希值相同的key(也就是冲突了)用链表把它们串起来,这是拉链法。

内部变量

//默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认装载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//哈希表
transient Entry<K,V>[] table;
//键值对的数量
transient int size;
//扩容的阈值
int threshold;
//哈希数组的装载因子
final float loadFactor;

在上面的变量中,capacity是指哈希表的长度,也就是table的大小,默认为16。装载因子loadFactor是哈希表的“装满程度”,JDK的文档是这样说的:

The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased. When the number of entries in the hash table exceeds the product of the load factor and the current capacity, the hash table is rehashed (that is, internal data structures are rebuilt) so that the hash table has approximately twice the number of buckets.

大体意思是:装载因子是哈希表在扩容之前能装多满的度量值。当哈希表中“键值对”的数量超过当前容量(capacity)和装载因子的乘积后,哈希表重新散列(也就是内部的数据结构重建了),并且哈希表的容量大约变为原来的两倍。

从上面的变量定义可以看出,默认的装载因子DEFAULT_LOAD_FACTOR 0.75。这个值越大,空间利用率越高,但查询速度(包括getput)操作会变慢。明白了装载因子之后,threshold也就能理解了,它其实等于容量*装载因子

构造器

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);

    // Find a power of 2 >= initialCapacity
    int capacity = 1;
    while (capacity < initialCapacity)  //计算出大于指定容量的最小的2的幂
        capacity <<= 1;

    this.loadFactor = loadFactor;
    threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];   //给哈希表分配空间
    useAltHashing = sun.misc.VM.isBooted() &&
            (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    init();
}

构造器有好几个,最终都会调用上面的这个。它接受两个参数,一个是初始容量,还有一个是装载因子。刚开始先判断值合不合法,有问题的话会抛出异常。重要的是下面的capacity的计算,它的逻辑是计算出大于initialCapacity的最小的2的幂。其实目的就是要让容量能大于等于指定的初始容量,但这个值还得是2的指数倍,这是一个关键的问题。这么做的原因主要是为了哈希值的映射。先来看一下HashMap中有关哈希的方法:

final int hash(Object k) {
    int h = 0;
    if (useAltHashing) {
        if (k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
        h = hashSeed;
    }
    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);
}
static int indexFor(int h, int length) {
    return h & (length-1);
}

hash()方法重新计算了key的哈希值,用了比较复杂的位运算,具体逻辑我也不清楚,反正肯定是比较好的方法,能减少冲突什么的。

下面的indexFor()是根据哈希值得到元素在哈希表中的下标。一般在哈希表中是用哈希值对表长取模得到。当length(也就是capacity)为2的幂时,h & (length-1)是同样的效果。并且,2的幂一定是偶数,那么减1之后就是奇数,二进制的最后一位一定是1。那么h & (length-1)的最后一位可能是1,也可能是0,可以均匀地散列。如果length是奇数,那么length-1就是偶数,最后一位是0。此时h & (length-1)的最后一位只可能是0,所有得到的下标都是偶数,那么哈希表就浪费了一半的空间。所以HashMap中的容量(capacity)一定要是2的幂。可以看到默认的DEFAULT_INITIAL_CAPACITY=16MAXIMUM_CAPACITY=1<<30都是这样的。

Entry对象

HashMap中的键值对被封装成Entry对象,这是HashMap中的一个内部类,看一下它的实现:

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

    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 (key==null   ? 0 : key.hashCode()) ^
               (value==null ? 0 : value.hashCode());
    }

    public final String toString() {
        return getKey() + "=" + getValue();
    }
    void recordAccess(HashMap<K,V> m) {
    }
    void recordRemoval(HashMap<K,V> m) {
    }
}

这个类的实现还是简洁易懂的。提供了getKey()getValue()等方法供调用,判断相等是要求keyvalue均相等。

put操作

put了才能get,所以先看一下put()方法:

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

在这个方法中,先判断key是否为null,是的话调用putForNullKey()方法,这说明HashMap允许keynull(其实value可以)。如果不是null,计算哈希值并且得到在表中的下标。然后到对应的链表中查询是否已经存在相同的key,如果已经存在就直接更新值(value)。否则,调用addEntry()方法进行插入。

看一下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;
}

可以看到,keynull时直接在下标0处插入,同样是存在就更新值,否则调用addEntry()插入。

下面是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);
}
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++;
}

首先判断是否要扩容(扩容会重新计算下标值,并且复制元素),然后计算数组下标,最后在createEntry()中使用头插法插入元素。

get操作

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

    return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null)
            return e.value;
    }
    return null;
}
final Entry<K,V> getEntry(Object key) {
    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;
}

这个比put()简单一些,同样要判断key是不是null,然后就是链表的遍历查询。

总结

HashMap的基本实现就如上面所分析的,最后做下总结:

  • HashMap内部用Entry对象保存键值对,基于哈希表存储,用拉链法解决冲突。
  • HashMap的默认容量大小为16,默认装载因子为0.75。可以指定容量大小,容量最终一定会被设置为2的幂,这是为了均匀地散列。
  • HashMapkeyvalue都可以是null,当然只能有一个keynullvalue可以有多个。
  • HashMap的键值对数量超过容量*装载因子时会扩容,扩容后的容量大约是原来的两倍。扩容会重新散列,所以元素的位置可能发生会变化,而且这是一个耗时操作。
  • HashMap是线程不安全的。

然则
1.2k 声望415 粉丝