HashMap
简介:HashMap是一个散列表,是我们常用的集合类之一。HashMap是基于哈希表的Map接口实现的,以键值对的形式存储数据。我们可以根据自己的需求在HashMap中存储key值以及其对应的value值,当然也可以根据key值从HashMap中取出相应的value值。接下来我们将深入地解析一下HashMap:
HashMap的数据结构
存储结构如下图:
基本的HashMap由数组和链表组成。数组是一种连续的存储结构,它的特点是可以通过下标来实现快速查找,时间复杂度为O(1)。但是增加数据和删除数据较慢,需要移动整个数组的数据,时间复杂度为O(n)。而链表是由一个一个节点组成,它的特点是可以通过改变节点的next节点来实现快速的增加或删除数据,但是链表的查找速度较慢,需要遍历整个链表。
HashMap则结合了两者的优点可以实现在快速定位查找的同时也能快速地修改数据。
HashMap的构造函数
通过查看源码,我们可以发现HashMap有4个构造函数:
//默认的构造函数
public HashMap(){....}
//指定初始容量大小的构造函数
public HashMap(int initialCapacity){....}
//指定初始容量和加载因子的构造函数
public HashMap(int initialCapacity, float loadFactor){......}
//子Map的构造函数
public HashMap(Map<? extends K, ? extends V> m){.....}
上面提到的初始容量指的是哈希表在初始化时的大小,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了阈值(加载因子与容量的乘积)时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而增大哈希表的容量。通常,默认的初始容量为16,加载因子为0.75。
观察其中的一个构造器:
public HashMap(int initialCapacity, float loadFactor) {
//此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(230)
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
threshold = initialCapacity;
init();//init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现
}
可以发现其实主干数组分配空间,这一步其实是在put函数中完成的,后续会给予介绍。
HashMap工作原理
HashMap根据key值来找到相应的数组存储的位置,过程如下:
hashCode()函数:获取hashcode值
该函数的作用是通过将我们输入的key值转换为一个hashCode,对于不同的数据类型可有不同的hashCode(),不同的Key值尽可能地对应不同的hashCode值。
hashcode()函数是可以根据不同情况进行重写的,如可以通过hashcode函数获得一段整数值等等。
源码如下:
public int hashCode() {
int h = hash;
int len = count;
if (h == 0 && len > 0) {
int off = offset;
char val[] = value;
for (int i = 0; i < len; i++) {
h = 31*h + val[off++];
}
hash = h;
}
return h;
}
hash()函数:获取存储到Entry数组的下标
通过之前获取的hashcode值将其转换为在数组长度范围内的一个下标值,hash函数也有很多种,给出源码中的hash函数:
static int hash(int h) {
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
其中:^为异或运算,>>>为无符号位移。
在实际使用hashmap时需要调用是put函数和get函数,先给出源码:
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key为null,存储位置为table[0]或table[0]的冲突链上
if (key == null)
return putForNullKey(value);
//对key的hashcode进一步计算,确保散列均匀
int hash = hash(key);
int i = indexFor(hash, table.length);//获取在table中的实际位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
addEntry(hash, key, value, i);//新增一个entry
return null;
}
public V get(Object key) {
//如果key为null,则直接去table[0]处去检索即可。
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
先判断数组是否为空数组,如果为空数组便根据参数threshold对数组进行填充,也就是为table分配实际内存空间,此时threshold为initialCapacity 默认是16。
关于哈希冲突
对于数组而言,每一个位置都可能引出一个链表来存放键值对,这是为了防止哈希冲突而准备的。如果数组上的某个位置原本为空,那么存放时直接新建一个节点,将HashMap的键值对存储在节点中,节点内有key,value,以及next指向下一个节点的引用等数据 ,如果在下标处已经存在一个节点,当我们再次通过索引准备把一个新的节点存入时,就会发生哈希碰撞。此时解决的办法有:
1)如果新的key值和之前存储在此处的节点的key值相同,则更新value即可。
2)如果新的key值和之前存储在此处的节点的key值不同,只是因为hashCode相同而导致了下标位置相同,那么我们会通过新的键值对生成新的节点,把它放在原先节点的前面。之后在查询的时候,我们需要遍历这两个节点才能找到对应的key值,获取value。
3)扩容:当添加的数据较多,满足之前提到的数组扩容条件(当节点总数达到设定的节点数阈值后初次发生发生哈希碰撞,数组就需要扩容),就会创建一个长度为原来两倍的数组,把原节点数据迁移到新的数组,再插入新节点,即可解决哈希碰撞问题,但是扩容消耗较大。
相关知识总结:
1)hashmap的长度一定为2的次幂
因为当我们进行数组的扩容时,数组的长度发生了变化,存储位置 index = h&(length-1)也可能会发生变化,需要重新计算index。此时我们将会遍历旧的数组将其中的旧的键值对复制到新的数组中,如果数组的长度为2的次幂,这样在进行位运算时能够大大减少了之前已经散列良好的老数组的数据位置重新调换,因为(长度-1)转化为二进制可以得到的二进制各位都是1。
2)Hashmap不是线程安全的
hashtable是线程安全的,而hashmap不是线程安全的,因为代码中没有相关的线程锁关键字,因此在使用多线程时可能会发生相应的错误。既然不是线程安全的,那么在使用过程中就要人为地注意这个问题,避免多个线程同时访问hashmap。
3)在hashcode函数中为什么要用31这个数
如果数值太小会使得最后得到的范围很小,容易发生哈希碰撞;如果数值超过100会使得到的数值超出int的范围;31+1得到的32是2的指数次,可以方便JVM进行移位运算。