剖析Java中HashMap数据结构的源码及其性能优化
作者:然则
存储结构
首先,HashMap是基于哈希表存储的。它内部有一个数组,当元素要存储的时候,先计算其key的哈希值,根据哈希值找到元素在数组中对应的下标。如果这个位置没有元素,就直接把当前元素放进去,如果有元素了(这里记为A),就把当前元素链接到元素A的前面,然后把当前元素放入数组中。所以在Hashmap中,数组其实保存的是链表的首节点。下面是百度百科的一张图:
如上图,每个元素是一个Entry对象,在其中保存了元素的key和value,还有一个指针可用于指向下一个对象。所有哈希值相同的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。这个值越大,空间利用率越高,但查询速度(包括get和put)操作会变慢。明白了装载因子之后,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=16和MAXIMUM_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()等方法供调用,判断相等是要求key和value均相等。
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允许key为null(其实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; }
可以看到,key为null时直接在下标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是一个高效通用的数据结构,它在每一个Java程序中都随处可见。先来介绍些基础知识。你可能也知 道,HashMap使用key的hashCode()和equals()方法来将值划分到不同的桶里。桶的数量通常要比map中的记录的数量要稍大,这样 每个桶包括的值会比较少(最好是一个)。当通过key进行查找时,我们可以在常数时间内迅速定位到某个桶(使用hashCode()对桶的数量进行取模) 以及要找的对象。
这些东西你应该都已经知道了。你可能还知道哈希碰撞会对hashMap的性能带来灾难性的影响。如果多个hashCode()的值落到同一个桶内的 时候,这些值是存储到一个链表中的。最坏的情况下,所有的key都映射到同一个桶中,这样hashmap就退化成了一个链表——查找时间从O(1)到 O(n)。我们先来测试下正常情况下hashmap在Java 7和Java 8中的表现。为了能完成控制hashCode()方法的行为,我们定义了如下的一个Key类:
class Key implements Comparable<Key> { private final int value; Key(int value) { this.value = value; } @Override public int compareTo(Key o) { return Integer.compare(this.value, o.value); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Key key = (Key) o; return value == key.value; } @Override public int hashCode() { return value; } }
Key类的实现中规中矩:它重写了equals()方法并且提供了一个还算过得去的hashCode()方法。为了避免过度的GC,我将不可变的Key对象缓存了起来,而不是每次都重新开始创建一遍:
class Key implements Comparable<Key> { public class Keys { public static final int MAX_KEY = 10_000_000; private static final Key[] KEYS_CACHE = new Key[MAX_KEY]; static { for (int i = 0; i < MAX_KEY; ++i) { KEYS_CACHE[i] = new Key(i); } } public static Key of(int value) { return KEYS_CACHE[value]; }
现在我们可以开始进行测试了。我们的基准测试使用连续的Key值来创建了不同的大小的HashMap(10的乘方,从1到1百万)。在测试中我们还会使用key来进行查找,并测量不同大小的HashMap所花费的时间:
import com.google.caliper.Param; import com.google.caliper.Runner; import com.google.caliper.SimpleBenchmark; public class MapBenchmark extends SimpleBenchmark { private HashMap<Key, Integer> map; @Param private int mapSize; @Override protected void setUp() throws Exception { map = new HashMap<>(mapSize); for (int i = 0; i < mapSize; ++i) { map.put(Keys.of(i), i); } } public void timeMapGet(int reps) { for (int i = 0; i < reps; i++) { map.get(Keys.of(i % mapSize)); } } }
有意思的是这个简单的HashMap.get()里面,Java 8比Java 7要快20%。整体的性能也相当不错:尽管HashMap里有一百万条记录,单个查询也只花了不到10纳秒,也就是大概我机器上的大概20个CPU周期。 相当令人震撼!不过这并不是我们想要测量的目标。
假设有一个很差劲的key,他总是返回同一个值。这是最糟糕的场景了,这种情况完全就不应该使用HashMap:
class Key implements Comparable<Key> { //... @Override public int hashCode() { return 0; } }
Java 7的结果是预料中的。随着HashMap的大小的增长,get()方法的开销也越来越大。由于所有的记录都在同一个桶里的超长链表内,平均查询一条记录就需要遍历一半的列表。因此从图上可以看到,它的时间复杂度是O(n)。
不过Java 8的表现要好许多!它是一个log的曲线,因此它的性能要好上好几个数量级。尽管有严重的哈希碰撞,已是最坏的情况了,但这个同样的基准测试在JDK8中的时间复杂度是O(logn)。单独来看JDK 8的曲线的话会更清楚,这是一个对数线性分布:
为什么会有这么大的性能提升,尽管这里用的是大O符号(大O描述的是渐近上界)?其实这个优化在JEP-180中已经提到了。如果某个桶中的记录过 大的话(当前是TREEIFY_THRESHOLD = 8),HashMap会动态的使用一个专门的treemap实现来替换掉它。这样做的结果会更好,是O(logn),而不是糟糕的O(n)。它是如何工作 的?前面产生冲突的那些KEY对应的记录只是简单的追加到一个链表后面,这些记录只能通过遍历来进行查找。但是超过这个阈值后HashMap开始将列表升 级成一个二叉树,使用哈希值作为树的分支变量,如果两个哈希值不等,但指向同一个桶的话,较大的那个会插入到右子树里。如果哈希值相等,HashMap希 望key值最好是实现了Comparable接口的,这样它可以按照顺序来进行插入。这对HashMap的key来说并不是必须的,不过如果实现了当然最 好。如果没有实现这个接口,在出现严重的哈希碰撞的时候,你就并别指望能获得性能提升了。
这个性能提升有什么用处?比方说恶意的程序,如果它知道我们用的是哈希算法,它可能会发送大量的请求,导致产生严重的哈希碰撞。然后不停的访问这些 key就能显著的影响服务器的性能,这样就形成了一次拒绝服务攻击(DoS)。JDK 8中从O(n)到O(logn)的飞跃,可以有效地防止类似的攻击,同时也让HashMap性能的可预测性稍微增强了一些。我希望这个提升能最终说服你的 老大同意升级到JDK 8来。
测试使用的环境是:Intel Core i7-3635QM @ 2.4 GHz,8GB内存,SSD硬盘,使用默认的JVM参数,运行在64位的Windows 8.1系统 上。
总结
HashMap的基本实现就如上面所分析的,最后做下总结:
- HashMap内部用Entry对象保存键值对,基于哈希表存储,用拉链法解决冲突。
- HashMap的默认容量大小为16,默认装载因子为0.75。可以指定容量大小,容量最终一定会被设置为2的幂,这是为了均匀地散列。
- HashMap的key和value都可以是null,当然只能有一个key是null,value可以有多个。
- HashMap的键值对数量超过容量*装载因子时会扩容,扩容后的容量大约是原来的两倍。扩容会重新散列,所以元素的位置可能发生会变化,而且这是一个耗时操作。
- HashMap是线程不安全的。