弄懂HashMap,这一篇就够了
如果你点开了这篇博客,请一定要读完,可能会花费你20分钟,因为它真的可以帮助你了解到hashmap的底层实现以及使用hashmap的注意事项,声明:这篇博文是摘抄至国外的一个大牛的博客,地址在博文底端。
大多数JAVA开发人员都在使用Maps,尤其是HashMaps。 HashMap是一种简单而强大的数据存储和获取方法。 但是,有多少开发人员知道HashMap在内部如何工作? 在本文中,我将解释java.util.HashMap的实现,介绍JAVA 8实现中的新增功能,并讨论使用HashMaps时的性能,内存和已知问题。
内部存储
Java中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是一个简单的键值对,其中包含两个额外的数据:
- 对另一个条目的引用,以便HashMap可以存储诸如单个链接列表之类的条目
- 表示键的哈希值的哈希值。存储此哈希值是为了避免每次HashMap需要哈希时都进行哈希计算。
这是JAVA 7中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实例的内部存储。每个条目可以链接到另一个条目以形成链接列表。
具有相同哈希值的所有键都放在相同的链表(存储桶)中。具有不同哈希值的键可以最终出现在同一存储桶中。
当我们调用put(K key,V value)或get(Object key)时,该函数将计算条目应在其中的存储区的索引。然后,该函数遍历列表以查找具有相同键的Entry(使用键的equals()函数)。
在get()的情况下,该函数返回与该条目关联的值(如果该条目存在)
对于put(K,V),如果该条目存在,则该函数将其替换为新值,否则它将在单链接列表的开头创建一个新条目(根据键和参数中的值。这也就是在HashMap中不能存储相同键值的原因,因为这回导致原有的数据被覆盖。
该存储桶的索引(链结列表)一般分3步生成:
- 它首先获取密钥的哈希码。
- 接着会重新哈希,以防止将所有数据放入内部数组的相同索引(存储桶)的键中,造成不均匀的散列值。
- 它采用经过重整的哈希哈希码,并使用数组的长度(负1)对其进行掩码。此操作可确保索引不能大于数组的大小。您可以将其视为在计算上经过优化的模函数。
下面是Java 7 和Java 8 处理索引的源码:
// Java7 哈希值的计算方法
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// Java8 哈希值的计算方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//从重新哈希返回索引的函数
static int indexFor(int h, int length) {
return h & (length-1);
}
为了有效地工作,内部数组的大小必须为2的幂,让我们看看为什么。
假设数组大小为17,则掩码值将为16(大小为size-1)。 16的二进制表示为0…010000,因此,对于任何哈希值H,按位公式“ H AND 16”生成的索引将为16或0,这意味着大小为17的数组将仅用于2个存储桶:索引为0的一个存储桶和索引16的一个存储桶,效率不高…
但是,如果您现在使用的是2的幂(例如16),则按位索引公式为``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
这就是为什么数组大小是2的幂的原因。这种机制对开发人员是透明的:如果他选择大小为37的HashMap,则Map将为其内部数组的大小自动选择37(64)之后的下一个2的幂。
自动扩容
获取索引后,函数(get, put or remove)访问/迭代关联的链表,以查看给定键是否存在现有的Entry。 如果不进行修改,则此机制可能会导致性能问题,因为该功能需要遍历整个列表以查看条目是否存在。想象一下,内部数组的大小是默认值(16),您需要存储2百万个值。在最佳情况下,每个链接列表的大小将为125000个条目(2/16百万),因此,每个get(),remove()和put()都会导致125000次迭代/操作。为了避免这种情况,HashMap可以自动增加其内部数组以保留非常短的链表。创建HashMap时,可以使用以下构造函数指定初始大小和loadFactor:
public HashMap(int initialCapacity, float loadFactor)
如果未指定参数,则默认数组的初始大小为16,默认loadFactor(加载因子)为0.75。 initialCapacity表示链表内部数组的大小。
每次使用put(…)在映射中添加新的键/值时,该函数都会检查是否需要增加内部数组的容量。为此,map存储了2个数据:
- map的size大小:它表示HashMap中的条目数。每次添加或删除条目时都会更新此值。
- 一个阈值:等于(内部数组的容量)* loadFactor,并在每次调整内部数组的大小后刷新
在添加新条目之前,put(...)方法之前,如果size> threshold,它将重新创建一个大小加倍的新数组。由于新数组的大小已更改,因此索引函数(返回按位运算“ hash(key)AND(sizeOfArray-1)”)更改。因此,调整数组大小会再创建两个存储桶(即链表),并将所有现有条目重新分配到存储桶中(旧的和新创建的)。
调整大小操作的目的是减小链接列表的大小,以使put(),remove()和get()方法的时间成本保持较低。调整大小后,所有具有相同哈希值的键的条目将保留在同一存储桶中。但是,转换后,位于同一存储桶中的两个具有不同哈希键的条目可能不在同一存储桶中。
该图显示了调整内部数组大小之前和之后的表示。 在增加之前,为了获得条目E,地图必须迭代5个元素的列表。 调整大小后,相同的get()会遍历2个元素的链接列表,调整大小后,get()的速度提高了2倍。
注意:HashMap仅增加内部数组的大小,而没有提供减小内部数组的方法。
线程安全
如果您已经了解HashMaps,那么您知道这不是线程安全的,但是为什么呢?例如,假设您有一个Writer线程仅将新数据放入Map中,而一个Reader线程则从Map中读取数据,为什么它不起作用?
因为在自动调整大小机制期间,如果线程尝试放置或获取对象,则地图可能会使用旧的索引值,而不会找到条目所在的新存储桶。
最坏的情况是两个线程同时放置一个数据,而两个put()调用则同时调整Map的大小。 由于两个线程同时修改链表,因此Map可能在其链表之一中以一个内循环结束。 如果您尝试通过内部循环获取列表中的数据,则get()将永远不会结束。
HashTable实现是线程安全的实现,可以防止这种情况发生。但是,由于所有CRUD方法都是同步的,因此此实现非常慢。例如,如果线程1调用get(key1),线程2调用get(key2),线程3调用get(key3),且这三个线程可以同时访问数据,就只能有一个线程获得该值,其他两个线程只能等待。
自Java 5以来,存在一个更安全的线程安全HashMap实现:ConcurrentHashMap。只有存储桶是同步的,因此多个线程可以同时get(),remove()或put()数据(如果这并不意味着访问同一存储桶或调整内部数组的大小)。这是因为ConcurrentHashMap只给一个方法的一个片段加锁,所以在并发的效率下也能维持一个高的性能。下面是ConcurrentHashMap的部分源码。
else {
V oldVal = null;
// 只对这个片段加锁
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
Key的不变性
为什么Strings和Integers是HashMap的键的良好实现?主要是因为它们是一成不变的!如果您选择创建自己的Key类并且不使其保持不变,则可能会丢失HashMap中的数据。
看下面的示例:
- 您有一个内部值为1的键,
- 并使用此键将一个对象放入HashMap中.
- HashMap根据该键的哈希码生成一个哈希值(因此从``1''开始)
- .Map将此哈希值存储在新创建的Entry中
- 您如果将键的内部值修改为“ 2”。
- 键的哈希值被修改,但HashMap不知道(因为存储了旧的哈希值)。您尝试使用修改后的键获取对象 密钥的新哈希值(因此从“ 2”开始)以查找条目在哪个链表(存储桶)中
这就会出现你存储的值丢失,具体原因如下:
- 情况1:由于您修改了密钥,因此Map会尝试在错误的存储桶中找到该条目,但找不到它
- 情况2:幸运的是,修改后的密钥生成与旧密钥相同的存储桶。 然后,映射将迭代链接列表,以查找具有相同键的条目。 但是要找到键,映射首先比较哈希值,然后调用equals()比较。 由于修改过键的哈希值与旧哈希值(存储在条目中)的哈希值不同,因此map不会在链接列表中找到该条目。
这是Java中的具体示例。我将2个键值对放入地图中,我修改了第一个键,然后尝试获取2个值。从地图仅返回第二个值,第一个值在HashMap中“丢失”:
public class MapTest {
public void test(){
Map<String,String> map = new HashMap<>();
map.put("1","hello");
map.put("2","chen");
for (Map.Entry<String,String> entry: map.entrySet()) {
System.out.println(entry.getKey());
}
}
public static void main(String[] args) {
// 检验map的key值被改了以后的情况
class Mykeys{
private int i;
public Mykeys(int i){
this.i = i;
}
public int getI() {
return i;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Mykeys)) return false;
Mykeys mykeys = (Mykeys) o;
return i == mykeys.i;
}
@Override
public int hashCode() {
return Objects.hash(i);
}
public void setI(int i) {
this.i = i;
}
}
new MapTest().test();
Map<Mykeys,String> map = new HashMap<>();
Mykeys key1 = new Mykeys(1);
Mykeys key2 = new Mykeys(2);
map.put(key1,"test"+1);
map.put(key2,"test2"+2);
// 改变key的值
key1.setI(3);
String val1 = map.get(key1);
String val2 = map.get(key2);
System.out.println("test1="+val1+" "+"test2="+val2);
}
}
输出:
1
2
test1=null test2=test2
如预期的那样,map无法使用已修改的键1检索字符串1,所以test1的值为null,造成了数据的丢失。
JAVA 8改进
HashMap的内部表示在JAVA 8中发生了很大变化。确实,JAVA 7中的实现需要1k行代码,而JAVA 8中的实现则需要2k行。在JAVA8中,您仍然有一个数组,但是它现在存储的Node包含与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))中添加,删除或获取元素。
这是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处)。值区0是一棵树,因为它有8个以上的节点。
内存开销
Java7
HashMap的使用在内存方面要付出一定的代价。在JAVA 7中,HashMap将键值对包装在Entries中。条目具有:
- 对下一个entry的引用
- 预先计算的哈希(整数)
- Key的参考
- 对值的引用
此外,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个字节
提醒:在自动调整Map大小之后,内部数组的CAPACITY等于N之后的下一个2的幂。注意:自JAVA 7起,HashMap类具有一个惰性的init。 这意味着即使您分配了HashMap,条目的内部数组(花费4 * CAPACITY字节)也不会在第一次使用put()方法之前在内存中分配。
Java8
使用JAVA 8实施方案时,获取内存使用情况变得有些复杂,因为Node可以包含与Entry相同的数据或相同的数据以及6个引用和一个布尔值(如果是TreeNode)。
如果所有节点均为Nodes,则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 *容量字节
性能问题
倾斜的HashMap与平衡良好的HashMap
在最佳情况下,get()和put()方法的时间复杂度为O(1)。 但是,如果您不注意密钥的哈希函数,则可能会以非常慢的put()和get()调用结束。 put()和get的良好性能取决于将数据重新分配到内部数组(存储桶)的不同索引中。 如果密钥的哈希函数设计不当,您将有一个倾斜的分区(无论内部数组的容量有多大)。 所有使用最大链接条目列表的put()和get()都会变慢,因为它们需要迭代整个列表。 在最坏的情况下(如果大多数数据都在同一个存储桶中),最终可能会导致O(n)时间复杂度。
这是一个视觉示例。第一张图片显示了倾斜的HashMap,第二张图片显示了均衡的图片。
在这种偏斜的HashMap的情况下,存储区0上的get()/ put()操作成本很高。获得条目K将花费6次迭代
在这种平衡良好的HashMap的情况下,获取条目K将花费3次迭代。 两个HashMap都存储相同数量的数据,并且具有相同的内部数组大小。 唯一的不同是(项的)哈希(hash)功能,该功能在存储桶中分配条目。
这是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()));
}
}
在我的电脑上,i7第八代,使用Java 8u40花费的时间超过45分钟(我在45分钟后停止了该过程)。
如果我使用以下哈希函数运行相同的代码,则可以提供更好的哈希重新分区
@Override
public int hashCode() {
return i;
}
现在需要2秒
以上示例可以看到哈希函数的重要性。 如果在JAVA 7上进行了相同的测试,则在第一种情况和第二种情况下结果会更糟(因为put的时间复杂度在JAVA 7中为O(n)而在Java 8中为O(log(n))。 使用HashMap,您需要为您的密钥找到一个哈希函数,以将密钥散布到尽可能多的存储桶中。 为此,您需要避免哈希冲突。 字符串对象是一个很好的键,因为它具有良好的哈希功能。 整数也很好,因为它们的哈希码是它们自己的值。
怎么减小内存消耗
如果需要存储大量数据,则应创建初始容量接近预期容量的HashMap。
如果您不这样做,map将采用默认大小16,factorLoad为0.75。第11个put()会非常快,但是第12个(16 * 0.75)将重新创建一个新的内部数组(及其关联的链表/树),其新容量为32。
从13号到23号很快,但是24号(32 * 0.75)将重新创建(再次)昂贵的新表示,将内部数组的大小加倍。内部调整大小操作将出现在put()的第48、96、192等处。在低容量下,内部阵列的完全恢复速度很快,但在高容量下,可能需要几秒钟到几分钟。通过最初设置您的预期大小,您可以避免这些昂贵的操作。
对于简单的用例,您不需要了解HashMap的工作原理,因为您不会看到O(1)和O(n)或O(log(n))操作之间的区别。 但是,最好了解最常用的数据结构之一的底层机制。 此外,对于Java开发人员来说,这是一个典型的面试问题。 在高容量下,了解其工作原理并了解密钥的哈希函数的重要性变得很重要。 我希望本文能帮助您对HashMap实现有一个深刻的了解。
参考至: http://coding-geek.com/how-does-a-hashmap-work-in-java/
追本溯源,方能阔步前行。