HashMap源码保姆级分析
这里使用的是jdk1.7 jdk1.8就变成node节点了,先分析1.7的,1.8会生成红黑树,生成红黑树的条件,1.链表长度超过8 2.hashmap数组长度到64
一、什么是哈希表
哈希表(hash table)
是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,本文会对java集合框架中HashMap的实现原理进行讲解,并对JDK7的HashMap源码进行分析。
1.哈希碰撞:
对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞
2.解决方案:
数组+链表
二、HashMap的实现原理
1.hashmap骨架: 2的幂 Entry对象数组
HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。(其实所谓Map其实就是保存了两个对象之间的映射关系的一种集合)
/**
主干是一个Entry数组 2的幂
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
Entry是HashMap中的一个hashmap静态内部类对象。代码如下
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
其他几个重要字段
/**实际存储的key-value键值对的个数*/
transient int size;
/**阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,
threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到*/
int threshold;
/**负载因子,代表了table的填充度有多少,默认是0.75 为了减少碰撞而存在,使碰撞概率更低
加载因子存在的原因,还是因为减缓哈希冲突,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。
所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。
一旦扩容就是成倍的克隆。默认是16,0.75负载因子,会扩成32,然后是64
*/
final float loadFactor;
/**HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时,
如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),
需要抛出异常ConcurrentModificationException*/
可以认为是hashmap的版本号,有点cas的意思了
transient int modCount;
构造一个具有指定初始容量和默认加载因子 (0.75) 的空 HashMap
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中就会有对应实现
}
2.put():分配内存空间
从上面这段代码我们可以看出,在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组
public V put(K key, V value) {
//如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,
//此时threshold为initialCapacity 默认是1<<4(24=16)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key为null,存储位置为table[0]或table[0]的冲突链上
if (key == null)
return putForNullKey(value);
int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
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;
}
① inflateTable()方法分配存储空间
inflateTable这个方法用于为主干数组table在内存中分配存储空间,通过roundUpToPowerOf2(toSize)可以确保capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.
private void inflateTable(int toSize) {
int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次幂
/**此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,
capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1 */
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
完成初始化后:
threshold阈值:为12
table为:16
② hash函数:确保存储位置分布均匀
注意:如果key为null,存储位置为table[0]或table[0]的冲突链上 就是第一个位置上 null的hash值认为是0
/**这是一个神奇的函数,用了很多的异或,移位等运算
对key的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);
}
③ 通过indexFor进一步处理来获取实际的存储位置
/**
* 返回数组下标
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
④ 遍历指定数组位置的链表,是否存在旧值
遍历具体的某个链表,使用next查找下一个entry节点直到节点为null,如果该对应数据已存在,执行覆盖操作,返回旧value
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操作记录+1
modCount++;
⑥ addEntry的实现:保存当前key-value
当前key节点不存在,新增当前key-value
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
前面第一次put的时候,通过调试可知临界阈值threshold为12,
table[bucketIndex]就是table
size就是容器中已存在的key-value键值对的数量
所以满足 (容器中键值对数量大于等于12 ) +(键值对插入的table位置不为null)这两个条件就扩容成原来的2倍
说人话就是键值对超过阈值且同时发生hash碰撞才会扩容!!!
当发生哈希冲突并且size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Entry数组中的元素全部传输过去,扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。
这里的扩容方法后面再分析,继续走流程,当扩容后,刷新hash值,重新计算桶索引bucketIndex
⑦ createEntry()创建节点
void createEntry(int hash, K key, V value, int bucketIndex) {
//首先取出原来这个容器位置的数据,可能是空,可能是一个链表
Entry<K,V> e = table[bucketIndex];
//重新赋值 将当前位置的key value hash都更新为传入的key-value对象,将next指向原来这个位置的数据
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
key = k;
hash = h;
next = n;
}
⑧ resize()分析
先自己写代码搞个扩容看看
HashMap hashMap =new HashMap();
hashMap.put("1","lys");
hashMap.put("2","lys");
hashMap.put("3","lys");
hashMap.put("4","lys");
hashMap.put("5","lys");
hashMap.put("6","lys");
hashMap.put("7","lys");
hashMap.put("8","lys");
hashMap.put("9","lys");
hashMap.put("10","lys");
hashMap.put("11","lys");
hashMap.put("12","lys");
hashMap.put("13","lys");
hashMap.put("14","lys");
hashMap.put("15","lys");
hashMap.put("16","lys");
执行到扩容断点:发现是当key==15时,才发生扩容!
源码分析:
默认扩大成原来的2倍
resize(2 * table.length);
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);
}
如果桶的大小是 MAXIMUM_CAPACITY = 1 << 30; 2的30次方,就赋值threshold阈值为Integer.MAX_VALUE; 也就是2的31次方减一
说人话就是不会再扩容了,直接返回。threshold阈值已经超过容量值,触发这个逻辑之后,只会把这个table的链表越加越长
然后新建一个2倍的空ENTRY数组,然后执行transfer方法,最后重新计算新容器的阈值
//将所有条目从当前表转移到新表。
void transfer(Entry[] newTable, boolean rehash) {
//这个newCapacity就是32
int newCapacity = newTable.length;
//hashmap数组遍历
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);
//将当前entry的next链指向新的索引位置,newTable[i]有可能为空,有可能也是个entry链,如果是entry链,直接在链表头部插入。
e.next = newTable[i];
//将当前元素存放到新容器中区
newTable[i] = e;
//将e指向下一个元素
e = next;
}
}
}
结论:外层的for遍历数组,内层的while遍历链表,直到next,有点骚,本质上就是对旧容器中的元素重新计算桶索引,并将其添加到扩容后的容器里去
这里的e.next = newTable[i];比较难理解,看了半天才弄明白,这里举个栗子
比如当newtable[i]为空时,第一个entry元素取名为e1,那么此时e1的nextEntry就是为null的
当e2也要放到这个newtable[i]时,e2的nextEntry节点就是e1了
当e3也放这个newtable时,e3的nextEntry节点就是e2了,
。。。
也就是说新值是放到链表的头部插入的,存放在数组位置上,越久的数据在越下面。假设插入点i==1画个图
3.get()
public V get(Object key) {
//如果key为null,则直接去table[0]处去检索即可。
if (key == null)
return getForNullKey();
//调用getEntry方法获取entey
Entry<K,V> entry = getEntry(key);
//不为null就返回value值
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
//当前集合不存在键值对,返回null
if (size == 0) {
return null;
}
//通过key的hashcode值计算hash值
int hash = (key == null) ? 0 : hash(key);
//indexFor (hash&length-1) 获取最终数组索引,然后遍历链表,通过equals方法比对找出对应记录
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;
}
private V getForNullKey() {
//当前集合不存在键值对,返回null
if (size == 0) {
return null;
}
//遍历 table[0]链表,取出key值为null的entry,返回value值
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
4.问题:
问题一:为何HashMap的数组长度一定是2的次幂?
key 的 hash 值& (容量-1)
确定位置时碰撞概率会比较低,因为容量为 2 的幂时,减 1 之后的二进制数为全1,这样与运算的结果就等于 hash值后面与 1 进行与运算的几位
如果是其他的容量值,假设是9,进行与运算结果碰撞的概率就比较大
另外,每次都是 2 的幂也可以让 HashMap 扩容时可以方便的重新计算位置。
下面两个问题目前还每找到合理的解释,以后知道了再做解答。
问题二:为什么不在put()方法的第一行进行modCount++?
为什么都在每次执行完成后modCount++;?
比如当key值是null的时候,在 putForNullKey()方法里面modCount++;
在put()方法里的出口处modCount++;
为什么不在put()方法的第一行进行modCount++?,这样就只写了一遍代码
答:目前的感觉就是代码多写了,没必要。
问题三:addEntry里为什么又判断了key是否为null?又计算了一遍hash值?
if (key == null)
return putForNullKey(value);
前面已经做了key值的null判断,不理解这里为什么又来了一次判断?
hash值的算法也是相同的,算出来的值是一样的,为啥又算一遍,觉得只需要重新计算桶索引就足够了,因为计算桶索引得算法是
//需要传入h:hashcode和length桶的容量
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
因为容量变了,所以必须重算。
答:目前不理解。
4.JDK1.8中HashMap的性能优化
假如一个数组槽位上链上数据过多(即拉链过长的情况)导致性能下降该怎么办?
JDK1.8在JDK1.7的基础上针对增加了红黑树来进行优化。即当链表超过8时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。