关于HashMap你们肯定不会陌生,它是常用的一种数据类型,在java开发中占据了重要的地位。我觉得这样一个比喻非常的好:HashMap就像一个陶瓷碗,我们经常用到它,却又经常不小心将它摔碎。
在一些书中我们经常看到研究问题的一些基本思路:你是谁?你从哪里来?你去干啥类?这篇浅析HashMap的文章同样也要解决这几个问题:HashMap是谁(源码解读);HashMap是干啥的?(特点,使用场景);
当然,我发现自己有在废话了,还是开始吧!!
(1)HashMap的数据结构
HashMap底层是一个数组结构,数组中的每一项又是一个链表。当新建一个 HashMap 的时候,就会初始化一个数组,所以HashMap是数组和链表的完美的结合体。如下图所示:
再来看一下源码:
transient Entry[] table;
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
final int hash;
……
}
HashMap内部定义了一个静态的内部类Entry,Entry有三个重要的属性 Key、Value、next,而其中next为指向下一个Entry的引用,这样就构成了一个链表。
(2)HashMap的put实现
如下说是为HashMap的Put方法的源码,先简单梳理一下put方法的过程:
- 向HashMap中put元素的时候,先根据key得到其hashCode,在对得到的hashcode进行二次hash;
- 根据hash值找到这个元素在数组中的位置(即下标)
- 如果对应数组位置没有元素。直接将该元素放在数组的该位置上
- 如果对应数组位置已经存放了其他的元素,那么开始遍历这个链表,如果在链表找到对应的元素则将新的元素的value替换就旧的元素的value;如果没有找到的话,将该元素放在链表的头部,最先加入的放在链表的尾部。
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
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))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
举个例子:现在有一个空的map,假设”小明”的hash得到的index=0,将小明存入map,调用put方法之后:
Entry[0]="小明"
不一会,来一个“小猫”,“小猫”的hash得到index也为0,那么,现在结果是这样的:
Entry[0]="小猫"
"小猫".next="小明"
又过一会,来一个“小狗”,“小狗”的hash得到index也为0,那么,现在结果是这样的:
Entry[0]="小狗"
"小狗".next="小猫"
"小猫".next="小明"
也即是说数组存储的都是最新的插入数据!
在这里,就出现了几个非常严肃的问题
- 为啥要二次hash
这是干啥子,直接调用Object的hashcode方法不就完事了吗?大家仔细看一下源码:
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
二次hash的根源还是JVM生成的hashcode的低字节冲突概率较大,为了提高性能,使hashcode尽可能的散列,hashmap在hash()方法中加入高位运算。
为啥根据hash值获取数组下标时不采用取模的方法?
先来看看HashMap中indexFor方法
static int indexFor(int h, int length) {
return h & (length-1);
}
无非就是根据hash获取元素在数组的位置,通常来说,我们首先想到是把hash值对数组长度取模运算,
return hash % length;
这样一来,元素的分布总的来说还是比较均匀的。但是,我们要知道”模”运算的消耗是比较大的,进行位操作消耗比较低。但是,我们又不得不怀疑这个真的管用吗?真的与取模运算的效果是等价的吗?
在继续刨根问底的时候又引入下一个问题!
为啥hashmap的size为2的n次方?
回答了这个问题,同样回答了上面取模的问题。站在上一个问题的角度来讲,其实,追根溯源原因很简答就是:hashcode获取数组的index尽量服从均匀分布!
先来做一组实验看看!hashcode从0-100,length大小为14
int arry[]=new int[100];
for(int i=0;i<100;i++){
arry[i]=i;
}
for(int tmp:arry){
System.out.println(tmp&13);
}
for(int tmp:arry){
System.out.println(tmp%14);
}
统计之后的结果为:
很明显的发现该情况下取模方式的结果分布更加均匀!
假设length为16呢?两种方式是等价的!
为什么会这样呢?我们来仔细的分析一下!
2^n转换成二进制就是1+n个0,减1之后就是0+n个1,如16 -> 10000,15 -> 01111,而&位运算的规则,都为1(真)时,才为1,因此,length-1的二进制1越多,越是平均分布。到此,我明白了原因在当HashMap的大小为 2^n时,&与%的效果是等价的,但是&的处理性能高于%,我想这可能是HashMap作者考虑的,但是一味的限定hashmap的大小为2^n,有点浪费空间,但是我还是对这种设计由衷的赞叹,太精妙了。(hashtable就是粗暴的采用取模运算)
(3)HashMap的get实现
分析完put方法之后,我们来看看get方法
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
其中,getEntry(key)为
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
从上面的源代码中可以看出:
- 向HashMap中get元素的时候,先根据key得到其hashCode,在对得到的hashcode进行二次hash;
- 根据hash值找到这个元素在数组中的位置(即下标);
- 如果对应数组位置没有元素,返回null;
- 如果对应数组位置已经存放了其他的元素,那么开始遍历这个链表,如果在链表找到对应的元素返回其value,
(4)HashMap的初始化
HashMap初始化必将常用的有三个构造函数,在介绍之前首先介绍一下capacity和load factor。capacity表示HashMap的最大容量,即为底层数组的长度;load factor为负载因子,它是散列表的实际元素数目(n)/ 散列表的容量(m), 负载因子衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
HashMap()默认的capacity为16,load factor为0.75。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
HashMap(int initialCapacity)构建一个初始容量为 initialCapacity,负载因子为 0.75 的 HashMap
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;
threshold = initialCapacity;
init();
}
HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。
面试总经常会问:如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
HashMap()默认的capacity为16,load factor为0.75,在HashMap内有一个字段threshold,它表征了HashMap的最大容量
threshold = (int)(capacity * loadFactor);
当hashmap的大小超过12的时候,将会创建一个长度为32的bucket数组(原来hashmap的两倍),来重新调整map的大小,并将原来的对象放入新的数组。这就引入了下一个问题rehash!
(5)HashMap的rehash过程
上面已经将了,当HashMap大小超过了最大容量threshold,将会发生resize(rehash)的 过程。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
这是put方法中的addEntry()方法,如果size >= threshold,将会执行resize(2 * table.length),再来看看resize()方法:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
我么可以看出:
- 首先将会创建一个原来数组长度2倍的新的数组new Entry[newCapacity]
- 然后调用transfer()方法,将原来数组中数据重新装入新的数组
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
resize(rehash)是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数,减小resize(rehash)的过程,这样能够有效的提高HashMap的性能。
(6)HashMap的线程安全问题
那么,问题又来了,如果两个线程都发现HashMap需要重新调整大小,它们会同时试着调整大小,这样就会产生条件竞争。在调整大小的过程中,存储在LinkedList中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在LinkedList的尾部,而是放在头部,这是为了避免尾部遍历。如果条件竞争发生了,那么就死循环了。
这时候HashTable 与ConcurrentHashMap到了出场的时候了!