Java 1.8 HashMap实现(译注)

来源:互联网 发布:移动短信平台软件 编辑:程序博客网 时间:2024/05/29 04:30

译者序

作者整个博客只有这一篇文章,而就这一篇文章,却是介绍HashMap与Java中Hash策略的精品。作者从Java 2讲述到Java 8,细数种种变更,并且用数学公式和清晰的思路解释其原理。全文行文流畅,排版规范典雅,有着论文般的美感,就技术博客而言,实乃佳品。配合HashMap源码消化更佳。
因为时间仓促,在翻译的过程中,难免会有错漏,希望多加指正。

Source: How does Java HashMap work?
Author: CodeHiker42

概述

这篇文档阐述了Java中的HashMap,从早期版本一直到基于Oracle的JDK和OpenJDK的Java 7,8中的实现原理。在文档中,所有引用的源码都来自于Oracle JDK和OpenJDK——这两者在纯粹的Java SDK实现上是完全相同的。我希望这篇文档能够帮助各位到开发者,甚至是那些从未使用过Java的开发者。因为这些内容与如何设计框架或者库无关,它们更多的针对于如何去以实现语言无关的HashMap。

HashMap是Java集合框架(Java Collection Framework, JCF)中一个基础类,它在1998年12月,加入到Java 2版本中。在此之后,Map接口本身除了在Java 5中引入了泛型以外,再没有发生过明显变化。然而HashMap的实现,则为了提升性能,不断地在改变。

实现HashMap时一个重要的考量,就是如何尽可能地规避哈希碰撞。而HashMap实现变更的路线图,也大多与此相关。

HashMap与HashTable

HashMap和HashTable这两个术语,在此文档中指的都是Java的API。

HashTable在Java出现之初,就已经被引入,而HashMap直到Java 2,才随着JCF出现到人们的视野之中。
HashTable和HashMap一样,也实现了Map接口,因此他们从函数的视角上是等价的。

public class Hashtable<K,V> extends Dictionary<K,V>    implements Map<K,V>, Cloneable, java.io.Serializable {public class HashMap<K,V> extends AbstractMap<K,V>    implements Map<K,V>, Cloneable, Serializable {

Code No.1 HashTable 与 HashMap的声明

然而,在它们之间,有许多处不同。首先,HashTable是一个线程安全的API,它的方法通过synchronized关键字进行修饰。尽管并不推荐使用HashTable来开发一个高性能的应用,但是它确实能够保证你的应用线程安全。相反,HashMap并不保证线程安全。因此当你构建一个多线程应用时,请使用ConcurrentHashMap。
而在单线程应用中,HashMap有这个比HashTable更好的性能,这得益于HashMap使用了多种方式来规避哈希碰撞,其中,使用辅助Hash函数是一种著名的方式。在Java 8中,一种更好的方式被用来处理高频碰撞的问题。不过,我们需要记住一点,没有完美的哈希函数。但是即使我们无法创造一个完美的世界,让它变得更好也是值得的。

这里,我想要指出HashTable和HashMap这个两个术语的来源。基本上,他们都可以被看做是一种关联数组,关联数组与数组最大的不同,就是对于每一个数据,关联数组会有一个key与之关联,当使用关联数组时,每个数据都可以通过对应的Key来获取。关联数组有许多别名,比如Map(映射)、Dictionary(字典)和Symbol-Table(符号表)。尽管名字不同,他们的含义都是相同的。
字典和符号表都是非常直观的术语,无须解释它们的行为。映射来自于数学领域。在函数中,一个域(集合)中的值被与另一个域(集合)中的值关联,这种关联关系叫做映射。
映射
*Figure No.1 函数中的映射 XfY

因此HashTable和HashMap都是基础的关联数组,哈希指的是一种通过Key来获取数据的过程。

哈希分布和哈希碰撞

对于每个对象X和Y,如果当(且仅当,译者注)X.equals(Y)为false,使得X.hashCode() != Y.hashCode()为true,这样的函数叫做完美Hash函数。下面是完美哈希函数的数学表达.

X,YS, (h(X)=h(Y))X=Y:S h

基于对象中变化的域(字段),我们很容易构造一个完美哈希函数。一个Boolean对象有true和false两个值,因此Boolean对象的Hash值可以通过一个二进制位 bit 表达,即0b0, 0b1。对于一些Number对象,比如IntegerLongDouble等,他们都可以使用自身原始的值作为Hash值。然而,想要构造这样的完美哈希函数,我们需要无限的内存大小,这种假设显然是不可能的。而且,即时我们能够为每个POJO(Plain Ordinary Java Object)或者String对象构造一个理论上不会有冲突的哈希函数,但是hashCode()函数的返回值是int型。根据鸽笼理论,当我们的对象超过232个时,这些对象会发生哈希碰撞。
这里还有一个点需要我们考虑。我们是否可以在某些限制下,通过允许哈希碰撞来节省内存?这往往是一个提升总体性能不错的方式。许多关联数组的实现,包括HashMap,使用了大小为M的桶来储存N个对象(MN)。在这种情况下,我们使用模值hashValue % M作为桶的索引,而不是hashValue本身。

int index = X.hashCode() % M;

Code No.2 获取hash桶索引的方式

因此,当一个对象的插入HashMap,发生哈希冲突的概率是1M,这与哈希函数的实现无关。根据我们的需要,即使是存在哈希冲突的环境中,数据的读取也应该能够被良好的执行。这里有两种著名的方式来解决这个问题,一种是开放寻址,一种是分离链接。其他的用于解决Hash冲突的方式,大多基于这两种方法。
hash实现
Figure No.2 Open Adressing and Seperate Chaning

开放寻址是一种解决哈希冲突的方式,当计算出的桶索引的位置被占据时,通过一定的探索方式,来寻找未被占据的哈希桶(适合数量确定,冲突较少的情况,译者注)。而分离链接则将每一个哈希桶作为一个链表的头结点,当哈希碰撞发生时,仅需在链表中进行储存、查找。

这两种方法都有着同样的最坏时间复杂度O(M),但是开放寻址使用连续的空间,因此有着缓存效率的提升。因此当数据量较小时,能够放到系统缓存中时,开放寻址会表现出比分离链接更好的性能。但是当数据量增长时,它的性能就会越差,因为我们无法期待一个大的数组能够得到缓存性能优化。这也是HashMap使用分离链表来解决哈希冲突的原因。此外,开放寻址还有一个弱点。我们调用remove()方法会十分频繁,当我们删除数据时,一个特定的哈希冲突,可能会干扰总体的性能,而分离链表则没有这样的缺点。

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;// the reason why transient keyword is used is because of efficienty,// when it comes to serialize the HashMap instance, // storing key-value pairs is the better// than serializing object itself.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() { … }public final V getValue() { …}        public final V setValue(V newValue) { … }        public final boolean equals(Object o) { … }        public final int hashCode() {…}        public final String toString() { …}void recordAccess(HashMap<K,V> m) {… }void recordRemoval(HashMap<K,V> m) {…}}

Code No.3 Java 7中哈希桶的实现

代码4呈现了put()使用分离链表实现的方式。

public V put(K key, V value) {        if (table == EMPTY_TABLE) {              inflateTable(threshold);             // creating 'table' array        }         // null can be a key in HashMap        if (key == null)            return putForNullKey(value);        // rather than using value.hashCode() without altering        // modified hash values is used         // with a Supplement Hash Function        // the Supplement Hash Function is explained         // in 'Supplement Hash Function' section        int hash = hash(key);        // value 'i' is an index of hash bucket        // indexFor() is related with 'hash % table.length'        int i = indexFor(hash, table.length);        // scaning a linked list in a hash bucket        // if there is a data with the correspondence key        // the data is replaced with new one.        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 is for managing how many times         // this HashMap has been modificated        // it is also used to determine         // whether throw ConcurrentModificationException        modCount++;        // create new Entry only if the key is never used yet.        addEntry(hash, key, value, i);        return null;    }

Code No.4 Java 1.7中HashMap的put()方法的实现

Java 8 HashMap的分离链表

从Java 2到Java 1.7,HashMap在分离链表上的改变并不多,他们的算法基本上是相同的。如果我们假设对象的Hash值服从平均分布,那么获取一个对象需要的次数时间复杂度应该是O(NM)(原为E(NM),但数学期望应改为E(N2M)疑有误,译者注)。Java 8 在没有降低哈希冲突的度的情况下,使用红黑书代替链表,将这个值降低到了O(log(NM))(与上同,疑有误,译者注)。

数据越多,O(NM)O(log(NM))的差别就会越明显。此外,在实践中,Hash值的分布并非均匀的,正如”生日问题”所描述那样,哈希值有时也会集中在几个特定值上。因此使用平衡树比如红黑树有着比使用链表更强的性能。

使用链表还是树,与一个哈希桶中的元素数目有关。代码5中中展示了Java 8的HashMap在使用树和使用链表之间切换的阈值。当冲突的元素数增加到8时,链表变为树;当减少至6时,树切换为链表。中间有2个缓冲值的原因是避免频繁的切换浪费计算机资源。

static final int TREEIFY_THRESHOLD = 8;static final int UNTREEIFY_THRESHOLD = 6;

Code No.5 Java 8 HashMap中的TREEIFY_THRESHOLD & UNTREEIFY_THRESHOLD

Java 8 HashMap使用Node类替代了Entry类,它们的结构大体相同。一个显著地差别是,Node类具有导出类TreeNode,通过这种继承关系,一个链表很容易被转换成树。

Java 8 HashMap使用的树是红黑树,它的实现基本与JCF中的TreeMap相同。通常,树的有序性通过两个或更多对象比较大小来保证。Java 8 HashMap中的树也通过对象的Hash值(这个hash值与哈希桶索引值不同,索引值在这个hash值的基础上对桶大小M取模,译者注)作为对象的排序键。因为使用Hash值作为排序键打破了Total Ordering(可以理解为数学中的小于等于关系,译者注),因此这里有一个tieBreakOrder()方法来处理这个问题。

transient Node<K,V>[] table;static class Node<K,V> implements Map.Entry<K,V> {  // the name of class is different from Java7's  // but this class has almost identical structure   // with Java7's except for 'treefying'}// LinkedHashMap.Entry extends HashMap.Node// so TreeNode instacne can be inserted into 'table' arraystatic final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {        TreeNode<K,V> parent;          TreeNode<K,V> left;        TreeNode<K,V> right;        TreeNode<K,V> prev;           // in Red-Black Tree node is either Red or Black.        boolean red;        TreeNode(int hash, K key, V val, Node<K,V> next) {            super(hash, key, val, next);        }        final TreeNode<K,V> root() {        // returns the root of Tree Node        }        static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {        // root is the 'first gate' whenever work with trees.         }        // for traversing        final TreeNode<K,V> find(int h, Object k, Class<?> kc) {}        final TreeNode<K,V> getTreeNode(int h, Object k) {}        /**         * Tie-breaking utility for ordering insertions when equal         * hashCodes and non-comparable. We don't require a total         * order, just a consistent insertion rule to maintain         * equivalence across rebalancings. Tie-breaking further than         * necessary simplifies testing a bit.         */        static int tieBreakOrder(Object a, Object b) {            int d;            if (a == null || b == null ||                (d = a.getClass().getName().                 compareTo(b.getClass().getName())) == 0)                d = (System.identityHashCode(a) <= System.identityHashCode(b) ?                     -1 : 1);            return d;        }        final void treeify(Node<K,V>[] tab) {          // turn a  linked list to a tree.        }        final Node<K,V> untreeify(HashMap<K,V> map) {          // turn a tree to a linked list        }        // method names explain everything.        final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,                                       int h, K k, V v) {}        final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab,                                  boolean movable) {}        // according to Red-Black theconstruction rule,        // these methods are to keep trees' balance        final void split (…)        static <K,V> TreeNode<K,V> rotateLeft(…)        static <K,V> TreeNode<K,V> rotateRight(…)        static <K,V> TreeNode<K,V> balanceInsertion(…)        static <K,V> TreeNode<K,V> balanceDeletion(…)        static <K,V> boolean checkInvariants(TreeNode<K,V> t) {        // this is for verifying the construction of a tree.        }    }

Code No.6 Java 8中的Node类

Hash桶动态扩容

小数目的哈希桶可以有效的利用内存,但是会产生更高概率的哈希碰撞,最终损失性能。因此,HashMap会在数据量达到一定大小时,将哈希桶的数量扩充到两倍。当哈希桶的数量变为两倍后,NM会对应下降,Hash值重复的Key的数量也得以减少。

哈希桶的默认数量是16,最大值是230。当哈希桶的数量成倍增长时,所有的数据需要重新插入。一种HashMap构造器包含初始桶数量这个参数。如果我们能够在使用这个构造器时指定桶的数量,这将使HashMap节约不必要的重新构造分离链表的时间。

// newCapacity always has a value of powers of 2 $void resize(int newCapacity) {        Entry[] oldTable = table;        int oldCapacity = oldTable.length;        // MAXIMIM_CAPACITY는 230이다.        if (oldCapacity == MAXIMUM_CAPACITY) {            threshold = Integer.MAX_VALUE;            return;        }        Entry[] newTable = new Entry[newCapacity];        // after creating new hash buckets, all stored key-value paired data        // are stored in new hash buckets.        transfer(newTable, initHashSeedAsNeeded(newCapacity));        table = newTable;        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);    }    void transfer(Entry[] newTable, boolean rehash) {        int newCapacity = newTable.length;        // traversing all hash buckets        for (Entry<K,V> e : table) {            // traversing a linked list in a hash bucket            while(null != e) {                Entry<K,V> next = e.next;                if (rehash) {                    e.hash = null == e.key ? 0 : hash(e.key);                }                // as we have new M, the size of hash buckets                // so need to recompute new index value(hashCode % M)                int i = indexFor(e.hash, newCapacity);                e.next = newTable[i];                newTable[i] = e;                e = next;            }        }    }

Code No.7 Java 1.7中的哈希桶扩容

确定是否需要对桶进行扩展的临界值是loadFactor×currentBucketSize,其中loadFactor是负载因子,currentBucketSize是当前桶的数量。当数据量到达这个大小时,扩容就会发生,直到桶的数量达到230为止。默认的负载银子是0.75,它与默认桶大小16,一同作为构造默认的HashMap的参数。

因为在临界点的扩容会导致所有数据重新插入,那么从一个默认的HashMap一直扩容到当前包含有N个元素的HashMap的消耗,也就是数据的插入次数,可以大致估算出。(原文公式不严格,没有给出上下界,因此没有评估意义。此处公式和结论由译者给出,译者注)
ϕ(N)=N+34(16+32+64++2(log43N))
=N+34k=4(log43N)2k

=N+34(2log43N+121)

考虑N处于两个区间(2k,34×2k+1)[34×2k+1,2k+1]的不同行为,即前者使得log43N取值大于log23N,但是不大于k+1。后者使其小于等于log43N,但是大于k。在每种情况下,k和对应的log?4N的关系可以很容易推出。最终得到结论:

N+34(2log23N×2)<ϕ(N)N+34(2log43N×2)
2N<ϕ(N)3N

当我们向HashMap插入大量数据,而没有指定一个合适的初始桶的数量时,它将会进行至少额外的1N次插入,至多为2N插入。这意味着,如果在一开始就指定了合理的桶的数量,性能将提升1~2倍。

当扩容时,还有另一个问题需要考虑。因为哈希桶的大小M总是2k(k4)。当通过对象的哈希值计算桶的索引时,使用的值是(index = X.hashCode() % M)。这意味着即使对象的哈希函数被精心的设计来规避哈希冲突,在实践中也是没有多少意义的。

这也是HashMap使用辅助哈希函数的原因。

辅助哈希函数

使用辅助哈希函数的目的是通过改变初始的哈希值,降低发生哈希冲突的概率。辅助哈希函数从JDK 1.4开始被引入,但是Java 5使用了与JDK 1.4中不同的实现。这种实现方式一直延续到了Java 1.7。Java 8使用了比早期版本(Java 5 - Java 8)更为简单的方式来实现。

final int hash(Object k) {        // Since Java7, by specifing JVM option         // let JRE use another hash function to String Object        // when the number of objects exceeds the certain amount.        // If this option is not specified, hashSeed is always 0.        int h = hashSeed;        if (0 != h && k instanceof String) {            return sun.misc.Hashing.stringHash32((String) k);        }        h ^= k.hashCode();        // By using shift and XOR operator         // let upper bits of the original hash value        // affect lower bits of it        h ^= (h >>> 20) ^ (h >>> 12);        return h ^ (h >>> 7) ^ (h >>> 4);    }

Code No.8 Java7 HashMap中的辅助哈希函数

Java 8使用了更为简洁的方式,仅仅是将哈希值的高位与低位混合。

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

Code No.9 Java8 HashMap中的辅助哈希函数

我认为,Java 8使用了更简单的方式有两个原因。第一,Java 8引入了树来解决较多哈希冲突的问题;第二,目前哈希函数的设计已经能够很好地避免冲突,因此用一个简单的版本也能够处理冲突的问题。

概念上讲,哈希值的索引通过index = X.hashCode() % M计算。但是M总是2的整数次幂。因此取模操作可以通过一系列性能更高的按位操作符,比如AND, XOR, SHIFT来完成(在程序中,使用 hash(X) & (M - 1)优化性能,其中hash是辅助哈希函数,译者注)。

String对象的Hash函数

String对象的Hash函数的时间开销与String值的长度成正比。在JDK 1.1中,为了提升String类的hashCode的性能,在计算时并没有逐字符进行计算。

public int hashCode() {    int hash = 0;     int skip = Math.max(1, length() / 8);     for (int i = 0; i < length(): i+= skip)            hash = s[i] + (37 * hash);    return hash;}

Code No.10 JDK 1.1中String类的hashCode函数

正如我们猜测的那样,这会导致一个严峻的问题,尤其是在处理Web URL的时候。因此它很快被丢弃了,一个更加稳定的版本被推出,一直使用到Java 8也没有改变。

public int hashCode() {        int h = hash;        if (h == 0 && value.length > 0) {            char val[] = value;            for (int i = 0; i < value.length; i++) {                h = 31 * h + val[i];            }            hash = h;        }        return h;    }

Code No.11

代码11展示了hashCode实现。使用秦九韶算法(原文是Horner算法,译者注)来计算。秦九韶算法将一个多项式分解为多个单项式,使之更加容易计算。代码11中的公式可以被如下展开:
h=i=0L1val[i]31L1i=val[0]31L1+val[1]31L2++val[L1]310
=31(val[0]31L2+val[1]31L3+)+val[L1]
=31(31(val[0]31L3+val[1]31L4+)+val[L2])+val[L1]

使用31的有两个原因。首先31是一个质数;乘31可以被非常快的计算。因为31N=32NN,其中32=25,因此乘以31的计算只需要两个CPU指令31N=(N<<5)N

Java7中另一种String对象Hash函数的实现

从JDK 7u7到 7u25,用户可以通过激活一个特殊操作,使得当HashMap中的超过特定数量时,其中的String对象的哈希值使用一个特殊的哈希函数来计算。这个操作仅当在启动JVM时进行特殊的设置后才生效。JDK 7u40以后,这个操作被移除。因此Java 8中也不存在这样的操作。

hashSeed = useAltHashing                ? sun.misc.Hashing.randomHashSeed(this)                : 0;….int h = hashSeed;        if (0 != h && k instanceof String) {            return sun.misc.Hashing.stringHash32((String) k);        }……// hash32() method in String class int hash32() {        int h = hash32;        if (0 == h) {           h = sun.misc.Hashing.murmur3_32(HASHING_SEED, value, 0, value.length);           h = (0 != h) ? h : 1;           hash32 = h;        }        return h;    }

Code No.12 additional hash function for String objects in Java7

这个选项叫做jdk.map.althashing.threshold,使用的函数名为sun.misc.Hashing.stringHash32(),它的算法基于MurMur哈希。使用MurMur的原因也是为了避免哈希冲突。但是它有一个副作用,MurMur需要sum.misc.Hashing.randomHashSeed()产生的哈希种子。这个方法使用Romdum.nextInt()实现。Rondom.nextInt()使用AtomicLong,它的操作是CAS的(Compare And Swap)。这个CAS操作当有个CPU核心时,会存在许多性能问题。因此,这个替代函数在多核处理器中表现出了糟糕的性能。因此JDK 7u40抛弃了这个函数,Java 8中也没有包含。

总结

这篇文档阐述了从早期版本到现在HashMap的内部实现。HashMap使用分离链表和辅助哈希函数解决哈希冲突问题。Java 8引入了平衡树在一定场合下代理链表进行优化。这篇文档也阐述了为什么String哈希函数使用31这个数字。

HashMap从最早期的阶段开始,进行了不断的改进提升。其中辅助Hash函数的在1.4中的引入和平衡树在Java 8中的引入尤为典型。

有许多很快就消失了的方法,比如Java 7中的MurMur哈希函数,尽管被期望带来更好的时间效率,但是它们很难达成目标。

就在刚刚的这个一个Http请求发生的瞬间,有许多HashMap的实例被创建了。仅仅一秒,它们就可能已经成为了GC的目标。随着内存容量的增长,以内存为中心的应用也不断的增多,其中大量的数据大多被储存到一个单独的HashMap之中。

此时此刻,我们无法的得知HashMap在Java 9、Java 10中的变化,但是有一点很明显,HashMap会随着计算环境的发展不断改变。

1 0
原创粉丝点击