这篇文章主要讲解了JAVA 7和JAVA 8里HashMap的工作原理。
原文链接:http://coding-geek.com/how-does-a-hashmap-work-in-java/#!parentId=11378
大多数Java程序员都使用过或者正在使用Map,特别是HashMap。HashMap虽然简单但却能够非常高效地存取数据。可是有多少人知道HashMap的内部原理呢?为了深入理解HashMap,几天前,我读了java.util.HashMap(Java7和Java8)的大部分源码。在这篇文章中,我将解释HashMap的实现和Java8中HashMap的新特性,并对性能、内存方面以及在使用HashMap时的注意点进行说明。
Contents
1.内部存储
2.自动调整
3.线程安全
4.键的不可变性
5.Java8 优化
6.内存开销
6.1 JAVA 7
6.2 JAVA 8
7.性能问题
7.1 “倾斜”的HashMap vs 平衡HashMap
7.2 自动调整的开销
8.总结
内部存储
HashMap类实现了Map<K,V>接口。该接口的主要方法如下:
- V put(K key,V value)
- V get(Object key)
- V remove(Object key)
- Boolean containsKey(Object key)
HashMap使用内部类来存储数据:Entry<K,V>。一个Entry是一个简单的键值对加上两个附加的数据项:
- 一个指向另一个Entry的引用,因此HashMap能像单链表一样存储多个Entry.
- 键的哈希值,将该值存储起来避免每次HashMap需要时的重新计算.
以下是JAVA7里Entry类的部分实现:
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash; … }HashMap将数据存储在多个 Entry单链表中(称作bucket或者bins)。HashMap内部有一个内部数组,保存每一个Entry链表的表头,数组的默认大小是16.
这张图表明了HashMap的内存存储结构,Entry链表可以容纳null值。每个Entry能够指向另一个Entry以形成一个单链表。
所有哈希值相同的键的键值对都被存在同一个Entry链表中(bucket)。哈希值不同的键的键值对也可以出现在同一个链表中。
当用户调用put(K key,V value)或者get(Object key)方法的时候,该方法会计算出对应Entry所在bucket在内部数组中的下标。然后,该方法会继续遍历找到的bucket,以找出含有相同键的Entry对象(通过equal()方法)。
在调用get()的时候,方法返回找到的Entry的value值(如果对应的Entry存在的话)
在调用put(K key,V value)的时候,如果key对应的Entry存在,该方法就将其value值进行替换,否则就在链表的头部创建一个新的Entry(以传入的key和value为参数)。
bucket(Entry单链表)的下标通过以下三个步骤产生:
- 首先获取键的哈希值
- 对哈希值进行再一次哈希操作(rehash)以避免键本身的hashCode()方法导致将所有数据存入了相同的bucket,即得到的内部数组下标相同
- 使用再一次哈希操作得到的哈希值和数组的长度减一进行“与”操作。这个操作保证了得到的数组下标不会大于数组的大小。
JAVA7和JAVA8里对数组下标的处理的代码如下:
// the "rehash" function in JAVA 7 that takes the hashcode of the key static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } // the "rehash" function in JAVA 8 that directly takes the key static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } // the function that returns the index from the rehashed hash static int indexFor(int h, int length) { return h & (length-1); }
为了提高运行效率,内部数组的大小需要是2的次方,原因如下:
假设数组大小是17,长度减一为16,。16的二进制表示是0....010000,所有对于任意的哈希值H,对应的内部数组下标为”H and 16“,其结果就只能是0或者16,。这意味着大小为17的内部数组会被使用到的就只有2个bucket:下标为0的和下标为16的,十分低效。
但,如果取大小为2的次方,如16,那么对于任意哈希值H,数组下标就是"H and 15"。因为15的二进制表示是0....001111,所以能够得到0-15之间的任意一个值,因此大小为16的数组能够被充分使用。例子如下:
- 如果 H = 952 ,它的二进制表示是 0..01110111000, 对应的数组下标为 0…01000 = 8
- 如果 H = 1576,它的二进制表示是 0..011000101000, 对应的数组下标为 0…01000 = 8
- 如果 H = 12356146, 它的二进制表示是 0..0101111001000101000110010, 对应的数组下标为 0…00010 = 2
- 如果 H = 59843, 它的二进制表示是 0..01110100111000011, 对应的数组下标为 0…00011 = 3
自动调整
获取了对应的内部数组下标之后,被调用的方法(get、put或者remove)会访问/遍历下标所在的链表以查看是否有对应给定键的Entry存在。这个过程会造成性能问题,因为被调用的方法需要对整个链表进行遍历来找到对应的Entry。假设内部数组为默认大小(16),并且你需要在HashMap中存储2百万个数据。在最好的情况下,每个链表会有12万5000个Entry对象(2百万/16)。因此,每次调用get()、put()、remove()将会导致12万5000次的遍历。为了防止这种情况,HashMap内部有一种机制能够增大内部数组的大小以使得链表的长度维持在一个较短的范围。
当创建HashMap对象的时候,我们能够指定一个初始大小(initial size)和一个装载因子(loadFactor):
public HashMap(int initialCapacity, float loadFactor)如果不指定这两个参数,默认的初始容量为16,装载因子为0.75.初始容量代表了内部数组的大小。
每一个调用put(...)方法加入一个新的键值对时,该方法会先检查是否需要增大内部数组的大小。为此,HashMap需要存储两个数据项:
- HashMap的大小size:即HashMap存储的所有Entry的数目。每次HashMap添加或者删除一个Entry,就更新该值。
- 阈值threshold:大小等于内部数组长度*装载因子,并且在每次自动调整后更新。
在加入新的Entry之前,put(...)方法会先检查size是否大于threshold,如果是的话,它就以原来大小的2倍新建一个内部数组。因为数组的大小改变了,定位数组下标的方法(返回“hash(key) AND (数组长度-1))一样也改变了。所以,新创建的数组会比原来拥有多一倍的bucket(单链表),然后将原有的Entry重新分布到所有的bucket(老的bucket和新建的bucket)中去。
自动调整的目的在于减小链表的长度,以使得消耗在put(),remove(),get()方法上的时间保持在低水平。键的哈希值相同的所有Entry在自动调整后都会被分布到同一个bucket中。但是,在调整前存在于同一个bucket的2个拥有不同哈希值的键的Entry在调整后不一定仍在同一个bucket中。
这张图片演示了自动调整前后内部数组的状况。在调整之前,要获取Entry E,需要遍历5个Entry对象;而在调整后,相同的get()操作只需要遍历2个Entry对象,比之前的效率提升了2倍。
注意:HashMap只能够增大内部数组的大小,而没有方法能够减小它。
线程安全
对于了解HashMap的人,应该都知道它是线程不安全的,但,这是为什么呢?举个例子,假设有一个写线程只向HashMap里放数据,和一个读线程从HashMap中读出数据,想一想为什么这样子并不能正常运行?
因为在自动调整的过程中,如果一个线程尝试放或者读取一个对象,HashMap可能仍会使用旧的数组下标,因此将不会找到Entry所在的新的bucket。
最坏的情况是当2个线程同时向Map放数据,并且这两个put()都引起Map的自动调整。因为两个线程同时对Map里的链表进行修改,Map可能最终在某个链表上出现
回路。之后如果我们尝试获取该链表中的数据,get()方法将会一直运行下去。
HashTable的实现是线程安全的,因此避免了以上的状况。但,因为所有的CRUD(新建、获取、更新、删除)都需要进行同步,所以效率很低。例如,如果线程1调用了get(key1),线程2调用了get(key2),同时线程3调用了get(key3),在同一时间点只有一个线程能够获取到需要的数据,尽管三个线程本来能够同时访问对应的数据。(译者注:不存在同步问题)
线程安全的HashMap更高效的实现出现在JAVA:也就是
ConcurrentHashMap。被同步的只有bucket,所以多个线程在不访问同一个bucket或者引起自动调整时能够同时进行get()、remove()、put()。
在多线程应用中,最好使用ConcurrentHashMap。
键不可变性
为什么字符串和整数适合当做Map的键呢?主要是因为它们是
不可变的!如果你决定要创建自己的键类(Key class)并且不将它设为不可变的,你可能会丢失存储在HashMap里的数据。
看下列例子:
- 我们有一个键,它的值是“1”
- 使用该键向HashMap中存入一个Object对象
- HashMap为该键的hashcode重新进行哈希运算(从"1"产生哈希值)
- 将重新计算得到的哈希值存入Map的Entry中
- 修改键的值为"2"
- 键的hashcode被修改了,因此对该键的哈希值重新进行哈希操作的结果也改变了,但HashMap并不知道这一点(因为旧的哈希值已经被存储)
- 使用修改过的键获取数据
- Map对键(“2”)计算出最终的哈希值,确定对应的bucekt:
- 可能性1:因为键被修改了,所以Map会从错误的bucket寻找Entry,并最终失败;
- 可能性2:幸运地,被修改的键和旧键对应到了相同的bucket,于是Map遍历该bucket寻找对应的Entry.但为了找到对应的键,Map会对新键的哈希值和Entry原先存储的哈希值调用equals()进行比较。因为新键的哈希值与旧键的哈希值不同,所以Map一样会查找失败。
以下是一个具体的例子。我向Map里放了两个键值对,修改了第一个的键,然后尝试获取两个键值对的值,只有第二个键值对的值能被成功获取,第一个的值“丢失”在了HashMap里:
public class MutableKeyTest { public static void main(String[] args) { class MyKey { Integer i; public void setI(Integer i) { this.i = i; } public MyKey(Integer i) { this.i = i; } @Override public int hashCode() { return i; } @Override public boolean equals(Object obj) { if (obj instanceof MyKey) { return i.equals(((MyKey) obj).i); } else return false; } } Map<MyKey, String> myMap = new HashMap<>(); MyKey key1 = new MyKey(1); MyKey key2 = new MyKey(2); myMap.put(key1, "test " + 1); myMap.put(key2, "test " + 2); // modifying key1 key1.setI(3); String test1 = myMap.get(key1); String test2 = myMap.get(key2); System.out.println("test1= " + test1 + " test2=" + test2); } }
JAVA 8 优化
HashMap的内部实现在JAVA8中有很大的变化。实际上,在JAVA7里这个类只有1000多行代码,而在JAVA8里却有2000多行。除了Entry链表,我以上所讲的大部分在JAVA8里都是一样的。在JAVA8里我们仍然需要一个数组,但现在这个数组被用来存储Node对象,它含有与Entry相同的信息,因此,实际上也是一个链表。
以下是Node类在JAVA8里的部分实现代码:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next;
那么,JAVA8里的HashMap和7里的有什么大不一样的吗?实际上,Node类拥有一个子类:TreeNode。TreeNode是红黑树的数据结构实现,它存储了更多的信息,因此能够在O(log(n))的时间复杂度之内完成增加、删除或者获取元素的操作。
下面是TreeNode里存储信息的详细列表:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { final int hash; // inherited from Node<K,V> final K key; // inherited from Node<K,V> V value; // inherited from Node<K,V> Node<K,V> next; // inherited from Node<K,V> Entry<K,V> before, after;// inherited from LinkedHashMap.Entry<K,V> TreeNode<K,V> parent; TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; boolean red;
红黑树是自平衡的二叉搜索树。无论是向其增加还是删除元素,其内部结构都能保证它的深度总是log(n)。使用红黑树的主要好处在于但大量数据被放在了同一个bucket中的时候,对树的搜索总能在对数时间内完成而不是使用链表时的O(n)复杂度。
就像见到的一样,红黑树会比链表消耗更多空间。(我们将在之后讨论这个问题)
通过继承机制,HashMap的内部数组能够同时存放Node对象(作为链表)和TreeNode对象(作为红黑树)。Oracle决定按以下规则使用这两种数据结构:
如果一个bucket里有多于8个元素(即键值对,译者注),就将这个bucket对应的链表转化为一棵红黑树
如果一个bucket里的元素少于6个,就将这个bucket对应的红黑树转化为链表
上面是JAVA8的HashMap如何同时使用红黑树(bucket 0)和链表(bucket 1,2,3)存储数据。Bucket 0是一棵红黑树,因为它的元素数目大于8。
内存开销
JAVA 7
HashMap的使用代价取决于它的内存开销。在JAVA7里,HashMap将键值对“包装”在Entry对象中。一个Entry对象含有:
- 一个指向下一个Entry对象的引用
- 已计算出的哈希值(整数)
- 指向键的引用
- 指向值的引用
除此之外,JAVA7还要使用一个内部数组来存储Entry。假设HashMap里有N个元素,它的数组大小为默认值CAPACITY,其内存消耗为:
sizeOf(integer)* N + sizeOf(reference)* (3*N+C)
其中:
- 整数大小为4字节
- 引用的大小取决于虚拟机/操作系统/处理器,大多数情况下为4字节
则内存消耗为:
16 * N + 4 * CAPACITY个字节。
提醒:在HashMap的自动调整后,内部数组的大小等于大于N的最小的2的次方。
注意:从JAVA7开始,HashMap使用了lazy init。所以即使实例化了一个HashMap,它的内部数组(占用4 * CAPACITY个字节)直到第一次调用了put()方法之前都不会被初始化。
JAVA8
在JAVA8的实现中,计算内存开销比较麻烦,因为一个Node对象可以拥有和Entry一样多想信息,或者比Entry多出6个引用和一个布尔值(当是红黑树节点时)。
如果所有元素都是Node对象,内存开销就和JAVA7的一样。
如果所有元素都是TreeNode对象,内存开销为:
N * sizeOf(integer) + N * sizeOf(boolean) + sizeOf(reference)* (9*N+CAPACITY )
在大多数虚拟机上,该值等于
44 * N + 4 * CAPACITY个字节。
性能问题
“倾斜”的HashMap vs 平衡的HashMap
在最好的情况下,get()、set()方法的时间复杂度是O(1)。但是,如果不考虑键的哈希方法,就有可能出现put()、get()的性能问题。put()、get()的高效率取决于数据如何分布到内部数组的不同下标(bucket)。如果键的哈希方法有问题的话,就会使得分布的过程十分不平均(无论内部数组有多大)。此时所有的put()、get()方法都会变得非常慢,因为它们都要对整个链表进行遍历。在最坏的情况下(大多数的数据都分布到了同一个bucket),时间复杂度就会变成O(n)。
以下是一个例子。第一张图片是一个“倾斜”的HashMap,第二张图片是一个平衡的HashMap。
在上面这个HashMap中,在bucket 0里的get、put操作会十分费时。获取Entry K需要查找6次。
在平衡的HashMap中,获取Entry K只需要查找3次。这两个HashMap拥有一样多的元素、一样大小的内部数组。唯一的不同在于用来分配Entry到一个bucket的哈希方法。
下面这个例子中,我实现了自己的hash方法,它会将所有数据都分配到同一个bucket,然后我向HashMap中加入2百万个数据。
public class Test { public static void main(String[] args) { class MyKey { Integer i; public MyKey(Integer i){ this.i =i; } @Override public int hashCode() { return 1; } @Override public boolean equals(Object obj) { … } } Date begin = new Date(); Map <MyKey,String> myMap= new HashMap<>(2_500_000,1); for (int i=0;i<2_000_000;i++){ myMap.put( new MyKey(i), "test "+i); } Date end = new Date(); System.out.println("Duration (ms) "+ (end.getTime()-begin.getTime())); } }在配置为 i5-2500k @ 3.6Ghz的主机上,这段代码在JDK8上的运行时间超过了45分钟。(我在45分钟后将进程关闭了)。
现在,改为使用下面的hash方法:
@Override public int hashCode() { int key = 2097152-1; return key+2097152*i; }它仅耗费了 46秒!这个hash方法比之前的能更好的对元素进行分配所以put()的调用要快得多。
如果我使用下面这个更好的hash方法:
@Override public int hashCode() { return i; }现在仅仅需要 2秒。
我希望你能够明白hash方法的重要性。如果我们在JDK7上运行以上的测试,第一个和第二个的结果会更差(因为在JAVA7里put()方法的时间复杂度是O(n),而JAVA8里是O(log(n)))。
在使用HashMap的时候,我们需要为键找到一个好的hash方法,使得它能够
将键分配到大部分的bucket上去。因此,需要
避免哈希冲突。String对象适合作为键因为它有很好的hash方法。整数也一样因为它们的hashcode就是它们的值。
自动调整的代价
如果需要存储大量的数据,我们应该在创建HashMap的时候指定一个接近预期的初始容量。
如果不指定的话,Map就会使用默认的16作为初始大小和默认的装载因子0.75。前11次的put()调用会很快的执行完,但在第12(16*0.75)次的put()时,一个新的内部数组会被创建,其大小为32.第13到23次put()会很快执行完,但是第24(32*0.75)次(原文为23th,译者注)时又会进行一次自动调整。以此类推,自动调整将在第48、96、192....次调用put()的时候进行。如果数据量较小的话,重建整个数组会很快。但是在数据量很大的情况下,可能要耗费数秒到数分钟的时间。而通过设定一个初始的容量大小,就能否
避免这些
耗时的操作。
但这个方法也有其
缺点:如果将数组大小设定为一个很大的数如2^28,而你只会用到2^26个bucket,那就会有大量的
内存被
浪费掉。
总结
对于简单的使用,我们并不需要了解HashMap内部是如何工作的,毕竟我们不会察觉到O(1)O(n)O(log(n))复杂度的操作间的区别。但了解最常使用的数据结构之一的底层原理总是有好处的。另外,对于JAVA程序员,这也是在面试时经常被问到的问题。
在数据量很大的时候,了解HashMap是如何工作的以及hash方法的重要性是非常重要的。
希望读完这篇文章以后,你们能够对HashMap有更深的了解。