HashMap实现原理—该死的HashMap
- 前言
进入正题前我们看看下文涉及到的一些概念
-
数组
数组在计算机中采用一段存储连续的,长度固定的存储单元。对于数组操作一般可以通过下标和元素的值进行查找,用给定下标查找,时间复杂度为O(1);通过给定的值进行查找,需要遍历数组,直到给定的值和数组中元素相同时才返回查找结果,时间复杂度为O(n);如果数组中存储的元素在逻辑上有序(有序数组),则可以用二分法,插值查找…等查找算法可以做到时间复杂度为O(logn);对数组的插入和删除操作,由于数组需要遍历指定位置并做相应的移动(插入元素:元素后面的都往后移动,删除元素:都往前移动元素填充删除后空出来的位置),因此平均复杂度为O(n)。
-
线性链表
链表用一组地址任意的存储单元存放线性表中的数据元素,逻辑上相邻的元素在物理上不要求也相邻,不能随机存取。一般用结点描述:结点(表示数据元素) =数据域(数据元素的映象) + 指针域(指示后继元素存储位置)。
对于链表的插入和删除元素操作一般在找到指定的操作位置以后,仅仅需要处理结点引用就可以。比如一个链表存储的为 A,B,D三个元素,在B和D之间插入一个元素C,可以 C.next=B.next; B.next=C;删除元素B时,A.next=B.next;时间复杂度为O(1)。而查找操作需要逐个遍历链表中的结点,因此时间复杂度为O(n)。 -
二叉树
对一颗平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度为O(longn);(本文与二叉树关系并没有多大关系,机上二叉树的增删查较为复杂并没有详细介绍)
-
哈希表
哈希表是本文的重点,相比于前面所说的集中数据结构,哈希报中进行添加,删除,查找操作性能很高,可以说哈希表很好的把以上集中数据结构的特点汇聚一身了。如果不考虑哈希冲突,那哈希表一次定位就能完成,就像数组用下标查找一样。时间复杂度为O(1)。
哈希表利用了数组下标一次定位这种特性,哈希表的干爹就是数组。比如我们要插入或查找某个元素,我们通过当前元素的关键字通过哈希函数映射到数组中某个位置(存储地址嘛),就像是y=f(x)这种抽象的,其中x就是那个要查找或者插入的某个元素,y就是用这个哈希函数得到(找到)的存储位置。
-
哈希冲突
所谓的哈希冲突就是,通过上面讲到的哈希函数得到存储地址与已经有元素的存储地址一样了,而且要插入的key与该位置上已经存在的key不同,也就是说我们想要新增一个元素,对其进行哈希运算想找到要放入的位置,结果这个值(地址)上已经有元素了,这个位置被其他元素占了。因此哈希函数的设计非常重要,我们很清楚,数组是一块连续的,长度固定的内存空间,因此再好的哈希函数也不能完全的保证不发生哈希冲突,那么如何做到尽可能解决哈希冲突呢?解决方法有:开放地址法(如发生冲突,继续寻找下一个未被占用的存储地址),再三列函数法,链地址法,而HashMap采用了链地址法,也就是数组+链表的方式。
-
该死的HashMap
HashMap继承了Abstact类,实现了Map接口,有个静态内部类Entry,是一个数组,Entry是HashMap的基本组成单元,每个Entry包含一个key-value键值对。
我们以最常用的put方法入手,请看源码
public V put(K key, V value) {
if (key == null)
return putForNullKey(value); //这是专门处理key为null的函数,null一般放在table的第一个位置
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length); //通过hash和table长度去定位要插入的地址
for (Entry<K,V> e = table[i]; e != null; e = e.next) { //拿到该位置上的(i)的entry
Object k;
//判断当前确定的索引位置是否存在相同hashcode和相同key的元素,如果存在相同的hashcode和相同的key的元素,那么新值覆盖原来的旧值,并返回旧值。
//如果存在相同的hashcode,那么他们确定的索引位置就相同,这时判断他们的key是否相同,如果不相同,这时就是产生了hash冲突。
//Hash冲突后,那么HashMap的单个bucket里存储的不是一个 Entry,而是一个 Entry 链。
//系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),
//那系统必须循环到最后才能找到该元素。
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i); //走这里因为(满足要插入的元素与原有元素hashconde相等却 ((k = e.key) == key || key.equals(k))不满足,注意这里是 || 条件,不同的key有相同的的hashcode 导致索引位置一样,可是这两个key的确不一样,怎么办?只能搞一个单链表(entry链保存,并且插在最头部))
return null;
}
HashMap整体结构(图片来自网络)
这是添加Entry,也就是发生hash冲突时的操作
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex]; //把这个位置上的元素给指向下一个元素e对象
table[bucketIndex] = new Entry<K,V>(hash, key, value, e); //用新加的entry填充之前的buchentIndex处,注意参数e,Enrty这个方法的这个参数就是指向下一个元素,即之前已经存在的bucketIndex位置的元素,这也就是说明了 每次插一个元素往往都会添加这个位置,让新加的元素指向到原来的元素
if (size++ >= threshold) //同时做了对容量的判断,size就是目前容器中事实存在的数组大小
resize(2 * table.length); //扩容到原来的两倍
上面方法的代码很简单,但其中包含了一个设计:系统总是将新添加的 Entry 对象放入 table 数组的 bucketIndex 索引处——如果 bucketIndex 索引处已经有了一个 Entry 对象,那新添加的 Entry 对象指向原有的 Entry 对象(产生一个 Entry 链),如果 bucketIndex 索引处没有 Entry 对象,也就是上面程序代码的 e 变量是 null,也就是新放入的 Entry 对象指向 null,也就是没有产生 Entry 链。
HashMap里面没有出现hash冲突时,没有形成单链表时,hashmap查找元素很快,get()方法能够直接定位到元素,但是出现单链表后,单个bucket 里存储的不是一个 Entry,而是一个 Entry 链,系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),那系统必须循环到最后才能找到该元素。
当创建 HashMap 时,有一个默认的负载因子(load factor),其默认值为 0.75,这是时间和空间成本上一种折衷:增大负载因子可以减少 Hash 表(就是那个 Entry 数组)所占用的内存空间,但会增加查询数据的时间开销,而查询是最频繁的的操作(HashMap 的 get() 与 put() 方法都要用到查询);减小负载因子会提高数据查询的性能,但会增加 Hash 表所占用的内存空间。
/** Entry是单向链表。
* 它是 “HashMap链式存储法”对应的链表。
*它实现了Map.Entry 接口,即实现getKey(), getValue(), setValue(V value), equals(Object o), hashCode()这些函数
**/
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
// 指向下一个节点
Entry<K,V> next;
final int hash;
// 构造函数。
// 输入参数包括"哈希值(h)", "键(k)", "值(v)", "下一节点(n)"
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
// 判断两个Entry是否相等
// 若两个Entry的“key”和“value”都相等,则返回true。
// 否则,返回false
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
// 实现hashCode()
public final int hashCode() {
return (key==null ? 0 : key.hashCode()) ^
(value==null ? 0 : value.hashCode());
}
public final String toString() {
return getKey() + "=" + getValue();
}
// 当向HashMap中添加元素时,绘调用recordAccess()。
// 这里不做任何处理
void recordAccess(HashMap<K,V> m) {
}
// 当从HashMap中删除元素时,绘调用recordRemoval()。
// 这里不做任何处理
void recordRemoval(HashMap<K,V> m) {
}
}
HashMap其实就是一个Entry数组,Entry对象中包含了键和值,其中next也是一个Entry对象,它就是用来处理hash冲突的,形成一个链表。
看看HashMap中关键的属相,有助于更好的看懂源码
transient Entry[] table;//存储元素的实体数组
2
3 transient int size;//存放元素的个数
4
5 int threshold; //临界值 当实际大小超过临界值时,会进行扩容threshold = 加载因子*容量
6
7 final float loadFactor; //加载因子
8
9 transient int modCount;//被修改的次数
其中loadFactor加载因子是表示Hsah表中元素的填满的程度.
若:加载因子越大,填满的元素越多,好处是,空间利用率高了,但:冲突的机会加大了.链表长度会越来越长,查找效率降低。
反之,加载因子越小,填满的元素越少,好处是:冲突的机会减小了,但:空间浪费多了.表中的数据将过于稀疏(很多空间还没用,就开始扩容了)
冲突的机会越大,则查找的成本越高.
因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷. 这种平衡与折衷本质上是数据结构中有名的"时-空"矛盾的平衡与折衷.
如果机器内存足够,并且想要提高查询速度的话可以将加载因子设置小一点;相反如果机器内存紧张,并且对查询速度没有什么要求的话可以将加载因子设置大一点。不过一般我们都不用去设置它,让它取默认值0.75就好了。
- 构造方法
public HashMap(int initialCapacity, float loadFactor) {
2 //确保数字合法
3 if (initialCapacity < 0)
4 throw new IllegalArgumentException("Illegal initial capacity: " +
5 initialCapacity);
6 if (initialCapacity > MAXIMUM_CAPACITY)
7 initialCapacity = MAXIMUM_CAPACITY;
8 if (loadFactor <= 0 || Float.isNaN(loadFactor))
9 throw new IllegalArgumentException("Illegal load factor: " +
10 loadFactor);
11
12 // Find a power of 2 >= initialCapacity
13 int capacity = 1; //初始容量
14 while (capacity < initialCapacity) //确保容量为2的n次幂,使capacity为大于initialCapacity的最小的2的n次幂
15 capacity <<= 1;
16
17 this.loadFactor = loadFactor;
18 threshold = (int)(capacity * loadFactor);
19 table = new Entry[capacity];
20 init();
21 }
22
23 public HashMap(int initialCapacity) {
24 this(initialCapacity, DEFAULT_LOAD_FACTOR);
25 }
26
27 public HashMap() {
28 this.loadFactor = DEFAULT_LOAD_FACTOR;
29 threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
30 table = new Entry[DEFAULT_INITIAL_CAPACITY];
31 init();
32 }
我们可以看到在构造HashMap的时候如果我们指定了加载因子和初始容量的话就调用第一个构造方法,否则的话就是用默认的。默认初始容量为16,默认加载因子为0.75。我们可以看到上面代码中13-15行,这段代码的作用是确保容量为2的n次幂,使capacity为大于initialCapacity的最小的2的n次幂,至于为什么要把容量设置为2的n次幂,下文会分析。
- 存储数据
public V put(K key, V value) {
// 若“key为null”,则将该键值对添加到table[0]中。
if (key == null)
return putForNullKey(value);
// 若“key不为null”,则计算该key的哈希值,然后将其添加到该哈希值对应的链表中。
int hash = hash(key.hashCode());
//搜索指定hash值在对应table中的索引
int i = indexFor(hash, table.length);
// 循环遍历Entry数组,若“该key”对应的键值对已经存在,则用新的value取代旧的value。然后退出!
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { //如果key相同则覆盖并返回旧值
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//修改次数+1
modCount++;
//将key-value添加到table[i]处
addEntry(hash, key, value, i);
return null;
}
我们慢慢的来分析这个函数,第2和3行的作用就是处理key值为null的情况,我们看看putForNullKey(value)方法:
private V putForNullKey(V value) {
2 for (Entry<K,V> e = table[0]; e != null; e = e.next) {
3 if (e.key == null) { //如果有key为null的对象存在,则覆盖掉
4 V oldValue = e.value;
5 e.value = value;
6 e.recordAccess(this);
7 return oldValue;
8 }
9 }
10 modCount++;
11 addEntry(0, null, value, 0); //如果键为null的话,则hash值为0
12 return null;
13 }
注意:如果key为null的话,hash值为0,对象存储在数组中索引为0的位置。即table[0]
我们再回去看看put方法中第4行,它是通过key的hashCode值计算hash码,下面是计算hash码的函数:
/**
* Computes key.hashCode() and spreads (XORs) higher bits of hash
* to lower. Because the table uses power-of-two masking, sets of
* hashes that vary only in bits above the current mask will
* always collide. (Among known examples are sets of Float keys
* holding consecutive whole numbers in small tables.) So we
* apply a transform that spreads the impact of higher bits
* downward. There is a tradeoff between speed, utility, and
* quality of bit-spreading. Because many common sets of hashes
* are already reasonably distributed (so don't benefit from
* spreading), and because we use trees to handle large sets of
* collisions in bins, we just XOR some shifted bits in the
* cheapest possible way to reduce systematic lossage, as well as
* to incorporate impact of the highest bits that would otherwise
* never be used in index calculations because of table bounds.
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这个官方标准API是说 计算key.hashcode()并将哈希的高位扩展到低位。因为该哈希表示使用2的整次幂作为容量的设置,这样的值的二进制掩码对高位并不起什么作用…
这么办吧我们先举一个例子来说明
int hashCode1 = 01110101
int hasdCode2 = 01010101
int index1 = 01110101 & 1111 -> 0101
int index2 - 01010101 & 1111 -> 0101
//十进制翻译
int hashCode1 = 117
int hashCode2 = 85
int index1 = 117 % 16 -> 5
int index2 = 85 % 16 -> 5
假设这里(就按初始容量为例了,不是16嘛),1111就是2的4次方为容量(16-1)=15的二进制掩码,单纯拿key.hashcode这个hashcode直接进行与运算发现,哈希冲突概率增大了,对高位无效了,你可以看到不同的hashcode值竟然都存储在5这个位置。
然后用这个hash(Object key)这个方法进一步计算hash值看看会怎么样?!
得到hash码之后就会通过hash码去计算出应该存储在数组中的索引,计算索引的函数如下:
这是以上代码的图示模拟,那为什么这么做呢?!
答案就是因为与后面的IndexFor(int h,int length)跟这个函数有关,因为,table的长度都是2的整次幂,因此index仅与hash值的低n位有关,hash值的高位都被与操作置为0了。(一般2^n-1 一般都是1111…这种数字而且都处在低位)
1 static int indexFor(int h, int length) { //根据hash值和数组长度算出索引值
2 return h & (length-1); //这里不能随便算取,用hash&(length-1)是有原因的,这样可以确保算出来的索引是在数组大小范围内,不会超出
3 }
假设table.length=2^4=16。
由上图可知由于2的整次幂,只有hash值的第四位参与了运算。(我们做的饿就是尽量改变这个低位,人家做的就是通过哪些异或算法,把高位的变化影响到了低位的变化,从而尽可能地减少哈希冲突)
这样做很容易产生碰撞。设计者权衡了speed, utility, and quality,将高16位与低16位异或来减少这种影响。设计者考虑到现在的hashCode分布的已经很不错了,而且当发生较大碰撞时也用树形存储降低了冲突。仅仅异或一下,既减少了系统的开销,也不会造成的因为高位没有参与下标的计算(table长度比较小时),从而引起的碰撞。
对于容量设置为2^n的补充就是扩容时也要好处
hashMap的数组长度一定保持2的次幂,比如16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h&(length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换)
还有,数组长度保持2的次幂,length-1的低位都为1,会使得获得的数组索引index更加均匀,比如:
- 调整大小
重新调整HashMap的大小,newCapacity是调整后的单位
1 void resize(int newCapacity) {
2 Entry[] oldTable = table;
3 int oldCapacity = oldTable.length;
4 if (oldCapacity == MAXIMUM_CAPACITY) {
5 threshold = Integer.MAX_VALUE;
6 return;
7 }
8
9 Entry[] newTable = new Entry[newCapacity];
10 transfer(newTable);//用来将原先table的元素全部移到newTable里面
11 table = newTable; //再将newTable赋值给table
12 threshold = (int)(newCapacity * loadFactor);//重新计算临界值
13 }
新建了一个HashMap的底层数组,上面代码中第10行为调用transfer方法,将HashMap的全部元素添加到新的HashMap中,并重新计算元素在新的数组中的索引位置
当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。
那么HashMap什么时候进行扩容呢?当HashMap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过160.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,扩容是需要进行数组复制的,复制数组是非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。
- 数据读取
1.public V get(Object key) {
2. if (key == null)
3. return getForNullKey();
4. int hash = hash(key.hashCode());
5. for (Entry<K,V> e = table[indexFor(hash, table.length)];
6. e != null;
7. e = e.next) {
8. Object k;
9. if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
10. return e.value;
11. }
12. return null;
13.}
有了上面存储时的hash算法作为基础,理解起来这段代码就很容易了。从上面的源代码中可以看出:从HashMap中get元素时,首先计算key的hashCode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。