Java HashMap类实现了Map<K, V>接口。这个接口中的主要方法包括: java
HashMap使用了一个内部类Entry<K, V>来存储数据。这个内部类是一个简单的键值对,并带有额外两个数据: node
下面是Entry<K, V>在Java 7下的一部分代码: 面试
1
2
3
4
5
6
7
|
staticclassEntry<K,V>implementsMap.Entry<K,V> {
finalK key;
V value;
Entry<K,V> next;
inthash;
…
}
|
HashMap将数据存储到多个单向Entry链表中(有时也被称为桶bucket或者容器orbins)。全部的列表都被注册到一个Entry数组中(Entry<K, V>[]数组),这个内部数组的默认长度是16。 数组
下面这幅图描述了一个HashMap实例的内部存储,它包含一个nullable对象组成的数组。每一个对象都链接到另一个对象,这样就构成了一个链表。 安全
全部具备相同哈希值的键都会被放到同一个链表(桶)中。具备不一样哈希值的键最终可能会在相同的桶中。 数据结构
当用户调用 put(K key, V value) 或者 get(Object key) 时,程序会计算对象应该在的桶的索引。而后,程序会迭代遍历对应的列表,来寻找具备相同键的Entry对象(使用键的equals()方法)。 多线程
对于调用get()的状况,程序会返回值所对应的Entry对象(若是Entry对象存在)。 ide
对于调用put(K key, V value)的状况,若是Entry对象已经存在,那么程序会将值替换为新值,不然,程序会在单向链表的表头建立一个新的Entry(从参数中的键和值)。 函数
桶(链表)的索引,是经过map的3个步骤生成的: 性能
下面是生成索引的源代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// the "rehash" function in JAVA 7 that takes the hashcode of the key
staticinthash(inth) {
h ^= (h >>>20) ^ (h >>>12);
returnh ^ (h >>>7) ^ (h >>>4);
}
// the "rehash" function in JAVA 8 that directly takes the key
staticfinalinthash(Object key) {
inth;
return(key ==null) ?0: (h = key.hashCode()) ^ (h >>>16);
}
// the function that returns the index from the rehashed hash
staticintindexFor(inth,intlength) {
returnh & (length-1);
}
|
为了更有效地工做,内部数组的大小必须是2的幂值。让咱们看一下为何:
假设数组的长度是17,那么掩码的值就是16(数组长度-1)。16的二进制表示是0…010000,这样对于任何值H来讲,“H & 16”的结果就是16或者0。这意味着长度为17的数组只能应用到两个桶上:一个是0,另一个是16,这样不是颇有效率。可是若是你将数组的长度设置为2的幂值,例如16,那么按位索引的工做变成“H & 15”。15的二进制表示是0…001111,索引公式输出的值能够从0到15,这样长度为16的数组就能够被充分使用了。例如:
这种机制对于开发者来讲是透明的:若是他选择一个长度为37的HashMap,Map会自动选择下一个大于37的2的幂值(64)做为内部数组的长度。
在获取索引后,get()、put()或者remove()方法会访问对应的链表,来查看针对指定键的Entry对象是否已经存在。在不作修改的状况下,这个机制可能会致使性能问题,由于这个方法须要迭代整个列表来查看Entry对象是否存在。假设内部数组的长度采用默认值16,而你须要存储2,000,000条记录。在最好的状况下,每一个链表会有125,000个Entry对象(2,000,000/16)。get()、remove()和put()方法在每一次执行时,都须要进行125,000次迭代。为了不这种状况,HashMap能够增长内部数组的长度,从而保证链表中只保留不多的Entry对象。
当你建立一个HashMap时,你能够经过如下构造函数指定一个初始长度,以及一个loadFactor:
1
2
3
|
</pre>
publicHashMap(intinitialCapacity,floatloadFactor)
<pre>
|
若是你不指定参数,那么默认的initialCapacity的值是16, loadFactor的默认值是0.75。initialCapacity表明内部数组的链表的长度。
当你每次使用put(…)方法向Map中添加一个新的键值对时,该方法会检查是否须要增长内部数组的长度。为了实现这一点,Map存储了2个数据:
在添加新的Entry对象以前,put(…)方法会检查当前Map的大小是否大于阀值。若是大于阀值,它会建立一个新的数组,数组长度是当前内部数组的两倍。由于新数组的大小已经发生改变,因此索引函数(就是返回“键的哈希值 & (数组长度-1)”的位运算结果)也随之改变。调整数组的大小会建立两个新的桶(链表),而且将全部现存Entry对象从新分配到桶上。调整数组大小的目标在于下降链表的大小,从而下降put()、remove()和get()方法的执行时间。对于具备相同哈希值的键所对应的全部Entry对象来讲,它们会在调整大小后分配到相同的桶中。可是,若是两个Entry对象的键的哈希值不同,但它们以前在同一个桶上,那么在调整之后,并不能保证它们依然在同一个桶上。
这幅图片描述了调整前和调整后的内部数组的状况。在调整数组长度以前,为了获得Entry对象E,Map须要迭代遍历一个包含5个元素的链表。在调整数组长度以后,一样的get()方法则只须要遍历一个包含2个元素的链表,这样get()方法在调整数组长度后的运行速度提升了2倍。
若是你已经很是熟悉HashMap,那么你确定知道它不是线程安全的,可是为何呢?例如假设你有一个Writer线程,它只会向Map中插入已经存在的数据,一个Reader线程,它会从Map中读取数据,那么它为何不工做呢?
由于在自动调整大小的机制下,若是线程试着去添加或者获取一个对象,Map可能会使用旧的索引值,这样就不会找到Entry对象所在的新桶。
在最糟糕的状况下,当2个线程同时插入数据,而2次put()调用会同时出发数组自动调整大小。既然两个线程在同时修改链表,那么Map有可能在一个链表的内部循环中退出。若是你试着去获取一个带有内部循环的列表中的数据,那么get()方法永远不会结束。
HashTable提供了一个线程安全的实现,能够阻止上述状况发生。可是,既然全部的同步的CRUD操做都很是慢。例如,若是线程1调用get(key1),而后线程2调用get(key2),线程2调用get(key3),那么在指定时间,只能有1个线程能够获得它的值,可是3个线程均可以同时访问这些数据。
从Java 5开始,咱们就拥有一个更好的、保证线程安全的HashMap实现:ConcurrentHashMap。对于ConcurrentMap来讲,只有桶是同步的,这样若是多个线程不使用同一个桶或者调整内部数组的大小,它们能够同时调用get()、remove()或者put()方法。在一个多线程应用程序中,这种方式是更好的选择。
为何将字符串和整数做为HashMap的键是一种很好的实现?主要是由于它们是不可变的!若是你选择本身建立一个类做为键,但不能保证这个类是不可变的,那么你可能会在HashMap内部丢失数据。
咱们来看下面的用例:
下面是一个Java示例,咱们向Map中插入两个键值对,而后我修改第一个键,并试着去获取这两个对象。你会发现从Map中返回的只有第二个对象,第一个对象已经“丢失”在HashMap中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
publicclassMutableKeyTest {
publicstaticvoidmain(String[] args) {
classMyKey {
Integer i;
publicvoidsetI(Integer i) {
this.i = i;
}
publicMyKey(Integer i) {
this.i = i;
}
@Override
publicinthashCode() {
returni;
}
@Override
publicbooleanequals(Object obj) {
if(objinstanceofMyKey) {
returni.equals(((MyKey) obj).i);
}else
returnfalse;
}
}
Map<MyKey, String> myMap =newHashMap<>();
MyKey key1 =newMyKey(1);
MyKey key2 =newMyKey(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);
}
}
|
上述代码的输出是“test1=null test2=test 2”。如咱们指望的那样,Map没有能力获取通过修改的键 1所对应的字符串1。
在Java 8中,HashMap中的内部实现进行了不少修改。的确如此,Java 7使用了1000行代码来实现,而Java 8中使用了2000行代码。我在前面描述的大部份内容在Java 8中依然是对的,除了使用链表来保存Entry对象。在Java 8中,咱们仍然使用数组,但它会被保存在Node中,Node中包含了和以前Entry对象同样的信息,而且也会使用链表:
下面是在Java 8中Node实现的一部分代码:
1
2
3
4
5
|
staticclassNode<K,V>implementsMap.Entry<K,V> {
finalinthash;
finalK key;
V value;
Node<K,V> next;
|
那么和Java 7相比,到底有什么大的区别呢?好吧,Node能够被扩展成TreeNode。TreeNode是一个红黑树的数据结构,它能够存储更多的信息,这样咱们能够在O(log(n))的复杂度下添加、删除或者获取一个元素。下面的示例描述了TreeNode保存的全部信息:
1
2
3
4
5
6
7
8
9
10
11
|
staticfinalclassTreeNode<K,V>extendsLinkedHashMap.Entry<K,V> {
finalinthash;// inherited from Node<K,V>
finalK 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;
booleanred;
|
红黑树是自平衡的二叉搜索树。它的内部机制能够保证它的长度老是log(n),无论咱们是添加仍是删除节点。使用这种类型的树,最主要的好处是针对内部表中许多数据都具备相同索引(桶)的状况,这时对树进行搜索的复杂度是O(log(n)),而对于链表来讲,执行相同的操做,复杂度是O(n)。
如你所见,咱们在树中确实存储了比链表更多的数据。根据继承原则,内部表中能够包含Node(链表)或者TreeNode(红黑树)。Oracle决定根据下面的规则来使用这两种数据结构:
- 对于内部表中的指定索引(桶),若是node的数目多于8个,那么链表就会被转换成红黑树。
- 对于内部表中的指定索引(桶),若是node的数目小于6个,那么红黑树就会被转换成链表。
这张图片描述了在Java 8 HashMap中的内部数组,它既包含树(桶0),也包含链表(桶1,2和3)。桶0是一个树结构是由于它包含的节点大于8个。
使用HashMap会消耗一些内存。在Java 7中,HashMap将键值对封装成Entry对象,一个Entry对象包含如下信息:
此外,Java 7中的HashMap使用了Entry对象的内部数组。假设一个Java 7 HashMap包含N个元素,它的内部数组的容量是CAPACITY,那么额外的内存消耗大约是:
sizeOf(integer)* N + sizeOf(reference)* (3*N+C)
其中:
这就意味着内存总开销一般是16 * N + 4 * CAPACITY字节。
注意:在Map自动调整大小后,CAPACITY的值是下一个大于N的最小的2的幂值。
注意:从Java 7开始,HashMap采用了延迟加载的机制。这意味着即便你为HashMap指定了大小,在咱们第一次使用put()方法以前,记录使用的内部数组(耗费4*CAPACITY字节)也不会在内存中分配空间。
在Java 8实现中,计算内存使用状况变得复杂一些,由于Node可能会和Entry存储相同的数据,或者在此基础上再增长6个引用和一个Boolean属性(指定是不是TreeNode)。
若是全部的节点都只是Node,那么Java 8 HashMap消耗的内存和Java 7 HashMap消耗的内存是同样的。
若是全部的节点都是TreeNode,那么Java 8 HashMap消耗的内存就变成:
N * sizeOf(integer) + N * sizeOf(boolean) + sizeOf(reference)* (9*N+CAPACITY )
在大部分标准JVM中,上述公式的结果是44 * N + 4 * CAPACITY 字节。
在最好的状况下,get()和put()方法都只有O(1)的复杂度。可是,若是你不去关心键的哈希函数,那么你的put()和get()方法可能会执行很是慢。put()和get()方法的高效执行,取决于数据被分配到内部数组(桶)的不一样的索引上。若是键的哈希函数设计不合理,你会获得一个非对称的分区(无论内部数据的是多大)。全部的put()和get()方法会使用最大的链表,这样就会执行很慢,由于它须要迭代链表中的所有记录。在最坏的状况下(若是大部分数据都在同一个桶上),那么你的时间复杂度就会变为O(n)。
下面是一个可视化的示例。第一张图描述了一个非对称HashMap,第二张图描述了一个均衡HashMap。
在这个非对称HashMap中,在桶0上运行get()和put()方法会很花费时间。获取记录K须要花费6次迭代。
在这个均衡HashMap中,获取记录K只须要花费3次迭代。这两个HashMap存储了相同数量的数据,而且内部数组的大小同样。惟一的区别是键的哈希函数,这个函数用来将记录分布到不一样的桶上。
下面是一个使用Java编写的极端示例,在这个示例中,我使用哈希函数将全部的数据放到相同的链表(桶),而后我添加了2,000,000条数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
publicclassTest {
publicstaticvoidmain(String[] args) {
classMyKey {
Integer i;
publicMyKey(Integer i){
this.i =i;
}
@Override
publicinthashCode() {
return1;
}
@Override
publicbooleanequals(Object obj) {
…
}
}
Date begin =newDate();
Map <MyKey,String> myMap=newHashMap<>(2_500_000,1);
for(inti=0;i<2_000_000;i++){
myMap.put(newMyKey(i),"test "+i);
}
Date end =newDate();
System.out.println("Duration (ms) "+ (end.getTime()-begin.getTime()));
}
}
|
个人机器配置是core i5-2500k @ 3.6G,在java 8u40下须要花费超过45分钟的时间来运行(我在45分钟后中止了进程)。若是我运行一样的代码, 可是我使用以下的hash函数:
1
2
3
4
5
|
@Override
publicinthashCode() {
intkey =2097152-1;
returnkey+2097152*i;
}
|
运行它须要花费46秒,和以前比,这种方式好不少了!新的hash函数比旧的hash函数在处理哈希分区时更合理,所以调用put()方法会更快一些。若是你如今运行相同的代码,可是使用下面的hash函数,它提供了更好的哈希分区:
1
2
3
4
|
@Override
publicinthashCode() {
returni;
}
|
如今只须要花费2秒!
我但愿你可以意识到哈希函数有多重要。若是在Java 7上面运行一样的测试,第一个和第二个的状况会更糟(由于Java 7中的put()方法复杂度是O(n),而Java 8中的复杂度是O(log(n))。
在使用HashMap时,你须要针对键找到一种哈希函数,能够将键扩散到最可能的桶上。为此,你须要避免哈希冲突。String对象是一个很是好的键,由于它有很好的哈希函数。Integer也很好,由于它的哈希值就是它自身的值。
若是你须要存储大量数据,你应该在建立HashMap时指定一个初始的容量,这个容量应该接近你指望的大小。
若是你不这样作,Map会使用默认的大小,即16,factorLoad的值是0.75。前11次调用put()方法会很是快,可是第12次(16*0.75)调用时会建立一个新的长度为32的内部数组(以及对应的链表/树),第13次到第22次调用put()方法会很快,可是第23次(32*0.75)调用时会从新建立(再一次)一个新的内部数组,数组的长度翻倍。而后内部调整大小的操做会在第48次、96次、192次…..调用put()方法时触发。若是数据量不大,重建内部数组的操做会很快,可是数据量很大时,花费的时间可能会从秒级到分钟级。经过初始化时指定Map指望的大小,你能够避免调整大小操做带来的消耗。
但这里也有一个缺点:若是你将数组设置的很是大,例如2^28,但你只是用了数组中的2^26个桶,那么你将会浪费大量的内存(在这个示例中大约是2^30字节)。
对于简单的用例,你没有必要知道HashMap是如何工做的,由于你不会看到O(1)、O(n)以及O(log(n))之间的区别。可是若是可以理解这一常用的数据结构背后的机制,老是有好处的。另外,对于Java开发者职位来讲,这是一道典型的面试问题。
对于大数据量的状况,了解HashMap如何工做以及理解键的哈希函数的重要性就变得很是重要。
我但愿这篇文章能够帮助你对HashMap的实现有一个深刻的理解。