HashMap
HashMap结构
transient Node<K,V>[] table;
...
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;// 哈希值
final K key; // 键值
V value; // 数据
Node<K,V> next;// 下一个节点
...
}
HashMap内就是由多个(默认16个)链表元素组成的数组,
每个链表由由多个Node<K, V>对象组成,
每个Node包含上面四个属性。
HashMap常量
/**
* 初始化时容器的大小,必须是2的幂,默认是16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 容器最大容量
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认加载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 树的阈值,当单链表达到阈值则开始进行树结构,JDK8开始
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 在哈希表扩容时,如果发现链表长度小于 6,则会由树重新退化为链表。
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 在转变成树之前,还会有一次判断,只有键值对数量大于 64 才会发生转换。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
其他关键参数
transient Set<Map.Entry<K,V>> entrySet; //储存 键对 的集合
transient int size; //键对数
transient int modCount; //记录map的修改次数,为了线程安全
/*请注意,此异常并不总是表示对象已被其他线程同时修改。如果单个线程发出一系列违反对象约定的方法调用,则该对象可能会抛出此异常。例如,如果线程使用有fail-fast机制的迭代器在集合上迭代时修改了集合,迭代器将抛出此异常。
*/
int threshold; //数组扩容的阈值
final float loadFactor; //负载因子
函数源码
hash()
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
算法解释: key为空则返回0 , 否则取key的hash值赋值给h,h无符号右移16位 , h 和 h无符号右移16位 之后的值异或运算作为哈希值hash
(补充:java提供两种右移运算符
>> :算术右移运算符,也称符号右移。用最高位填充移位后左侧的空位。
>>>:逻辑右移运算符,也称无符号右移。只对位进行操作,用0填充左侧的空位)
getNode()
final Node<K,V> getNode(Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & (hash = hash(key))]) != null) { //取出相应链表first
if (first.hash == hash && // always check first node //检查是否是对应链表
((k = first.key) == key || (key != null && key.equals(k)))) //查找首结点
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode) //树,则使用树的方法找
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null); //遍历查找链表
}
}
return null;
}
简述过程:先用first = tab[(n - 1) & (hash = hash(key))])计算数组下标并取出该链表到first,
首先检查首节点,不符合要求则接下来若是树则调用树的((TreeNode<K,V>)first).getTreeNode(hash, key)方法查找Node,否则遍历查找链表查找Node
关键在于(n - 1) & (hash = hash(key)),计算出储存的对应链表,并保证了下标不会越界。
hash(key)返回的值仍非常大,因为n是2的倍数,是容量,
假如n为默认值16,n-1为15,化为二进制为00001111,再做按位与运算,保证了(n - 1) & (hash = hash(key))的范围在0-15,不会越界。
putVal()
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)// 首次插入数组为空,或者数组长度为0
n = (tab = resize()).length; //扩容
if ((p = tab[i = (n - 1) & hash]) == null)// 数组中该下标的第一个链表节点,若是空的则新建节点
tab[i] = newNode(hash, key, value, null);
else { //不空,哈希碰撞
Node<K,V> e; K k;
if (p.hash == hash && // key相同则替换原有数据
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode) // 该节点为树节点,则进行树插入操作
e = ((TreeNode<K,V>)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);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);// 如果哈希表长度大于等于8,则转换为树操作
break;
}
if (e.hash == hash &&// key相同情况,覆盖原值
((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; //记录修改次数,线程安全,迭代安全
if (++size > threshold)// 元素大于阈值,进行扩容操作
resize();
afterNodeInsertion(evict);
return null;
}
关键在于p = tab[i = (n - 1) & hash]) == null找出key对应的链表。
流程也大致与getNode相同,先找出对应链表,再检查首个结点,接着检查是否为树,最后遍历链表,不存在相同key就插入末尾,存在相同key就覆盖原值。
最后检查元素大于阈值,进行扩容操作resize();
每次put之后,会检测一下是否需要扩容,size超过了 总容量 * 负载因子,则会扩容。默认情况下,16 * 0.75 = 12个。
Node<K,V>[] resize()
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//首次初始化后table为Null
int oldCap = (oldTab == null) ? 0 : oldTab.length; //记录扩容前长度
int oldThr = threshold;//默认构造器的情况下为0
int newCap, newThr = 0;
if (oldCap > 0) {//table扩容过(首次初始化时oldCap为0)
//当前table容量大于最大值得时候返回当前table,并更新阈值为最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//table的容量乘以2,threshold的值也乘以2
newThr = oldThr << 1; // double threshold
}
//table没扩容过,刚带有初始容量的初始化
else if (oldThr > 0) // initial capacity was placed in threshold
//使用带有初始容量的构造器时,table容量为初始化得到的threshold
newCap = oldThr;
//table没扩容过,刚默认构造器下进行扩容
else {
// zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
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<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //强制类型转化
table = newTab;
if (oldTab != null) { //复制旧表
for (int j = 0; j < oldCap; ++j) {
HashMap.Node<K,V> e;
if ((e = oldTab[j]) != null) { //取出链表到e
// help gc
oldTab[j] = null;
if (e.next == null)
// 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1)
// 扩容都是按照2的幂次方扩容,因此newCap = 2^n
newTab[e.hash & (newCap - 1)] = e; //放在新的哈希表,e.hash & (newCap - 1)是新的哈希表的位置的下标
else if (e instanceof HashMap.TreeNode)
// 当前index对应的节点为红黑树,这里篇幅比较长且需要了解其数据结构跟算法,因此不进行详解,当树的个数小于等于UNTREEIFY_THRESHOLD则转成链表
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 把当前index对应的链表分成两个链表,减少扩容的迁移量
HashMap.Node<K,V> loHead = null, loTail = null;
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
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) {
// help gc
loTail.next = null;
newTab[j] = loHead; //不需移动的链表复制引用
}
if (hiTail != null) {
// help gc
hiTail.next = null;
// 扩容长度为当前index位置+旧的容量
newTab[j + oldCap] = hiHead; //需要移动的链表复制引用
}
}
}
}
}
return newTab;
}
HashMap扩容可以分为三种情况:
1 ) : 使用默认构造方法初始化HashMap。HashMap在一开始初始化的时候会返回一个空的table,并且thershold为0。因此第一次扩容的容量为默认值DEFAULT_INITIAL_CAPACITY也就是16。同时阈值
newThr = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12
。
else { //默认构造器下进行扩容
// zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
2):指定初始容量的构造方法初始化HashMap。那么新阈值newThr = 当前的容量(newCap) * DEFAULT_LOAD_FACTOR(loadFactor)。
if (newThr == 0) {
//使用带有初始容量的构造器在此处进行扩容
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
3):HashMap不是第一次扩容。如果HashMap已经扩容过的话,那么每次table的容量以及threshold量为原有的两倍。
if (oldCap > 0) {//table扩容过
//当前table容量大于等于最大值得时候返回当前table不再扩容,阈值调到最大
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//table的容量乘以2,threshold的值也乘以2
newThr = oldThr << 1; // double threshold
}
复制过程:
根据hash值把一个链表拆成两个链表,一个不需移动,另一个需要移动。
原来下标的范围0-15,取低四位即e.hash&(oldCap-1),
扩容后下标的范围0-31,取低5位即即e.hash&(newCap-1),若新取得第五位是1,就要移动,否则跟原下标值一样,不需移动。
比如原来下标为15的链表,现在拆成下标为15和31(+oldCap)的两个链表。
而当(e.hash & oldCap) == 0(取低位第5个)时该节点就是不用移动的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LQBC6wLp-1660665682159)(D:\学习资料\笔记\watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl8zOTY2Nzc4Nw==,size_16,color_FFFFFF,t_70.png)]
当前是链表, JAVA7时还需要重新计算hash位(即e.hash&(newCap-1)), 但是JAVA8做了优化, 通过(e.hash & oldCap) == 0(正好取低第五位)来判断是否需要移位; 如果为真则在原位不动, 否则则需要移动到当前hash槽位 + oldCap的位置;
HashMap::resize的核心就是上图, 链表与红黑树的resize过程大同小异: 红黑树是把构建新链表的过程变为构建两颗新的红黑树, 定位table中的index都是用的 e.hash & oldCap == 0 来判断;
再来看下 e.hash & oldCap == 0为什么可以判断当前节点是否需要移位, 而不是再次计算hash;
仍然是原始长度为16举例:
old:
10: 0000 1010
15: 0000 1111
&: 0000 1010
new:
10: 0000 1010
31: 0001 1111
&: 0001 1010
从上面的示例可以很轻易的看出,e.hash & oldCap == 0取低位第5个1, 而这个变化的1刚好是oldCap, 那么只需要判断原key的hash这个位上是否为1: 若是1, 则需要移动至oldCap + i的槽位, 若为0, 则不需要移动;
而e.hash & (oldCap-1)取低四位,限制下标范围在0-15
这也是HashMap的长度必须保证是2的倍数的原因, 正因为这种环环相扣的设计, HashMap.loadFactor的选值是3/4就能理解了, table.length * 3/4可以被优化为(table.length >> 2) << 2) - (table.length >> 2) == table.length - (table.lenght >> 2), JAVA的位运算比乘除的效率更高, 所以取3/4在保证hash冲突小的情况下兼顾了效率;
红黑树部分暂时没看
注意的点
HashMap是不同步的,需要外部的同步机制
HashMap设计时就是没有保证线程安全的, 所以在多线程环境请使用ConcurrentHashMap;
HashMap无序的,但内部按照 Hash 码排序,每次输出顺序不变,不可指定排序方式
初始容量为16,容量必须为2的幂 ,默认负载系数(0.75)
1、为什么初始容量是16
当容量为2的幂时,上述n -1 对应的二进制数全为1,这样才能保证它和key的hashcode做&运算后,能够均匀分布,这样才能减少hash碰撞的次数。
至于默认值为什么是16,而不是2 、4、8,或者32、64、1024等,我想应该就是个折中处理,过小会导致放不下几个元素,就要进行扩容了,而扩容是一个很消耗性能的操作。取值过大的话,无疑会浪费更多的内存空间。因此在日常开发中,如果可以预估HashMap会存入节点的数量,则应该在初始化时,指定其容量。
2、为什么负载因子是0.75
也是一个综合考虑,如果设置过小,HashMap每put少量的数据,都要进行一次扩容,而扩容操作会消耗大量的性能。如果设置过大的话,如果设成1,容量还是16,假设现在数组上已经占用的15个,再要put数据进来,计算数组index时,发生hash碰撞的概率将达到15/16,这违背的HashMap减少hash碰撞的原则。
常用方法
HashMap()
构造一个空的HashMap
,默认初始容量(16)和默认负载系数(0.75)。HashMap(Map<? extends K,? extends V> m)
构造一个新的HashMap
与指定的相同的映射Map
。containsKey(Object key)
如果此映射包含指定键的映射,则返回true
。containsValue(Object value)
如果此地图将一个或多个键映射到指定值,则返回true
。entrySet()
返回此地图中包含的映射的Set
视图。keySet()
返回此地图中包含的键的Set
视图。values()
返回此地图中包含的值的Collection
视图。get(Object key)
返回到指定键所映射的值,或null
如果此映射包含该键的映射getOrDefault(Object key, V defaultValue)
返回到指定键所映射的值,不存在则返回defaultValue
put(K key, V value)
将指定的值与此映射中的指定键相关联。putAll(Map<? extends K,? extends V> m)
将指定地图的所有映射复制到此地图remove(Object key)
从该地图中删除指定键的映射(如果存在)。replace(K key, V value)
只有当目标映射到某个值时,才能替换指定键的条目。