深入解析HashMap
前言
本文所有内容如若未特殊说明,均为JDK1.8版本。
哈希函数
对key对象的hashcode进行扰动 通过取模求得数组下标
static final int hash(Object key) {
int h;
// 获取到key的hashcode,在高低位异或运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
...
// 与数组长度-1进行位与运算,得到下标
if ((p = tab[i = (n - 1) & hash]) == null)
...
}
初始化时指定的长度 扩容时的长度增量
public HashMap(int initialCapacity, float loadFactor) {
...
this.loadFactor = loadFactor;
// 这里调用了tableSizeFor方法
this.threshold = tableSizeFor(initialCapacity);
}
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;
}
00100 --高位1之后全变1--> 00111 --加1---> 01000
final Node
[] resize() {
...
if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 设置为原来的两倍
newThr = oldThr << 1;
...
}
小结:
HashMap通过高16位与低16位进行异或运算来让高位参与散列,提高散列效果; HashMap控制数组的长度为2的整数次幂来简化取模运算,提高性能; HashMap通过控制初始化的数组长度为2的整数次幂、扩容为原来的2倍来控制数组长度一定为2的整数次幂。
哈希冲突解决方案
当链表的长度>=8且数组长度>=64时,会把链表转化成红黑树。 当链表长度>=8,但数组长度<64时,会优先进行扩容,而不是转化成红黑树。 当红黑树节点数<=6,自动转化成链表。
为什么需要数组长度到64才会转化红黑树? 当数组长度较短时,如16,链表长度达到8已经是占用了最大限度的50%,意味着负载已经快要达到上限,此时如果转化成红黑树,之后的扩容又会再一次把红黑树拆分平均到新的数组中,这样非但没有带来性能的好处,反而会降低性能。所以在数组长度低于64时,优先进行扩容。 为什么要大于等于8转化为红黑树,而不是7或9? 树节点的比普通节点更大,在链表较短时红黑树并未能明显体现性能优势,反而会浪费空间,在链表较短是采用链表而不是红黑树。在理论数学计算中(装载因子=0.75),链表的长度到达8的概率是百万分之一;把7作为分水岭,大于7转化为红黑树,小于7转化为链表。红黑树的出现是为了在某些极端的情况下,抗住大量的hash冲突,正常情况下使用链表是更加合适的。
小结:
HashMap采用链地址法,当发生冲突时会转化为链表,当链表过长会转化为红黑树提高效率。 HashMap对红黑树进行了限制,让红黑树只有在极少数极端情况下进行抗压。
扩容方案
小结:
装载因子决定了HashMap扩容的阈值,需要权衡时间与空间,一般情况下保持0.75不作改动; HashMap扩容机制结合了数组长度为2的整数次幂的特点,以一种更高的效率完成数据迁移,同时避免头插法造成链表环。
线程安全
采用Hashtable 调用Collections.synchronizeMap()方法来让HashMap具有多线程能力 采用ConcurrentHashMap
// Hashtable
public synchronized V get(Object key) {...}
public synchronized V put(K key, V value) {...}
public synchronized V remove(Object key) {...}
public synchronized V replace(K key, V value) {...}
...
final Object mutex; // Object on which to synchronize
SynchronizedMap(Mapm) {
this.m = Objects.requireNonNull(m);
// 默认为本对象
mutex = this;
}
SynchronizedMap(Mapm, Object mutex) {
this.m = m;
this.mutex = mutex;
}
锁是非常重量级的,会严重影响性能。 同一时间只能有一个线程进行读写,限制了并发效率。
ConcurrentHashMap
map = new ConcurrentHashMap<>();
map.put("abc","123");
Thread1:
if (map.containsKey("abc")){
String s = map.get("abc");
}
Thread2:
map.remove("abc");
final Node
nextNode() {
...
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
...
}
小结
HashMap并不能保证线程安全,在多线程并发访问下会出现意想不到的问题,如数据丢失等 HashMap1.8采用尾插法进行扩容,防止出现链表环导致的死循环问题 解决并发问题的的方案有Hashtable、Collections.synchronizeMap()、ConcurrentHashMap。其中最佳解决方案是ConcurrentHashMap 上述解决方案并不能完全保证线程安全 快速失败是HashMap迭代机制中的一种并发安全保证
源码解析
关键变量的理解
// 存放数据的数组
transient Node[] table;
// 存储的键值对数目
transient int size;
// HashMap结构修改的次数,主要用于判断fast-fail
transient int modCount;
// 最大限度存储键值对的数目(threshodl=table.length*loadFactor),也称为阈值
int threshold;
// 装载因子,表示可最大容纳数据数量的比例
final float loadFactor;
// 静态内部类,HashMap存储的节点类型;可存储键值对,本身是个链表结构。
static class Nodeimplements Map.Entry {...}
扩容
final Node
[] resize() {
// 变量分别是原数组、原数组大小、原阈值;新数组大小、新阈值
Node[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 如果原数组长度大于0
if (oldCap > 0) {
// 如果已经超过了设置的最大长度(1<<30,也就是最大整型正数)
if (oldCap >= MAXIMUM_CAPACITY) {
// 直接把阈值设置为最大正数
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 设置为原来的两倍
newThr = oldThr << 1;
}
// 原数组长度为0,但最大限度不是0,把长度设置为阈值
// 对应的情况就是新建HashMap的时候指定了数组长度
else if (oldThr > 0)
newCap = oldThr;
// 第一次初始化,默认16和0.75
// 对应使用默认构造器新建HashMap对象
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果原数组长度小于16或者翻倍之后超过了最大限制长度,则重新计算阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 建立新的数组
Node[] newTab = (Node [])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 循环遍历原数组,并给每个节点计算新的位置
for (int j = 0; j < oldCap; ++j) {
Nodee;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 如果他没有后继节点,那么直接使用新的数组长度取模得到新下标
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果是红黑树,调用红黑树的拆解方法
else if (e instanceof TreeNode)
((TreeNode)e).split(this, newTab, j, oldCap);
// 新的位置只有两种可能:原位置,原位置+老数组长度
// 把原链表拆成两个链表,然后再分别插入到新数组的两个位置上
// 不用多次调用put方法
else {
// 分别是原位置不变的链表和原位置+原数组长度位置的链表
NodeloHead = null, loTail = null;
NodehiHead = null, hiTail = null;
Nodenext;
// 遍历老链表,判断新增判定位是1or0进行分类
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 最后赋值给新的数组
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 返回新数组
return newTab;
}
添加数值
public V put(K key, V value) {
// 获取hash值,再调用putVal方法插入数据
return putVal(hash(key), key, value, false, true);
}
// onlyIfAbsent表示是否覆盖旧值,true表示不覆盖,false表示覆盖,默认为false
// evict和LinkHashMap的回调方法有关,不在本文讨论范围
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab是HashMap内部数组,n是数组的长度,i是要插入的下标,p是该下标对应的节点
Node[] tab; Node p; int n, i;
// 判断数组是否是null或者是否是空,若是,则调用resize()方法进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 使用位与运算代替取模得到下标
// 判断当前下标是否是null,若是则创建节点直接插入,若不是,进入下面else逻辑
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// e表示和当前key相同的节点,若不存在该节点则为null
// k是当前数组下标节点的key
Nodee; K k;
// 判断当前节点与要插入的key是否相同,是则表示找到了已经存在的key
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时转化为红黑树
// 注意,treeifyBin方法中会进行数组长度判断,
// 若小于64,则优先进行数组扩容而不是转化为树
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 找到相同的直接跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果找到相同的key节点,则判断onlyIfAbsent和旧值是否为null
// 执行更新或者不操作,最后返回旧值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 如果不是更新旧值,说明HashMap中键值对数量发生变化
// modCount数值+1表示结构改变
++modCount;
// 判断长度是否达到最大限度,如果是则进行扩容
if (++size > threshold)
resize();
// 最后返回null(afterNodeInsertion是LinkHashMap的回调)
afterNodeInsertion(evict);
return null;
}
总体上分为两种情况:找到相同的key和找不到相同的key。找了需要判断是否更新并返回旧value,没找到需要插入新的Node、更新节点数并判断是否需要扩容。 查找分为三种情况:数组、链表、红黑树。数组下标i位置不为空且不等于key,那么就需要判断是否树节点还是链表节点并进行查找。 链表到达一定长度后需要扩展为红黑树,当且仅当链表长度>=8且数组长度>=64。
其他问题
为什么jdk1.7以前控制数组的长度为素数,而jdk1.8之后却采用的是2的整数次幂?
为什么插入HashMap的数据需要实现hashcode和equals方法?对这两个方法有什么要求?
最后
评论