大多数JAVA开发人员正在使用Maps,尤其是HashMaps。HashMap是一种简单而强大的方式来存储和获取数据。但是有多少开发人员知道HashMap在内部工作?前几天,我已经阅读了java.util.HashMap的源代码(Java 7中的Java 8)的很大一部分,以便深入了解这个基础数据结构。在这篇文章中,我将解释java.util.HashMap的实现,介绍JAVA 8实现中的新功能,并讨论使用HashMaps时的性能,内存和已知问题。
内容[ 显示 ]
内部存储器
JAVA HashMap类实现了接口Map <K,V>。这个接口的主要方法是:
- V put(K键,V值)
- V get(Object key)
- V删除(对象键)
- Boolean containsKey(Object key)
HashMaps使用内部类来存储数据:Entry <K,V>。此条目是一个带有两个额外数据的简单键值对:
- 引用另一个条目,以便HashMap可以存储像单链表的条目
- 表示密钥的哈希值的哈希值。存储此哈希值以避免每次HashMap需要时计算哈希值。
这是JAVA 7中Entry Entry的一部分:
static
class
Entry<K,V>
implements
Map.Entry<K,V> {
final
K key;
V value;
Entry<K,V> next;
int
hash;
…
}
|
一个HashMap将数据存储到项的多个单链表(也称为桶或桶)。所有列表都注册在Entry(Entry <K,V> []数组)的数组中,该内部数组的默认容量为16。
下图显示了具有可空条目数组的HashMap实例的内部存储。每个条目可以链接到另一个条目以形成链表。
具有相同散列值的所有密钥都放在相同的链表(bucket)中。具有不同哈希值的密钥可以在同一个桶中结束。
当用户调用put(K key,V value)或get(Object key)时,该函数计算Entry应该在哪个bucket的索引。然后,该函数遍历列表以查找具有相同键的Entry(使用键的equals())。
在get()的情况下,函数返回与条目相关联的值(如果条目存在)。
在put(K key,V value)的情况下,如果条目存在,则函数用新值替换它,否则它将在单链表的头部创建一个新条目(来自参数中的键和值)。
桶的这个索引(链表)由地图分三步生成:
- 它首先获取密钥的哈希码。
- 它重新创建哈希码,以防止从将所有数据放在内部数组的相同索引(bucket)中的密钥的错误散列函数
- 它需要重新排列的散列哈希码,并将其与数组的长度(减1)进行位掩码。此操作确保索引不能大于数组的大小。您可以将其视为非常计算优化的模函数。
这是处理索引的JAVA 7和8源代码:
// 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(大小-1)。16的二进制表示为0 ... 010000,因此对于任何散列值H,按位公式“H AND 16”生成的索引将为16或0.这意味着大小为17的数组将仅用于2个桶:索引为0,索引16为1,效率不高
但是,如果现在取大小为16的幂,则按位指数公式为“H AND 15”。15的二进制表示为0 ... 001111,因此索引公式可以输出从0到15的值,并且大小为16的数组被完全使用。例如:
- 如果H = 952,其二进制表示为0..0111011 1000,相关索引为0 ... 0 1000 = 8
- 如果H = 1576,其二进制表示为0..01100010 1000,则相关索引为0 ... 0 1000 = 8
- 如果H = 12356146,其二进制表示为0..010111100100010100011 0010,相关索引为0 ... 0 0010= 2
- 如果H = 59843,其二进制表示为0..0111010011100 0011,相关索引为0 ... 0 0011 = 3
这就是为什么阵列大小是二的幂。这个机制对于开发人员来说是透明的:如果他选择一个大小为37的HashMap,Map将自动为37(64)之后的内部数组大小自动选择2的下一个幂。
自动调整大小
获取索引后,函数(get,put或remove)访问/重复相关链接列表,以查看给定键是否存在现有条目。没有修改,这种机制可能会导致性能问题,因为函数需要遍历整个列表来查看该条目是否存在。想象一下,内部数组的大小是默认值(16),您需要存储2百万个值。在最佳情况下,每个链表的大小将为125 000个条目(2,16百万)。所以,每个get(),remove()和put()将导致125 000次迭代/操作。为了避免这种情况,HashMap有能力增加其内部数组,以保持非常短的链表。
创建HashMap时,可以使用以下构造函数指定初始大小和loadFactor:
public
HashMap(
int
initialCapacity,
float
loadFactor)
|
如果不指定参数,则默认的initialCapacity为16,默认的loadFactor为0.75。initialCapacity表示链表的内部数组的大小。
每次在put(...)的Map中添加新的键/值时,函数将检查是否需要增加内部数组的容量。为了做到这一点,地图存储2个数据:
- 映射的大小:它表示HashMap中的条目数。每次添加或删除条目时,此值都会更新。
- 一个阈值:它等于(内部数组的容量)* loadFactor,并且在内部数组的每个调整大小之后刷新它
在添加新条目之前,put(...)检查size> threshold,如果是这样,它将重新创建一个双倍大小的新数组。由于新数组的大小已更改,索引函数(返回按位运算“hash(key)AND(sizeOfArray-1)”)会发生更改。因此,调整数组大小会创建两倍的桶(即链表),并将 所有现有条目重新分配到桶(旧版和新创建)中。
这种调整大小操作的目的是减少链表的大小,以便put(),remove()和get()方法的时间成本保持在较低水平。密钥具有相同哈希值的所有条目在调整大小后将保留在相同的存储桶中。但是,具有不同哈希键的2个条目在之前的相同的桶中可能在转换后可能不在同一个桶中。
该图显示了在内部数组调整大小之前和之后的表示。在增加之前,为了获得Entry E,地图必须遍历5个元素的列表。调整大小后,相同的get()只是遍历2个元素的链接列表,get()在调整大小之后快2倍!
注意:HashMap只增加了内部数组的大小,它不提供减少它的方法。
线程安全
如果你已经知道HashMaps,你知道这不是线程安全的,但是为什么?例如,假设您有一个Writer线程,只将新数据放入Map中,还有一个Reader线程从Map中读取数据,为什么不运行?
因为在自动调整大小的机制中,如果线程尝试放置或获取对象,则映射可能会使用旧的索引值,并且不会找到条目所在的新存储桶。
最糟糕的情况是当2个线程同时放置一个数据时,2个put()调用可以同时调整Map的大小。由于两个线程同时修改链接列表,所以Map可能会在其链接列表之一中出现内循环。如果您尝试使用内部循环获取列表中的数据,则get()将永远不会结束。
该哈希表的实现是线程安全的实现,从这种情况可以防止。但是,由于所有的CRUD方法都是同步的,所以实现速度非常慢。例如,如果线程1调用get(key1),线程2调用get(key2)和线程3调用get(key3),一次只能有一个线程能够获取其值,而其中3个可以访问数据与此同时。
自JAVA 5:ConcurrentHashMap以来,线程安全的HashMap的更智能的实现。只有桶被同步,所以多个线程可以同时获取get(),remove()或put()数据,如果它不意味着访问同一个bucket或调整内部数组的大小。最好在多线程应用程序中使用此实现。
重要的不变性
为什么字符串和整数是HashMap的一个很好的实现键?主要是因为它们是不变的!如果您选择创建自己的Key类,并且不使其成为不可变的,那么可能会丢失HashMap中的数据。
看下面的用例:
- 你有一个内部值为“1”的键
- 您使用此键将对象放在HashMap中
- HashMap从Key的哈希码生成哈希(所以从“1”)
- 地图 将此哈希存储 在新创建的条目中
- 您将密钥的内部值修改为“2”
- 密钥的哈希值被修改,但是HashMap不知道(因为旧的哈希值被存储)
- 您尝试使用修改的密钥获取对象
- 该地图计算您的密钥的新哈希(所以从“2”)查找条目在哪个链接列表(桶)
-
- 情况1:由于您修改了密钥,地图会尝试在错误的桶中找到该条目,但找不到
- 情况2:幸运的是,修改的密钥生成与旧密钥相同的桶。然后,映射遍历链表以查找具有相同键的条目。但要找到密钥,地图首先比较哈希值,然后调用equals()比较。由于修改的密钥与旧的哈希值(存储在条目中)没有相同的哈希值,所以地图将不会在链表中找到该条目。
这是Java中的一个具体例子。我在我的Map中放置了2个键值对,我修改了第一个键,然后尝试获取2个值。只有第二个值从地图返回,第一个值在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);
}
}
|
输出为:“test1 = null test2 = test 2”。如预期的那样,Map无法使用修改的密钥1检索字符串1。
JAVA 8改进
HashMap的内部表示在JAVA 8中发生了很大的变化。实际上,JAVA 7中的实现需要1k行代码,而在JAVA 8中的实现需要2k行。除了条目的链接列表之外,我之前说过的大部分内容都是正确的。在JAVA8中,您仍然有一个数组,但它现在存储包含与Entries完全相同的信息的节点,因此也是链接列表:
这是JAVA 8中Node实现的一部分:
static
class
Node<K,V>
implements
Map.Entry<K,V> {
final
int
hash;
final
K key;
V value;
Node<K,V> next;
|
那么JAVA 7有什么不同呢?那么,节点可以扩展到TreeNodes。TreeNode是一个红黑树结构,可以存储更多的信息,以便它可以添加,删除或获取O(log(n))中的元素。
FYI,这里是存储在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)中。使用这些树的主要优点是在许多数据位于内表的相同索引(桶)的情况下,树中的搜索将花费 O(log(n)),而它将具有成本O(n)有一个链表。
正如你所看到的,树比链表具有更多的空间(我们将在下一部分中讨论它)。
通过继承,内表可以包含 Node(链表)和 TreeNode(红黑树)。Oracle决定使用以下规则的两个数据结构:
- 如果对于内部表中的给定索引(存储桶),存在多于8个节点,链表将转换为红色黑色树
- 如果为给定索引(桶)在内表中有少于6个节点,树被转换成一个链表
这张照片显示了一个JAVA 8 HashMap的内部数组,其中包含树(桶0)和链表(在1,2和3桶)。Bucket 0是一个Tree,因为它有8个以上的节点。
内存开销
JAVA 7
使用HashMap在内存方面是有代价的。在JAVA 7中,一个HashMap包含条目中的键值对。一个条目有:
- 引用下一个条目
- 预先计算的散列(整数)
- 参考的关键
- 参考价值
此外,JAVA 7 HashMap使用Entry的内部数组。假设一个JAVA 7 HashMap包含N个元素,其内部数组具有容量CAPACITY,则额外的内存成本约为:
sizeOf(integer)* N + sizeOf(reference)*(3 * N + C)
哪里:
- 整数的大小等于4字节
- 引用的大小取决于JVM / OS / Processor,但通常为4个字节。
这意味着开销通常是16 * N + 4 * CAPACITY字节
提醒:在自动调整地图大小后,内部阵列的CAPACITY等于N之后的下一个功率。
注意:由于JAVA 7,HashMap类有一个懒惰的init。这意味着即使您分配了HashMap,在第一次使用put()方法之前,内部数组的条目(将花费4 * CAPACITY字节)也不会在内存中分配。
JAVA 8
使用JAVA 8实现,获取内存使用情况变得有点复杂,因为Node可以包含与条目相同的数据或相同的数据加上6个引用和布尔值(如果它是一个TreeNode)。
如果所有节点只是节点,则JAVA 8 HashMap的内存消耗与JAVA 7 HashMap相同。
如果所有节点都是TreeNodes,则JAVA 8 HashMap的内存消耗将变为:
N * sizeOf(integer)+ N * sizeOf(boolean)+ sizeOf(reference)*(9 * N + CAPACITY)
在大多数标准JVM中,它等于44 * N + 4 * CAPACITY字节
性能问题
倾斜的HashMap和平衡的HashMap
在最佳情况下,get()和put()方法的时间复杂度为O(1)。但是,如果您不关心该键的哈希功能,则可能会导致非常慢的put()和get()调用。put()和get的良好性能取决于将数据重新分配到内部数组(桶)的不同索引中。如果您的密钥的散列函数设计不当,您将会有一个偏斜重新分区(无论内部数组的容量有多大)。所有使用最大的条目列表的put()和get()将很慢,因为它们需要遍历整个列表。在最坏的情况下(如果大多数数据在同一个桶中),则可能会导致O(n)时间复杂度。
这是一个视觉示例。第一张照片显示了一个倾斜的HashMap,第二张照片是平衡的。
在这种倾斜的HashMap的情况下,桶0上的get()/ put()操作是昂贵的。获得条目K将花费6次迭代
在这种平衡良好的HashMap的情况下,获得Entry K将花费3次迭代。两个HashMaps都存储相同数量的数据,并且具有相同的内部数组大小。唯一的区别是分配桶中条目的哈希(key)函数。
这是JAVA中的一个极端例子,其中我创建一个哈希函数,将所有数据放在同一个数据桶中,然后添加200万个元素。
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它需要超过45分钟与java 8u40(我停止了45分钟后的过程)。
现在,如果我运行相同的代码,但这次我使用以下哈希函数
@Override
public
int
hashCode() {
int
key =
2097152
-
1
;
return
key+
2097152
*i;
}
|
它需要46秒,这是更好的方式!这个哈希函数比前一个更好的重新分配,所以put()调用更快。
并且如果我运行相同的代码与下面的哈希函数,提供了一个更好的哈希重新分区
@Override
public
int
hashCode() {
return
i;
}
|
现在需要2秒钟。
我希望你意识到哈希函数的重要性。如果在JAVA 7中运行相同的测试,则第一和第二种情况的结果会更糟(由于JAVA 7中的put的时间复杂度为JA(7)中的O(n),而在JAVA 8中为0(log(n)))
当使用HashMap时,您需要为您的密钥找到一个散列函数,将键扩展到最可能的存储区。为此,您需要避免哈希冲突。String对象是一个很好的键,因为它具有很好的散列函数。整数也很好,因为它们的哈希码是他们自己的价值。
调整开销大小
如果您需要存储大量数据,则应创建一个初始容量接近预期卷的HashMap。
如果不这样做,地图将使用默认大小16,因子加法为0.75。第11个put()将非常快,但第12个(16 * 0.75)将重新创建一个新的内部数组(与其相关联的列表/树),新的容量为32.第13到第23个将很快,但第24个(32 * 0.75)将重新创建一个昂贵的新表示,使内部数组的大小加倍。内部调整大小的操作将出现在第48,第96,第192,...的调用put()。在低容量下,内部阵列的完全娱乐是快速的,但是在高音量下可能需要几分钟到几分钟。通过初始设置您的预期大小,您可以避免这些 昂贵的操作。
但是有一个缺点:如果你设置一个非常高的数组大小像2 ^ 28,而你只在阵列中使用2 ^ 26个桶,你将浪费大量的内存(在这种情况下约为2 ^ 30个字节)。
结论
对于简单的用例,您不需要知道HashMaps如何工作,因为您不会看到O(1)和O(n)或O(log(n))操作之间的差异。但是,了解最常用的数据结构之一的底层技术总是更好。而且,对于java开发人员来说,这是一个典型的面试问题。
在高音量下,重要的是知道它的工作原理,并了解键的散列函数的重要性。
我希望这篇文章帮助您深入了解HashMap实现。