心得:相较于JDK 1.7,Java 8中的HashMap有了较大的性能提升。修改了hash和resize方式,增加了红黑树的支持。
学习参考资料:
(1)[jdk7 HashMap的死循环](https://blog.csdn.net/maohoo/article/details/81531925)
1. HashMap要点
(1)结构特点:Java中的HashMap是基于“数组+链表”的方式(链表法解决冲突),到了Java 8,应该是“数组+链表/红黑树”的方式。
(2)线程安全:HashMap是不安全,Collections Framework中有两种线程安全的实现:Collections.synchronizedMap(new java.util.HashMap<>());和ConcurrentHashMap,前者是锁整个表,后者是16个分段锁:
学习参考资料(1),展示了Java 7中,并发出现“死循环”的一种情形,就是在resize过程中,迁移Entry到新桶中是产生了一个有环的链表造成的,Java 7中resize的transfer是在链表头部插入新节点,Java 8中的新节点的插入是尾部;
对于Java 8中resize一个桶中的如果是链表的话,会被分两个链表一个保留在原来索引位置上,一个在(oldCap + oldIndex)位置,因为是在尾部插入,所以它们相对位置不变,但HashMap还是线程不安全的;
(3)性能特点:HashMap可以在常数时间内增加,删除,查找元素,但这也是一种平均情况,使用load factor装载因子计算阈值就是为了减少冲突过多,带来的性能退化;
(4)Java 8相对于Java 7中HashMap的区别和优化:;
(1)计算hash值的方法:Java 7中会基于一个随机种子计算hash值,这样每次resize如果得到不同的随机种子,那么原来一个桶中的元素,会被“随机”散列到桶数组中,Java 8放弃了这种做法,基于key的hashCode(通过异或计算综合高位和地位),旧桶的元素如上面所说只可能散列到两个确定位置的桶中,基于好的hashCode计算,这也是随机分布的,这样可以简化了计算并且省去了随机种子的计算;
(2)红黑树的应用:当桶的数量超过MIN_TREEIFY_CAPACITY时,向一个元素数达到TREEIFY_THRESHOLD的桶中插入节点时会将桶中的链表转化为红黑树实现,也就是变O(n)的查找转变为O(log n);
(5)HashMap中优化性能的设计:
(1)何时进行resize:和ArrayList一次扩展为原大小的3/2类似,HashMap的桶数组一次扩展为原数组的2倍,控制扩展和移动的次数;
(2)桶数组的容量是2的幂次方,这样设计有三个好处,一是2的幂次方减1正好可以得到一个计算index的掩码,二是扩展大小时一次位运算(<<)既可以计算出新的容量同时有保持了2的幂次方这一特点,三是进行迁移旧桶元素时,可以方便计算出元素新桶数组中两个位置;
(6)HashMap的应用:
根据HashMap特点,可以知道它可以实现常数时间的精确查找,插入和删除,可以通过它建立在内存中一些简单的运行时缓存数据;
但是显然哈希表不支持很好的范围查找,另外的对于过多的数据在Java 7中可能造成退化成链表的情形,因此一个好的hashCode实现是十分重要的,当然过大的数据也不太可能到放在内存里(内存泄漏,HashMap中有大量过期数据是个需要注意的问题,当然我们可以使用WeakHashMap);
PS:覆盖了equals,一定要覆盖hashCode函数,否则equals相等,hashCode不相等就扯淡了。
2. 结构
2.1 重要的值
容量:
//默认初始化容量,HashMap容量必须是2的幂次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
//最大容量不得超过1<<30
static final int MAXIMUM_CAPACITY = 1 << 30;
扩展:
(1)容量 × 装载因子:超过这个阈值,将进行resize扩展为原来大小的2倍;
(2)桶中元素树结构化(用红黑树代替链表):桶中元素数超过TREEIFY_THRESHOLD并且桶的数量超过MIN_TREEIFY_CAPACITY会进行树结构化,否则超过TREEIFY_THRESHOLD使用resize扩展桶容量;
//默认装载因子,0.75是权衡空间和时间开销之后的综合考虑
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//超过这个阈值将使用红黑树组织桶中的结点,而不是链表
static final int TREEIFY_THRESHOLD = 8;
//只有表的大小超过这个阈值,桶才可以被转换成树而不是链表(为超过这个值时,应该使用resize)
//这个值是TREEIFY_THRESHOLD的4倍,以便resizing和treeification之间产生冲突
static final int MIN_TREEIFY_CAPACITY = 64;
2.2 重要的属性
(1)桶数组(table):延迟加载,第一次插入数据前分配内存,桶的大小是2的幂次方,好处是便于快速计算hash值和扩展;
(2)阈值和装载因子(threshold,loadFactor):有capacity × load factor,用一个变量保存它是因为每次put新键值对都要检查它,显然不能每次都计算;
(3)键值对数量(size);
(4)修改计数器:fail-fast机制,这个机制不能用于维护正确性,只能用于调试bug;
(5)视图(entrySet,keySet,values):键值对的保存方式使用“数组+链表/红黑树”,这三个视图基于这个实现采用集合方式返回数据,主要用于遍历;
//延迟加载,长度总为2的幂次方
transient Node<K,V>[] table;
//键值对数量
transient int size;
//fail-fast
transient int modCount;
//下一次resize的阈值 (capacity * load factor)
int threshold;
//装载因子
final float loadFactor;
//视图
//键值对缓存,它们的映射关系集合保存在entrySet中,即使Key在外部修改导致hashCode变化,缓存中还可以找到映射关系
transient Set<Map.Entry<K,V>> entrySet;
transient volatile Set<K> keySet;
transient volatile Collection<V> values;
2.3 构造器
构造器重载版本:
(1)指定初始容量和装载因子:不指定是使用默认的值,延迟初始化到第一次添加键值对;
(2)拷贝构造器:使用默认装载因子,容量大小是不小于DEFAULT_INITIAL_CAPACITY的,最小的,超过传入键值对数量的的2的幂次方;
//传入指定初始化容量,将计算好threshold的值,第一次放入元素时分配threshold大小的数组
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;
//此时table还未分配到内存,threshold就是将要分配的数组大小
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
3. 操作
3.1 核心内部操作
计算容量,hash值,索引值:
(1)计算容量:tableSizeFor()
方法,使用位运算快速计算;
(2)hash值的计算:因为容量最小是16,而计算索引值的时候用(容量-1)作为掩码的,那可能因为hash值的高位不会被计算而导致冲突的概率增加;
(3)计算索引值:JDK 7中有一个indexFor方法计算索引值,Java 8中去掉了,但是逻辑没有变,比如在putVal方法中p = tab[i = (n - 1) & hash]
;
//找到最小的大于等于cap的2的幂次方,二进制位运算
static final int tableSizeFor(int cap) {
int n = cap - 1; //减1是为了排除“100000”这种情况
n |= n >>> 1; //这里位运算就是在用最高位的1“铺满所有位”
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
//计算key的hash值,这里在hashCode基础上做了一次“高位向低位传播”
//因为计算索引值是(cap - 1) & hash,当cap小于等于16时高位将无法起作用
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
重新分配桶数组(resize方法,三步:先确定桶的大小,再创建数组对象,最后给旧桶中元素搬家)
(1)桶数组不为空,扩展为原大小两倍(newCap = oldCap << 1)
,2倍扩展+阈值是一个重要的分配优化策略,这样可以大大减少分配数组对象并复制元素的次数:
//一,原数组不为空
if(oldCap > 0) {
//如果oldCap已经为最大容量
if(oldCap >= MAXIMUM_CAPACITY) {
threshold = MAXIMUM_CAPACITY;
return oldTab;
} else if((newCap = oldCap << 1) <= MAXIMUM_CAPACITY &&
oldCap > DEFAULT_INITIAL_CAPACITY)
threshold = oldThr << 1; //增加阈值
}
(2)桶数组为空,第一次分配,结合不同构造器的情况细节稍有不同:
//重新创建table数组
//原数组为空,oldThr不为空,扩展为oldThr大小
else if(oldThr > 0)
newCap = oldThr;
//原数组为空,oldThr为空,全部使用默认值
else {
//全部使用默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_INITIAL_CAPACITY * loadFactor);
}
(3)分配新内存:
//Node[]不具备类型检查的能力,因此要通过强制类型转换
//另外,不能创建参数化类型的数组
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
将元素“移动”到新桶中也分几种情况:
(4)桶中只有一个元素:
if(e.next == null)
//桶中只有一个元素,不可能是TreeNode直接放入新表的指定位置
newTab[e.hash & (newCap - 1)] = e;
(5)第一个元素是红黑树节点:
else if(e instanceof TreeNode) {
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
}
(6)桶中存放的是链表:
相对与Java 7这里也有优化:
不存在rehash重新计算的可能,由于hash值不变,容量是直接×2的,因此旧桶中的一个链表实际上最多会被分成两个链表一个在原来的索引位置(oldIndex)上,另一个就在oldIndex+oldCap位置上。
/*
桶中存在一个链表,需要将链表重新整理到新表当中,因为newCap是oldCap的两倍所以原节点的索引值要么和原来一样,要么就是原(索引+oldCap)和JDK 1.7中实现不同这里不存在rehash,直接使用原hash值JDK 1.7中resize过程是在链表头插入,这里是在链表尾插入
*/
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
do {
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 = e.next) != null);
if(loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if(hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
添加键值对
(1)第一次插入或桶是空的:
//如果是第一次添加元素
if((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//对应索引位置的桶是空的,直接创建新节点填入
if((p = tab[(i = (n - 1) & hash)]) == null)
tab[i] = newNode(hash, key, value, null);
(2)桶中有元素,首先检查第一个元素,因为树结构必须大于2个节点,再分类型检查;
//首先检查第一个节点
if(p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
(3)桶中是红黑树:
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
(4)桶中是链表,添加新节点,如果达到了TREEIFY_THRESHOLD,需要检查是否要转换为红黑树结构,treeifyBin()
会检查桶数组的大小是否超过MIN_TREEIFY_CAPACITY(64),不超过只是进行resize扩展,否则才转换树:
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
查找键值对(根据hash值和key查找)
这里hash值有两个作用:
一是根据hash确定桶的位置,基于良好的hashCode实现,这一步正式HashMap常数操作时间的保证。
(2)hash和key本质上是key的hashCode和equals方法的应用,hashCode不相等,equals必然不相等,hashCode相等再检查equals是否相等。反映到程序上就是一个短路优化。
如果桶数组不为空,而且对应的桶(hash & (table.length - 1))中有节点:
(1)首先检查桶中第一个节点:
//总是检查第一个节点的原因:无论是树结构还是链表,都可以方便的检查第一个节点,树结构的节点数必然大于1
//先检查hash,利用好短路特性
if(first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
(2)多于一个节点,检查类型,分别处理:
//多于一个节点,继续检查
if((e = first.next) != null) {
if(first instanceof TreeNode)
return (TreeNode)first.getTreeNode(hast, key);
do {
if(e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
删除键值对
基本的操作和添加大致相同,另外
如果桶数组不为空,而且对应的桶(hash & (table.length - 1))中有节点:
(1)检查桶中第一个节点:
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
(2)多于一个节点,检查类型,分别处理:
else if ((e = p.next) != null) {
//桶中是红黑树
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
//桶中是链表
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
(3)找了要删除的节点之后,:
if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) {
//如果是红黑树,删除要保持完美黑平衡
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//如果删除的是链表第一个节点
else if (node == p)
tab[index] = node.next;
//删除非表头节点
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
清除(clear方法)
清除所有节点,这里只是将“桶给清空了”,链表或者红黑树本身并没有置空操作;
public void clear() {
Node<K,V>[] tab;
modCount++;
if ((tab = table) != null && size > 0) {
size = 0;
for (int i = 0; i < tab.length; ++i)
tab[i] = null;
}
}
视图操作
HashMap本身是数组加链表的关系,但如果需要遍历的话以Set接口来遍历显然是一种很统一的设计。
因此Map接口提供了Set视图,基于HashMap的存储方式,实现了对键值对集合,键集合,值集合视图访问。
Set的遍历关键一点是Iterator的实现:
HashIterator
依据HashMap“数组+链表/红黑树”的存储特点,HashMap包含一个骨架类:HashIterator
PS:HashMap中红黑树的实现,TreeNode维护了next节点,可以通过next以类似链表的方式遍历;
HashIterator的迭代方式,是沿着桶数组找到一个非空的,迭代这个链表/红黑树,迭代完之后,找到下一个非空的桶继续遍历;
视图迭代器的实现:
//键集合迭代器
final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
implements Iterator<V> {
public final V next() { return nextNode().value; }
}
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
视图集合实现:
首先一个共同的特点是不能基于视图Set以及它们的迭代器执行添加操作;
remove,clear,contains基于HashMap对应方法实现的。
3. HashMap的“树化”
前面说过当桶的数量大于MIN_TREEIFY_CAPACITY(64)并且一个桶中的元素数超过TREEIFY_THRESHOLD时就会将这个桶中的链表变成红黑树结构,但是在树化的同时,这个红黑树保持了节点之间的“next”链接关系,使得可以向链表一样遍历,这在迭代其中十分有用,那它是如何保持的呢?
TreeNode的结构:
和普通的红黑树节点相比,TreeNode多了两个引用变量:next和prev,这说明它同时保持了一个双向链表的结构,之所以要是双向链表是因为,在添加,删除是用使用红黑树的操作,但是为了支持链表同时也要维护链表链接,显然再遍历一边找到前序节点就又退化成链表了,故而使用双向链表。
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
树化过程(treeifyBin()方法):
(1)当小于MIN_TREEIFY_CAPACITY,不要树化,通过resize扩展桶数组:
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
(2)超过MIN_TREEIFY_CAPACITY,开始树化,首先是替换链表节点对象(Node)为TreeNode节点,建立双向链表。在从头开始进行树化。
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
(3)树化中的节点顺序问题,红黑树是搜索树,因此需要节点是有序的,但是HashMap的类型参数没有Comparable的限定,因此当key对象类型未实现Comparable接口,将使用这个对象的原始hashCode(即Object的hashCode,无论有没有覆盖hashCode方法,null的hashCode为0)进行比较;
static int tieBreakOrder(Object a, Object b) {
int d;
if (a == null || b == null ||
(d = a.getClass().getName().
compareTo(b.getClass().getName())) == 0)
d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
-1 : 1);
return d;
}
(4)基于此树化中的两个疑问就弄清了:一是如何保持链表结构,二是有序性的获得,TreeNode.treeify这个方法的工作就是从这个节点开始遍历链表插入每个节点到红黑树中,每次插入之后修补黑平衡性这已经是很熟悉的内容了;