HashMap作为一个经典的平时使用非常多的集合,这里简单介绍一下它的原理机制等等。
HashMap是什么?
首先它是基于哈希表的 Map 接口的实现,以key-value的形式存在。在HashMap中,key-value总是会当做一个整体来处理,系统会根据hash算法来来计算key-value的存储位置,我们总是可以通过key快速地存、取value。
数组、链表、红黑树
数据结构中有数组和链表来实现对数据的存储,但这两者有比较大区别。
首先数组的存储区间是连续的,占用内存比较大,空间复杂比较大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难,适合频繁查询的场景;
链表的存储区间离散而不要求连续,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易,适合增删较多的场景。
红黑树这里简单说一下,它是一种自平衡二叉查找树,查找性能较高,这里用来弥补链表查找效率缓慢的不足。
实现
hashMap通过数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的。简单的说,当put一对值时,先通过散列算法计算出key的hashcode。然后放在内部数组的某个位置,若当前位置已经存了数据,则插入链表尾部,JDK1.8之后若链表长度大于8则转换为红黑树。
初始容量是多大?
我们看一下hashMap的几个构造方法
// 容量和负债因子都用默认值
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(int initialCapacity, float loadFactor) {
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;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
这里需要介绍下什么是容量,什么是负载因子,简单的说:
容量: 就是哈希桶的容量,哈希桶就是一个存放Node的数组。Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。哈希桶的默认值为16
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
负载因子: HashMap能装多满, 就取决于这个值,默认值 0.75 比较均衡, 高了容量利用重复, 但查询效率下降, 低了效率高, 空间利用不足。 一般不会小于0.5或者大于1。
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
什么时候需要扩容?
当存储数据个数大于 容量 * 负载因子, 就该扩容了。
怎样扩容
具体的代码就不贴了,又是一长篇大论,总之会扩大为原来的2倍,这时候要重新计算 hash 值, 重新分布已有的数据, 比较费时,所以hashMap在使用之前如果预计了要存放的内容较多可以创建的时候就创建一个稍微大一点的。
另外值得一提的是在HashMap中,哈希桶数组的长度大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数[2].
HashMap之所以采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
而且Java8采用了红黑树来提高性能,有兴趣的同学可以去研究一下源码。
什么是哈希表?
哈希表也叫散列表,它综合了两者的特性,做出一种相对来说寻址容易,插入删除也容易的数据结构。哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。数组的每个成员包括一个指针,指向一个链表的头,当然这个链表可能为空,也可能元素很多。我们根据元素的一些特征把元素分配到不同的链表中去,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,也是根据这些特征,找到正确的链表,再从链表中找出这个元素。
如何做这个散列的呢?
这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
我们先看一段源码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
首先可以看到key值是可以取null的,此时hash值为0,并会放入数组头部。
从上面的代码可以看到key的hash值的计算方法。key的hash值高16位不变,低16位与高16位异或作为key的最终hash值。(h >>> 16,表示无符号右移16位,高位补0,任何数跟0异或都是其本身,因此key的hash值高16位不变。
而上面代码里key的hashCode用的是自带的方法,会返回一个int型的散列值,考虑到2进制32位带符号的int表值范围从-2147483648到2147483648。前后加起来大概40亿的映射空间,是几乎不可能出现碰撞的,但是HashMap不可能存放这么多数组,我们需要进行一个对数组长度取模的操作来决定访问数组的下标。
//jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
static int indexFor(int h, int length) {
return h & (length-1);
}
这里就是对散列值与数组 长度-1 做一个‘与’操作,这里hashMap要求容量必须是2的幂,这是为什么呢?
首先看下一段代码:
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
这个方法的功能(不考虑大于最大容量的情况)是返回大于输入参数且最近的2的整数次幂的数。比如5,则返回8。
首先,为什么要对cap做减1操作。int n = cap - 1;
这是为了防止,cap已经是2的幂。如果cap已经是2的幂, 又没有执行这个减1操作,则执行完后面的几条无符号右移操作之后,返回的capacity将是这个cap的2倍。
下面看看这几个无符号右移操作,这里只讨论n不等于0的情况。
第一次右移,首先 >>> 表示无符号右移,|表示位的或操作,n |= n >>> 1 表示什么呢,我们假设有个数,至少有一个位是1,假设是 XXXXX 1XXXX,右移一位变成XXXXX X1XXX,然后再进行或操作,肯定会变成 XXXXX 11XXX,总之目的就是在最高位的1后面添一个1,总而言之,目的就是在这个数第一个出现的1之后全部变成1。
那这又是为什么呢?看下代码最后:
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
结果如果没大于最大的容量值,就会n+1,变成如XXXXX 10000这样一个2的幂。感叹一下,真是巧妙。
看了上面的代码,你应该知道了长度只有是2的幂的时候,长度减一这个数的二进制表示的后面几位都是1,简单来说,当length = 2^n时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。
hashmap的put方法
下面我们详细看下hashmap的put方法
首先看下这张流程图:final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
// tab为空则创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 计算index,并对null做处理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
// 节点key存在,直接覆盖value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 判断该链是否为红黑树
else if (p instanceof TreeNode)
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//链表长度大于8转换为红黑树进行处理 TREEIFY_THRESHOLD = 8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// key已经存在直接覆盖value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 步骤5:超过最大容量 -> 扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
线程不安全
HashMap 是线程不安全的, 在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。 如果发生在 HashMap 扩容的时候, hashcode 重新计算, 可能造成死循环等问题。当然你可以使用 HashTable, 它也是安全的, 不过它比较粗暴, 它直接使用 synchronized 加锁。
JDK1.8与JDK1.7的性能对比
HashMap中,如果key经过hash算法得出的数组索引位置全部不相同,即Hash算法非常好,那样的话,getKey方法的时间复杂度就是O(1),如果Hash算法技术的结果碰撞非常多,假如Hash算极其差,所有的Hash算法结果得出的索引位置一样,那样所有的键值对都集中到一个桶中,或者在一个链表中,或者在一个红黑树中,时间复杂度分别为O(n)和O(lgn)。 鉴于JDK1.8做了多方面的优化,总体性能优于JDK1.7,下面我们从两个方面用例子证明这一点。
参考: