HashMap基本用法
通过HashMap与Hashtable比较:
- HashMap能接受为null的键和值,Hashtable键和值都不能为null(通过put方法跟踪源码就一目了然);
- HashMap是非synchronized的,所以快,Hashtable是synchronized,相对慢(源码);
- HashMap 数组+链表 的存储结构,存储键值对;而一般的集合List、Set则是存储单个对象。
HashMap的工作原理
HashMap是基于hashing的原理,我们在使用put(key,value)存储对象到HashMap中,使用get(key)从HashMap中获取对象;当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket的位置来存储Entry对象。我们来看看这句话涉及的源码,首先从put(key, value)方法开始。
put()方法源码实现
HashMap存储结构在外层是数组,在源码中有:
- 1
- 2
- 3
在下面的put方法中,第2~4行,若是第一次操作HashMap,这里table必然是空的,需要去初始化。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
在第5行,key为null时,并没有抛出异常,说明HashMap中允许键为null值的。
在第7行,调用了hash方法,键作为参数传入。
这里看看hash()方法具体实现:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
对于hashCode(),它是一个本地方法,实质就是地址取样运算
- 1
该方法第7行,调用了key的hashCode()方法,并通过一系列位运算,获取最后的hash值。
反观HashTable中put()方法中调用的hash()方法实现:
- 1
- 2
- 3
- 4
HashMap在HashTable的基础上做了优化,我们继续HashMap的put()方法的源码研究。
在第8行:
- 1
通过返回的hash值找到(table中)bucket的位置来存储Entry对象。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
在第9~17行,在判断value值是不是在HashMap中已经存在,存在的话就返回旧值。
在第20行,
- 1
看方法名就知道,这里就是真正将键值对添加到HashMap中的方法。传入四个参数:hash值,key-value,以及该Entry对象存储的位置。看看具体实现:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
如果HashMap的大小(size)超过了阀值(threshold)并且该Entry对象存储的位置被占用了,这时候就需要“扩容”了。也就是所谓的rehash。
- 1
将HashMap的大小扩充为原来大小的两倍,并且重新计算该Entry对象存储的位置。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
通过resize()方法,我们可以看到在第9,10行,重新创建了容量是原来两倍大小的新Entry数组,并且在方法transfer()中会把原来Entry对象中的数据迁移到新的Entry中。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
这一过程还是相当耗时的。
在addEntry方法中的第8行:
- 1
创建具体的Entry对象:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
这里很清晰了,根据之前计算的Entry对象的位置和键值对,创建了Entry对象。这里需要注意这行代码:
- 1
计算出来的该key在bucket中的下标bucketIndex,返回该下标在数组中存储的对象,然后通过Entry构造器,在新Entry对象中,最为next存储。这里就利用到了链表结构。后面会详细讲到,这就是整个put()方法的调用过程。
get()方法源码实现
关于通过键获取值的get(key)方法,我们需要了解其中的碰撞探测以及碰撞的解决办法。首先看看get()方法的实现:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
key为null值这里就不看了,逻辑很简单。
在第4行,这里通过key获取到了Entry对象。我们看看getEntry()的具体实现:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
在第6行,这里调用的key的hash方法,计算key的hashCode值。
在第7行,通过返回的hashCode值获取该key在bucket中的index(下标,索引),并返回该key对应的Entry对象在数组中的存在。
在9行if语句中,首先判断计算的hash值和该Entry对象创建时存储的key的hash值是否相等,一般人会认为两个key比较时,只要hash值相等,这个key就相等,其实这是不对的,这里就涉及到了碰撞探测。换句话说,这里用到了HashMap的存储结构–数组+链表。
首先是数组,我们上面的代码中计算hashCode值在bucket中的位置,这个bucket就是数组(table);
- 1
若有两个key对象的hash值相同的话,也就是说两个值对象存储在同一个bucket中,这时候怎么获取值对象(value)?继续看第9行后面的部分:
- 1
首先用的运算符&&,也就是说光传入key的hash和存储在Entry中的hash值相等还不行,后面部分运算结果也得是true。后面“||”左面部分为true的条件是,该传入的key和获取的Entry对象中的key是同一个,这样肯定能精确获得想要的值对象(hash值相同,key也相同);“||”右面部分调用的key的equals()方法,返回结果为true,当然是同一个key,也就是说左右部分的运算都是为了找到同一个key。那么具体在同一个bucket中怎么同时存两个或多个hash值相同的不同key对象的呢?明白了怎么存的也就清楚了怎么取了。我们先回到:
- 1
- 2
- 3
- 4
- 5
- 6
若前面已经通过put(key, value)形式存储的一个Entry对象,这里又来了一个key1,通过计算key和key1拥有相同的hashCode值,也即在bucket中的位置是一致的,即bucketIndex相同。
在第3行中,首先就是通过下标取出数组中的Entry对象;
在第4行中,新建了一个Entry对象,将新的key,value,计算的hash值以及上一个相同hash值的Entry对象一起保存在该新Entry对象中,然后在相同的bucket位置返回新的Entry对象,旧的Entry对象被挂起了;后来再来一个不同的key2,若计算得出的hash值也相同,刚新建的Entry对象也将变成后来这个新Entry对象的next被挂起。这样就是链表的形式存储了具有相同hash值的不同的key对象。
所以说,HashMap就是通过数组+链表的形式实现的。这里说到了怎么存具有相同hash值的不同key对象,取呢?
- 1
- 2
- 3
- 4
首先这里用到了for循环遍历,其实也说明了“数不止一个” 。在循环时用到了 e = e.next ,这里正是遍历挂起的Entry对象。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
以上就是HashMap中最重要的存取方法:put(key,value),get(key)源码解析过程。了解了这些,下面常见的问题也很容易回答。
常见问题
-
当两个不同的键对象的hashCode相同时会发生什么?
它们会存储在同一个bucket位置的HashMap.Entry组成的链表中。 -
若两个键的hashCode值相同,你如何正确取出值对象的?
当我们调用get(key)方法时,会先计算key的hashCode值,通过该值找到key在bucket(数组)中的位置,找到bucket位置后,循环遍历(next),并调用keys.equals()方法找到链表中正确的节点。 -
什么是hash,什么是碰撞?
hash:是一种信息摘要算法,它还叫做哈希,或者散列。我们平时使用的MD5就属于Hash算法,通过输入key进行Hash计算,就可以获取key的HashCode(),比如我们通过校验MD5来验证文件的完整性。
碰撞:好的Hash算法可以出计算几乎出独一无二的HashCode,如果出现了重复的hashCode,就称作碰撞;
就算是MD5这样优秀的算法也会发生碰撞,即两个不同的key也有可能生成相同的MD5。 -
如何减少碰撞?
使用不可变的,声明做final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生(若has不同对象的hashCode值都不相同,自然就不需要链表来存储了),提高效率。不可变性使得能够缓存不同键的hashCode,这将提高整个获取对象的速度(不需要遍历,速度当然就快了),使用String,Integer这样的wrapper类作为键是非常好的选择。 -
为什么String, Interger这样的wrapper类适合作为键?
因为String是不可变的,是final的,已经重写了hashCode()和equals()方法,其他的Wrapper类也有类似的特点。不可变性是必要的,因为为了要计算hashCode值,就要防止键值改变,如果键值在put和get时,返回了不同的hash值,也就不能正确的从HashMap中获取想要的对象了;如果可以仅仅通过将某个对象声明成final就能保证hashCode是不变的,就可以这么处理。因为获取对象时,需要调用hashCode()和equals()方法,对键值对象正确重写这两个方法时非常重要的。如果两个不相等的键值对象返回不同的hash值,那么碰撞的几率会小很多,这样较少了不必要的对链表的操作,就能提高HashMap的性能。 -
可以使用自定义的对象作为键吗?
当然可以,只要其遵守equals()方法和hashCode()方法规则,当键值对象插入HashMap中不会再改变就可以。 -
如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
默认的负载因子是0.75,也就是说,当一个HashMap中填满了75%的bucket时,将会创建原来两倍大小的新bucket数组,并将原来的对象迁移到新创建的bucket中。- 1
-
重新调整HashMap大小存在什么问题吗?
由于HashMap是非线程安全的,在多线程环境下,会产生条件竞争。因为若两个线程都发现需要调整HashMap时,都会尝试去调整。我们看下扩容的源码:- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
假设有两个不同的key:A,B对应了同一个hash值,假设当前bucket中存储最上面的是A,A.next = B。那么从上面的源码可以看到,当前是 A = e,e.next就是B,
第11行,先将e.next(B)处“清空”,因为newTable[i]目前只是空位置;
第12行,将A放入新bucket位置;
第13行,将当前对象e对象设置成B,继续while循环。
在B的循环中,B.next 为null,
第11行,此处newTable[i]是前面的A,这里赋值给了当前对象e(B)的next对象;
第12行,将B对象存储在该bucket位置;
第13行,next为null赋值给当前e,while循环为false,结束循环。
也就是说新的bucket中存储的最上面的是B,B.next = A,整个链表反过来了。这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。所以在多线程环境下,不能使用HashMap。 -
能否让HashMap同步?
HashMap可以通过下面的手段实现同步:- 1
-
如何提升HashMap的性能?
解决扩容损失:如果知道大致需要的容量,把初始容量设置好以解决扩容损失;
比如我现在有1000个数据,需要 1000/0.75 = 1333,又 1024 < 1333 < 2048,所以最好使用2048作为初始容量。
2048 = roundUpToPowerOf2(1333)
本文深入剖析了HashMap的基本用法、工作原理及内部实现细节,包括put()和get()方法的源码解读,探讨了hash碰撞及其解决方案,同时介绍了HashMap的优化策略和常见问题。

4958

被折叠的 条评论
为什么被折叠?



