结合Java8源码探究HashMap原理

摘要

HashMap是Java中重要的数据结构,HashMap用来存储键值对对象。HashMap查找元素效率非常高,所以使用频率非常的高,而这也归功于HashMap内部巧妙的存储结构和优秀的Hash算法。HashSet在功能实现上也是复用了HashMap的功能。接下来我将以JDK1.8的源码以及之前版本的源码来探究HashMap的原理以及JDk1.8中HashMap与之前版本的HashMap的区别。由于本文主要讨论HashMap所以对红黑树都是一笔带过,如果需要了解详细,可以参考其他红黑树相关的文章。

存储结构

在java 8中HashMap的一个主要的变化便是存储结构上的改变,之前版本的HashMap是采用数组 + 链表的方式作为存储结构,而Java 8中HashMap采用了数组 + 链表 + 红黑树的结构进行存储。
我们知道,哈希表(一般采用数组实现)根据不同的哈希值将对象存储在不同的桶中,而当两个具有相同哈希值需要存入哈希表中时,它们会存入同一个桶中,这种情况称为“冲突”。解决冲突有很多方式,这里我们不展开讨论,之前版本采用链表的方式解决冲突。但当HashMap中“冲突”很多时,在查找元素时需要遍历对应桶中的链表,大家知道,在链表中进行查找效率很低,平均时间复杂度是n。而在Java 8中做出了改进,当桶中链表长度超过8时,将链表转化成红黑树,红黑树在进行查找操作时的平均时间复杂度是log(n),相较于链表,效率提高了很多。

  static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
        //省略TreeNode函数部分
        ...... 
}
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

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

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

上述代码分别是HashMap中红黑树节点和链表节点。

重要字段


    transient Node<K,V>[] table;
    transient Set<Map.Entry<K,V>> entrySet;
    transient int size;
    transient int modCount;
    int threshold;
    final float loadFactor;

modCount : 主要用于多线程访问HashMap时Fail-fast,这里不展开讨论。

Capacity : 虽然HashMap中没有个这个字段,但它是HashMap中的重要盖帘capacity译为容量。capacity就是指HashMap中桶的数量,也就是table数组的长度。默认值为16。一般第一次扩容时会扩容到64,之后好像是2倍。总之,容量都是2的幂,这是个很重要的特性,之后会重点讨论

threshold : 表示当HashMap的size大于threshold时会执行resize操作。
threshold=capacity*loadFactor

loadFactor : 译为装载因子。装载因子用来衡量HashMap满的程度。loadFactor的默认值为0.75f。计算HashMap的实时装载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。

构造器

    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;
        this.threshold = tableSizeFor(initialCapacity);
    }
    public HashMap(int initialCapacity) {
            this(initialCapacity, DEFAULT_LOAD_FACTOR);
        }
     public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

HashMap提供了三个构造器,最主要的构造器是第一个,在第一个构造器中对初始容量和加载因子进行了限制和设置,虽然设置了初始容量,但在构造器中并未根据初始容量和初始化table[]数组,table[]数组真正初始化是在进行扩容时即调用resize()方法时初始化。
在构造器中将threshold设置成大于或等于初始容量initialCapacity且最接近initialCapacity的二次幂,这是为了在扩容时设置table[]数组大小也就是capacity做铺垫。因为之前我们说过capacity只能是2的幂次方。

接下来看tableSizeFor方法:

//Returns a power of two size for the given target capacity.
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

理解这个函数需要一些位运算的知识。这个理解有点困难,我举个例子说明,当我们传入2^29+1时,其二进制表示为0010,0000,0000,0000,0000,0000,0000,0001。函数的主要流程和每一步的结果如下:

    int n = cap - 1;
    cap =       0010,0000,0000,0000,0000,0000,0000,0001
    n =         0010,0000,0000,0000,0000,0000,0000,0000

    n |= n >>> 1; // 等价于 n = n | (n >>> 1)

    n =         0010,0000,0000,0000,0000,0000,0000,0000
    n >>> 1 =   0001,0000,0000,0000,0000,0000,0000,0000 
    n =         0011,0000,0000,0000,0000,0000,0000,0000 

    n |= n >>> 2; // 等价于 n = n | (n >>> 2)

    n =         0011,0000,0000,0000,0000,0000,0000,0000 
    n >>> 2 =   0000,1100,0000,0000,0000,0000,0000,0000 
    n =         0011,1100,0000,0000,0000,0000,0000,0000

    n |= n >>> 4; // 等价于 n = n | (n >>> 4)

    n =         0011,1100,0000,0000,0000,0000,0000,0000
    n >>> 4 =   0000,0011,1100,0000,0000,0000,0000,0000 
    n =         0011,1111,1100,0000,0000,0000,0000,0000

    n |= n >>> 8; // 等价于 n = n | (n >>> 8)

    n =         0011,1111,1100,0000,0000,0000,0000,0000
    n >>> 8 =   0000,0000,0011,1111,1100,0000,0000,0000 
    n =         0011,1111,1111,1111,1100,0000,0000,0000

    n |= n >>> 16; // 等价于 n = n | (n >>> 16)

    n =         0011,1111,1111,1111,1100,0000,0000,0000
    n =         0000,0000,0000,0000,0011,1111,1111,1111
    n >>> 8 =   0011,1111,1111,1111,1111,1111,1111,1111

    n + 1 =     0100,0000,0000,0000,0000,0000,0000,0000

注意最后返回的是n+1而并不是n。 如果n不是0,n必然存在一个最高位的1,在上例中是第30位是1。经过 n |= n >>> 1 后最高位后面的1位变成了1,经过n |= n >>> 2,之后的两位变成了1,这样不断进行,最终,最高的1位到最低位均变成了1,再通过加1操作,从而得到大于或等于cap的最接近cap的二次幂。这样经过5步位运算巧妙的得到了结果,效率很高,在java源码中也有其他地方有相同的方式实现高效的操作,可以参考我的另一篇文章http://blog.csdn.net/u013190513/article/details/70216730

哈希算法

HashMap的所有操作都需要通过哈希值定位到元素,如何让不同的哈希值的对象均匀的分布到哈希表中减少冲突,是提高HashMap操作效率的重要课题。不同的哈希值可以放入相同的桶中,也可放入不同的桶中,但相同的哈希值必须放入同一个桶中。HashMap的哈希算法决定了HashMap的性能。

方法一:
static final int hash(Object key) {   //jdk1.8 & jdk1.7
     int h;
     // h = key.hashCode() 为第一步 取hashCode值
     // h ^ (h >>> 16)  为第二步 高位参与运算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法二: 
static int indexFor(int h, int length) {  //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
     return h & (length-1);  //第三步 取模运算
}

根据对象hashcode()确定最终存储位置的流程:

这里写图片描述

分为三部分 : 获取对象hashcode() 、 高位运算、取模运算

对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。

这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

下面举例说明下,n为table的长度:
这里写图片描述

扩容机制

由于JAVA 8 中HashMap引入了红黑树,所以Java 8的 HashMap扩容机制与之前有一些不同,先来看看之前版本的扩容机制:

 1 void resize(int newCapacity) {   //传入新的容量
 2     Entry[] oldTable = table;    //引用扩容前的Entry数组
 3     int oldCapacity = oldTable.length;         
 4     if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了
 5         threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
 6         return;
 7     }
 8  
 9     Entry[] newTable = new Entry[newCapacity];  //初始化一个新的Entry数组
10     transfer(newTable);                         //!!将数据转移到新的Entry数组里
11     table = newTable;                           //HashMap的table属性引用新的Entry数组
12     threshold = (int)(newCapacity * loadFactor);//修改阈值
13 }

 1 void transfer(Entry[] newTable) {
 2     Entry[] src = table;                   //src引用了旧的Entry数组
 3     int newCapacity = newTable.length;
 4     for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
 5         Entry<K,V> e = src[j];             //取得旧Entry数组的每个元素
 6         if (e != null) {
 7             src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
 8             do {
 9                 Entry<K,V> next = e.next;
10                 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置   
                    //头插法
11                 e.next = newTable[i]; //标记[1]
12                 newTable[i] = e;      //将元素放在数组上
13                 e = next;             //访问下一个Entry链上的元素
14             } while (e != null);
15         }
16     }
17 }
 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);    }

扩容的逻辑非常简洁清楚,这里我们注意两个地方,由于扩容需要将元素转移到新哈希表上,在转移元素的时候调用了indexFor()方法来重新计算元素在数组中的位置;而且在转移元素时使用了头插法,将元素放在链表的头部,这样在扩容之后链表的顺序颠倒。

JAVA 8 扩容机制:

  final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
        //当容量过大就不再调整,任由其冲突
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //主要部分, 将容量变为2倍,将threshold也调整为2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        //初始化table数组
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        //复制元素到新哈希表
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                    //红黑树复制元素
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                        //链表复制元素
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

resize主要分为两个部分,第一部分:对容量和threshold进行扩容;第二部分:复制元素到新的哈希表。复制元素会先判断节点是TreeNode(红黑树节点)还是链表节点Node,如果是红黑树节点,由于同一个桶中的元素会可能分到其他的桶中,所以桶中元素会减少,当少于6时会退化成链表。
未扩容前存于同一个桶中的元素可以具有不同的哈希值,只是它们根据哈希值得到的存储位置相同。所以扩容之后,由于哈希表长度变化,根据哈希算法,它们可能分到不同的桶中。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,经过扩容之后,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。结合代码可以发现确实如此。

看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
这里写图片描述

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

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

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。

Put方法

1.7版本 :

 public V put(K key, V value) {
        //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(24=16)
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
       //如果key为null,存储位置为table[0]或table[0]的冲突链上
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
        int i = indexFor(hash, table.length);//获取在table中的实际位置
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
            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++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
        addEntry(hash, key, value, i);//新增一个entry
        return null;
    }  

1.8版本:


    public V put(K key, V value) {
        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;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        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;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

put方法逻辑图

线程安全性

在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。并且由于在Jdk1.7中HashMap扩容时会将链表倒置,所以有可能导致死循环。具体情况参看http://blog.csdn.net/qq_27093465/article/details/52207135

小结

JDK1.8和JDK1.7中HashMap有所区别(这里主要讨论一些大的变化):

  1. 引入了红黑树,提高了性能效率。
  2. 更改了扩容机制中复制链表元素部分,避免了重新通过哈希值计算位置的操作。
  3. 更改了扩容机制中复制链表元素部分,将头插法复制元素改为尾接法

扩容是个复杂的过程,所以如果能够估算元素数量,应该给定初始容量避免扩容过程。

致谢

本文虽是本人原创,但其中很多内容借鉴了他人的优秀文章,在此致谢

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值