`HashMap`底层原理

2016/3/25 posted in  Java

引言

HashMap基于哈希表的Map接口的实现。此实现提供所有可选的映射操作,并允许使用null值和null键。(除了不同步和允许使用null之外,HashMap类与Hashtable大致相同)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

值得注意的是HashMap不是线程安全的,如果想要线程安全的HashMap,可以通过Collections类的静态方法synchronizedMap获得线程安全的HashMap

Map map = Collections.synchronizedMap(new HashMap());

一、数据结构与冲突

HashMap的底层主要是基于数组和链表来实现的,它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap中主要是通过keyhashCode来计算hash值的,只要hashCode相同,计算出来的hash值就一样。如果存储的对象对多了,就有可能不同的对象所算出来的hash值是相同的,这就出现了所谓的hash冲突。解决hash冲突的方法有很多,HashMap底层是通过链表来解决hash冲突的

图中,左边部分代表哈希表,也称为哈希数组,数组的每个元素都是一个单链表的头节点,链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中。右边部分则显示数组内部结构,HashMap其实就是一个Entry数组,Entry对象中包含了键和值,其中next也是一个Entry对象,它就是用来处理hash冲突的,形成一个链表。

二、HashMap相关属性

transient Entry[] table;//存储元素的实体数组

transient int size;//存放元素的个数

int threshold; //临界值,当实际大小超过临界值时,会进行扩容,扩容大小为当前的2倍。threshold = 负载因子*容量

final float loadFactor; //负载因子

transient int modCount;//被修改的次数

其中比较重要的两个参数是容量(Capacity) 和 负载因子(Load factor)

Initial capacity: The capacity is the number of buckets in the hash table, The initial capacity is simply the capacity at the time the hash table is created.
Load factor: The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased.

简单的说,Capacity就是bucket的大小,loadFactor就是bucket填满程度的最大比例。

loadFactor越大,填满的元素越多,好处是,空间利用率高了,但冲突的机会加大了,链表长度会越来越长,查找效率降低。

反之,loadFactor越小,填满的元素越少,好处是冲突的机会减小了,但空间浪费多了,表中的数据将过于稀疏(很多空间还没用,就开始扩容了)

因此,必须在 “冲突的机会” 与 “空间利用率” 之间寻找一种平衡与折衷. 这种平衡与折衷本质上是数据结构中有名的 “时-空” 矛盾的平衡与折衷.

如果机器内存足够,并且想要提高查询速度的话可以将loadFactor设置小一点;相反如果机器内存紧张,并且对查询速度没有什么要求的话可以将loadFactor设置大一点。不过一般取默认值0.75就好。

三、使用频率最高的两个方法putget

put函数大致的思路为:

1、对keyhashCode()hash,然后再计算index

2、如果没碰撞直接放到bucket里;

3、如果碰撞了,以链表的形式存在buckets后;

4、如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;

5、如果节点已经存在就替换old value(保证key的唯一性);

6、如果bucket满了(超过loadFactor * current capacity),就要resize

public V put(K key, V value) 
{
    // 对key的hashCode()做hash
    return putVal(hash(key), key, value, false, true);
}
 
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) 
{
    Node<K,V>[] tab; Node<K,V> p; int n, i;

    // tab为空则创建
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    // 计算index,并对null做处理
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

    else 
    {
        Node<K,V> e; K k;

        // 节点存在
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;

        // 该链为树
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

        // 该链为链表
        else 
        {
            for (int binCount = 0; ; ++binCount) 
            {
                if ((e = p.next) == null) 
                {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 写入
        if (e != null) 
        { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 超过load factor*current capacity,resize
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

get函数大致思路如下:

1、bucket里的第一个节点,直接命中;

2、如果有冲突,则通过key.equals(k)去查找对应的entry

3、若为树,则在树中通过key.equals(k)查找,O(logn)

4、若为链表,则在链表中通过key.equals(k)查找,O(n)

public V get(Object key) 
{
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
 
final Node<K,V> getNode(int hash, Object key) 
{
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) 
    {
        // 直接命中
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 未命中
        if ((e = first.next) != null) 
        {
            // 在树中get
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 在链表中get
            do 
            {
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

四、resize的实现

put时,如果发现目前的bucket占用程度已经超过了loadFactor所希望的比例,那么就会发生resize。在resize的过程,简单的说就是把bucket扩充为2倍,之后重新计算index,把节点再放到新的bucket中。resize的注释是这样描述的:

Initializes or doubles table size. If null, allocates in accord with initial capacity target held in field threshold. Otherwise, because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table.

大致意思就是说,当超过限制的时候会resize,然而又因为我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置

怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:

因此元素在重新计算hash之后,因为n变为2倍,那么n-1mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成原索引+oldCap。可以看看下图为16扩充为32的resize示意图:

这个设计非常巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

总结

通过hash的方法,通过putget存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过loadFactorresize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,HashMap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。