java8对Hashmap的改进

先推荐一个看java源码的网站 grepcode.com
提供各种不同版本的源码在线查看和下载,而且有语法高亮,函数跳转,简单的注释,非常适合学习。良心推荐,java学习必备。

PS:这篇只是简单总结了一下JDK8里hashmap类的改变,想到哪写到哪,没什么逻辑算初稿吧,等有时间再详细看一遍源码再写完整的hashmap源码分析。

HashMap简单说就是它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。可以简单的当作非线程安全的hash table,当然其实它们是不同的两个类,有很多差别。hash table是一个过时的类,保留它的目的只有兼容旧代码,我们写代码时候不应该使用。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null(hashtable是不允许的,会报异常)。源码里对当key为空的情况会单独处理,放在数组头位置。

Hash table based implementation of the Map interface. This implementation provides all of the optional map operations, and permits null values and the null key. (The HashMap class is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls.) This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.

因为key-value查询在实际应用中非常广泛,所以hashmap是非常重要的一个容器,像python里专门有字典这个数据类型,而且是很核心的一个知识。我感觉java容器里hash map的源码也是设计的最走心的,很多优化都感觉很巧妙。比如通过key的hashcode计算下标位置的时候,它不是直接的hashcode取模运算,要先经过高位运算进行扰动,就是把hashcode右移然后做异或,这样就保证在数组比较小时候hashcode的高低位都能参与到运算中,防止一些散列不均匀的情况。Jdk1.8以前是进行四次扰动计算,可能从速度功效各方面考虑,jdk1.8变成扰动一次,低16位和高16位进行异或计算。取模的时候考虑取模运算的速度比较慢,改用与操作优化效率,很巧妙,hash table就没设计的这么好。

//JDK1.7
final int hash(Object k) {
        int h = 0;
        h ^= k.hashCode();

        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

static int indexFor(int h, int length) {
        return h & (length-1);
    }

//JDK1.8
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

因为考虑等差数列对取2的幂模碰撞这种情况,一般对质数取模比较好,所以hash table设计的时候估计也是考虑这个问题,扩容逻辑的默认初始11,每次*2+1保证长度是奇数,然后直接取模。但这样效率明显就不如hash map的那种设计高,hash map通过扰动技术避免等差数列会碰撞这种情况。长度取2的幂能通过与运算提高取模效率,感觉挺有意思,包括jdk8对扰动次数的改动,有种不放过每个细节的感觉。

JDK1.8里对hashmap最大的改变是引入了红黑树,这一点在hash不均匀并且元素个数很多的情况时,对hashmap的性能提升非常大。Hashmap的底层实现是使用一个entry数组存储,默认初始大小16,不过jdk8换了名字叫node,可能是因为引入了树,叫node更合适吧,另外我也不喜欢entry这个名字,不能望文生义,我在刚学的时候还以为是什么神秘的东西呢,其实就是个键值对对象而已。Node里有next引用指向下一个节点,因为hashmap解决冲突的思路是拉链法。

JDK7中HashMap采用的是位桶+链表的方式。而JDK8中采用的是位桶+链表/红黑树的方式,当某个位桶的链表的长度超过8的时候,这个链表就将转换成红黑树。因为引入了树,所以其他操作也更复杂了,比如put方法以前只要通过hash计算下标位置,判断该位置有没有元素,如果有就往下遍历,如果存在相同的key就替换value,如果不存在就添加。但是到了8以后,就要判断是链表还是树,如果是链表,插入后还要判断要不要转化成树。不过这些操作都是常量级别的,复杂度还是O(1)的,但是对整体性能提升非常大。链表转换红黑树在treeify方法里实现,给树插入节点在puttreeval方法,修正红黑树是balanceInsertion方法,源码都不复杂,就是实现了红黑树的算法(说的好像很简单似的。。其实我也没看懂树操作部分的代码呢。。)
下面贴上相关代码,执行的操作就是上面说那些。

                   Node<K,V> e; K k;
633             if (p.hash == hash &&
634                 ((k = p.key) == key || (key != null && key.equals(k))))
635                 e = p;
636             else if (p instanceof TreeNode)
637                 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
638             else {
639                 for (int binCount = 0; ; ++binCount) {
640                     if ((e = p.next) == null) {
641                         p.next = newNode(hash, key, value, null);
642                         if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
643                             treeifyBin(tab, hash);
644                         break;
645                     }
646                     if (e.hash == hash &&
647                         ((k = e.key) == key || (key != null && key.equals(k))))
648                         break;
649                     p = e;
650                 }
651             }
652             if (e != null) { // existing mapping for key
653                 V oldValue = e.value;
654                 if (!onlyIfAbsent || oldValue == null)
655                     e.value = value;
656                 afterNodeAccess(e);
657                 return oldValue;
658             }

另外变化比较大的还有扩容机制,也就是resize方法。因为树的引入,源码也变的复杂好多。。。网上资料也不好找,各种和tree有关的方法看的真心累。只能说为了优化hashmap,设计者的确走心了。。

resize方法的关键代码如下:

                         if (e.next == null)
711                         newTab[e.hash & (newCap - 1)] = e;
712                     else if (e instanceof TreeNode)
713                         ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
714                     else { // preserve order
715                         Node<K,V> loHead = null, loTail = null;
716                         Node<K,V> hiHead = null, hiTail = null;
717                         Node<K,V> next;
718                         do {
719                             next = e.next;
720                             if ((e.hash & oldCap) == 0) {
721                                 if (loTail == null)
722                                     loHead = e;
723                                 else
724                                     loTail.next = e;
725                                 loTail = e;
726                             }
727                             else {
728                                 if (hiTail == null)
729                                     hiHead = e;
730                                 else
731                                     hiTail.next = e;
732                                 hiTail = e;
733                             }
734                         } while ((e = next) != null);
735                         if (loTail != null) {
736                             loTail.next = null;
737                             newTab[j] = loHead;
738                         }
739                         if (hiTail != null) {
740                             hiTail.next = null;
741                             newTab[j + oldCap] = hiHead;
742                         }

上面的代码就是三个if-else if-else,分三种情况处理,

  1. 表项只有一个键值对时,针对新表计算新的索引位置并插入键值对
  2. 表项节点是红黑树节点时(说明这个bin元素较多已经转成红黑树了),调用split方法处理。
  3. 表项节点包含多个键值对组成的链表时(拉链法)

第一种情况就是直接对新的数组长度取模计算新索引,放到新数组的相应位置,和jdk7一样的。第二种情况是引入了红黑树后独有的,通过调用一个split方法处理,关于这个方法一会再细说。第三种情况在jdk7里没引入树时也有,但是jdk8里对这种情况也做了算法上的优化。可以看到上面的代码主要也是在处理第三部分。

下面先说第三部分的处理。

void transfer(Entry[] newTable) {
     Entry[] src = table;                   //src引用了旧的Entry数组
     int newCapacity = newTable.length;
     for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
         Entry<K,V> e = src[j];             //取得旧Entry数组的每个元素
         if (e != null) {
             src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
             do {
                 Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                e.next = newTable[i]; //标记[1]
                newTable[i] = e;      //将元素放在数组上
                e = next;             //访问下一个Entry链上的元素
            } while (e != null);
        }
    }
}

这是jdk7里hashmap resize时对每个位桶的链表的处理方式(transfer方法),整体过程就是先新建两倍的新数组,然后遍历旧数组的每一个entry,直接重新计算新的索引位置然后头插法往拉链里填坑(这里因为是新加入的元素插入到链表头,所以顺序会倒置,jdk8里不会)。看jdk8的代码发现好像完全不是这么做的。

jdk8的代码里是这么处理的,把链表上的键值对按hash值分成lo和hi两串,lo串的新索引位置与原先相同[原先位置j],hi串的新索引位置为[原先位置j+oldCap]。这么做的原因是,我们使用的是2次幂的扩展(newCap是oldCap的两倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置,也就是原索引+oldCap。为啥?自己举个例子就知道了。那怎么判断该假如lo串还是hi串?这个取决于 判断条件if ((e.hash & oldCap) == 0),如果条件为真,加入lo串,条件为假,加入hi串。那这是为什么?因为这个&运算其实相当于做了一个掩码,查看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。

下面我把美团点评技术团队博客里的一遍文章里的讲解搬过来,这个讲得更清楚些

下面举个例子说明下扩容过程。假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的�哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。


jdk1.7扩容例图

下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。


hashMap 1.8 哈希算法例图1

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

下面再说一下第二种情况的split方法。grepcode里的代码注释是这么说的

Splits nodes in a tree bin into lower and upper tree bins, or untreeifies if now too small. Called only from resize; see above discussion about split bits and indices.
Parameters:
map the map
tab the table for recording bin heads
index the index of the table being split
bit the bit of hash to split on

代码比较长我就不贴了,之所以放到后面说split方法,是因为它的原理和上面介绍的一样,用和上面一样的方法,把红黑树拆成lo树和hi树,放到原位置或者移动2次幂的位置,如果树大小小于8就不转化成树。

剩下还有fail-fast机制,迭代器里的所以方法会先判断modcount有没有变,这个还是一样的。

然后是序列化问题,把一些成员变量如Node<K,V>[] table用transient修饰,不直接序列化(原因可能是因为数组很大,但实际存储元素没那么多),然后自己重写writeObject方法和readObject方法。这些也没什么变化。也不是很重要的知识点。

大致就是这些了,剩下关于hash map的内容就是线程安全问题了,HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。多个线程同时put的时候可能造成链表有环,再get就会死循环。详情看这篇博客https://my.oschina.net/xianggao/blog/393990。所以并发情况下,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。或者根据自己需求也能实现一个copy on write的hash map。

This is typically accomplished by synchronizing on some object that naturally encapsulates the map. If no such object exists, the map should be "wrapped" using the Collections.synchronizedMap method. This is best done at creation time, to prevent accidental unsynchronized access to the map:
Map m = Collections.synchronizedMap(new HashMap(...));

虽然hashtable也是线程安全的,但是hashtable是个过时的类,实现的还是字典接口,尽量不要用。对线程安全性要求高的时候可以用同步包装器(Collections.synchronizedMap())包装一个线程安全的hashmap。通过这种方式实现线程安全,所有访问的线程都必须竞争同一把锁,不管是get还是put。好处是比较可靠,但代价就是性能会差一点。不过关于并发有一种想法就是,并发这个东西太复杂,随机性太强,经常引发各种未知错误,程序员很难说完全掌握并发编程,所以如果并发访问不是影响性能的瓶颈,或者说有其他可优化的地方,最好不要为了提高一点点性能就牺牲可靠的线程安全。《Thinking in Java》里也建议尽量用synchronizid关键字,尽管它并不是很新的技术。

ConcurrentHashmap通过分段锁技术提高了并发的性能,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。另外concurrenthashmap的get操作没有锁,是通过volatile关键字保证数据的内存可见性。所以性能提高很多。JDK8对ConcurrentHashmap也有了巨大的的升级,同样底层引入了红黑树,并且摒弃segment方式,采用新的CAS算法思路去实现线程安全,再次把ConcurrentHashmap的性能提升了一个台阶。但同样的,代码实现更加复杂了许多。。。反正我已经放弃源码阅读了。。。

另外java8是一个有重大改变的发行版,还有许多新特性值得了解,特别是引入了一些函数式编程的东西。附上java8新特性的学习手册,祝大家学习愉快~
https://www.javacodegeeks.com/2014/05/java-8-features-tutorial.html

推荐阅读更多精彩内容